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
515 lines
23 KiB
Swift
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 != "+\\"
|
|
}
|
|
}
|