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(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) } } } }