// // OOSAPIClient.swift // PSCB-OOS-iOS // // Created by Antonov Ilia on 12.10.2020. // import Foundation import PassKit // MARK: - Environments public enum BackendEnvironment: String { case production = "oos.pscb.ru" case sandbox = "oosdemo.pscb.ru" } // MARK: - Request status /// Backend request status /// Consists of two possible states: `.success` and `.failure`. /// /// This one represents if request to the backend server was successful public enum RequestStatus { case success, failure } // MARK: - Response wrapper /// Backend response public struct BackendResponse { /// Represents request status public let status: RequestStatus /// Hold error information if request is a `.failure` public let error: Error? /// Holds response information if request is a `.success` public let response: Response? } // MARK: - Alias for completion handler /// Callback for `OOSAPIClient.send` method public typealias APICompletionHandler = (BackendResponse) -> Void /// Callback for another `OOSAPIClient.send` method which fires after certain guards ensure success or failure /// to simplify API usage public typealias PostResponseHandler = (Error?, Response?) -> Void // MARK: - API Integration errors /// Possible erros when dealing with OOS backend public enum OOSErrors: Error { case noData case parse(Error) case cause(Error) /// Consists of error code and error description case backend(String, String) } // MARK: - API Client /// Implementation of OOS Merchant API HTTP protocol. final public class PSCBOnlineClient { private lazy var urlSession: URLSession = { URLSession(configuration: .default, delegate: nil, delegateQueue: .main) }() private lazy var url: URL = { var components = URLComponents() components.scheme = "https" components.host = self.environment.rawValue components.path = "/merchantApi/payShpa" return components.url! }() private let decoder = JSONDecoders.iso8601DateAwareDecoder() // init: let environment: BackendEnvironment let marketPlaceId: String let signingKey: String /// Initializes `OOSAPIClient` /// /// - Parameters: /// - environment: Backend environment. Which one to use. For testing environment use `.sandbox` /// - marketPlaceId: Your OOS market-place ID /// - siginingKey: Your OOS signing key public init(environment: BackendEnvironment, marketPlaceId: String, signingKey: String) { self.environment = environment self.marketPlaceId = marketPlaceId self.signingKey = signingKey } // impl: /// Creates instance of `RequestWrapper` for a backend to process from given PKPayment instance and other details /// /// - Parameters: /// - payment: authorized PKPayment instance /// - amount: total order amount /// - orderId: Unique merchant order ID /// /// - Returns: RequestWrapper instance for backend public func makeRequestWithPayment(payment: PKPayment, amount: Decimal, orderId: String) throws -> RequestWrapper { return try makeRequestWithPayment(pkPayment: payment, payment: Payment(amount: amount, orderId: orderId)) } /// Creates instance of `RequestWrapper` for a backend to process from given `PKPayment` instance and backend `Payment` instance /// /// - Parameters: /// - pkPayment: authorized `PKPayment` instance /// - payment: backend `Payment` details /// /// - Returns: RequestWrapper instance for backend public func makeRequestWithPayment(pkPayment: PKPayment, payment: Payment) throws -> RequestWrapper { // Get card data cryptogram from payment token let cardDataCrypto = try pkPayment.token.toCryptogramString() // Backend request wrapper let requestWrapper = RequestWrapper( marketPlaceId: self.marketPlaceId, payment: payment, cardData: cardDataCrypto ) // Fail early try requestWrapper.assumeSerializes() return requestWrapper } /// Creates instance of `RequestWrapper` for a backend to process from raw card data instead of ApplePay token /// /// Example: /// ``` /// let card = CardData( /// pan: "409444400001234", /// expiryYear: 2025, /// expiryMonth: 12, /// cvCode: "000", /// cardholder: "JOHN DOE" /// ) /// /// let payment = Payment(amount: Decimal(1500), orderId: "XC-12345") /// let request = try apiClient.createRequestWithCardData(card: card, payment: payment) /// /// // Send to backend: /// apiClient.send(request) { (response) in /code/ } /// ``` /// /// - Parameters: /// - cardData: an instance of card data. public func makeRequestWithCardData(card: CardData, payment: Payment) throws -> RequestWrapper { let cryptogram = try card.toCryptgramString() let request = RequestWrapper( marketPlaceId: self.marketPlaceId, payment: payment, cardData: cryptogram ) // Fail-early compile try request.assumeSerializes() return request } /// Signs and sends compiled `RequestWrapper` to the backend server /// Fires `APICompletionHandler` once requests succeeds or fails /// /// - Parameters: /// - request: `RequestWrapper` created from `createRequestWithPayment(...)` /// - completionHander: `APICompletionHandler` a callback for when requests succeeds or fails /// public func send(_ request: RequestWrapper, completionHandler: @escaping APICompletionHandler) { // Calculate signature and get HTTP body at the same time let httpBody = try! request.serializeToString() let signature = calculateSignature(httpBody) // Request params let url = self.url var req = URLRequest(url: url) // Set request parameters req.httpMethod = "POST" req.httpBody = httpBody.data(using: .utf8) req.setValue(signature, forHTTPHeaderField: "Signature") print(">> Request JSON: \(String(describing: String(data: req.httpBody!, encoding: .utf8)))") let task = urlSession.dataTask(with: req) { (data, res, err) in guard err == nil else { let error = self.err_requestError(req, error: err) completionHandler(error) return } guard let data = data else { let error = self.failure(req, error: OOSErrors.noData) completionHandler(error) return } print("<< Response data: \(String(data: data, encoding: .utf8) ?? "N/A")") do { let response = try self.decoder.decode(Response.self, from: data) // Finaly success let success = BackendResponse(status: .success, error: nil, response: response) completionHandler(success) } catch let error { print("Failed to do task on request \(req)") print(error.localizedDescription) // Reading and parsing errors let failure = self.failure(req, error: .parse(error)) completionHandler(failure) } } // Fire task task.resume() } /// Signs and sends compiled `RequestWrapper` to the backend server /// Fires `PostResponseHandler` once requests succeeds or fails. /// `PostResponseHandler` accepts two arguments: `Error?` and `Response?`. /// /// If requests succeeds and payment in desired state `Error?` will always be `nil`. /// `Response?` presents on some errors and on all successes. /// /// This is an utility method to reduce boilerplate for necessary checks. /// /// - Parameters: /// - request: `RequestWrapper` created from `createRequestWithPayment(...)` /// - completionHander: `APICompletionHandler` a callback for when requests succeeds or fails /// public func send(_ request: RequestWrapper, responseHandler: @escaping PostResponseHandler) { // Construct a closure wrapper with predefined guards and checks let wrapper: APICompletionHandler = { (backendResponse) in // HTTP request succeeded? guard backendResponse.status == .success else { print("Failed to execute request due to error: \(String(describing: backendResponse.error))") responseHandler(OOSErrors.cause(backendResponse.error!), nil) return } // Backend returned valid response? guard let response = backendResponse.response else { print("Backend returned empty body") responseHandler(OOSErrors.noData, nil) return } // Backend payment succeeded? guard response.status == .success else { print("Unable to process payment") let responseError = response.error let backendError = OOSErrors.backend(responseError?.code ?? "FAILED", responseError?.description ?? "") // At this point we can pass response too responseHandler(backendError, response) return } // Finally succeeds responseHandler(nil, response) } // Send using more lower-level method above send(request, completionHandler: wrapper) } // MARK: - Private private func err_requestError(_ req: URLRequest, error: Error?) -> BackendResponse { print("Failed to do task on request \(req) with error: \(String(describing: error))") return BackendResponse(status: .failure, error: OOSErrors.cause(error!), response: nil) } private func failure(_ req: URLRequest, error: OOSErrors) -> BackendResponse { print("Request \(req) failed with error: \(String(describing: error))") return BackendResponse(status: .failure, error: error, response: nil) } private func calculateSignature(_ jsonString: String) -> String { let composite = (jsonString + self.signingKey) return DigestHelper.sha256String(composite) } }