diff options
22 files changed, 830 insertions, 495 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 21cc7f746b..4fdfac7208 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -104,7 +104,6 @@ 5850368C25A49E2200A43E93 /* PrivateKeyWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */; }; 5850368D25A49E2200A43E93 /* PrivateKeyWithMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B35322BB87C4003C19AD /* PrivateKeyWithMetadata.swift */; }; 58554F73280AFA5A00013055 /* RESTAuthenticationProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F72280AFA5A00013055 /* RESTAuthenticationProxy.swift */; }; - 58554F75280AFAE900013055 /* RESTResponseDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F74280AFAE900013055 /* RESTResponseDecoder.swift */; }; 58554F77280AFD5C00013055 /* RESTTaskIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F76280AFD5C00013055 /* RESTTaskIdentifier.swift */; }; 58554F79280B037400013055 /* RESTAccessTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F78280B037400013055 /* RESTAccessTokenManager.swift */; }; 58554F7B280B125F00013055 /* RESTAccountsProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58554F7A280B125F00013055 /* RESTAccountsProxy.swift */; }; @@ -424,7 +423,6 @@ 584EBDBC2747C98F00A0C9FD /* NSAttributedString+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+Markdown.swift"; sourceTree = "<group>"; }; 5850366725A47AC700A43E93 /* IPAddressRange+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IPAddressRange+Codable.swift"; sourceTree = "<group>"; }; 58554F72280AFA5A00013055 /* RESTAuthenticationProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAuthenticationProxy.swift; sourceTree = "<group>"; }; - 58554F74280AFAE900013055 /* RESTResponseDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTResponseDecoder.swift; sourceTree = "<group>"; }; 58554F76280AFD5C00013055 /* RESTTaskIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTaskIdentifier.swift; sourceTree = "<group>"; }; 58554F78280B037400013055 /* RESTAccessTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAccessTokenManager.swift; sourceTree = "<group>"; }; 58554F7A280B125F00013055 /* RESTAccountsProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTAccountsProxy.swift; sourceTree = "<group>"; }; @@ -779,7 +777,6 @@ 58F97A1A280EEBC00050C2FC /* RESTProxyFactory.swift */, 58B5A894280AACC4009FDE99 /* RESTRequestFactory.swift */, 58F97A1D280FDE230050C2FC /* RESTRequestHandler.swift */, - 58554F74280AFAE900013055 /* RESTResponseDecoder.swift */, 588BCF272816D664009ADCEC /* RESTResponseHandler.swift */, 58095C582762155700890776 /* RESTRetryStrategy.swift */, 58554F76280AFD5C00013055 /* RESTTaskIdentifier.swift */, @@ -1496,7 +1493,6 @@ 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58B43C1925F77DB60002C8C3 /* ConnectMainContentView.swift in Sources */, 58561C99239A5D1500BD6B5E /* IPEndpoint.swift in Sources */, - 58554F75280AFAE900013055 /* RESTResponseDecoder.swift in Sources */, 58F97A1E280FDE230050C2FC /* RESTRequestHandler.swift in Sources */, 58FD5BF22424F7D700112C88 /* UserInterfaceInteractionRestriction.swift in Sources */, 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, diff --git a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5493113eef..83c0aec460 100644 --- a/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/MullvadVPN.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,7 +15,7 @@ "repositoryURL": "https://github.com/mullvad/wireguard-apple.git", "state": { "branch": "mullvad-master", - "revision": "736a4bb5baba4e2c70686f59416d50c3b06e8424", + "revision": "eeb980058f5bff593868e34b718b4519a3236dcc", "version": null } } diff --git a/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift b/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift index 3bcfa7c4f3..befd7da698 100644 --- a/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift +++ b/ios/MullvadVPN/AppStorePaymentManager/SendAppStoreReceiptOperation.swift @@ -65,7 +65,7 @@ class SendAppStoreReceiptOperation: ResultOperation<REST.CreateApplePaymentRespo private func sendReceipt(_ receiptData: Data) { submitReceiptTask = apiProxy.createApplePayment( - token: self.accountToken, + accountNumber: self.accountToken, receiptString: receiptData, retryStrategy: .noRetry) { result in switch result { diff --git a/ios/MullvadVPN/DisplayChainedError.swift b/ios/MullvadVPN/DisplayChainedError.swift index 5e3d154f28..aa7ec26b2d 100644 --- a/ios/MullvadVPN/DisplayChainedError.swift +++ b/ios/MullvadVPN/DisplayChainedError.swift @@ -22,44 +22,34 @@ extension REST.Error: DisplayChainedError { "NETWORK_ERROR", tableName: "REST", value: "Network error: %@", - comment: "Network error. Use %@ placeholder to place localized failure description." + comment: "" ), urlError.localizedDescription ) - case .server(let serverError): - if let knownErrorDescription = serverError.errorDescription { - return knownErrorDescription - } else { - return String( - format: NSLocalizedString( - "SERVER_ERROR", - tableName: "REST", - value: "Server error: %@", - comment: "Server error. Use %@ placeholder to place localized failure description." - ), - serverError.error ?? "(empty)" - ) - } - case .encodePayload: + case .unhandledResponse(let statusCode, let serverResponse): + return String( + format: NSLocalizedString( + "SERVER_ERROR", + tableName: "REST", + value: "Unexpected server response: %1$@ (HTTP status: %2$d)", + comment: "" + ), + serverResponse?.code.rawValue ?? "(no code)", + statusCode + ) + case .createURLRequest: return NSLocalizedString( "SERVER_REQUEST_ENCODING_ERROR", tableName: "REST", - value: "Server request encoding error", - comment: "Failure to encode the server request." + value: "Failure to create URL request", + comment: "" ) - case .decodeSuccessResponse: + case .decodeResponse: return NSLocalizedString( "SERVER_SUCCESS_RESPONSE_DECODING_ERROR", tableName: "REST", - value: "Server success response decoding error", - comment: "Failure to decode the server success response." - ) - case .decodeErrorResponse: - return NSLocalizedString( - "SERVER_FAILURE_RESPONSE_DECODING_ERROR", - tableName: "REST", - value: "Server error response decoding error", - comment: "Failure to decode the server failure response." + value: "Server response decoding error", + comment: "" ) } } @@ -194,7 +184,9 @@ extension TunnelManager.Error: DisplayChainedError { reason ) - if case .server(.keyLimitReached) = restError { + if case .unhandledResponse(_, let serverErrorResponse) = restError, + serverErrorResponse?.code == .keyLimitReached + { // TODO: maybe use `restError.recoverySuggestion` instead? message.append("\n\n") message.append(NSLocalizedString( @@ -219,7 +211,9 @@ extension TunnelManager.Error: DisplayChainedError { reason ) - if case .server(.keyLimitReached) = restError { + if case .unhandledResponse(_, let serverErrorResponse) = restError, + serverErrorResponse?.code == .keyLimitReached + { // TODO: maybe use `restError.recoverySuggestion` instead? message.append("\n\n") message.append(NSLocalizedString( @@ -346,7 +340,9 @@ extension AppStorePaymentManager.Error: DisplayChainedError { case .validateAccount(let restError): let reason = restError.errorChainDescription ?? "" - if case .server(.invalidAccount) = restError { + if case .unhandledResponse(_, let serverErrorResponse) = restError, + serverErrorResponse?.code == .invalidAccount + { return String( format: NSLocalizedString( "INVALID_ACCOUNT_ERROR", diff --git a/ios/MullvadVPN/LoginViewController.swift b/ios/MullvadVPN/LoginViewController.swift index fa63d40ed7..f345798941 100644 --- a/ios/MullvadVPN/LoginViewController.swift +++ b/ios/MullvadVPN/LoginViewController.swift @@ -364,20 +364,14 @@ private extension LoginState { urlError.localizedDescription ) - case .server(let serverError): - var message = serverError.errorDescription ?? NSLocalizedString( + case .unhandledResponse(_, let serverError): + return serverError?.detail ?? NSLocalizedString( "SUBHEAD_TITLE_UNKNOWN_SERVER_ERROR", tableName: "Login", comment: "Subhead displayed in the event of unknown server error." ) - if let recoverySuggestion = serverError.recoverySuggestion { - message.append("\n\(recoverySuggestion)") - } - - return message - - case .encodePayload, .decodeErrorResponse, .decodeSuccessResponse: + case .createURLRequest, .decodeResponse: return localizedUnknownInternalError } } else { diff --git a/ios/MullvadVPN/REST/HTTP.swift b/ios/MullvadVPN/REST/HTTP.swift index 595809ee0e..82178dc2f5 100644 --- a/ios/MullvadVPN/REST/HTTP.swift +++ b/ios/MullvadVPN/REST/HTTP.swift @@ -13,6 +13,7 @@ struct HTTPMethod: RawRepresentable { static let get = HTTPMethod(rawValue: "GET") static let post = HTTPMethod(rawValue: "POST") static let delete = HTTPMethod(rawValue: "DELETE") + static let put = HTTPMethod(rawValue: "PUT") let rawValue: String init(rawValue: String) { @@ -20,12 +21,23 @@ struct HTTPMethod: RawRepresentable { } } -enum HTTPStatus { - static let notModified = 304 +struct HTTPStatus: RawRepresentable, Equatable { + static let notModified = HTTPStatus(rawValue: 304) + static let badRequest = HTTPStatus(rawValue: 400) + static let notFound = HTTPStatus(rawValue: 404) static func isSuccess(_ code: Int) -> Bool { return (200..<300).contains(code) } + + let rawValue: Int + init(rawValue: Int) { + self.rawValue = rawValue + } + + var isSuccess: Bool { + return Self.isSuccess(rawValue) + } } /// HTTP headers diff --git a/ios/MullvadVPN/REST/RESTAPIProxy.swift b/ios/MullvadVPN/REST/RESTAPIProxy.swift index 15cc807c16..1271422be1 100644 --- a/ios/MullvadVPN/REST/RESTAPIProxy.swift +++ b/ios/MullvadVPN/REST/RESTAPIProxy.swift @@ -21,9 +21,7 @@ extension REST { pathPrefix: "/app/v1", bodyEncoder: Coding.makeJSONEncoder() ), - responseDecoder: ResponseDecoder( - decoder: Coding.makeJSONDecoder() - ) + responseDecoder: Coding.makeJSONDecoder() ) } @@ -33,13 +31,11 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - let request = self.requestFactory.createURLRequest( + return try self.requestFactory.createRequest( endpoint: endpoint, method: .post, - path: "accounts" + pathTemplate: "accounts" ) - - return .success(request) } let responseHandler = REST.defaultResponseHandler( @@ -62,13 +58,11 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - let request = self.requestFactory.createURLRequest( + return try self.requestFactory.createRequest( endpoint: endpoint, method: .get, - path: "api-addrs" + pathTemplate: "api-addrs" ) - - return .success(request) } let responseHandler = REST.defaultResponseHandler( @@ -92,30 +86,44 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - var requestBuilder = self.requestFactory.createURLRequestBuilder( + var requestBuilder = try self.requestFactory.createRequestBuilder( endpoint: endpoint, method: .get, - path: "relays" + pathTemplate: "relays" ) if let etag = etag { requestBuilder.setETagHeader(etag: etag) } - return .success(requestBuilder.getURLRequest()) + return requestBuilder.getRequest() } - let responseHandler = AnyResponseHandler { response, data -> Result<ServerRelaysCacheResponse, REST.Error> in - if HTTPStatus.isSuccess(response.statusCode) { - return self.responseDecoder.decodeSuccessResponse(ServerRelaysResponse.self, from: data) - .map { serverRelays in - let newEtag = response.value(forCaseInsensitiveHTTPHeaderField: HTTPHeader.etag) - return .newContent(newEtag, serverRelays) - } - } else if response.statusCode == HTTPStatus.notModified && etag != nil { + let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<ServerRelaysCacheResponse> in + let httpStatus = HTTPStatus(rawValue: response.statusCode) + + switch httpStatus { + case let httpStatus where httpStatus.isSuccess: + return .decoding { + let serverRelays = try self.responseDecoder.decode( + ServerRelaysResponse.self, + from: data + ) + let newEtag = response.value(forCaseInsensitiveHTTPHeaderField: HTTPHeader.etag) + + return .newContent(newEtag, serverRelays) + } + + case .notModified where etag != nil: return .success(.notModified) - } else { - return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + + default: + return .unhandledResponse( + try? self.responseDecoder.decode( + ServerErrorResponse.self, + from: data + ) + ) } } @@ -135,15 +143,15 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - var requestBuilder = self.requestFactory - .createURLRequestBuilder( + var requestBuilder = try self.requestFactory + .createRequestBuilder( endpoint: endpoint, method: .get, - path: "me" + pathTemplate: "me" ) requestBuilder.setAuthorization(.accountNumber(accountNumber)) - return .success(requestBuilder.getURLRequest()) + return requestBuilder.getRequest() } let responseHandler = REST.defaultResponseHandler( @@ -168,19 +176,22 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - let urlEncodedPublicKey = publicKey.base64Key - .addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - let path = "wireguard-keys/".appending(urlEncodedPublicKey) + var path: URLPathTemplate = "wireguard-keys/{pubkey}" + try path.addPercentEncodedReplacement( + name: "pubkey", + value: publicKey.base64Key, + allowedCharacters: .alphanumerics + ) - var requestBuilder = self.requestFactory - .createURLRequestBuilder( + var requestBuilder = try self.requestFactory + .createRequestBuilder( endpoint: endpoint, method: .get, - path: path + pathTemplate: path ) requestBuilder.setAuthorization(.accountNumber(accountNumber)) - return .success(requestBuilder.getURLRequest()) + return requestBuilder.getRequest() } let responseHandler = REST.defaultResponseHandler( @@ -205,25 +216,20 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - var requestBuilder = self.requestFactory.createURLRequestBuilder( + var requestBuilder = try self.requestFactory.createRequestBuilder( endpoint: endpoint, method: .post, - path: "wireguard-keys" + pathTemplate: "wireguard-keys" ) requestBuilder.setAuthorization(.accountNumber(accountNumber)) - return Result { - let body = PushWireguardKeyRequest( - pubkey: publicKey.rawValue - ) - try requestBuilder.setHTTPBody(value: body) - } - .mapError { error in - return .encodePayload(error) - } - .map { _ in - return requestBuilder.getURLRequest() - } + let body = PushWireguardKeyRequest( + pubkey: publicKey.rawValue + ) + + try requestBuilder.setHTTPBody(value: body) + + return requestBuilder.getRequest() } let responseHandler = REST.defaultResponseHandler( @@ -249,26 +255,21 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - var requestBuilder = self.requestFactory.createURLRequestBuilder( + var requestBuilder = try self.requestFactory.createRequestBuilder( endpoint: endpoint, method: .post, - path: "replace-wireguard-key" + pathTemplate: "replace-wireguard-key" ) requestBuilder.setAuthorization(.accountNumber(accountNumber)) - return Result { - let body = ReplaceWireguardKeyRequest( - old: oldPublicKey.rawValue, - new: newPublicKey.rawValue - ) - try requestBuilder.setHTTPBody(value: body) - } - .mapError { error in - return .encodePayload(error) - } - .map { _ in - return requestBuilder.getURLRequest() - } + let body = ReplaceWireguardKeyRequest( + old: oldPublicKey.rawValue, + new: newPublicKey.rawValue + ) + + try requestBuilder.setHTTPBody(value: body) + + return requestBuilder.getRequest() } let responseHandler = REST.defaultResponseHandler( @@ -293,26 +294,35 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - let urlEncodedPublicKey = publicKey.base64Key - .addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + var path: URLPathTemplate = "wireguard-keys/{pubkey}" - let path = "wireguard-keys/".appending(urlEncodedPublicKey) - var requestBuilder = self.requestFactory - .createURLRequestBuilder( + try path.addPercentEncodedReplacement( + name: "pubkey", + value: publicKey.base64Key, + allowedCharacters: .alphanumerics + ) + + var requestBuilder = try self.requestFactory + .createRequestBuilder( endpoint: endpoint, method: .delete, - path: path + pathTemplate: path ) requestBuilder.setAuthorization(.accountNumber(accountNumber)) - return .success(requestBuilder.getURLRequest()) + return requestBuilder.getRequest() } - let responseHandler = AnyResponseHandler { response, data -> Result<Void, REST.Error> in + let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<Void> in if HTTPStatus.isSuccess(response.statusCode) { return .success(()) } else { - return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + return .unhandledResponse( + try? self.responseDecoder.decode( + ServerErrorResponse.self, + from: data + ) + ) } } @@ -333,40 +343,42 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - var requestBuilder = self.requestFactory - .createURLRequestBuilder( + var requestBuilder = try self.requestFactory + .createRequestBuilder( endpoint: endpoint, method: .post, - path: "create-apple-payment" + pathTemplate: "create-apple-payment" ) requestBuilder.setAuthorization(.accountNumber(accountNumber)) - return Result { - let body = CreateApplePaymentRequest( - receiptString: receiptString - ) - try requestBuilder.setHTTPBody(value: body) - } - .mapError { error in - return .encodePayload(error) - } - .map { _ in - return requestBuilder.getURLRequest() - } + let body = CreateApplePaymentRequest( + receiptString: receiptString + ) + try requestBuilder.setHTTPBody(value: body) + + return requestBuilder.getRequest() } - let responseHandler = AnyResponseHandler { response, data -> Result<CreateApplePaymentResponse, REST.Error> in + let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<CreateApplePaymentResponse> in if HTTPStatus.isSuccess(response.statusCode) { - return self.responseDecoder.decodeSuccessResponse(CreateApplePaymentRawResponse.self, from: data) - .map { (response) in - if response.timeAdded > 0 { - return .timeAdded(response.timeAdded, response.newExpiry) - } else { - return .noTimeAdded(response.newExpiry) - } + return .decoding { + let serverResponse = try self.responseDecoder.decode( + CreateApplePaymentRawResponse.self, + from: data + ) + if serverResponse.timeAdded > 0 { + return .timeAdded(serverResponse.timeAdded, serverResponse.newExpiry) + } else { + return .noTimeAdded(serverResponse.newExpiry) } + } } else { - return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + return .unhandledResponse( + try? self.responseDecoder.decode( + ServerErrorResponse.self, + from: data + ) + ) } } @@ -386,28 +398,27 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - var requestBuilder = self.requestFactory.createURLRequestBuilder( + var requestBuilder = try self.requestFactory.createRequestBuilder( endpoint: endpoint, method: .post, - path: "problem-report" + pathTemplate: "problem-report" ) - return Result { - try requestBuilder.setHTTPBody(value: body) - } - .mapError { error in - return .encodePayload(error) - } - .map { _ in - return requestBuilder.getURLRequest() - } + try requestBuilder.setHTTPBody(value: body) + + return requestBuilder.getRequest() } - let responseHandler = AnyResponseHandler { response, data -> Result<Void, REST.Error> in + let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<Void> in if HTTPStatus.isSuccess(response.statusCode) { return .success(()) } else { - return self.responseDecoder.decodeErrorResponseAndMapToServerError(from: data) + return .unhandledResponse( + try? self.responseDecoder.decode( + ServerErrorResponse.self, + from: data + ) + ) } } diff --git a/ios/MullvadVPN/REST/RESTAccountsProxy.swift b/ios/MullvadVPN/REST/RESTAccountsProxy.swift index 2bc05e088c..e9c44f0f63 100644 --- a/ios/MullvadVPN/REST/RESTAccountsProxy.swift +++ b/ios/MullvadVPN/REST/RESTAccountsProxy.swift @@ -15,48 +15,65 @@ extension REST { name: "AccountsProxy", configuration: configuration, requestFactory: RequestFactory.withDefaultAPICredentials( - pathPrefix: "/accounts/v1-beta1", + pathPrefix: "/accounts/v1", bodyEncoder: Coding.makeJSONEncoder() ), - responseDecoder: ResponseDecoder( - decoder: Coding.makeJSONDecoder() + responseDecoder: Coding.makeJSONDecoder() + ) + } + + func createAccount( + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<NewAccountData> + ) -> Cancellable { + let requestHandler = AnyRequestHandler { endpoint in + return try self.requestFactory.createRequest( + endpoint: endpoint, + method: .post, + pathTemplate: "accounts" ) + } + + let responseHandler = REST.defaultResponseHandler( + decoding: NewAccountData.self, + with: responseDecoder + ) + + return addOperation( + name: "create-account", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion ) } - func getMyAccount( + func getAccountData( accountNumber: String, retryStrategy: REST.RetryStrategy, - completion: @escaping CompletionHandler<BetaAccountResponse> + completion: @escaping CompletionHandler<AccountData> ) -> Cancellable { let requestHandler = AnyRequestHandler( createURLRequest: { endpoint, authorization in - var requestBuilder = self.requestFactory.createURLRequestBuilder( + var requestBuilder = try self.requestFactory.createRequestBuilder( endpoint: endpoint, method: .get, - path: "/accounts/me" + pathTemplate: "accounts/me" ) requestBuilder.setAuthorization(authorization) - return .success(requestBuilder.getURLRequest()) + return requestBuilder.getRequest() }, - requestAuthorization: { completion in - return self.configuration.accessTokenManager - .getAccessToken( - accountNumber: accountNumber, - retryStrategy: retryStrategy - ) { operationCompletion in - completion(operationCompletion.map { tokenData in - return .accessToken(tokenData.accessToken) - }) - } - } + authorizationProvider: createAuthorizationProvider( + accountNumber: accountNumber, + retryStrategy: .default + ) ) let responseHandler = REST.defaultResponseHandler( - decoding: BetaAccountResponse.self, + decoding: AccountData.self, with: responseDecoder ) @@ -70,13 +87,22 @@ extension REST { } } - struct BetaAccountResponse: Decodable { + struct AccountData: Decodable { + let id: String + let expiry: Date + let maxPorts: Int + let canAddPorts: Bool + let maxDevices: Int + let canAddDevices: Bool + } + + struct NewAccountData: Decodable { let id: String - let number: String let expiry: Date let maxPorts: Int let canAddPorts: Bool let maxDevices: Int let canAddDevices: Bool + let number: String } } diff --git a/ios/MullvadVPN/REST/RESTAuthenticationProxy.swift b/ios/MullvadVPN/REST/RESTAuthenticationProxy.swift index c4ab8e9d04..a9843ab750 100644 --- a/ios/MullvadVPN/REST/RESTAuthenticationProxy.swift +++ b/ios/MullvadVPN/REST/RESTAuthenticationProxy.swift @@ -15,12 +15,10 @@ extension REST { name: "AuthenticationProxy", configuration: configuration, requestFactory: RequestFactory.withDefaultAPICredentials( - pathPrefix: "/auth/v1-beta1", + pathPrefix: "/auth/v1", bodyEncoder: Coding.makeJSONEncoder() ), - responseDecoder: ResponseDecoder( - decoder: Coding.makeJSONDecoder() - ) + responseDecoder: Coding.makeJSONDecoder() ) } @@ -31,23 +29,17 @@ extension REST { ) -> Cancellable { let requestHandler = AnyRequestHandler { endpoint in - var requestBuilder = self.requestFactory.createURLRequestBuilder( + var requestBuilder = try self.requestFactory.createRequestBuilder( endpoint: endpoint, method: .post, - path: "/token" + pathTemplate: "token" ) - return Result { - let request = AccessTokenRequest(accountNumber: accountNumber) + let request = AccessTokenRequest(accountNumber: accountNumber) + + try requestBuilder.setHTTPBody(value: request) - try requestBuilder.setHTTPBody(value: request) - } - .mapError { error in - return .encodePayload(error) - } - .map { _ in - return requestBuilder.getURLRequest() - } + return requestBuilder.getRequest() } let responseHandler = REST.defaultResponseHandler( diff --git a/ios/MullvadVPN/REST/RESTAuthorization.swift b/ios/MullvadVPN/REST/RESTAuthorization.swift index b9a701b1ac..864cdb83a6 100644 --- a/ios/MullvadVPN/REST/RESTAuthorization.swift +++ b/ios/MullvadVPN/REST/RESTAuthorization.swift @@ -8,9 +8,51 @@ import Foundation +protocol RESTAuthorizationProvider { + typealias Completion = OperationCompletion<REST.Authorization, REST.Error> + + func getAuthorization(completion: @escaping (Completion) -> Void) -> Cancellable +} + extension REST { enum Authorization { case accountNumber(String) case accessToken(String) } + + struct AccessTokenProvider: RESTAuthorizationProvider { + private let accessTokenManager: AccessTokenManager + private let accountNumber: String + private let retryStrategy: REST.RetryStrategy + + init(accessTokenManager: AccessTokenManager, accountNumber: String, retryStrategy: REST.RetryStrategy) { + self.accessTokenManager = accessTokenManager + self.accountNumber = accountNumber + self.retryStrategy = retryStrategy + } + + func getAuthorization(completion: @escaping (Completion) -> Void) -> Cancellable { + return accessTokenManager.getAccessToken( + accountNumber: accountNumber, + retryStrategy: retryStrategy + ) { operationCompletion in + completion(operationCompletion.map { tokenData in + return .accessToken(tokenData.accessToken) + }) + } + } + } +} + +extension REST.Proxy where ConfigurationType == REST.AuthProxyConfiguration { + func createAuthorizationProvider( + accountNumber: String, + retryStrategy: REST.RetryStrategy + ) -> RESTAuthorizationProvider { + return REST.AccessTokenProvider( + accessTokenManager: configuration.accessTokenManager, + accountNumber: accountNumber, + retryStrategy: retryStrategy + ) + } } diff --git a/ios/MullvadVPN/REST/RESTCoding.swift b/ios/MullvadVPN/REST/RESTCoding.swift index 8a5356e3bc..b8c917e6b4 100644 --- a/ios/MullvadVPN/REST/RESTCoding.swift +++ b/ios/MullvadVPN/REST/RESTCoding.swift @@ -27,36 +27,7 @@ extension REST.Coding { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.dataDecodingStrategy = .base64 - - let iso8601Formatter = ISO8601DateFormatter() - - // Setup additional formatter to account for fractional seconds returned - // by some of the API calls. - lazy var iso8601WithSubSecondsFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions.insert(.withFractionalSeconds) - return formatter - }() - - decoder.dateDecodingStrategy = .custom({ decoder in - let container = try decoder.singleValueContainer() - let value = try container.decode(String.self) - - let date = iso8601Formatter.date(from: value) ?? - iso8601WithSubSecondsFormatter.date(from: value) - - switch date { - case .some(let parsedDate): - return parsedDate - - case .none: - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Expected date string to be RFC3339 or ISO8601-formatted." - ) - } - }) - + decoder.dateDecodingStrategy = .iso8601 return decoder } } diff --git a/ios/MullvadVPN/REST/RESTDevicesProxy.swift b/ios/MullvadVPN/REST/RESTDevicesProxy.swift index 698c51681b..5a1fa115fd 100644 --- a/ios/MullvadVPN/REST/RESTDevicesProxy.swift +++ b/ios/MullvadVPN/REST/RESTDevicesProxy.swift @@ -17,15 +17,80 @@ extension REST { name: "DevicesProxy", configuration: configuration, requestFactory: RequestFactory.withDefaultAPICredentials( - pathPrefix: "/accounts/v1-beta1", + pathPrefix: "/accounts/v1", bodyEncoder: Coding.makeJSONEncoder() ), - responseDecoder: ResponseDecoder( - decoder: Coding.makeJSONDecoder() + responseDecoder: Coding.makeJSONDecoder() + ) + } + + /// Fetch device by identifier. + /// The completion handler receives `nil` if device is not found. + func getDevice( + accountNumber: String, + identifier: String, + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<Device?> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler( + createURLRequest: { endpoint, authorization in + var path: URLPathTemplate = "devices/{id}" + + try path.addPercentEncodedReplacement( + name: "id", + value: identifier, + allowedCharacters: .urlPathAllowed + ) + + var requestBuilder = try self.requestFactory.createRequestBuilder( + endpoint: endpoint, + method: .get, + pathTemplate: path + ) + + requestBuilder.setAuthorization(authorization) + + return requestBuilder.getRequest() + }, + authorizationProvider: createAuthorizationProvider( + accountNumber: accountNumber, + retryStrategy: .default ) ) + + let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<Device?> in + let httpStatus = HTTPStatus(rawValue: response.statusCode) + + switch httpStatus { + case let httpStatus where httpStatus.isSuccess: + return .decoding { + return try self.responseDecoder.decode(Device.self, from: data) + } + + case .notFound: + return .success(nil) + + default: + return .unhandledResponse( + try? self.responseDecoder.decode( + ServerErrorResponse.self, + from: data + ) + ) + } + } + + return addOperation( + name: "get-device", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) } + /// Fetch a list of created devices. func getDevices( accountNumber: String, retryStrategy: REST.RetryStrategy, @@ -34,27 +99,20 @@ extension REST { { let requestHandler = AnyRequestHandler( createURLRequest: { endpoint, authorization in - var requestBuilder = self.requestFactory.createURLRequestBuilder( + var requestBuilder = try self.requestFactory.createRequestBuilder( endpoint: endpoint, method: .get, - path: "/devices" + pathTemplate: "devices" ) requestBuilder.setAuthorization(authorization) - return .success(requestBuilder.getURLRequest()) + return requestBuilder.getRequest() }, - requestAuthorization: { completion in - return self.configuration.accessTokenManager - .getAccessToken( - accountNumber: accountNumber, - retryStrategy: retryStrategy - ) { operationCompletion in - completion(operationCompletion.map { tokenData in - return .accessToken(tokenData.accessToken) - }) - } - } + authorizationProvider: createAuthorizationProvider( + accountNumber: accountNumber, + retryStrategy: .default + ) ) let responseHandler = REST.defaultResponseHandler( @@ -71,12 +129,208 @@ extension REST { ) } + /// Create new device. + /// The completion handler will receive a `CreateDeviceResponse.created(Device)` on success. + /// Other `CreateDeviceResponse` variants describe errors. + func createDevice( + accountNumber: String, + request: CreateDeviceRequest, + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<Device> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler( + createURLRequest: { endpoint, authorization in + var requestBuilder = try self.requestFactory.createRequestBuilder( + endpoint: endpoint, + method: .post, + pathTemplate: "devices" + ) + requestBuilder.setAuthorization(authorization) + + try requestBuilder.setHTTPBody(value: request) + + return requestBuilder.getRequest() + }, + authorizationProvider: createAuthorizationProvider( + accountNumber: accountNumber, + retryStrategy: .default + ) + ) + + let responseHandler = REST.defaultResponseHandler( + decoding: Device.self, + with: responseDecoder + ) + + return addOperation( + name: "create-device", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) + } + + /// Delete device by identifier. + /// The completion handler will receive `true` if device is successfully removed, + /// otherwise `false` if device is not found or already removed. + func deleteDevice( + accountNumber: String, + identifier: String, + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<Bool> + ) -> Cancellable + { + let requestHandler = AnyRequestHandler( + createURLRequest: { endpoint, authorization in + var path: URLPathTemplate = "devices/{id}" + + try path.addPercentEncodedReplacement( + name: "id", + value: identifier, + allowedCharacters: .urlPathAllowed + ) + + var requestBuilder = try self.requestFactory + .createRequestBuilder( + endpoint: endpoint, + method: .delete, + pathTemplate: path + ) + + requestBuilder.setAuthorization(authorization) + + return requestBuilder.getRequest() + }, + authorizationProvider: createAuthorizationProvider( + accountNumber: accountNumber, + retryStrategy: .default + ) + ) + + let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult<Bool> in + let statusCode = HTTPStatus(rawValue: response.statusCode) + + switch statusCode { + case let statusCode where statusCode.isSuccess: + return .success(true) + + case .notFound: + return .success(false) + + default: + return .unhandledResponse( + try? self.responseDecoder.decode( + ServerErrorResponse.self, + from: data + ) + ) + } + } + + return addOperation( + name: "delete-device", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) + } + + /// Rotate device key + func rotateDeviceKey( + accountNumber: String, + identifier: String, + publicKey: PublicKey, + retryStrategy: REST.RetryStrategy, + completion: @escaping CompletionHandler<Device> + ) -> Cancellable { + let requestHandler = AnyRequestHandler( + createURLRequest: { endpoint, authorization in + var path: URLPathTemplate = "devices/{id}/pubkey" + + try path.addPercentEncodedReplacement( + name: "id", + value: identifier, + allowedCharacters: .urlPathAllowed + ) + + var requestBuilder = try self.requestFactory + .createRequestBuilder( + endpoint: endpoint, + method: .put, + pathTemplate: path + ) + + requestBuilder.setAuthorization(authorization) + + let request = RotateDeviceKeyRequest( + publicKey: publicKey + ) + try requestBuilder.setHTTPBody(value: request) + + let urlRequest = requestBuilder.getRequest() + + return urlRequest + }, + authorizationProvider: createAuthorizationProvider( + accountNumber: accountNumber, + retryStrategy: .default + ) + ) + + let responseHandler = REST.defaultResponseHandler( + decoding: Device.self, + with: responseDecoder + ) + + return addOperation( + name: "rotate-device-key", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) + } + + } + + struct CreateDeviceRequest: Encodable { + let publicKey: PublicKey + let hijackDNS: Bool + + private enum CodingKeys: String, CodingKey { + case hijackDNS = "hijackDns" + case publicKey = "pubkey" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(publicKey.base64Key, forKey: .publicKey) + try container.encode(hijackDNS, forKey: .hijackDNS) + } + } + + fileprivate struct RotateDeviceKeyRequest: Encodable { + let publicKey: PublicKey + + private enum CodingKeys: String, CodingKey { + case publicKey = "pubkey" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(publicKey.base64Key, forKey: .publicKey) + } } struct Device: Decodable { let id: String let name: String - let pubkey: Data + let pubkey: PublicKey let hijackDNS: Bool let created: Date let ipv4Address: IPAddressRange diff --git a/ios/MullvadVPN/REST/RESTError.swift b/ios/MullvadVPN/REST/RESTError.swift index 6ee7e74d26..3b6f24c49a 100644 --- a/ios/MullvadVPN/REST/RESTError.swift +++ b/ios/MullvadVPN/REST/RESTError.swift @@ -12,117 +12,71 @@ extension REST { /// An error type returned by REST API classes. enum Error: ChainedError { - /// A failure to encode the payload - case encodePayload(Swift.Error) + /// A failure to create URL request. + case createURLRequest(Swift.Error) - /// A failure during networking + /// A failure during networking. case network(URLError) - /// A failure reported by server - case server(REST.ServerErrorResponse) + /// A failure to handle response. + case unhandledResponse(_ statusCode: Int, _ serverResponse: ServerErrorResponse?) - /// A failure to decode the error response from server - case decodeErrorResponse(Swift.Error) - - /// A failure to decode the success response from server - case decodeSuccessResponse(Swift.Error) + /// A failure to decode server response. + case decodeResponse(Swift.Error) var errorDescription: String? { switch self { - case .encodePayload: - return "Failure to encode the payload." + case .createURLRequest: + return "Failure to create URL request." case .network: return "Network error." - case .server: - return "Server error." - case .decodeErrorResponse: - return "Failure to decode error response from server." - case .decodeSuccessResponse: - return "Failure to decode success response from server." - } - } - } + case .unhandledResponse(let statusCode, let serverResponse): + var str = "Failure to handle server response: HTTP/\(statusCode)." - /// A struct that represents a server response in case of error (any HTTP status code except 2xx). - struct ServerErrorResponse: LocalizedError, Decodable, Equatable { - /// A list of known server error codes - enum Code: String, Equatable { - case invalidAccount = "INVALID_ACCOUNT" - case keyLimitReached = "KEY_LIMIT_REACHED" - case pubKeyNotFound = "PUBKEY_NOT_FOUND" - case invalidAccessToken = "INVALID_ACCESS_TOKEN" + if let code = serverResponse?.code { + str += " Error code: \(code)." + } - static func ~= (pattern: Self, value: REST.ServerErrorResponse) -> Bool { - return pattern.rawValue == value.code + if let detail = serverResponse?.detail { + str += " Detail: \(detail)." + } + + return str + case .decodeResponse: + return "Failure to decode URL response data." } } + } - static var invalidAccount: Code { - return .invalidAccount - } - static var keyLimitReached: Code { - return .keyLimitReached - } - static var pubKeyNotFound: Code { - return .pubKeyNotFound - } - static var invalidAccessToken: Code { - return .invalidAccessToken - } + struct ServerErrorResponse: Decodable { + let code: ServerResponseCode + let detail: String? - let code: String - let error: String? + private enum CodingKeys: String, CodingKey { + case code, detail, error + } - var errorDescription: String? { - switch code { - case Code.keyLimitReached.rawValue: - return NSLocalizedString( - "KEY_LIMIT_REACHED_ERROR_DESCRIPTION", - tableName: "REST", - value: "Too many WireGuard keys in use.", - comment: "" - ) - case Code.invalidAccount.rawValue: - return NSLocalizedString( - "INVALID_ACCOUNT_ERROR_DESCRIPTION", - tableName: "REST", - value: "Invalid account.", - comment: "" - ) + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawValue = try container.decode(String.self, forKey: .code) - case Code.invalidAccessToken.rawValue: - return NSLocalizedString( - "INVALID_ACCESS_TOKEN_ERROR_DESCRIPTION", - tableName: "REST", - value: "Invalid access token.", - comment: "") - default: - let localizedString = NSLocalizedString( - "UNKNOWN_ERROR_DESCRIPTION", - tableName: "REST", - value: "Unknown error: %@", - comment: "Use %@ placeholder to place the error code into the localized string." - ) - return String(format: localizedString, code) - } + code = ServerResponseCode(rawValue: rawValue) + detail = try container.decodeIfPresent(String.self, forKey: .detail) + ?? container.decodeIfPresent(String.self, forKey: .error) } + } - var recoverySuggestion: String? { - switch code { - case Code.keyLimitReached.rawValue: - return NSLocalizedString( - "KEY_LIMIT_REACHED_ERROR_RECOVERY_SUGGESTION", - tableName: "REST", - value: "Please visit the website to revoke a key before login is possible.", - comment: "" - ) - default: - return nil - } - } + struct ServerResponseCode: RawRepresentable, Equatable { + static let invalidAccount = ServerResponseCode(rawValue: "INVALID_ACCOUNT") + static let keyLimitReached = ServerResponseCode(rawValue: "KEY_LIMIT_REACHED") + static let publicKeyNotFound = ServerResponseCode(rawValue: "PUBKEY_NOT_FOUND") + static let publicKeyInUse = ServerResponseCode(rawValue: "PUBKEY_IN_USE") + static let maxDevicesReached = ServerResponseCode(rawValue: "MAX_DEVICES_REACHED") + static let invalidAccessToken = ServerResponseCode(rawValue: "INVALID_ACCESS_TOKEN") - static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.code == rhs.code + let rawValue: String + init(rawValue: String) { + self.rawValue = rawValue } } diff --git a/ios/MullvadVPN/REST/RESTNetworkOperation.swift b/ios/MullvadVPN/REST/RESTNetworkOperation.swift index 97e7c0e9a6..14b441fca9 100644 --- a/ios/MullvadVPN/REST/RESTNetworkOperation.swift +++ b/ios/MullvadVPN/REST/RESTNetworkOperation.swift @@ -15,6 +15,7 @@ extension REST { private let responseHandler: AnyResponseHandler<Success> private let dispatchQueue: DispatchQueue + private let logger: Logger private let urlSession: URLSession private let addressCacheStore: AddressCache.Store @@ -28,9 +29,6 @@ extension REST { private var retryTimer: DispatchSourceTimer? private var retryCount = 0 - private let logger = Logger(label: "REST.NetworkOperation") - private let loggerMetadata: Logger.Metadata - init( name: String, dispatchQueue: DispatchQueue, @@ -48,7 +46,9 @@ extension REST { self.requestHandler = requestHandler self.responseHandler = responseHandler - loggerMetadata = ["name": .string(name)] + var logger = Logger(label: "REST.NetworkOperation") + logger[metadataKey: "name"] = .string(name) + self.logger = logger super.init(completionQueue: .main, completionHandler: completionHandler) } @@ -81,10 +81,15 @@ extension REST { return } - let authorizationResult = requestHandler.requestAuthorization { completion in - self.dispatchQueue.async { - assert(self.requiresAuthorization, "Illegal use of completion handler.") + guard let authorizationProvider = requestHandler.authorizationProvider else { + requiresAuthorization = false + didReceiveAuthorization(nil) + return + } + requiresAuthorization = true + authorizationTask = authorizationProvider.getAuthorization { completion in + self.dispatchQueue.async { switch completion { case .success(let authorization): self.didReceiveAuthorization(authorization) @@ -97,16 +102,6 @@ extension REST { } } } - - switch authorizationResult { - case .pending(let task): - requiresAuthorization = true - authorizationTask = task - - case .noRequirement: - requiresAuthorization = false - didReceiveAuthorization(nil) - } } private func didReceiveAuthorization(_ authorization: REST.Authorization?) { @@ -119,17 +114,15 @@ extension REST { let endpoint = self.addressCacheStore.getCurrentEndpoint() - let result = requestHandler.createURLRequest( - endpoint: endpoint, - authorization: authorization - ) + do { + let request = try requestHandler.createURLRequest( + endpoint: endpoint, + authorization: authorization + ) - switch result { - case .success(let request): didReceiveURLRequest(request, endpoint: endpoint) - - case .failure(let error): - didFailToCreateURLRequest(error) + } catch { + didFailToCreateURLRequest(.createURLRequest(error)) } } @@ -138,22 +131,18 @@ extension REST { logger.error( chainedError: error, - message: "Failed to request authorization.", - metadata: loggerMetadata + message: "Failed to request authorization." ) finish(completion: .failure(error)) } - private func didReceiveURLRequest(_ urlRequest: URLRequest, endpoint: AnyIPEndpoint) { + private func didReceiveURLRequest(_ restRequest: REST.Request, endpoint: AnyIPEndpoint) { dispatchPrecondition(condition: .onQueue(dispatchQueue)) - logger.debug( - "Executing request using \(endpoint).", - metadata: loggerMetadata - ) + logger.debug("Send request to \(restRequest.pathTemplate.templateString) via \(endpoint).") - networkTask = urlSession.dataTask(with: urlRequest) { [weak self] data, response, error in + networkTask = urlSession.dataTask(with: restRequest.urlRequest) { [weak self] data, response, error in guard let self = self else { return } self.dispatchQueue.async { @@ -178,8 +167,7 @@ extension REST { logger.error( chainedError: error, - message: "Failed to create URLRequest.", - metadata: loggerMetadata + message: "Failed to create URLRequest." ) finish(completion: .failure(error)) @@ -202,17 +190,13 @@ extension REST { logger.error( chainedError: AnyChainedError(urlError), - message: "Failed to perform request to \(endpoint).", - metadata: loggerMetadata + message: "Failed to perform request to \(endpoint)." ) // Check if retry count is not exceeded. guard retryCount < retryStrategy.maxRetryCount else { if retryStrategy.maxRetryCount > 0 { - logger.debug( - "Ran out of retry attempts (\(retryStrategy.maxRetryCount))", - metadata: loggerMetadata - ) + logger.debug("Ran out of retry attempts (\(retryStrategy.maxRetryCount))") } finish(completion: OperationCompletion(result: .failure(.network(urlError)))) @@ -248,19 +232,39 @@ extension REST { private func didReceiveURLResponse(_ response: HTTPURLResponse, data: Data, endpoint: AnyIPEndpoint) { dispatchPrecondition(condition: .onQueue(dispatchQueue)) - let result = responseHandler.handleURLResponse(response, data: data) + logger.debug("Response: \(response.statusCode).") - if case .server(.invalidAccessToken) = result.error, - requiresAuthorization, retryInvalidAccessTokenError - { - logger.debug( - "Received invalid access token error. Retry once.", - metadata: loggerMetadata - ) - retryInvalidAccessTokenError = false - startRequest() - } else { - finish(completion: OperationCompletion(result: result)) + let handlerResult = responseHandler.handleURLResponse(response, data: data) + + switch handlerResult { + case .success(let output): + // Response handler produced value. + finish(completion: .success(output)) + + case .decoding(let decoderBlock): + // Response handler returned a block decoding value. + let decodeResult = Result { try decoderBlock() } + .mapError { error -> REST.Error in + return .decodeResponse(error) + } + finish(completion: OperationCompletion(result: decodeResult)) + + case .unhandledResponse(let serverErrorResponse): + // Response handler couldn't handle the response. + if serverErrorResponse?.code == .invalidAccessToken, + requiresAuthorization, + retryInvalidAccessTokenError + { + logger.debug("Received invalid access token error. Retry once.") + retryInvalidAccessTokenError = false + startRequest() + } else { + finish( + completion: .failure( + .unhandledResponse(response.statusCode, serverErrorResponse) + ) + ) + } } } } diff --git a/ios/MullvadVPN/REST/RESTProxy.swift b/ios/MullvadVPN/REST/RESTProxy.swift index 611f4d9c34..f166338976 100644 --- a/ios/MullvadVPN/REST/RESTProxy.swift +++ b/ios/MullvadVPN/REST/RESTProxy.swift @@ -25,13 +25,13 @@ extension REST { let requestFactory: REST.RequestFactory /// URL response decoder. - let responseDecoder: REST.ResponseDecoder + let responseDecoder: JSONDecoder init( name: String, configuration: ConfigurationType, requestFactory: REST.RequestFactory, - responseDecoder: REST.ResponseDecoder + responseDecoder: JSONDecoder ) { dispatchQueue = DispatchQueue(label: "REST.\(name).dispatchQueue") diff --git a/ios/MullvadVPN/REST/RESTRequestFactory.swift b/ios/MullvadVPN/REST/RESTRequestFactory.swift index 064af103fd..2046f26daa 100644 --- a/ios/MullvadVPN/REST/RESTRequestFactory.swift +++ b/ios/MullvadVPN/REST/RESTRequestFactory.swift @@ -37,14 +37,15 @@ extension REST { self.bodyEncoder = bodyEncoder } - func createURLRequest(endpoint: AnyIPEndpoint, method: HTTPMethod, path: String) -> URLRequest { + func createRequest(endpoint: AnyIPEndpoint, method: HTTPMethod, pathTemplate: URLPathTemplate) throws -> REST.Request { var urlComponents = URLComponents() urlComponents.scheme = "https" urlComponents.path = pathPrefix urlComponents.host = "\(endpoint.ip)" urlComponents.port = Int(endpoint.port) - let requestURL = urlComponents.url!.appendingPathComponent(path) + let pathString = try pathTemplate.pathString() + let requestURL = urlComponents.url!.appendingPathComponent(pathString) var request = URLRequest( url: requestURL, @@ -55,38 +56,44 @@ extension REST { request.addValue(hostname, forHTTPHeaderField: HTTPHeader.host) request.addValue("application/json", forHTTPHeaderField: HTTPHeader.contentType) request.httpMethod = method.rawValue - return request + + let prefixedPathTemplate = URLPathTemplate(stringLiteral: pathPrefix) + pathTemplate + + return REST.Request( + urlRequest: request, + pathTemplate: prefixedPathTemplate + ) } - func createURLRequestBuilder( + func createRequestBuilder( endpoint: AnyIPEndpoint, method: HTTPMethod, - path: String - ) -> RequestBuilder { - let request = createURLRequest( + pathTemplate: URLPathTemplate + ) throws -> RequestBuilder { + let request = try createRequest( endpoint: endpoint, method: method, - path: path + pathTemplate: pathTemplate ) return RequestBuilder( - request: request, + restRequest: request, bodyEncoder: bodyEncoder ) } } struct RequestBuilder { - private var request: URLRequest + private var restRequest: REST.Request private let bodyEncoder: JSONEncoder - init(request: URLRequest, bodyEncoder: JSONEncoder) { - self.request = request + init(restRequest: REST.Request, bodyEncoder: JSONEncoder) { + self.restRequest = restRequest self.bodyEncoder = bodyEncoder } mutating func setHTTPBody<T: Encodable>(value: T) throws { - request.httpBody = try bodyEncoder.encode(value) + restRequest.urlRequest.httpBody = try bodyEncoder.encode(value) } mutating func setETagHeader(etag: String) { @@ -95,7 +102,7 @@ extension REST { if etag.starts(with: "\"") { etag.insert(contentsOf: "W/", at: etag.startIndex) } - request.setValue(etag, forHTTPHeaderField: HTTPHeader.ifNoneMatch) + restRequest.urlRequest.setValue(etag, forHTTPHeaderField: HTTPHeader.ifNoneMatch) } mutating func setAuthorization(_ authorization: REST.Authorization) { @@ -108,11 +115,119 @@ extension REST { value = "Bearer \(accessToken)" } - request.addValue(value, forHTTPHeaderField: HTTPHeader.authorization) + restRequest.urlRequest.addValue(value, forHTTPHeaderField: HTTPHeader.authorization) } - func getURLRequest() -> URLRequest { - return request + func getRequest() -> REST.Request { + return restRequest } } + + struct URLPathTemplate: ExpressibleByStringLiteral { + enum Component { + case literal(String) + case placeholder(String) + } + + enum Error: LocalizedError { + /// Replacement value is not provided for placeholder. + case noReplacement(_ name: String) + + /// Failure to perecent encode replacement value. + case percentEncoding + + var errorDescription: String? { + switch self { + case .noReplacement(let placeholder): + return "Replacement is not provided for \(placeholder)." + + case .percentEncoding: + return "Failed to percent encode replacement value." + } + } + } + + private var components: [Component] + private var replacements = [String: String]() + + init(stringLiteral value: StringLiteralType) { + let slashCharset = CharacterSet(charactersIn: "/") + + components = value.split(separator: "/").map { subpath -> Component in + if subpath.hasPrefix("{") && subpath.hasSuffix("}") { + let name = String(subpath.dropFirst().dropLast()) + + return .placeholder(name) + } else { + return .literal( + subpath.trimmingCharacters(in: slashCharset) + ) + } + } + } + + private init(components: [Component]) { + self.components = components + } + + mutating func addPercentEncodedReplacement( + name: String, + value: String, + allowedCharacters: CharacterSet + ) throws { + let encoded = value.addingPercentEncoding( + withAllowedCharacters: allowedCharacters + ) + + if let encoded = encoded { + replacements[name] = encoded + } else { + throw Error.percentEncoding + } + } + + var templateString: String { + var combinedString = "" + + for component in components { + combinedString += "/" + + switch component { + case .literal(let string): + combinedString += string + case .placeholder(let name): + combinedString += "{\(name)}" + } + } + + return combinedString + } + + func pathString() throws -> String { + var combinedPath = "" + + for component in components { + combinedPath += "/" + + switch component { + case .literal(let string): + combinedPath += string + + case .placeholder(let name): + if let string = replacements[name] { + combinedPath += string + } else { + throw Error.noReplacement(name) + } + } + } + + return combinedPath + } + + static func + (lhs: URLPathTemplate, rhs: URLPathTemplate) -> URLPathTemplate { + return URLPathTemplate(components: lhs.components + rhs.components) + } + } + } diff --git a/ios/MullvadVPN/REST/RESTRequestHandler.swift b/ios/MullvadVPN/REST/RESTRequestHandler.swift index d0f7b54dc3..6d690d19e7 100644 --- a/ios/MullvadVPN/REST/RESTRequestHandler.swift +++ b/ios/MullvadVPN/REST/RESTRequestHandler.swift @@ -9,59 +9,47 @@ import Foundation protocol RESTRequestHandler { - typealias AuthorizationCompletion = (OperationCompletion<REST.Authorization, REST.Error>) -> Void + func createURLRequest( + endpoint: AnyIPEndpoint, + authorization: REST.Authorization? + ) throws -> REST.Request - func createURLRequest(endpoint: AnyIPEndpoint, authorization: REST.Authorization?) -> Result<URLRequest, REST.Error> - func requestAuthorization(completion: @escaping AuthorizationCompletion) -> REST.AuthorizationResult + var authorizationProvider: RESTAuthorizationProvider? { get } } extension REST { - - enum AuthorizationResult { - /// There is no requirement for authorizing this request. - case noRequirement - - /// Authorization request is initiated. - /// Associated value contains a handle that can be used to cancel - /// the request. - case pending(Cancellable) + struct Request { + var urlRequest: URLRequest + var pathTemplate: URLPathTemplate } final class AnyRequestHandler: RESTRequestHandler { - private let _createURLRequest: (AnyIPEndpoint, REST.Authorization?) -> Result<URLRequest, REST.Error> - private let _requestAuthorization: ((@escaping AuthorizationCompletion) -> AuthorizationResult)? + private let _createURLRequest: (AnyIPEndpoint, REST.Authorization?) throws -> REST.Request - init(createURLRequest: @escaping (AnyIPEndpoint) -> Result<URLRequest, REST.Error>) { + let authorizationProvider: RESTAuthorizationProvider? + + init(createURLRequest: @escaping (AnyIPEndpoint) throws -> REST.Request) { _createURLRequest = { endpoint, authorization in - createURLRequest(endpoint) + return try createURLRequest(endpoint) } - _requestAuthorization = nil + authorizationProvider = nil } init( - createURLRequest: @escaping (AnyIPEndpoint, REST.Authorization) -> Result<URLRequest, REST.Error>, - requestAuthorization: @escaping (@escaping AuthorizationCompletion) -> Cancellable + createURLRequest: @escaping (AnyIPEndpoint, REST.Authorization) throws -> REST.Request, + authorizationProvider: RESTAuthorizationProvider ) { _createURLRequest = { endpoint, authorization in - return createURLRequest(endpoint, authorization!) - } - _requestAuthorization = { completion in - return .pending(requestAuthorization(completion)) + return try createURLRequest(endpoint, authorization!) } + self.authorizationProvider = authorizationProvider } func createURLRequest( endpoint: AnyIPEndpoint, authorization: REST.Authorization? - ) -> Result<URLRequest, REST.Error> { - return _createURLRequest(endpoint, authorization) - } - - func requestAuthorization( - completion: @escaping (OperationCompletion<REST.Authorization, REST.Error>) -> Void - ) -> REST.AuthorizationResult { - return _requestAuthorization?(completion) ?? .noRequirement + ) throws -> REST.Request { + return try _createURLRequest(endpoint, authorization) } } - } diff --git a/ios/MullvadVPN/REST/RESTResponseDecoder.swift b/ios/MullvadVPN/REST/RESTResponseDecoder.swift deleted file mode 100644 index 2e79bbc9e3..0000000000 --- a/ios/MullvadVPN/REST/RESTResponseDecoder.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// RESTResponseDecoder.swift -// MullvadVPN -// -// Created by pronebird on 16/04/2022. -// Copyright © 2022 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -extension REST { - struct ResponseDecoder { - let decoder: JSONDecoder - - init(decoder: JSONDecoder) { - self.decoder = decoder - } - - // Parse JSON response into the given `Decodable` type. - func decodeSuccessResponse<T: Decodable>(_ type: T.Type, from data: Data) -> Result<T, REST.Error> { - return Result { try decoder.decode(type, from: data) } - .mapError { error in - return .decodeSuccessResponse(error) - } - } - - /// Parse server error response from JSON. - func decodeErrorResponse(from data: Data) -> Result<REST.ServerErrorResponse, REST.Error> { - return Result { () -> REST.ServerErrorResponse in - return try decoder.decode(REST.ServerErrorResponse.self, from: data) - } - .mapError { error in - return .decodeErrorResponse(error) - } - } - - /// Parse server error response from JSON and map it to `RESTError.server` error kind. - func decodeErrorResponseAndMapToServerError<T>(from data: Data) -> Result<T, REST.Error> { - return decodeErrorResponse(from: data) - .flatMap { serverError in - return .failure(.server(serverError)) - } - } - } - -} diff --git a/ios/MullvadVPN/REST/RESTResponseHandler.swift b/ios/MullvadVPN/REST/RESTResponseHandler.swift index 65ae4b6e2d..1ec56541fa 100644 --- a/ios/MullvadVPN/REST/RESTResponseHandler.swift +++ b/ios/MullvadVPN/REST/RESTResponseHandler.swift @@ -11,12 +11,25 @@ import Foundation protocol RESTResponseHandler { associatedtype Success - func handleURLResponse(_ response: HTTPURLResponse, data: Data) -> Result<Success, REST.Error> + func handleURLResponse(_ response: HTTPURLResponse, data: Data) -> REST.ResponseHandlerResult<Success> } extension REST { + /// Responser handler result type. + enum ResponseHandlerResult<Success> { + /// Response handler succeeded and produced a value. + case success(Success) + + /// Response handler succeeded and returned a block that decodes the value. + case decoding(_ decoderBlock: () throws -> Success) + + /// Response handler received the response that it cannot handle. + /// Server error response is attached when available. + case unhandledResponse(ServerErrorResponse?) + } + final class AnyResponseHandler<Success>: RESTResponseHandler { - typealias HandlerBlock = (HTTPURLResponse, Data) -> Result<Success, REST.Error> + typealias HandlerBlock = (HTTPURLResponse, Data) -> REST.ResponseHandlerResult<Success> private let handlerBlock: HandlerBlock @@ -24,7 +37,7 @@ extension REST { handlerBlock = block } - func handleURLResponse(_ response: HTTPURLResponse, data: Data) -> Result<Success, REST.Error> { + func handleURLResponse(_ response: HTTPURLResponse, data: Data) -> REST.ResponseHandlerResult<Success> { return handlerBlock(response, data) } } @@ -32,12 +45,22 @@ extension REST { /// Returns default response handler that parses JSON response into the /// given `Decodable` type when it encounters HTTP `2xx` code, otherwise /// attempts to decode the server error. - static func defaultResponseHandler<T: Decodable>(decoding type: T.Type, with decoder: REST.ResponseDecoder) -> AnyResponseHandler<T> { + static func defaultResponseHandler<T: Decodable>( + decoding type: T.Type, + with decoder: JSONDecoder + ) -> AnyResponseHandler<T> { return AnyResponseHandler { response, data in if HTTPStatus.isSuccess(response.statusCode) { - return decoder.decodeSuccessResponse(type, from: data) + return .decoding { + try decoder.decode(type, from: data) + } } else { - return decoder.decodeErrorResponseAndMapToServerError(from: data) + return .unhandledResponse( + try? decoder.decode( + ServerErrorResponse.self, + from: data + ) + ) } } } diff --git a/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift b/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift index 6952a4130e..a38a23bb6c 100644 --- a/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift +++ b/ios/MullvadVPN/TunnelManager/SetAccountOperation.swift @@ -158,7 +158,8 @@ class SetAccountOperation: ResultOperation<(), TunnelManager.Error> { case .success: self.logger.info("Removed key (\(index)) from server.") - case .failure(.server(.pubKeyNotFound)): + case .failure(.unhandledResponse(_, let serverErrorResponse)) + where serverErrorResponse?.code == .publicKeyNotFound: self.logger.debug("Key (\(index)) was not found on server.") case .failure(let error): diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 76f7c0945b..755e92306c 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -845,7 +845,8 @@ extension TunnelManager { // Do not retry if logged out. return nil - case .replaceWireguardKey(.server(.invalidAccount)): + case .replaceWireguardKey(.unhandledResponse(_, let serverErrorResponse)) + where serverErrorResponse?.code == .invalidAccount: // Do not retry if account was removed. return nil diff --git a/ios/MullvadVPN/WireguardKeysViewController.swift b/ios/MullvadVPN/WireguardKeysViewController.swift index d53739edae..f3fad9cbc6 100644 --- a/ios/MullvadVPN/WireguardKeysViewController.swift +++ b/ios/MullvadVPN/WireguardKeysViewController.swift @@ -227,7 +227,8 @@ class WireguardKeysViewController: UIViewController, TunnelObserver { self.updateViewState(.verifiedKey(true)) case .failure(let error): - if case .server(.pubKeyNotFound) = error { + if case .unhandledResponse(_, let serverErrorResponse) = error, + serverErrorResponse?.code == .publicKeyNotFound { self.updateViewState(.verifiedKey(false)) } else { self.showKeyVerificationFailureAlert(error) |
