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

515 lines
23 KiB
Swift

import Foundation
// Base class for combining a Session and Configuration to make network requests
// Session handles constructing and making requests, Configuration handles url structure for the current target
// TODO: Standardize on returning CancellationKey or URLSessionTask
// TODO: Centralize cancellation and remove other cancellation implementations (ReadingListsAPI)
// TODO: Think about utilizing a request buildler instead of so many separate functions
// TODO: Utilize Result type where possible (Swift only)
@objc(WMFFetcher)
open class Fetcher: NSObject {
@objc public let configuration: Configuration
@objc public let session: Session
public typealias CancellationKey = String
private var tasks = [String: URLSessionTask]()
private let semaphore = DispatchSemaphore.init(value: 1)
@objc override public convenience init() {
let dataStore = MWKDataStore.shared()
self.init(session: dataStore.session, configuration: dataStore.configuration)
}
@objc required public init(session: Session, configuration: Configuration) {
self.session = session
self.configuration = configuration
}
@discardableResult public func requestMediaWikiAPIAuthToken(for URL: URL?, type: TokenType, cancellationKey: CancellationKey? = nil, completionHandler: @escaping (Result<Token, Error>) -> Swift.Void) -> URLSessionTask? {
let parameters = [
"action": "query",
"meta": "tokens",
"type": type.stringValue,
"format": "json"
]
return performMediaWikiAPIPOST(for: URL, with: parameters) { (result, response, error) in
if let error = error {
completionHandler(Result.failure(error))
return
}
guard
let query = result?["query"] as? [String: Any],
let tokens = query["tokens"] as? [String: Any],
let tokenValue = tokens[type.stringValue + "token"] as? String
else {
completionHandler(Result.failure(RequestError.unexpectedResponse))
return
}
guard !tokenValue.isEmpty else {
completionHandler(Result.failure(RequestError.unexpectedResponse))
return
}
completionHandler(Result.success(Token(value: tokenValue, type: type)))
}
}
@objc(requestMediaWikiAPIAuthToken:withType:cancellationKey:completionHandler:)
@discardableResult public func requestMediaWikiAPIAuthToken(for URL: URL?, with type: TokenType, cancellationKey: CancellationKey? = nil, completionHandler: @escaping (Token?, Error?) -> Swift.Void) -> URLSessionTask? {
return requestMediaWikiAPIAuthToken(for: URL, type: type, cancellationKey: cancellationKey) { (result) in
switch result {
case .failure(let error):
completionHandler(nil, error)
case .success(let token):
completionHandler(token, nil)
}
}
}
@objc(performTokenizedMediaWikiAPIPOSTWithTokenType:toURL:withBodyParameters:cancellationKey:reattemptLoginOn401Response:completionHandler:)
@discardableResult public func performTokenizedMediaWikiAPIPOST(tokenType: TokenType = .csrf, to URL: URL?, with bodyParameters: [String: String]?, cancellationKey: CancellationKey? = nil, reattemptLoginOn401Response: Bool = true, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> CancellationKey? {
let key = cancellationKey ?? UUID().uuidString
let task = requestMediaWikiAPIAuthToken(for: URL, type: tokenType, cancellationKey: key) { (result) in
switch result {
case .failure(let error):
completionHandler(nil, nil, error)
self.untrack(taskFor: key)
case .success(let token):
var mutableBodyParameters = bodyParameters ?? [:]
mutableBodyParameters[tokenType.parameterName] = token.value
self.performMediaWikiAPIPOST(for: URL, with: mutableBodyParameters, cancellationKey: key, reattemptLoginOn401Response: reattemptLoginOn401Response, completionHandler: completionHandler)
}
}
track(task: task, for: key)
return key
}
@objc(performMediaWikiAPIPOSTForURL:withBodyParameters:cancellationKey:reattemptLoginOn401Response:completionHandler:)
@discardableResult public func performMediaWikiAPIPOST(for URL: URL?, with bodyParameters: [String: String]?, cancellationKey: CancellationKey? = nil, reattemptLoginOn401Response: Bool = true, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> URLSessionTask? {
let url = configuration.mediaWikiAPIURLForURL(URL, with: nil)
let key = cancellationKey ?? UUID().uuidString
let task = session.postFormEncodedBodyParametersToURL(to: url, bodyParameters: bodyParameters, reattemptLoginOn401Response:reattemptLoginOn401Response) { (result, response, error) in
completionHandler(result, response, error)
self.untrack(taskFor: key)
}
track(task: task, for: key)
return task
}
@objc(performMediaWikiAPIGETForURL:withQueryParameters:cancellationKey:completionHandler:)
@discardableResult public func performMediaWikiAPIGET(for URL: URL?, with queryParameters: [String: Any]?, cancellationKey: CancellationKey?, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> URLSessionTask? {
let url = configuration.mediaWikiAPIURLForURL(URL, with: queryParameters)
let key = cancellationKey ?? UUID().uuidString
let task = session.getJSONDictionary(from: url) { (result, response, error) in
let returnError = error ?? RequestError.from(result?["error"] as? [String : Any])
completionHandler(result, response, returnError)
self.untrack(taskFor: key)
}
track(task: task, for: key)
return task
}
@objc(performMediaWikiAPIGETForURLRequest:cancellationKey:completionHandler:)
@discardableResult public func performMediaWikiAPIGET(for urlRequest: URLRequest, cancellationKey: CancellationKey?, completionHandler: @escaping ([String: Any]?, HTTPURLResponse?, Error?) -> Swift.Void) -> URLSessionTask? {
let key = cancellationKey ?? UUID().uuidString
let task = session.getJSONDictionary(from: urlRequest) { (result, response, error) in
let returnError = error ?? RequestError.from(result?["error"] as? [String : Any])
completionHandler(result, response, returnError)
self.untrack(taskFor: key)
}
track(task: task, for: key)
return task
}
// MARK: Resolving MediaWiki Errors For Display
/// Chain from MediaWiki API response if you want to resolve a set of error messages into a full html string for display. Use this method for raw dictionary responses. For Swift Codable responses, use resolveMediaWikiBlockedError(from apiErrors: [MediaWikiAPIError]...).
/// - Parameters:
/// - result: Serialized dictionary from MediaWiki API response
/// - completionHandler: Completion handler called when full html is determined, which is packaged up in a MediaWikiAPIDisplayError object.
@objc(resolveMediaWikiApiErrorFromResult:siteURL:completionHandler:)
func resolveMediaWikiApiErrorFromResult(_ result: [String: Any], siteURL: URL, completionHandler: @escaping (MediaWikiAPIDisplayError?) -> Void) {
var apiErrors: [MediaWikiAPIError] = []
guard let errorsDict = result["errors"] as? [[String: Any]] else {
completionHandler(nil)
return
}
for errorDict in errorsDict {
if let error = MediaWikiAPIError(dict: errorDict) {
apiErrors.append(error)
}
}
resolveMediaWikiError(from: apiErrors, siteURL: siteURL, completion: completionHandler)
}
/// Chain from MediaWiki API response if you want to resolve a set of error messages into a full html string for display. Use from Swift Codable responses that capture a collection of [MediaWikiAPIError] items.
/// - Parameters:
/// - apiErrors: Decoded MediaWikiAPIError items from API response
/// - completion: Called when full html is determined, which is packaged up in a MediaWikiAPIDisplayError object.
public func resolveMediaWikiError(from apiErrors: [MediaWikiAPIError], siteURL: URL, completion: @escaping (MediaWikiAPIDisplayError?) -> Void) {
let protectedPageError = apiErrors.filter { $0.code.contains("protectedpage") }
let blockedApiErrors = apiErrors.filter { $0.code.contains("block") }
let firstBlockedApiErrorWithInfo = blockedApiErrors.first(where: { $0.data?.blockInfo != nil })
let fallbackBlockedApiError = blockedApiErrors.first(where: { !$0.html.isEmpty })
let firstAbuseFilterError = apiErrors.first(where: { $0.code.contains("abusefilter") && !$0.html.isEmpty })
let fallbackCompletion: () -> Void = {
guard let fallbackBlockedApiError else {
guard let firstAbuseFilterError else {
completion(nil)
return
}
let displayError = MediaWikiAPIDisplayError(messageHtml: firstAbuseFilterError.html, linkBaseURL: siteURL, code: firstAbuseFilterError.code)
completion(displayError)
return
}
let displayError = MediaWikiAPIDisplayError(messageHtml: fallbackBlockedApiError.html, linkBaseURL: siteURL, code: fallbackBlockedApiError.code)
completion(displayError)
return
}
if let blockedApiError = firstBlockedApiErrorWithInfo,
let blockedApiInfo = blockedApiError.data?.blockInfo {
resolveMediaWikiApiBlockError(siteURL: siteURL, code: blockedApiError.code, html: blockedApiError.html, blockInfo: blockedApiInfo) { displayError in
guard let displayError = displayError else {
fallbackCompletion()
return
}
completion(displayError)
}
} else if let protectedPageError = protectedPageError.first(where: {!$0.html.isEmpty}) {
let displayError = MediaWikiAPIDisplayError(messageHtml: protectedPageError.html, linkBaseURL: siteURL, code: protectedPageError.code)
completion(displayError)
} else {
fallbackCompletion()
}
}
private func resolveMediaWikiApiBlockError(siteURL: URL, code: String, html: String, blockInfo: MediaWikiAPIError.Data.BlockInfo, completionHandler: @escaping (MediaWikiAPIDisplayError?) -> Void) {
// First turn blockReason into html, if needed
let group = DispatchGroup()
var blockReasonHtml: String?
var templateHtml: String?
var templateSiteURL: URL?
group.enter()
parseBlockReason(siteURL: siteURL, blockReason: blockInfo.blockReason) { text in
blockReasonHtml = text
group.leave()
}
group.enter()
fetchBlockedTextTemplate(isPartial: blockInfo.blockPartial, siteURL: siteURL) { text, siteURL in
templateHtml = text
templateSiteURL = siteURL
group.leave()
}
group.notify(queue: DispatchQueue.global(qos: .default)) {
guard var templateHtml = templateHtml else {
completionHandler(nil)
return
}
let linkBaseURL = templateSiteURL ?? siteURL
// Replace encoded placeholders first, before replacing them with blocked text.
templateHtml = templateHtml.replacingOccurrences(of: "%241", with: "$1")
templateHtml = templateHtml.replacingOccurrences(of: "%242", with: "$2")
templateHtml = templateHtml.replacingOccurrences(of: "%243", with: "") // stripped out below
templateHtml = templateHtml.replacingOccurrences(of: "%244", with: "") // stripped out below
templateHtml = templateHtml.replacingOccurrences(of: "%245", with: "$5")
templateHtml = templateHtml.replacingOccurrences(of: "%246", with: "$6")
templateHtml = templateHtml.replacingOccurrences(of: "%247", with: "$7")
templateHtml = templateHtml.replacingOccurrences(of: "%248", with: "$8")
// Replace placeholders with blocked text
templateHtml = templateHtml.replacingOccurrences(of: "$1", with: blockInfo.blockedBy)
if let blockReasonHtml {
templateHtml = templateHtml.replacingOccurrences(of: "$2", with: blockReasonHtml)
}
templateHtml = templateHtml.replacingOccurrences(of: "$3", with: "") // IP Address
templateHtml = templateHtml.replacingOccurrences(of: "$4", with: "") // unknown parameter (unused?)
templateHtml = templateHtml.replacingOccurrences(of: "$5", with: String(blockInfo.blockID))
let blockExpiryDisplayDate = self.blockedDateForDisplay(iso8601DateString: blockInfo.blockExpiry, siteURL: linkBaseURL)
templateHtml = templateHtml.replacingOccurrences(of: "$6", with: blockExpiryDisplayDate)
let username = MWKDataStore.shared().authenticationManager.loggedInUsername ?? ""
templateHtml = templateHtml.replacingOccurrences(of: "$7", with: username)
let blockedTimestampDisplayDate = self.blockedDateForDisplay(iso8601DateString: blockInfo.blockedTimestamp, siteURL: linkBaseURL)
templateHtml = templateHtml.replacingOccurrences(of: "$8", with: blockedTimestampDisplayDate)
let displayError = MediaWikiAPIDisplayError(messageHtml: templateHtml, linkBaseURL: linkBaseURL, code: code)
completionHandler(displayError)
}
}
private func blockedDateForDisplay(iso8601DateString: String, siteURL: URL) -> String {
var formattedDateString: String? = nil
if let date = (iso8601DateString as NSString).wmf_iso8601Date() {
let dateFormatter = DateFormatter.wmf_localCustomShortDateFormatterWithTime(for: NSLocale.wmf_locale(for: siteURL.wmf_languageCode))
formattedDateString = dateFormatter?.string(from: date)
}
return formattedDateString ?? ""
}
private func parseBlockReason(attempt: Int = 1, siteURL: URL, blockReason: String, completion: @escaping (String?) -> Void) {
let params: [String: Any] = [
"action": "parse",
"prop": "text",
"mobileformat": 1,
"text": blockReason,
"errorformat": "html",
"erroruselocal": 1,
"format": "json",
"formatversion": 2
]
performMediaWikiAPIGET(for: siteURL, with: params, cancellationKey: nil) { [weak self] result, response, error in
guard let parse = result?["parse"] as? [String: Any],
let text = parse["text"] as? String else {
// If unable to find, try app language once. Otherwise return nil.
guard attempt == 1 else {
completion(nil)
return
}
guard let appLangSiteURL = MWKDataStore.shared().languageLinkController.appLanguage?.siteURL else {
completion(nil)
return
}
self?.parseBlockReason(attempt: attempt + 1, siteURL: appLangSiteURL, blockReason: blockReason, completion: completion)
return
}
completion(text)
}
}
private func fetchBlockedTextTemplate(isPartial: Bool = false, attempt: Int = 1, siteURL: URL, completion: @escaping (String?, URL) -> Void) {
// Note: Not enough languages seem to have MediaWiki:Blockedtext-partial, so forcing MediaWiki:Blockedtext for now.
let templateName = "MediaWiki:Blockedtext"
if let parseText = templateName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
let params: [String: Any] = [
"action": "parse",
"prop": "text",
"mobileformat": 1,
"page": parseText,
"errorformat": "html",
"erroruselocal": 1,
"format": "json",
"formatversion": 2
]
performMediaWikiAPIGET(for: siteURL, with: params, cancellationKey: nil) { [weak self] result, response, error in
guard let parse = result?["parse"] as? [String: Any],
let text = parse["text"] as? String else {
// If unable to find, try app language once. Otherwise return nil.
guard attempt == 1 else {
completion(nil, siteURL)
return
}
guard let appLangSiteURL = MWKDataStore.shared().languageLinkController.appLanguage?.siteURL else {
completion(nil, siteURL)
return
}
self?.fetchBlockedTextTemplate(isPartial: isPartial, attempt: attempt + 1, siteURL: appLangSiteURL, completion: completion)
return
}
completion(text, siteURL)
}
}
}
// MARK: Decodable
@discardableResult public func performDecodableMediaWikiAPIGET<T: Decodable>(for URL: URL?, with queryParameters: [String: Any]?, cancellationKey: CancellationKey? = nil, completionHandler: @escaping (Result<T, Error>) -> Swift.Void) -> CancellationKey? {
let url = configuration.mediaWikiAPIURLForURL(URL, with: queryParameters)
let key = cancellationKey ?? UUID().uuidString
let task = session.jsonDecodableTask(with: url) { (result: T?, response: URLResponse?, error: Error?) in
guard let result = result else {
let error = error ?? RequestError.unexpectedResponse
completionHandler(.failure(error))
return
}
completionHandler(.success(result))
self.untrack(taskFor: key)
}
track(task: task, for: key)
return key
}
/// Creates and kicks off a URLSessionTask, tracking it in case the session needs to cancel it later. From fetchers, prefer using this method over calling session.jsonDecodableTask directly as it ensures the task is tracked and uses the result type
@discardableResult public func trackedJSONDecodableTask<T: Decodable>(with urlRequest: URLRequest, completionHandler: @escaping (Result<T, Error>, HTTPURLResponse?) -> Swift.Void) -> URLSessionTask? {
let key = UUID().uuidString
let task = session.jsonDecodableTask(with: urlRequest) { (result: T?, response: URLResponse?, error: Error?) in
defer {
self.untrack(taskFor: key)
}
guard let result = result else {
completionHandler(.failure(error ?? RequestError.unexpectedResponse), response as? HTTPURLResponse)
return
}
completionHandler(.success(result), response as? HTTPURLResponse)
}
track(task: task, for: key)
return task
}
// MARK: Tracking
@objc(trackTask:forKey:)
public func track(task: URLSessionTask?, for key: String) {
guard let task = task else {
return
}
semaphore.wait()
tasks[key] = task
semaphore.signal()
}
@objc(untrackTaskForKey:)
public func untrack(taskFor key: String) {
semaphore.wait()
tasks.removeValue(forKey: key)
semaphore.signal()
}
@objc(cancelTaskForKey:)
public func cancel(taskFor key: String) {
semaphore.wait()
tasks[key]?.cancel()
tasks.removeValue(forKey: key)
semaphore.signal()
}
@objc(cancelAllTasks)
public func cancelAllTasks() {
semaphore.wait()
for (_, task) in tasks {
task.cancel()
}
tasks.removeAll(keepingCapacity: true)
semaphore.signal()
}
}
// MARK: Modern Swift Concurrency APIs
extension Fetcher {
public func performDecodableMediaWikiAPIGet<T: Decodable>(for URL: URL, with queryParameters: [String: Any]?) async throws -> T {
guard let url = configuration.mediaWikiAPIURLForURL(URL, with: queryParameters) else {
throw RequestError.invalidParameters
}
let (data, response) = try await session.data(for: url)
guard let httpResponse = (response as? HTTPURLResponse) else {
throw RequestError.unexpectedResponse
}
guard HTTPStatusCode.isSuccessful(httpResponse.statusCode) else {
throw RequestError.http(httpResponse.statusCode)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
// These are for bridging to Obj-C only
@objc public extension Fetcher {
@objc class var unexpectedResponseError: NSError {
return RequestError.unexpectedResponse as NSError
}
@objc class var invalidParametersError: NSError {
return RequestError.invalidParameters as NSError
}
@objc class var noNewDataError: NSError {
return RequestError.noNewData as NSError
}
@objc class var cancelledError: NSError {
return NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: [NSLocalizedDescriptionKey: RequestError.unexpectedResponse.localizedDescription])
}
}
@objc(WMFTokenType)
public enum TokenType: Int {
case csrf, login, createAccount
var stringValue: String {
switch self {
case .login:
return "login"
case .createAccount:
return "createaccount"
case .csrf:
return "csrf"
}
}
var parameterName: String {
switch self {
case .login:
return "logintoken"
case .createAccount:
return "createtoken"
default:
return "token"
}
}
}
@objc(WMFToken)
public class Token: NSObject {
@objc public var value: String
@objc public var type: TokenType
public var isAuthorized: Bool
@objc init(value: String, type: TokenType) {
self.value = value
self.type = type
self.isAuthorized = value != "+\\"
}
}