// // Copyright RevenueCat Inc. All Rights Reserved. // // Licensed under the MIT License (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://opensource.org/licenses/MIT // // HTTPClient.swift // // Created by César de la Vega on 7/22/21. import Foundation // swiftlint:disable file_length class HTTPClient { typealias RequestHeaders = HTTPRequest.Headers typealias ResponseHeaders = HTTPResponse.Headers typealias Completion = (VerifiedHTTPResponse.Result) -> Void let systemInfo: SystemInfo let timeout: TimeInterval let apiKey: String let authHeaders: RequestHeaders private let session: URLSession private let state: Atomic = .init(.initial) private let eTagManager: ETagManager private let dnsChecker: DNSCheckerType.Type private let signing: SigningType init(apiKey: String, systemInfo: SystemInfo, eTagManager: ETagManager, signing: SigningType, dnsChecker: DNSCheckerType.Type = DNSChecker.self, requestTimeout: TimeInterval = Configuration.networkTimeoutDefault) { let config = URLSessionConfiguration.ephemeral config.httpMaximumConnectionsPerHost = 1 config.timeoutIntervalForRequest = requestTimeout config.timeoutIntervalForResource = requestTimeout config.urlCache = nil // We implement our own caching with `ETagManager`. self.session = URLSession(configuration: config, delegate: RedirectLoggerSessionDelegate(), delegateQueue: nil) self.systemInfo = systemInfo self.eTagManager = eTagManager self.signing = signing self.dnsChecker = dnsChecker self.timeout = requestTimeout self.apiKey = apiKey self.authHeaders = HTTPClient.authorizationHeader(withAPIKey: apiKey) } /// - Parameter verificationMode: if `nil`, this will default to `SystemInfo.responseVerificationMode` func perform( _ request: HTTPRequest, with verificationMode: Signing.ResponseVerificationMode? = nil, completionHandler: Completion? ) { #if DEBUG guard !self.systemInfo.dangerousSettings.internalSettings.forceServerErrors else { Logger.warn(Strings.network.api_request_forcing_server_error(request)) completionHandler?( .failure(.errorResponse(Self.serverErrorResponse, .internalServerError)) ) return } #endif self.perform(request: .init(httpRequest: request, authHeaders: self.authHeaders, verificationMode: verificationMode ?? self.systemInfo.responseVerificationMode, completionHandler: completionHandler)) } func clearCaches() { self.eTagManager.clearCaches() } var signatureVerificationEnabled: Bool { return self.systemInfo.responseVerificationMode.isEnabled } } extension HTTPClient { static func authorizationHeader(withAPIKey apiKey: String) -> RequestHeaders { return [RequestHeader.authorization.rawValue: "Bearer \(apiKey)"] } static func nonceHeader(with data: Data) -> RequestHeaders { return [RequestHeader.nonce.rawValue: data.base64EncodedString()] } enum RequestHeader: String { case authorization = "Authorization" case nonce = "X-Nonce" case eTag = "X-RevenueCat-ETag" case eTagValidationTime = "X-RC-Last-Refresh-Time" } enum ResponseHeader: String { case eTag = "X-RevenueCat-ETag" case location = "Location" case signature = "X-Signature" case requestDate = "X-RevenueCat-Request-Time" case contentType = "Content-Type" case isLoadShedder = "X-RevenueCat-Fortress" } } // @unchecked because: // - Class is not `final` (it's mocked). This implicitly makes subclasses `Sendable` even if they're not thread-safe. extension HTTPClient: @unchecked Sendable {} // MARK: - Private private extension HTTPClient { struct State { var queuedRequests: [Request] var currentSerialRequest: Request? static let initial: Self = .init(queuedRequests: [], currentSerialRequest: nil) } struct Request: CustomStringConvertible { var httpRequest: HTTPRequest var headers: HTTPClient.RequestHeaders var verificationMode: Signing.ResponseVerificationMode var completionHandler: HTTPClient.Completion? var retried: Bool = false init(httpRequest: HTTPRequest, authHeaders: HTTPClient.RequestHeaders, verificationMode: Signing.ResponseVerificationMode, completionHandler: HTTPClient.Completion?) { self.httpRequest = httpRequest.requestAddingNonceIfRequired(with: verificationMode) self.headers = self.httpRequest.headers(with: authHeaders) self.verificationMode = verificationMode if let completionHandler = completionHandler { self.completionHandler = { result in completionHandler(result.parseResponse()) } } else { self.completionHandler = nil } } var method: HTTPRequest.Method { self.httpRequest.method } var path: String { self.httpRequest.path.description } func adding(defaultHeaders: HTTPClient.RequestHeaders) -> Self { var copy = self copy.headers = defaultHeaders.merging(self.headers) return copy } func retriedRequest() -> Self { var copy = self copy.retried = true return copy } var description: String { """ <\(type(of: self)): httpMethod=\(self.method.httpMethod) path=\(self.path) headers=\(self.headers.description ) retried=\(self.retried) > """ } } } private extension HTTPClient { var defaultHeaders: [String: String] { var headers: [String: String] = [ "content-type": "application/json", "X-Version": SystemInfo.frameworkVersion, "X-Platform": SystemInfo.platformHeader, "X-Platform-Version": SystemInfo.systemVersion, "X-Platform-Flavor": systemInfo.platformFlavor, "X-Client-Version": SystemInfo.appVersion, "X-Client-Build-Version": SystemInfo.buildVersion, "X-Client-Bundle-ID": SystemInfo.bundleIdentifier, "X-StoreKit2-Enabled": "\(self.systemInfo.storeKit2Setting.isEnabledAndAvailable)", "X-Observer-Mode-Enabled": "\(self.systemInfo.observerMode)", "X-Is-Sandbox": "\(self.systemInfo.isSandbox)" ] if let platformFlavorVersion = self.systemInfo.platformFlavorVersion { headers["X-Platform-Flavor-Version"] = platformFlavorVersion } if let idfv = systemInfo.identifierForVendor { headers["X-Apple-Device-Identifier"] = idfv } if systemInfo.dangerousSettings.customEntitlementComputation { headers["X-Custom-Entitlements-Computation"] = "\(true)" } return headers } static let serverErrorResponse: ErrorResponse = .init(code: .internalServerError, originalCode: BackendErrorCode.unknownBackendError.rawValue) func perform(request: Request) { if !request.retried { let requestEnqueued: Bool = self.state.modify { if $0.currentSerialRequest != nil { Logger.debug(Strings.network.serial_request_queued(httpMethod: request.method.httpMethod, path: request.path, queuedRequestsCount: $0.queuedRequests.count)) $0.queuedRequests.append(request) return true } else { Logger.debug(Strings.network.starting_request(httpMethod: request.method.httpMethod, path: request.path)) $0.currentSerialRequest = request return false } } guard !requestEnqueued else { return } } self.start(request: request) } /// - Returns: `nil` if the request must be retried func parse(urlResponse: URLResponse?, request: Request, urlRequest: URLRequest, data: Data?, error networkError: Error?) -> VerifiedHTTPResponse.Result? { if let networkError = networkError { return .failure(NetworkError(networkError, dnsChecker: self.dnsChecker)) } guard let httpURLResponse = urlResponse as? HTTPURLResponse else { return .failure(.unexpectedResponse(urlResponse)) } let statusCode: HTTPStatusCode = .init(rawValue: httpURLResponse.statusCode) // `nil` if status code is 304, since the response will be empty and fetched from the eTag. let dataIfAvailable = statusCode == .notModified ? nil : data return self.createVerifiedResponse(request: request, urlRequest: urlRequest, data: dataIfAvailable, response: httpURLResponse) } /// - Returns `Result, NetworkError>?` private func createVerifiedResponse( request: Request, urlRequest: URLRequest, data: Data?, response httpURLResponse: HTTPURLResponse ) -> VerifiedHTTPResponse.Result? { return Result .success(data) .mapToResponse(response: httpURLResponse, request: request.httpRequest) // Verify response .map { cachedResponse -> VerifiedHTTPResponse in return cachedResponse.verify( signing: self.signing(for: request.httpRequest), request: request.httpRequest, publicKey: request.verificationMode.publicKey ) } // Fetch from ETagManager if available .map { (response) -> VerifiedHTTPResponse? in return self.eTagManager.httpResultFromCacheOrBackend( with: response, request: urlRequest, retried: request.retried ) } // Upgrade to error in enforced mode .flatMap { response -> Result?, NetworkError> in if let response = response, response.verificationResult == .failed { if case .enforced = request.verificationMode { return .failure(.signatureVerificationFailed(path: request.httpRequest.path, code: response.statusCode)) } else { // Any other mode gets forwarded as a success, but we log the error Logger.error(Strings.signing.request_failed_verification(request.httpRequest)) return .success(response) } } else { return .success(response) } } .asOptionalResult? .convertUnsuccessfulResponseToError() } func handle(urlResponse: URLResponse?, request: Request, urlRequest: URLRequest, data: Data?, error networkError: Error?) { RCTestAssertNotMainThread() let response = self.parse( urlResponse: urlResponse, request: request, urlRequest: urlRequest, data: data, error: networkError ) if let response = response { switch response { case let .success(response): Logger.debug(Strings.network.api_request_completed( request.httpRequest, // Getting status code from the original response to detect 304s // If that can't be extracted, get status code from the parsed response. httpCode: urlResponse?.httpStatusCode ?? response.statusCode )) if response.isLoadShedder { Logger.debug(Strings.network.request_handled_by_load_shedder(request.httpRequest.path)) } case let .failure(error): Logger.debug(Strings.network.api_request_failed(request.httpRequest, httpCode: urlResponse?.httpStatusCode, error: error)) } request.completionHandler?(response) } else { Logger.debug(Strings.network.retrying_request(httpMethod: request.method.httpMethod, path: request.path)) self.state.modify { $0.queuedRequests.insert(request.retriedRequest(), at: 0) } } self.beginNextRequest() } func beginNextRequest() { let nextRequest: Request? = self.state.modify { Logger.debug(Strings.network.serial_request_done(httpMethod: $0.currentSerialRequest?.method.httpMethod, path: $0.currentSerialRequest?.path, queuedRequestsCount: $0.queuedRequests.count)) $0.currentSerialRequest = $0.queuedRequests.popFirst() return $0.currentSerialRequest } if let nextRequest = nextRequest { Logger.debug(Strings.network.starting_next_request(request: nextRequest.description)) self.start(request: nextRequest) } } func start(request: Request) { let urlRequest = self.convert(request: request.adding(defaultHeaders: self.defaultHeaders)) guard let urlRequest = urlRequest else { let error: NetworkError = .unableToCreateRequest(request.httpRequest.path) Logger.error(error.description) request.completionHandler?(.failure(error)) return } Logger.debug(Strings.network.api_request_started(request.httpRequest)) let task = self.session.dataTask(with: urlRequest) { (data, urlResponse, error) -> Void in self.handle(urlResponse: urlResponse, request: request, urlRequest: urlRequest, data: data, error: error) } task.resume() } func convert(request: Request) -> URLRequest? { guard let requestURL = request.httpRequest.path.url else { return nil } var urlRequest = URLRequest(url: requestURL) urlRequest.httpMethod = request.method.httpMethod urlRequest.allHTTPHeaderFields = self.headers(for: request, urlRequest: urlRequest) do { urlRequest.httpBody = try request.httpRequest.requestBody?.jsonEncodedData } catch { Logger.error(Strings.network.creating_json_error(error: error.localizedDescription)) return nil } return urlRequest } private func headers(for request: Request, urlRequest: URLRequest) -> HTTPClient.RequestHeaders { if request.httpRequest.path.shouldSendEtag { let eTagHeader = self.eTagManager.eTagHeader( for: urlRequest, withSignatureVerification: request.verificationMode.isEnabled, refreshETag: request.retried ) return request.headers.merging(eTagHeader) } else { return request.headers } } private func signing(for request: HTTPRequest) -> SigningType { #if DEBUG if self.systemInfo.dangerousSettings.internalSettings.forceSignatureFailures { Logger.warn(Strings.network.api_request_forcing_signature_failure(request)) return FakeSigning.default } #endif return self.signing } } // MARK: - Extensions extension HTTPRequest { func requestAddingNonceIfRequired( with verificationMode: Signing.ResponseVerificationMode ) -> HTTPRequest { var result = self if result.nonce == nil, result.path.needsNonceForSigning, verificationMode.isEnabled, #available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) { result.addRandomNonce() } return result } func headers(with authHeaders: HTTPClient.RequestHeaders) -> HTTPClient.RequestHeaders { var result: HTTPClient.RequestHeaders = [:] if self.path.authenticated { result += authHeaders } if let nonce = self.nonce { result += HTTPClient.nonceHeader(with: nonce) } return result } } extension HTTPRequest.Path { var url: URL? { return URL(string: self.relativePath, relativeTo: SystemInfo.serverHostURL) } var relativePath: String { return "\(Self.pathPrefix)/\(self.description)" } private static let pathPrefix: String = "/v1" } private extension NetworkError { /// Creates a `NetworkError` from any request `Error`. init(_ error: Error, dnsChecker: DNSCheckerType.Type) { if let blockedError = dnsChecker.errorWithBlockedHostFromError(error) { Logger.error(blockedError.description) self = blockedError } else { let nsError = error as NSError switch (nsError.domain, nsError.code) { case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet): self = .offlineConnection() default: self = .networkError(error) } } } } extension Result where Success == Data?, Failure == NetworkError { /// Converts a `Result` into `Result, NetworkError>` func mapToResponse( response: HTTPURLResponse, request: HTTPRequest ) -> Result, Failure> { return self.flatMap { body in return .success( .init( statusCode: .init(rawValue: response.statusCode), responseHeaders: response.allHeaderFields, body: body ) ) } } } extension Result where Success == VerifiedHTTPResponse, Failure == NetworkError { // Parses a `Result>` to `Result>` func parseResponse() -> VerifiedHTTPResponse.Result { return self.flatMap { response in // Convert the `Result` type Result, Error> { // Create a new `Result` try response.mapBody { data in // Convert the from `Data` -> `Value` try Value.create(with: data) // Decode `Data` into `Value` } .copyWithNewRequestDate() // Update request date for 304 responses } // Convert decoding errors into `NetworkError.decoding` .mapError { NetworkError.decoding($0, response.response.body) } } } } extension Result where Success == VerifiedHTTPResponse, Failure == NetworkError { // Converts an unsuccessful response into a `Result.failure` fileprivate func convertUnsuccessfulResponseToError() -> Self { return self.flatMap { $0.response.statusCode.isSuccessfulResponse ? .success($0) : .failure($0.response.parseUnsuccessfulResponse()) } } } private extension VerifiedHTTPResponse { func copyWithNewRequestDate() -> Self { // Update request time from server unless it failed verification. guard self.verificationResult != .failed, let requestDate = self.requestDate else { return self } return self.mapBody { return $0.copy(with: requestDate) } } var isLoadShedder: Bool { return self.response.value(forHeaderField: HTTPClient.ResponseHeader.isLoadShedder) == "true" } } private extension HTTPResponse where Body == Data { func parseUnsuccessfulResponse() -> NetworkError { let isJSON = self.value(forHeaderField: HTTPClient.ResponseHeader.contentType) == "application/json" return .errorResponse( isJSON ? .from(self.body) : .defaultResponse, self.statusCode ) } }