Skip to content

Commit

Permalink
Add streams API endpoint for fetching Reader cards (#744)
Browse files Browse the repository at this point in the history
  • Loading branch information
wargcm authored Mar 11, 2024
2 parents 94fca75 + 8b8d209 commit d83d2fe
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 37 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ _None._

### New Features

_None._
- Add Reader discover streams endpoint. [#744]

### Bug Fixes

Expand Down
85 changes: 61 additions & 24 deletions WordPressKit/ReaderPostServiceRemote+Cards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public enum ReaderSortingOption: String, CaseIterable {
}

extension ReaderPostServiceRemote {
/// Returns a collection of RemoteReaderCard
/// Returns a collection of RemoteReaderCard using the tags API
/// a Reader Card can represent an item for the reader feed, such as
/// - Reader Post
/// - Topics you may like
Expand All @@ -29,45 +29,82 @@ extension ReaderPostServiceRemote {
refreshCount: Int? = nil,
success: @escaping ([RemoteReaderCard], String?) -> Void,
failure: @escaping (Error) -> Void) {
guard let requestUrl = cardsEndpoint(for: topics, page: page, sortingOption: sortingOption, refreshCount: refreshCount) else {
let path = "read/tags/cards"
guard let requestUrl = cardsEndpoint(with: path,
topics: topics,
page: page,
sortingOption: sortingOption,
refreshCount: refreshCount) else {
return
}
fetch(requestUrl, success: success, failure: failure)
}

wordPressComRestApi.GET(requestUrl,
parameters: nil,
success: { response, _ in

do {
let decoder = JSONDecoder()
let data = try JSONSerialization.data(withJSONObject: response, options: [])
let envelope = try decoder.decode(ReaderCardEnvelope.self, from: data)

success(envelope.cards, envelope.nextPageHandle)
} catch {
WPKitLogError("Error parsing the reader cards response: \(error)")
failure(error)
}
}, failure: { error, _ in
WPKitLogError("Error fetching reader cards: \(error)")
/// Returns a collection of RemoteReaderCard using the discover streams API
/// a Reader Card can represent an item for the reader feed, such as
/// - Reader Post
/// - Topics you may like
/// - Blogs you may like and so on
///
/// - Parameter topics: an array of String representing the topics
/// - Parameter page: a String that represents a page handle
/// - Parameter sortingOption: a ReaderSortingOption that represents a sorting option
/// - Parameter count: the number of cards to fetch. Warning: This also changes the number of objects returned for recommended sites/tags.
/// - Parameter success: Called when the request succeeds and the data returned is valid
/// - Parameter failure: Called if the request fails for any reason, or the response data is invalid
public func fetchStreamCards(for topics: [String],
page: String? = nil,
sortingOption: ReaderSortingOption = .noSorting,
refreshCount: Int? = nil,
count: Int? = nil,
success: @escaping ([RemoteReaderCard], String?) -> Void,
failure: @escaping (Error) -> Void) {
let path = "read/streams/discover"
guard let requestUrl = cardsEndpoint(with: path,
topics: topics,
page: page,
sortingOption: sortingOption,
count: count,
refreshCount: refreshCount) else {
return
}
fetch(requestUrl, success: success, failure: failure)
}

failure(error)
})
private func fetch(_ endpoint: String,
success: @escaping ([RemoteReaderCard], String?) -> Void,
failure: @escaping (Error) -> Void) {
Task { @MainActor [wordPressComRestApi] in
await wordPressComRestApi.perform(.get, URLString: endpoint, type: ReaderCardEnvelope.self)
.map { ($0.body.cards, $0.body.nextPageHandle) }
.mapError { error -> Error in error.asNSError() }
.execute(onSuccess: success, onFailure: failure)
}
}

private func cardsEndpoint(for topics: [String], page: String? = nil, sortingOption: ReaderSortingOption = .noSorting, refreshCount: Int? = nil) -> String? {
var path = URLComponents(string: "read/tags/cards")
private func cardsEndpoint(with path: String,
topics: [String],
page: String? = nil,
sortingOption: ReaderSortingOption = .noSorting,
count: Int? = nil,
refreshCount: Int? = nil) -> String? {
var path = URLComponents(string: path)

path?.queryItems = topics.map { URLQueryItem(name: "tags[]", value: $0) }

if let page = page {
if let page {
path?.queryItems?.append(URLQueryItem(name: "page_handle", value: page))
}

if let sortingOption = sortingOption.queryValue {
path?.queryItems?.append(URLQueryItem(name: "sort", value: sortingOption))
}

if let refreshCount = refreshCount {
if let count {
path?.queryItems?.append(URLQueryItem(name: "count", value: String(count)))
}

if let refreshCount {
path?.queryItems?.append(URLQueryItem(name: "refresh", value: String(refreshCount)))
}

Expand Down
15 changes: 15 additions & 0 deletions WordPressKitTests/MockWordPressComRestApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class MockWordPressComRestApi: WordPressComRestApi {
@objc var successBlockPassedIn: ((AnyObject, HTTPURLResponse?) -> Void)?
@objc var failureBlockPassedIn: ((NSError, HTTPURLResponse?) -> Void)?

var performMethodCall: HTTPRequestBuilder.Method?

override func GET(_ URLString: String?, parameters: [String: AnyObject]?, success: @escaping ((AnyObject, HTTPURLResponse?) -> Void), failure: @escaping ((NSError, HTTPURLResponse?) -> Void)) -> Progress? {
getMethodCalled = true
URLStringPassedIn = URLString
Expand Down Expand Up @@ -44,6 +46,19 @@ class MockWordPressComRestApi: WordPressComRestApi {
return Progress()
}

override func perform<T: Decodable>(
_ method: HTTPRequestBuilder.Method,
URLString: String,
parameters: [String: AnyObject]? = nil,
fulfilling progress: Progress? = nil,
jsonDecoder: JSONDecoder? = nil,
type: T.Type = T.self
) async -> APIResult<T> {
performMethodCall = method
URLStringPassedIn = URLString
return .failure(.connection(.init(.cancelled)))
}

@objc func methodCalled() -> String {

var method = "Unknown"
Expand Down
193 changes: 181 additions & 12 deletions WordPressKitTests/ReaderPostServiceRemote+CardsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class ReaderPostServiceRemoteCardTests: RemoteTestCase, RESTTestable {
readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: getRestApi())
}

// MARK: - Tags fetch cards

// Return an array of cards
//
func testReturnCards() {
Expand Down Expand Up @@ -84,51 +86,66 @@ class ReaderPostServiceRemoteCardTests: RemoteTestCase, RESTTestable {
waitForExpectations(timeout: timeout, handler: nil)
}

func testHTTPMethod() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchCards(for: ["dogs"], success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertEqual(mockRemoteApi.performMethodCall, .get)
}

// Calls the API with the given page handle
//
func testCallAPIWithTheGivenPageHandle() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchCards(for: ["dogs"], page: "foobar", success: { _, _ in }, failure: { _ in })

XCTAssertTrue(mockRemoteApi.getMethodCalled)
readerPostServiceRemote.fetchCards(for: ["dogs"], page: "foobar", success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("&page_handle=foobar") ?? false)
}

// Calls the API with .popularity as the given sorting option
//
func testCallAPIWithPopularityAsTheGivenSortingOption() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchCards(for: [], sortingOption: .popularity, success: { _, _ in }, failure: { _ in })

XCTAssertTrue(mockRemoteApi.getMethodCalled)
readerPostServiceRemote.fetchCards(for: [], sortingOption: .popularity, success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=popularity") ?? false)
}

// Calls the API with .date as the given sorting option
//
func testCallAPIWithDateAsTheGivenSortingOption() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchCards(for: [], sortingOption: .date, success: { _, _ in }, failure: { _ in })

XCTAssertTrue(mockRemoteApi.getMethodCalled)
readerPostServiceRemote.fetchCards(for: [], sortingOption: .date, success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=date") ?? false)
}

// Calls the API without the given sorting option
//
func testCallAPIWithoutTheGivenSortingOption() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchCards(for: [], success: { _, _ in }, failure: { _ in })

XCTAssertTrue(mockRemoteApi.getMethodCalled)
readerPostServiceRemote.fetchCards(for: [], success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertFalse(mockRemoteApi.URLStringPassedIn?.contains("sort=") ?? true)
}

Expand All @@ -153,4 +170,156 @@ class ReaderPostServiceRemoteCardTests: RemoteTestCase, RESTTestable {

waitForExpectations(timeout: timeout, handler: nil)
}

// MARK: - Streams fetch cards

func testStreamsReturnCards() {
let expect = expectation(description: "Get cards successfully")
stubRemoteResponse("read/streams/discover?tags%5B%5D=dogs", filename: "reader-cards-success.json", contentType: .ApplicationJSON)

readerPostServiceRemote.fetchStreamCards(for: ["dogs"], success: { cards, _ in
XCTAssertTrue(cards.count == 10)
expect.fulfill()
}, failure: { _ in })

waitForExpectations(timeout: timeout, handler: nil)
}

func testStreamsReturnPosts() {
let expect = expectation(description: "Get cards successfully")
stubRemoteResponse("read/streams/discover?tags%5B%5D=cats", filename: "reader-cards-success.json", contentType: .ApplicationJSON)

readerPostServiceRemote.fetchStreamCards(for: ["cats"], success: { cards, _ in
let postCards = cards.filter { $0.type == .post }
XCTAssertTrue(postCards.allSatisfy { $0.post != nil })
expect.fulfill()
}, failure: { _ in })

waitForExpectations(timeout: timeout, handler: nil)
}

func testStreamsReturnCorrectCardType() {
let expect = expectation(description: "Get cards successfully")
stubRemoteResponse("read/streams/discover?tags%5B%5D=cats", filename: "reader-cards-success.json", contentType: .ApplicationJSON)

readerPostServiceRemote.fetchStreamCards(for: ["cats"], success: { cards, _ in
let postTypes = cards.map { $0.type }
let expectedPostTypes: [RemoteReaderCard.CardType] = [.interests, .sites, .post, .post, .post, .post, .post, .post, .post, .post]
XCTAssertTrue(postTypes == expectedPostTypes)
expect.fulfill()
}, failure: { _ in })

waitForExpectations(timeout: timeout, handler: nil)
}

func testStreamsReturnError() {
let expect = expectation(description: "Get cards successfully")
stubRemoteResponse("read/streams/discover?tags%5B%5D=cats", filename: "reader-cards-success.json", contentType: .ApplicationJSON, status: 503)

readerPostServiceRemote.fetchStreamCards(for: ["cats"], success: { _, _ in }, failure: { error in
XCTAssertNotNil(error)
expect.fulfill()
})

waitForExpectations(timeout: timeout, handler: nil)
}

func testStreamsReturnNextPageHandle() {
let expect = expectation(description: "Returns next page handle")
stubRemoteResponse("read/streams/discover?tags%5B%5D=dogs", filename: "reader-cards-success.json", contentType: .ApplicationJSON)

readerPostServiceRemote.fetchStreamCards(for: ["dogs"], success: { _, nextPageHandle in
XCTAssertTrue(nextPageHandle == "ZnJvbT0xMCZiZWZvcmU9MjAyMC0wNy0yNlQxMyUzQTU1JTNBMDMlMkIwMSUzQTAw")
expect.fulfill()
}, failure: { _ in })

waitForExpectations(timeout: timeout, handler: nil)
}

func testStreamsHTTPMethod() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchStreamCards(for: ["dogs"], success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertEqual(mockRemoteApi.performMethodCall, .get)
}

func testStreamsCallAPIWithTheGivenPageHandle() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchStreamCards(for: ["dogs"], page: "foobar", success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("&page_handle=foobar") ?? false)
}

func testStreamsCallAPIWithPopularityAsTheGivenSortingOption() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchStreamCards(for: [], sortingOption: .popularity, success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=popularity") ?? false)
}

func testStreamsCallAPIWithDateAsTheGivenSortingOption() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchStreamCards(for: [], sortingOption: .date, success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("sort=date") ?? false)
}

func testStreamsCallAPIWithoutTheGivenSortingOption() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)

readerPostServiceRemote.fetchStreamCards(for: [], success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertFalse(mockRemoteApi.URLStringPassedIn?.contains("sort=") ?? true)
}

func testStreamsCallAPIWithCountValue() {
let expect = expectation(description: "Executes fetch call")
let failure: (Error) -> Void = { _ in expect.fulfill() }
let readerPostServiceRemote = ReaderPostServiceRemote(wordPressComRestApi: mockRemoteApi)
let expectedCount = 5

readerPostServiceRemote.fetchStreamCards(for: ["dogs"], count: expectedCount, success: { _, _ in }, failure: failure)

waitForExpectations(timeout: timeout)
XCTAssertTrue(mockRemoteApi.URLStringPassedIn?.contains("&count=\(expectedCount)") ?? false)
}

func testStreamsPostsInCallAPIWithDateAsGivenSortOption() {
let expect = expectation(description: "Get cards sorted by date")
stubRemoteResponse("read/streams/discover?tags%5B%5D=cats&sort=date", filename: "reader-cards-success.json", contentType: .ApplicationJSON)

readerPostServiceRemote.fetchStreamCards(for: ["cats"], sortingOption: .date, success: { cards, _ in
let posts = cards.filter { $0.type == .post }
for i in 1..<posts.count {
guard let firstPostDate = posts[i-1].post?.sortDate,
let secondPostDate = posts[i].post?.sortDate,
firstPostDate > secondPostDate else {
XCTFail("Posts should be sorted by date, starting with most recent post")
return
}
}
expect.fulfill()
}, failure: { _ in })

waitForExpectations(timeout: timeout, handler: nil)
}
}

0 comments on commit d83d2fe

Please sign in to comment.