deep-linking-sample/Apps/Wikipedia/WMF Framework/ReadingListsAPIController.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

555 lines
25 KiB
Swift

import Foundation
import CocoaLumberjackSwift
internal let APIReadingListUpdateLimitForFullSyncFallback = 1000 // if we receive over this # of updated items, fall back to full sync
public enum APIReadingListError: String, Error, Equatable {
case generic = "readinglists-client-error-generic"
case notLoggedIn = "notloggedin"
case badtoken = "badtoken"
case notSetup = "readinglists-db-error-not-set-up"
case alreadySetUp = "readinglists-db-error-already-set-up"
case listLimit = "readinglists-db-error-list-limit"
case entryLimit = "readinglists-db-error-entry-limit"
case duplicateEntry = "readinglists-db-error-duplicate-page"
case needsFullSync = "readinglists-client-error-needs-full-sync"
case listDeleted = "readinglists-db-error-list-deleted"
case listEntryDeleted = "readinglists-db-error-list-entry-deleted"
case defaultListCannotBeUpdated = "readinglists-db-error-cannot-update-default-list"
case defaultListCannotBeDeleted = "readinglists-db-error-cannot-delete-default-list"
case noSuchProject = "readinglists-db-error-no-such-project"
case noSuchListEntry = "readinglists-db-error-no-such-list-entry"
case noSuchList = "readinglists-db-error-no-such-list"
case duplicateList = "readinglists-db-error-duplicate-list"
public var localizedDescription: String {
switch self {
case .listLimit:
return WMFLocalizedString("reading-list-api-error-list-limit", value: "This list is not synced because you have reached the limit for the number of synced lists.", comment: "You have too many lists.")
case .entryLimit:
return WMFLocalizedString("reading-list-api-error-entry-limit", value: "This entry is not synced because you have reached the limit for the number of entries in this list.", comment: "You have too many entries in this list.")
default:
return WMFLocalizedString("reading-list-api-error-generic", value: "An unexpected error occurred while syncing your reading lists.", comment: "An unexpected error occurred while syncing your reading lists.")
}
}
}
struct APIReadingLists: Codable {
let lists: [APIReadingList]
let next: String?
let since: String?
enum CodingKeys: String, CodingKey {
case lists
case next
case since = "continue-from"
}
}
public struct APIReadingList: Codable {
enum CodingKeys: String, CodingKey {
case id
case name
case description
case created
case updated
case deleted
case isDefault = "default"
}
public let id: Int64
let name: String
let description: String
let created: String
let updated: String
let deleted: Bool?
let isDefault: Bool
}
struct APIReadingListEntries: Codable {
let entries: [APIReadingListEntry]
let next: String?
}
public struct APIReadingListEntry: Codable {
let id: Int64
let project: String
let title: String
let created: String
let updated: String
let listId: Int64?
let deleted: Bool?
}
struct APIReadingListChanges: Codable {
let lists: [APIReadingList]?
let entries: [APIReadingListEntry]?
let next: String?
let since: String?
enum CodingKeys: String, CodingKey {
case lists
case entries
case next
case since = "continue-from"
}
}
struct APIReadingListErrorResponse: Codable {
let type: String?
let title: String
let method: String?
let detail: String?
}
enum APIReadingListRequestType: String {
case setup, teardown
}
/* Note that because the reading list API does not support language variants,
* the articleURL will always have a nil language variant.
*
* The RemoteReadingListArticleKey type is a type alias for String.
* Since ReadingListsSyncOperation handles remote entries that don't have a variant,
* and local entries that do have a variant, this type makes it more clear when
* a non-variant aware key is being used.
*
* Also, if the remote API adds variant support, it should be straightforward to
* update the type alias from String to WMFInMemoryURLKey.
*/
typealias RemoteReadingListArticleKey = String
extension APIReadingListEntry {
var articleURL: URL? {
guard let site = URL(string: project) else {
return nil
}
return site.wmf_URL(withTitle: title)
}
var articleKey: RemoteReadingListArticleKey? {
return articleURL?.wmf_databaseKey
}
}
public class ReadingListsAPIController: Fetcher {
private let builder = Configuration.current.pageContentServiceBuilder(withWikiHost: "en.wikipedia.org")
private let basePathComponents = ["data", "lists"]
var lastRequestType: APIReadingListRequestType?
fileprivate func get<T: Codable>(path: [String], queryParameters: [String: Any]? = nil, completionHandler: @escaping (T?, URLResponse?, Error?) -> Swift.Void) {
let key = UUID().uuidString
let components = builder.components(byAppending: basePathComponents + path, queryParameters: queryParameters)
guard
let task = session.jsonDecodableTaskWithDecodableError(with: components.url, method: .get, completionHandler: { (result: T?, errorResult: APIReadingListErrorResponse?, response, error) in
if let errorResult = errorResult, let error = APIReadingListError(rawValue: errorResult.title) {
completionHandler(nil, nil, error)
} else {
completionHandler(result, response, error)
}
self.untrack(taskFor: key)
}) else {
return
}
track(task: task, for: key)
task.resume()
}
fileprivate func requestWithCSRF(path: [String], method: Session.Request.Method, bodyParameters: [String: Any]? = nil, completion: @escaping ([String: Any]?, URLResponse?, Error?) -> Void) {
let components = builder.components(byAppending: basePathComponents + path)
requestMediaWikiAPIAuthToken(for: components.url, type: .csrf) { (result) in
switch result {
case .failure(let error):
completion(nil, nil, error)
case .success(let token):
let tokenQueryParameters = ["csrf_token": token.value]
var componentsWithToken = components
componentsWithToken.appendQueryParametersToPercentEncodedQuery(tokenQueryParameters)
let identifier = UUID().uuidString
let task = self.session.jsonDictionaryTask(with: componentsWithToken.url, method: method, bodyParameters: bodyParameters, completionHandler: { (result, response, error) in
defer {
self.untrack(taskFor: identifier)
}
if let apiErrorType = result?["title"] as? String, let apiError = APIReadingListError(rawValue: apiErrorType), apiError != .alreadySetUp {
DDLogDebug("RLAPI FAILED: \(method.stringValue) \(path) \(apiError)")
} else {
#if DEBUG
if let error = error {
DDLogDebug("RLAPI FAILED: \(method.stringValue) \(path) \(error)")
} else {
DDLogDebug("RLAPI: \(method.stringValue) \(path)")
}
#endif
}
completion(result, response, error)
})
self.track(task: task, for: identifier)
task?.resume()
}
}
}
fileprivate func post(path: [String], bodyParameters: [String: Any]? = nil, completion: @escaping ([String: Any]?, URLResponse?, Error?) -> Void) {
requestWithCSRF(path: path, method: .post, bodyParameters: bodyParameters, completion: completion)
}
fileprivate func delete(path: [String], completion: @escaping ([String: Any]?, URLResponse?, Error?) -> Void) {
requestWithCSRF(path: path, method: .delete, completion: completion)
}
fileprivate func put(path: [String], bodyParameters: [String: Any]? = nil, completion: @escaping ([String: Any]?, URLResponse?, Error?) -> Void) {
requestWithCSRF(path: path, method: .put, bodyParameters: bodyParameters, completion: completion)
}
@objc func setupReadingLists(completion: @escaping (Error?) -> Void) {
let requestType = APIReadingListRequestType.setup
post(path: [requestType.rawValue]) { (result, response, error) in
self.lastRequestType = requestType
completion(error)
}
}
@objc func teardownReadingLists(completion: @escaping (Error?) -> Void) {
let requestType = APIReadingListRequestType.teardown
post(path: [requestType.rawValue]) { (result, response, error) in
self.lastRequestType = requestType
completion(error)
}
}
/**
Creates a new reading list using the reading list API
- parameters:
- name: The name for the new list
- description: The description for the new list
- completion: Called after the request completes
- listID: The list ID if it was created
- error: Any error preventing list creation
*/
func createList(name: String, description: String?, completion: @escaping (_ listID: Int64?,_ error: Error?) -> Swift.Void ) {
let bodyParams = ["name": name.precomposedStringWithCanonicalMapping, "description": description ?? ""]
// empty string path is required to add the trailing slash, server 404s otherwise
post(path: [""], bodyParameters: bodyParams) { (result, response, error) in
guard let id = result?["id"] as? Int64 else {
completion(nil, error ?? ReadingListError.unableToCreateList)
return
}
completion(id, nil)
}
}
/**
Creates a new reading list using the reading list API
- parameters:
- lists: The names and descriptions for the new lists
- completion: Called after the request completes
- listIDs: The list IDs if they were created
- error: Any error preventing list creation
*/
func createLists(_ lists: [(name: String, description: String?)], completion: @escaping (_ listIDs: [(Int64?, Error?)]?,_ error: Error?) -> Swift.Void ) {
guard !lists.isEmpty else {
completion([], nil)
return
}
let bodyParams = ["batch": lists.map { ["name": $0.name.precomposedStringWithCanonicalMapping, "description": $0.description ?? ""] } ]
post(path: ["batch"], bodyParameters: bodyParams) { (result, response, error) in
guard let batch = result?["batch"] as? [[String: Any]] else {
guard lists.count > 1 else {
completion([(nil, error ?? APIReadingListError.generic)], nil)
return
}
DispatchQueue.global().async {
let taskGroup = WMFTaskGroup()
var listsByName: [String: (Int64?, Error?)] = [:]
for list in lists {
taskGroup.enter()
self.createList(name: list.name, description: list.description, completion: { (listID, error) in
taskGroup.leave()
listsByName[list.name] = (listID, error)
})
}
taskGroup.wait()
var listsOrErrors: [(Int64?, Error?)] = []
for list in lists {
guard let list = listsByName[list.name] else {
completion(nil, ReadingListError.unableToCreateList)
return
}
listsOrErrors.append(list)
}
completion(listsOrErrors, nil)
}
return
}
completion(batch.compactMap {
let id = $0["id"] as? Int64
var error: Error? = nil
if let errorString = $0["error"] as? String {
error = APIReadingListError(rawValue: errorString) ?? APIReadingListError.generic
}
return (id, error)
}, nil)
}
}
/**
Adds a new entry to a reading list using the reading list API
- parameters:
- listID: The list ID of the list that is getting an entry
- project: The project name of the new entry
- title: The title of the new entry
- completion: Called after the request completes
- entryID: The entry ID if it was created
- error: Any error preventing entry creation
*/
func addEntryToList(withListID listID: Int64, project: String, title: String, completion: @escaping (_ entryID: Int64?,_ error: Error?) -> Swift.Void ) {
let title = title.precomposedStringWithCanonicalMapping
let project = project.precomposedStringWithCanonicalMapping
let bodyParams = ["project": project, "title": title]
// "" for trailing slash is required, server 404s otherwise
post(path: ["\(listID)", "entries", ""], bodyParameters: bodyParams) { (result, response, error) in
if let apiError = error as? APIReadingListError {
switch apiError {
case .duplicateEntry:
// TODO: Remove when error response returns ID
self.getAllEntriesForReadingListWithID(readingListID: listID, completion: { (entries, error) in
guard let entry = entries.first(where: { (entry) -> Bool in entry.title == title && entry.project == project }) else {
completion(nil, error ?? ReadingListError.unableToAddEntry)
return
}
completion(entry.id, nil)
})
default:
completion(nil, apiError)
}
return
} else if let error = error {
completion(nil, error)
return
}
guard let id = result?["id"] as? Int64 else {
completion(nil, ReadingListError.unableToAddEntry)
return
}
completion(id, nil)
}
}
/**
Adds a new entry to a reading list using the reading list API
- parameters:
- listID: The list ID of the list that is getting an entry
- entries: The project and titles for each new entry
- completion: Called after the request completes
- entryIDs: The entry IDs if they were created
- error: Any error preventing entry creation
*/
func addEntriesToList(withListID listID: Int64, entries: [(project: String, title: String)], completion: @escaping (_ entryIDs: [(Int64?, Error?)]?,_ error: Error?) -> Swift.Void ) {
guard !entries.isEmpty else {
completion([], nil)
return
}
let bodyParams = ["batch": entries.map { ["project": $0.project.precomposedStringWithCanonicalMapping, "title": $0.title.precomposedStringWithCanonicalMapping] } ]
post(path: ["\(listID)", "entries", "batch"], bodyParameters: bodyParams) { (result, response, error) in
if let apiError = error as? APIReadingListError, apiError != .listDeleted {
guard entries.count > 1 else {
completion([(nil, apiError)], nil)
return
}
self.getAllEntriesForReadingListWithID(readingListID: listID, completion: { (remoteEntries, getAllEntriesError) in
var remoteEntriesByProjectAndTitle: [String: [String: APIReadingListEntry]] = [:]
for remoteEntry in remoteEntries {
remoteEntriesByProjectAndTitle[remoteEntry.project.precomposedStringWithCanonicalMapping, default: [:]][remoteEntry.title.precomposedStringWithCanonicalMapping] = remoteEntry
}
let results: [(Int64?, Error?)] = entries.map {
let project = $0.project.precomposedStringWithCanonicalMapping
let title = $0.title.precomposedStringWithCanonicalMapping
guard let remoteEntry = remoteEntriesByProjectAndTitle[project]?[title] else {
return (nil, apiError == .entryLimit ? apiError : APIReadingListError.generic)
}
return (remoteEntry.id, nil)
}
completion(results, nil)
})
return
} else if let error = error {
completion(nil, error)
return
}
guard let result = result else {
completion(nil, ReadingListError.unableToAddEntry)
return
}
guard let batch = result["batch"] as? [[String: Any]] else {
DDLogError("Unexpected result: \(result)")
completion(nil, ReadingListError.unableToAddEntry)
return
}
completion(batch.compactMap {
let id = $0["id"] as? Int64
var error: Error? = nil
if let errorString = $0["error"] as? String {
error = APIReadingListError(rawValue: errorString) ?? APIReadingListError.generic
}
return (id, error)
}, nil)
}
}
/**
Remove entry from reading list using the reading list API
- parameters:
- listID: The list ID of the list that will have an entry removed
- entryID: The entry ID to remove from the list
- completion: Called after the request completes
- error: Any error preventing entry deletion
*/
func removeEntry(withEntryID entryID: Int64, fromListWithListID listID: Int64, completion: @escaping (_ error: Error?) -> Swift.Void ) {
delete(path: ["\(listID)", "entries", "\(entryID)"]) { (result, response, error) in
guard error == nil else {
completion(error ?? ReadingListError.unableToRemoveEntry)
return
}
completion(nil)
}
}
/**
Deletes a reading list using the reading list API
- parameters:
- listID: The list ID of the list to delete
- completion: Called after the request completes
- error: Any error preventing list deletion
*/
func deleteList(withListID listID: Int64, completion: @escaping (_ error: Error?) -> Swift.Void ) {
delete(path: ["\(listID)"]) { (result, response, error) in
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
completion(error ?? ReadingListError.unableToDeleteList)
return
}
completion(nil)
}
}
/**
Updates a reading list using the reading list API
- parameters:
- listID: The list ID of the list to update
- name: The name of the list
- description: The description of the list
- completion: Called after the request completes
- error: Any error preventing list update
*/
func updateList(withListID listID: Int64, name: String, description: String?, completion: @escaping (_ error: Error?) -> Swift.Void ) {
put(path: ["\(listID)"], bodyParameters: ["name": name, "description": description ?? ""]) { (result, response, error) in
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
completion(error ?? ReadingListError.unableToDeleteList)
return
}
completion(nil)
}
}
/**
Gets updated lists and entries list API
- parameters:
- since: The continuation token for this whole list of updates. Lets the server know the current state of the device. Currently an ISO 8601 date string
- next: The continuation within this whole list of updates (since is the start of the whole list, next is the next page)
- nextSince: The paramater to use for "since" the next time you call this method to get the updates that have happened since this update.
- lists: Lists to append to the results
- entries: Entries to append to the results
- lists: All updated lists
- entries: All updated entries
- since: The date to use for the next update call
- error: Any error
*/
func updatedListsAndEntries(since: String, next: String? = nil, nextSince: String? = nil, lists: [APIReadingList] = [], entries: [APIReadingListEntry] = [], completion: @escaping (_ lists: [APIReadingList], _ entries: [APIReadingListEntry], _ since: String?, _ error: Error?) -> Swift.Void ) {
var queryParameters: [String: Any]? = nil
if let next = next {
queryParameters = ["next": next]
}
get(path: ["changes", "since", "\(since)"], queryParameters: queryParameters) { (result: APIReadingListChanges?, response, error) in
guard let result = result, let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
completion([], [], nil, error ?? ReadingListError.generic)
return
}
var combinedLists = lists
if let lists = result.lists {
combinedLists.append(contentsOf: lists)
}
var combinedEntries = entries
if let entries = result.entries {
combinedEntries.append(contentsOf: entries)
}
let nextSince = nextSince ?? result.since
if let next = result.next {
if combinedLists.count + combinedEntries.count > APIReadingListUpdateLimitForFullSyncFallback {
completion([], [], nil, APIReadingListError.needsFullSync)
} else {
self.updatedListsAndEntries(since: since, next: next, nextSince: nextSince, lists: combinedLists, entries: combinedEntries, completion: completion)
}
} else {
completion(combinedLists, combinedEntries, nextSince, nil)
}
}
}
/**
Gets all reading lists from the API
- parameters:
- next: Optional continuation token for this list of results
- lists: Lists to append to the results
- lists: All lists
- since: The string to use for the next /changes/since call
- error: Any error
*/
public func getAllReadingLists(next: String? = nil, nextSince: String? = nil, lists: [APIReadingList] = [], completion: @escaping ([APIReadingList], String?, Error?) -> Swift.Void ) {
var queryParameters: [String: Any]? = nil
if let next = next {
queryParameters = ["next": next]
}
// empty string path is required to add the trailing slash, server 404s otherwise
get(path: [""], queryParameters: queryParameters) { (apiListsResponse: APIReadingLists?, response, error) in
guard let apiListsResponse = apiListsResponse else {
completion([], nil, error)
return
}
var combinedList = lists
combinedList.append(contentsOf: apiListsResponse.lists)
let nextSince = nextSince ?? apiListsResponse.since
if let next = apiListsResponse.next {
self.getAllReadingLists(next: next, nextSince: nextSince, lists: combinedList, completion: completion)
} else {
completion(combinedList, nextSince, nil)
}
}
}
public func getAllEntriesForReadingListWithID(next: String? = nil, entries: [APIReadingListEntry] = [], readingListID: Int64, completion: @escaping ([APIReadingListEntry], Error?) -> Swift.Void ) {
var queryParameters: [String: Any]? = nil
if let next = next {
queryParameters = ["next": next]
}
// "" for trailing slash is required, server 404s otherwise
get(path: ["\(readingListID)", "entries", ""], queryParameters: queryParameters) { (apiEntriesResponse: APIReadingListEntries?, response, error) in
guard let apiEntriesResponse = apiEntriesResponse else {
completion([], error)
return
}
var combinedList = entries
combinedList.append(contentsOf: apiEntriesResponse.entries)
if let next = apiEntriesResponse.next {
self.getAllEntriesForReadingListWithID(next: next, entries: combinedList, readingListID: readingListID, completion: completion)
} else {
completion(combinedList, nil)
}
}
}
}