Use response headers for pagination

This commit is contained in:
Justin Mazzocchi 2020-09-23 18:33:13 -07:00
parent 012f970bdb
commit 3328306c44
No known key found for this signature in database
GPG key ID: E223E6937AAFB01C
8 changed files with 124 additions and 66 deletions

View file

@ -4,63 +4,51 @@ import Combine
import Foundation import Foundation
public enum HTTPError: Error { public enum HTTPError: Error {
case invalidStatusCode(HTTPURLResponse) case nonHTTPURLResponse(data: Data, response: URLResponse)
case invalidStatusCode(data: Data, response: HTTPURLResponse)
} }
open class HTTPClient { open class HTTPClient {
public let decoder: JSONDecoder
private let session: URLSession private let session: URLSession
private let decoder: JSONDecoder
public init(session: URLSession, decoder: JSONDecoder) { public init(session: URLSession, decoder: JSONDecoder) {
self.session = session self.session = session
self.decoder = decoder self.decoder = decoder
} }
open func dataTaskPublisher<T: DecodableTarget>(
_ target: T) -> AnyPublisher<(data: Data, response: HTTPURLResponse), Error> {
if let protocolClasses = session.configuration.protocolClasses {
for protocolClass in protocolClasses {
(protocolClass as? TargetProcessing.Type)?.process(target: target)
}
}
return session.dataTaskPublisher(for: target.urlRequest())
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw HTTPError.nonHTTPURLResponse(data: data, response: response)
}
guard Self.validStatusCodes.contains(httpResponse.statusCode) else {
throw HTTPError.invalidStatusCode(data: data, response: httpResponse)
}
return (data, httpResponse)
}
.eraseToAnyPublisher()
}
open func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> { open func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> {
dataTaskPublisher(target) dataTaskPublisher(target)
.map(\.data) .map(\.data)
.decode(type: T.ResultType.self, decoder: decoder) .decode(type: T.ResultType.self, decoder: decoder)
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
public func request<T: DecodableTarget, E: Error & Decodable>(
_ target: T,
decodeErrorsAs errorType: E.Type) -> AnyPublisher<T.ResultType, Error> {
let decoder = self.decoder
return dataTaskPublisher(target)
.tryMap { result -> Data in
if
let response = result.response as? HTTPURLResponse,
!Self.validStatusCodes.contains(response.statusCode) {
if let decodedError = try? decoder.decode(E.self, from: result.data) {
throw decodedError
} else {
throw HTTPError.invalidStatusCode(response)
}
} }
return result.data public extension HTTPClient {
}
.decode(type: T.ResultType.self, decoder: decoder)
.eraseToAnyPublisher()
}
}
private extension HTTPClient {
static let validStatusCodes = 200..<300 static let validStatusCodes = 200..<300
func dataTaskPublisher<T: DecodableTarget>(_ target: T) -> URLSession.DataTaskPublisher {
if let protocolClasses = session.configuration.protocolClasses {
for protocolClass in protocolClasses {
(protocolClass as? TargetProcessing.Type)?.process(target: target)
}
}
return session.dataTaskPublisher(for: target.urlRequest())
// return session.request(target.urlRequest())
// .validate()
// .publishDecodable(type: T.ResultType.self, queue: session.rootQueue, decoder: decoder)
}
} }

View file

@ -35,6 +35,7 @@ public extension Target {
if let jsonBody = jsonBody { if let jsonBody = jsonBody {
urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: jsonBody) urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: jsonBody)
urlRequest.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
} }
return urlRequest return urlRequest

View file

@ -21,8 +21,7 @@ public struct Paged<T: Endpoint> {
} }
extension Paged: Endpoint { extension Paged: Endpoint {
public typealias ResultType = T.ResultType public typealias ResultType = PagedResult<T.ResultType>
// public typealias ResultType = PagedResult<T.ResultType>
public var APIVersion: String { endpoint.APIVersion } public var APIVersion: String { endpoint.APIVersion }
@ -49,8 +48,13 @@ extension Paged: Endpoint {
public var headers: [String: String]? { endpoint.headers } public var headers: [String: String]? { endpoint.headers }
} }
//public struct PagedResult<T: Decodable>: Decodable { public struct PagedResult<T: Decodable>: Decodable {
// public let result: T public struct Info: Decodable {
// public let maxID: String? public let maxID: String?
// public let sinceID: String? public let minID: String?
//} public let sinceID: String?
}
public let result: T
public let info: Info
}

View file

@ -14,15 +14,72 @@ public final class MastodonAPIClient: HTTPClient {
super.init(session: session, decoder: MastodonDecoder()) super.init(session: session, decoder: MastodonDecoder())
} }
public override func request<T: DecodableTarget>(_ target: T) -> AnyPublisher<T.ResultType, Error> { public override func dataTaskPublisher<T: DecodableTarget>(
super.request(target, decodeErrorsAs: APIError.self) _ target: T) -> AnyPublisher<(data: Data, response: HTTPURLResponse), Error> {
super.dataTaskPublisher(target)
.mapError { [weak self] error -> Error in
if case let HTTPError.invalidStatusCode(data, _) = error,
let apiError = try? self?.decoder.decode(APIError.self, from: data) {
return apiError
}
return error
}
.eraseToAnyPublisher()
} }
} }
extension MastodonAPIClient { extension MastodonAPIClient {
public func request<E: Endpoint>(_ endpoint: E) -> AnyPublisher<E.ResultType, Error> { public func request<E: Endpoint>(_ endpoint: E) -> AnyPublisher<E.ResultType, Error> {
super.request( dataTaskPublisher(target(endpoint: endpoint))
MastodonAPITarget(baseURL: instanceURL, endpoint: endpoint, accessToken: accessToken), .map(\.data)
decodeErrorsAs: APIError.self) .decode(type: E.ResultType.self, decoder: decoder)
.eraseToAnyPublisher()
}
public func pagedRequest<E: Endpoint>(
_ endpoint: E,
maxID: String? = nil,
minID: String? = nil,
sinceID: String? = nil,
limit: Int? = nil) -> AnyPublisher<PagedResult<E.ResultType>, Error> {
let pagedTarget = target(endpoint: Paged(endpoint, maxID: maxID, minID: minID, sinceID: sinceID, limit: limit))
let dataTask = dataTaskPublisher(pagedTarget).share()
let decoded = dataTask.map(\.data).decode(type: E.ResultType.self, decoder: decoder)
let info = dataTask.map { _, response -> PagedResult<E.ResultType>.Info in
var maxID: String?
var minID: String?
var sinceID: String?
if let links = response.value(forHTTPHeaderField: "Link") {
let queryItems = Self.linkDataDetector.matches(
in: links,
range: .init(links.startIndex..<links.endIndex, in: links))
.compactMap { match -> [URLQueryItem]? in
guard let url = match.url else { return nil }
return URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems
}
.reduce([], +)
maxID = queryItems.first { $0.name == "max_id" }?.value
minID = queryItems.first { $0.name == "min_id" }?.value
sinceID = queryItems.first { $0.name == "since_id" }?.value
}
return PagedResult.Info(maxID: maxID, minID: minID, sinceID: sinceID)
}
return decoded.zip(info).map(PagedResult.init(result:info:)).eraseToAnyPublisher()
}
}
private extension MastodonAPIClient {
// swiftlint:disable force_try
static let linkDataDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
// swiftlint:enable force_try
func target<E: Endpoint>(endpoint: E) -> MastodonAPITarget<E> {
MastodonAPITarget(baseURL: instanceURL, endpoint: endpoint, accessToken: accessToken)
} }
} }

View file

@ -8,7 +8,7 @@ import MastodonAPI
public struct StatusListService { public struct StatusListService {
public let statusSections: AnyPublisher<[[Status]], Error> public let statusSections: AnyPublisher<[[Status]], Error>
public let paginates: Bool public let nextPageMaxIDs: AnyPublisher<String?, Never>
public let contextParentID: String? public let contextParentID: String?
public let title: String? public let title: String?
@ -35,15 +35,18 @@ extension StatusListService {
title = "#".appending(tag) title = "#".appending(tag)
} }
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
self.init(statusSections: contentDatabase.statusesObservation(timeline: timeline), self.init(statusSections: contentDatabase.statusesObservation(timeline: timeline),
paginates: true, nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
contextParentID: nil, contextParentID: nil,
title: title, title: title,
filterContext: filterContext, filterContext: filterContext,
mastodonAPIClient: mastodonAPIClient, mastodonAPIClient: mastodonAPIClient,
contentDatabase: contentDatabase) { maxID, minID in contentDatabase: contentDatabase) { maxID, minID in
mastodonAPIClient.request(Paged(timeline.endpoint, maxID: maxID, minID: minID)) mastodonAPIClient.pagedRequest(timeline.endpoint, maxID: maxID, minID: minID)
.flatMap { contentDatabase.insert(statuses: $0, timeline: timeline) } .handleEvents(receiveOutput: { nextPageMaxIDsSubject.send($0.info.maxID) })
.flatMap { contentDatabase.insert(statuses: $0.result, timeline: timeline) }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
@ -53,11 +56,13 @@ extension StatusListService {
collection: CurrentValueSubject<AccountStatusCollection, Never>, collection: CurrentValueSubject<AccountStatusCollection, Never>,
mastodonAPIClient: MastodonAPIClient, mastodonAPIClient: MastodonAPIClient,
contentDatabase: ContentDatabase) { contentDatabase: ContentDatabase) {
let nextPageMaxIDsSubject = PassthroughSubject<String?, Never>()
self.init( self.init(
statusSections: collection statusSections: collection
.flatMap { contentDatabase.statusesObservation(accountID: accountID, collection: $0) } .flatMap { contentDatabase.statusesObservation(accountID: accountID, collection: $0) }
.eraseToAnyPublisher(), .eraseToAnyPublisher(),
paginates: true, nextPageMaxIDs: nextPageMaxIDsSubject.eraseToAnyPublisher(),
contextParentID: nil, contextParentID: nil,
title: nil, title: nil,
filterContext: .account, filterContext: .account,
@ -83,8 +88,9 @@ extension StatusListService {
excludeReplies: excludeReplies, excludeReplies: excludeReplies,
onlyMedia: onlyMedia, onlyMedia: onlyMedia,
pinned: false) pinned: false)
return mastodonAPIClient.request(Paged(endpoint, maxID: maxID, minID: minID)) return mastodonAPIClient.pagedRequest(endpoint, maxID: maxID, minID: minID)
.flatMap { contentDatabase.insert(statuses: $0, accountID: accountID, collection: collection.value) } .handleEvents(receiveOutput: { nextPageMaxIDsSubject.send($0.info.maxID) })
.flatMap { contentDatabase.insert(statuses: $0.result, accountID: accountID, collection: collection.value) }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }
} }
@ -113,7 +119,7 @@ public extension StatusListService {
func contextService(statusID: String) -> Self { func contextService(statusID: String) -> Self {
Self(statusSections: contentDatabase.contextObservation(parentID: statusID), Self(statusSections: contentDatabase.contextObservation(parentID: statusID),
paginates: false, nextPageMaxIDs: Empty().eraseToAnyPublisher(),
contextParentID: statusID, contextParentID: statusID,
title: nil, title: nil,
filterContext: .thread, filterContext: .thread,

View file

@ -147,11 +147,10 @@ class CollectionViewController: UITableViewController {
extension CollectionViewController: UITableViewDataSourcePrefetching { extension CollectionViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
guard guard
viewModel.paginates, let maxID = viewModel.nextPageMaxID,
let indexPath = indexPaths.last, let indexPath = indexPaths.last,
indexPath.section == dataSource.numberOfSections(in: tableView) - 1, indexPath.section == dataSource.numberOfSections(in: tableView) - 1,
indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1, indexPath.row == dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section) - 1
let maxID = dataSource.itemIdentifier(for: indexPath)?.id
else { return } else { return }
viewModel.request(maxID: maxID, minID: nil) viewModel.request(maxID: maxID, minID: nil)

View file

@ -9,7 +9,7 @@ public protocol CollectionViewModel {
var alertItems: AnyPublisher<AlertItem, Never> { get } var alertItems: AnyPublisher<AlertItem, Never> { get }
var loading: AnyPublisher<Bool, Never> { get } var loading: AnyPublisher<Bool, Never> { get }
var navigationEvents: AnyPublisher<NavigationEvent, Never> { get } var navigationEvents: AnyPublisher<NavigationEvent, Never> { get }
var paginates: Bool { get } var nextPageMaxID: String? { get }
var maintainScrollPositionOfItem: CollectionItem? { get } var maintainScrollPositionOfItem: CollectionItem? { get }
func request(maxID: String?, minID: String?) func request(maxID: String?, minID: String?)
func itemSelected(_ item: CollectionItem) func itemSelected(_ item: CollectionItem)

View file

@ -9,6 +9,7 @@ public class StatusListViewModel: ObservableObject {
@Published public private(set) var items = [[CollectionItem]]() @Published public private(set) var items = [[CollectionItem]]()
@Published public var alertItem: AlertItem? @Published public var alertItem: AlertItem?
public let navigationEvents: AnyPublisher<NavigationEvent, Never> public let navigationEvents: AnyPublisher<NavigationEvent, Never>
public private(set) var nextPageMaxID: String?
public private(set) var maintainScrollPositionOfItem: CollectionItem? public private(set) var maintainScrollPositionOfItem: CollectionItem?
private var statuses = [String: Status]() private var statuses = [String: Status]()
@ -36,6 +37,10 @@ public class StatusListViewModel: ObservableObject {
.assignErrorsToAlertItem(to: \.alertItem, on: self) .assignErrorsToAlertItem(to: \.alertItem, on: self)
.map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .status) } } } .map { $0.map { $0.map { CollectionItem(id: $0.id, kind: .status) } } }
.assign(to: &$items) .assign(to: &$items)
statusListService.nextPageMaxIDs
.sink { [weak self] in self?.nextPageMaxID = $0 }
.store(in: &cancellables)
} }
public var title: AnyPublisher<String?, Never> { Just(statusListService.title).eraseToAnyPublisher() } public var title: AnyPublisher<String?, Never> { Just(statusListService.title).eraseToAnyPublisher() }
@ -96,8 +101,6 @@ extension StatusListViewModel: CollectionViewModel {
} }
public extension StatusListViewModel { public extension StatusListViewModel {
var paginates: Bool { statusListService.paginates }
var contextParentID: String? { statusListService.contextParentID } var contextParentID: String? { statusListService.contextParentID }
func statusViewModel(id: String) -> StatusViewModel? { func statusViewModel(id: String) -> StatusViewModel? {