From 11a8dd96a7e21ab568bfa531972e7ff2358f357d Mon Sep 17 00:00:00 2001 From: Tobi Omotayo Date: Tue, 18 Jun 2024 18:47:06 +0100 Subject: [PATCH] Convert AnyPublisher to async/await (#183) * update code formatting to remove warnings. * convert functions on networking interfaces from AnyPublisher to async throwing functions. (#180) * Update Core Networking Implementation (#181) * remove use of async bridge function * update the core urlsession implementation of send and upload methods of rest service. * remove URLUploadSessionPublisher interface and implementation * update core networking implementation based on the async await changes made to the networking interfaces. * update unit tests to use new async methods * fix some code formatting issues. * update the url session to use a protocol that is replaceable with a mock * update the url session to use a protocol that is replaceable with a mock * fix the issues with failing polling tests. * update changelog. * some code formatting * cleanup import statement. * restore disable lint rule * Make change log more descriptive. * update podspec file. update changelog. update SmileID.swift file and run pod install in example. --- CHANGELOG.md | 7 + Example/Podfile.lock | 4 +- ...ometricKycWithIdInputScreenViewModel.swift | 8 +- .../SmileID/DocumentSelectorViewModel.swift | 65 ++++---- .../DocumentVerificationIdTypeSelector.swift | 5 + ...nhancedKycWithIdInputScreenViewModel.swift | 12 +- SmileID.podspec | 6 +- .../OrchestratedBiometricKycViewModel.swift | 6 +- ...stratedDocumentVerificationViewModel.swift | 6 +- .../SmileID/Classes/Networking/APIError.swift | 40 ++--- .../Networking/RestServiceClient.swift | 7 +- .../Classes/Networking/ServiceRunnable.swift | 81 ++++------ .../Classes/Networking/SmileIDService.swift | 143 ++++++++-------- .../URLSession/URLSessionPublisher.swift | 6 +- .../URLSessionRestServiceClient.swift | 119 ++++++-------- .../SelfieCapture/SelfieViewModel.swift | 6 +- Sources/SmileID/Classes/SmileID.swift | 8 +- .../Classes/Views/JobSubmittable.swift | 20 ++- Tests/Mocks/NetworkingMocks.swift | 153 +++++++----------- Tests/Networking/EnhancedKycTest.swift | 1 - Tests/Networking/PollingTests.swift | 118 ++++++-------- .../URLSessionRestServiceClientTests.swift | 34 ++-- 22 files changed, 372 insertions(+), 483 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 956458a2b..c7764acb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Release Notes +## 10.2.0 + +#### Changed + +* **Breaking Change:** Updated the networking layer to use Swift's `async/await` instead of Combine's `AnyPublisher` and now return `async` functions. This improves readability and aligns with modern Swift concurrency practices. + * All instances where these methods are used have been updated accordingly. + ## 10.1.6 #### Added diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 1efd8b359..a25618881 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -4,7 +4,7 @@ PODS: - Sentry (8.26.0): - Sentry/Core (= 8.26.0) - Sentry/Core (8.26.0) - - SmileID (10.1.6): + - SmileID (10.2.0): - lottie-ios (~> 4.4.2) - Zip (~> 2.1.0) - SwiftLint (0.55.1) @@ -32,7 +32,7 @@ SPEC CHECKSUMS: lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418 netfox: 9d5cc727fe7576c4c7688a2504618a156b7d44b7 Sentry: 74a073c71c998117edb08f56f443c83570a31bed - SmileID: a3dd288193c84518e4f7c0fe9383de685afbac6e + SmileID: bfcd426e9a47560eb949c18591ad032a8d8cde0d SwiftLint: 3fe909719babe5537c552ee8181c0031392be933 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 diff --git a/Example/SmileID/BiometricKYC/BiometricKycWithIdInputScreenViewModel.swift b/Example/SmileID/BiometricKYC/BiometricKycWithIdInputScreenViewModel.swift index cf97a5a6e..d541199f3 100644 --- a/Example/SmileID/BiometricKYC/BiometricKycWithIdInputScreenViewModel.swift +++ b/Example/SmileID/BiometricKYC/BiometricKycWithIdInputScreenViewModel.swift @@ -31,16 +31,16 @@ class BiometricKycWithIdInputScreenViewModel: ObservableObject { } Task { do { - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) let productsConfigRequest = ProductsConfigRequest( timestamp: authResponse.timestamp, signature: authResponse.signature ) let productsConfigResponse = try await SmileID.api.getProductsConfig( request: productsConfigRequest - ).async() + ) let supportedCountries = productsConfigResponse.idSelection.biometricKyc - let servicesResponse = try await SmileID.api.getServices().async() + let servicesResponse = try await SmileID.api.getServices() let servicesCountryInfo = servicesResponse.hostedWeb.biometricKyc // sort by country name let countryList = servicesCountryInfo @@ -74,7 +74,7 @@ class BiometricKycWithIdInputScreenViewModel: ObservableObject { } Task { do { - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) if authResponse.consentInfo?.consentRequired == true { DispatchQueue.main.async { self.step = .consent( diff --git a/Example/SmileID/DocumentSelectorViewModel.swift b/Example/SmileID/DocumentSelectorViewModel.swift index f821f1f77..16899e75f 100644 --- a/Example/SmileID/DocumentSelectorViewModel.swift +++ b/Example/SmileID/DocumentSelectorViewModel.swift @@ -18,46 +18,35 @@ class DocumentSelectorViewModel: ObservableObject { fatalError("Only Document Verification jobs are supported") } self.jobType = jobType - let authRequest = AuthenticationRequest( - jobType: jobType, - enrollment: false, - userId: generateUserId() - ) - let services = SmileID.api.getServices() - subscriber = SmileID.api.authenticate(request: authRequest) - .flatMap { authResponse in - let productRequest = ProductsConfigRequest( - timestamp: authResponse.timestamp, - signature: authResponse.signature + } + + @MainActor + func getServices() async throws { + do { + let authRequest = AuthenticationRequest( + jobType: jobType, + enrollment: false, + userId: generateUserId() + ) + let servicesResponse = try await SmileID.api.getServices() + let authResponse = try await SmileID.api.authenticate(request: authRequest) + let productRequest = ProductsConfigRequest( + timestamp: authResponse.timestamp, + signature: authResponse.signature + ) + let validDocumentsResponse = try await SmileID.api.getValidDocuments(request: productRequest) + let supportedDocuments = servicesResponse.hostedWeb.enhancedDocumentVerification + if jobType == .enhancedDocumentVerification { + self.idTypes = self.filteredForEnhancedDocumentVerification( + allDocuments: validDocumentsResponse.validDocuments, + supportedDocuments: supportedDocuments ) - return SmileID.api.getValidDocuments(request: productRequest) + } else { + self.idTypes = validDocumentsResponse.validDocuments } - .zip(services) - .sink( - receiveCompletion: { completion in - switch completion { - case .failure(let error): - DispatchQueue.main.async { - self.errorMessage = error.localizedDescription - } - default: - break - } - }, - receiveValue: { (validDocumentsResponse, servicesResponse) in - let supportedDocuments = servicesResponse.hostedWeb.enhancedDocumentVerification - DispatchQueue.main.async { - if jobType == .enhancedDocumentVerification { - self.idTypes = self.filteredForEnhancedDocumentVerification( - allDocuments: validDocumentsResponse.validDocuments, - supportedDocuments: supportedDocuments - ) - } else { - self.idTypes = validDocumentsResponse.validDocuments - } - } - } - ) + } catch { + self.errorMessage = error.localizedDescription + } } private func filteredForEnhancedDocumentVerification( diff --git a/Example/SmileID/DocumentVerificationIdTypeSelector.swift b/Example/SmileID/DocumentVerificationIdTypeSelector.swift index a09044c0c..01b204a5f 100644 --- a/Example/SmileID/DocumentVerificationIdTypeSelector.swift +++ b/Example/SmileID/DocumentVerificationIdTypeSelector.swift @@ -63,5 +63,10 @@ struct DocumentVerificationIdTypeSelector: View { } } } + .onAppear { + Task { + try await self.viewModel.getServices() + } + } } } diff --git a/Example/SmileID/EnhancedKYC/EnhancedKycWithIdInputScreenViewModel.swift b/Example/SmileID/EnhancedKYC/EnhancedKycWithIdInputScreenViewModel.swift index 95612480c..24f5ed220 100644 --- a/Example/SmileID/EnhancedKYC/EnhancedKycWithIdInputScreenViewModel.swift +++ b/Example/SmileID/EnhancedKYC/EnhancedKycWithIdInputScreenViewModel.swift @@ -32,16 +32,16 @@ class EnhancedKycWithIdInputScreenViewModel: ObservableObject { DispatchQueue.main.async { self.step = .loading("Loading ID Types…") } Task { do { - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) let productsConfigRequest = ProductsConfigRequest( timestamp: authResponse.timestamp, signature: authResponse.signature ) let productsConfigResponse = try await SmileID.api.getProductsConfig( request: productsConfigRequest - ).async() + ) let supportedCountries = productsConfigResponse.idSelection.enhancedKyc - let servicesResponse = try await SmileID.api.getServices().async() + let servicesResponse = try await SmileID.api.getServices() let servicesCountryInfo = servicesResponse.hostedWeb.enhancedKyc // sort by country name let countryList = servicesCountryInfo @@ -75,7 +75,7 @@ class EnhancedKycWithIdInputScreenViewModel: ObservableObject { } Task { do { - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) if authResponse.consentInfo?.consentRequired == true { DispatchQueue.main.async { self.step = .consent( @@ -132,7 +132,7 @@ class EnhancedKycWithIdInputScreenViewModel: ObservableObject { jobId: jobId, userId: userId ) - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) let enhancedKycRequest = EnhancedKycRequest( country: idInfo.country, idType: idInfo.idType!, @@ -147,7 +147,7 @@ class EnhancedKycWithIdInputScreenViewModel: ObservableObject { ) enhancedKycResponse = try await SmileID.api.doEnhancedKyc( request: enhancedKycRequest - ).async() + ) DispatchQueue.main.async { self.step = .processing(.success) } } catch { self.error = error diff --git a/SmileID.podspec b/SmileID.podspec index afc138ed2..c5f30d08d 100644 --- a/SmileID.podspec +++ b/SmileID.podspec @@ -1,11 +1,11 @@ Pod::Spec.new do |s| s.name = 'SmileID' - s.version = '10.1.6' + s.version = '10.2.0' s.summary = 'The Official Smile Identity iOS SDK.' s.homepage = 'https://docs.usesmileid.com/integration-options/mobile/ios-v10-beta' s.license = { :type => 'MIT', :file => 'LICENSE' } s.author = { 'Japhet' => 'japhet@usesmileid.com', 'Juma Allan' => 'juma@usesmileid.com', 'Vansh Gandhi' => 'vansh@usesmileid.com'} - s.source = { :git => "https://github.com/smileidentity/ios.git", :tag => "v10.1.6" } + s.source = { :git => "https://github.com/smileidentity/ios.git", :tag => "v10.2.0" } s.ios.deployment_target = '13.0' s.dependency 'Zip', '~> 2.1.0' s.dependency 'lottie-ios', '~> 4.4.2' @@ -14,4 +14,4 @@ Pod::Spec.new do |s| s.resource_bundles = { 'SmileID_SmileID' => ['Sources/SmileID/Resources/**/*.{storyboard,storyboardc,xib,nib,xcassets,json,png,ttf,lproj,xcprivacy}'] } -end \ No newline at end of file +end diff --git a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift index 5dd1683b1..c4a6bbf06 100644 --- a/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift +++ b/Sources/SmileID/Classes/BiometricKYC/OrchestratedBiometricKycViewModel.swift @@ -100,7 +100,7 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { partnerParams: extraPartnerParams ) } - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) let prepUploadRequest = PrepUploadRequest( partnerParams: authResponse.partnerParams.copy(extras: extraPartnerParams), allowNewEnroll: String(allowNewEnroll), // TODO: - Fix when Michael changes this to boolean @@ -109,11 +109,11 @@ internal class OrchestratedBiometricKycViewModel: ObservableObject { ) let prepUploadResponse = try await SmileID.api.prepUpload( request: prepUploadRequest - ).async() + ) _ = try await SmileID.api.upload( zip: zip, to: prepUploadResponse.uploadUrl - ).async() + ) didSubmitBiometricJob = true do { try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) diff --git a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift index 6b6e804d6..5a7d66929 100644 --- a/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift +++ b/Sources/SmileID/Classes/DocumentVerification/Model/OrchestratedDocumentVerificationViewModel.swift @@ -179,18 +179,18 @@ internal class IOrchestratedDocumentVerificationViewModel: Obse partnerParams: extraPartnerParams ) } - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) let prepUploadRequest = PrepUploadRequest( partnerParams: authResponse.partnerParams.copy(extras: self.extraPartnerParams), allowNewEnroll: String(allowNewEnroll), // TODO: - Fix when Michael changes this to boolean timestamp: authResponse.timestamp, signature: authResponse.signature ) - let prepUploadResponse = try await SmileID.api.prepUpload(request: prepUploadRequest).async() + let prepUploadResponse = try await SmileID.api.prepUpload(request: prepUploadRequest) _ = try await SmileID.api.upload( zip: zip, to: prepUploadResponse.uploadUrl - ).async() + ) didSubmitJob = true do { try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) diff --git a/Sources/SmileID/Classes/Networking/APIError.swift b/Sources/SmileID/Classes/Networking/APIError.swift index 0ba206a3d..12f5617b5 100644 --- a/Sources/SmileID/Classes/Networking/APIError.swift +++ b/Sources/SmileID/Classes/Networking/APIError.swift @@ -16,26 +16,26 @@ public enum SmileIDError: Error { extension SmileIDError: LocalizedError { public var errorDescription: String? { switch self { - case .encode(let error): - return String(describing: error) - case .request(let error): - return String(describing: error) - case .decode(let error): - return String(describing: error) - case .unknown(let message): - return message - case .httpError(let statusCode, let data): - return "HTTP Error with status code \(statusCode) and \(String(describing: data))" - case .api(_, let message): - return message - case .jobStatusTimeOut: - return "Job submitted successfully but polling job status timed out" - case .consentDenied: - return "Consent Denied" - case .invalidJobId: - return "Invalid jobId or not found" - case .fileNotFound(let message): - return message + case .encode(let error): + return String(describing: error) + case .request(let error): + return String(describing: error) + case .decode(let error): + return String(describing: error) + case .unknown(let message): + return message + case .httpError(let statusCode, let data): + return "HTTP Error with status code \(statusCode) and \(String(describing: data))" + case .api(_, let message): + return message + case .jobStatusTimeOut: + return "Job submitted successfully but polling job status timed out" + case .consentDenied: + return "Consent Denied" + case .invalidJobId: + return "Invalid jobId or not found" + case .fileNotFound(let message): + return message } } } diff --git a/Sources/SmileID/Classes/Networking/RestServiceClient.swift b/Sources/SmileID/Classes/Networking/RestServiceClient.swift index a1fb1d9e3..027e1f930 100644 --- a/Sources/SmileID/Classes/Networking/RestServiceClient.swift +++ b/Sources/SmileID/Classes/Networking/RestServiceClient.swift @@ -1,8 +1,7 @@ -import Combine import Foundation protocol RestServiceClient { - func send(request: RestRequest) -> AnyPublisher - func multipart(request: RestRequest) -> AnyPublisher - func upload(request: RestRequest) -> AnyPublisher + func send(request: RestRequest) async throws -> T + func multipart(request: RestRequest) async throws -> T + func upload(request: RestRequest) async throws -> AsyncThrowingStream } diff --git a/Sources/SmileID/Classes/Networking/ServiceRunnable.swift b/Sources/SmileID/Classes/Networking/ServiceRunnable.swift index c254b1181..6e32bc841 100644 --- a/Sources/SmileID/Classes/Networking/ServiceRunnable.swift +++ b/Sources/SmileID/Classes/Networking/ServiceRunnable.swift @@ -1,4 +1,3 @@ -import Combine import Foundation protocol ServiceRunnable { @@ -10,12 +9,12 @@ protocol ServiceRunnable { /// - Parameters: /// - path: Endpoint to execute the POST call. /// - body: The contents of the body of the request. - func post(to path: PathType, with body: T) -> AnyPublisher + func post(to path: PathType, with body: T) async throws -> U /// Get service call to a particular path /// - Parameters: /// - path: Endpoint to execute the GET call. - func get(to path: PathType) -> AnyPublisher + func get(to path: PathType) async throws -> U // POST service call to make a multipart request. /// - Parameters: @@ -32,7 +31,7 @@ protocol ServiceRunnable { callbackUrl: String?, sandboxResult: Int?, allowNewEnroll: Bool? - ) -> AnyPublisher + ) async throws -> SmartSelfieResponse /// PUT service call to a particular path with a body. /// - Parameters: @@ -43,7 +42,7 @@ protocol ServiceRunnable { data: Data, to url: String, with restMethod: RestMethod - ) -> AnyPublisher + ) async throws -> AsyncThrowingStream } extension ServiceRunnable { @@ -57,24 +56,22 @@ extension ServiceRunnable { func post( to path: PathType, with body: T - ) -> AnyPublisher { - createRestRequest( + ) async throws -> U { + let request = try await createRestRequest( path: path, method: .post, headers: [.contentType(value: "application/json")], body: body ) - .flatMap(serviceClient.send) - .eraseToAnyPublisher() + return try await serviceClient.send(request: request) } - func get(to path: PathType) -> AnyPublisher { - createRestRequest( + func get(to path: PathType) async throws -> U { + let request = try createRestRequest( path: path, method: .get ) - .flatMap(serviceClient.send) - .eraseToAnyPublisher() + return try await serviceClient.send(request: request) } func multipart( @@ -88,7 +85,7 @@ extension ServiceRunnable { callbackUrl: String? = nil, sandboxResult: Int? = nil, allowNewEnroll: Bool? = nil - ) -> AnyPublisher { + ) async throws -> SmartSelfieResponse { let boundary = generateBoundary() var headers: [HTTPHeader] = [] headers.append(.contentType(value: "multipart/form-data; boundary=\(boundary)")) @@ -97,11 +94,11 @@ extension ServiceRunnable { headers.append(.timestamp(value: timestamp)) headers.append(.sourceSDK(value: "iOS")) headers.append(.sourceSDKVersion(value: SmileID.version)) - return createMultiPartRequest( + let request = try await createMultiPartRequest( url: path, method: .post, headers: headers, - uploadData: createMultiPartRequest( + uploadData: createMultiPartRequestData( selfieImage: selfieImage, livenessImages: livenessImages, userId: userId, @@ -112,8 +109,8 @@ extension ServiceRunnable { boundary: boundary ) ) - .flatMap(serviceClient.multipart) - .eraseToAnyPublisher() + + return try await serviceClient.multipart(request: request) } private func createMultiPartRequest( @@ -121,10 +118,10 @@ extension ServiceRunnable { method: RestMethod, headers: [HTTPHeader]? = nil, uploadData: Data - ) -> AnyPublisher { + ) async throws -> RestRequest { let path = String(describing: url) guard var baseURL = baseURL?.absoluteString else { - return Fail(error: URLError(.badURL)).eraseToAnyPublisher() + throw URLError(.badURL) } if let range = baseURL.range(of: "/v1/", options: .backwards) { @@ -132,7 +129,7 @@ extension ServiceRunnable { } guard let url = URL(string: baseURL)?.appendingPathComponent(path) else { - return Fail(error: URLError(.badURL)).eraseToAnyPublisher() + throw URLError(.badURL) } let request = RestRequest( @@ -141,24 +138,21 @@ extension ServiceRunnable { headers: headers, body: uploadData ) - return Just(request) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return request } func upload( data: Data, to url: String, with restMethod: RestMethod - ) -> AnyPublisher { - createUploadRequest( + ) async throws -> AsyncThrowingStream { + let uploadRequest = try await createUploadRequest( url: url, method: restMethod, headers: [.contentType(value: "application/zip")], uploadData: data ) - .flatMap { serviceClient.upload(request: $0) } - .eraseToAnyPublisher() + return try await serviceClient.upload(request: uploadRequest) } private func createUploadRequest( @@ -167,10 +161,9 @@ extension ServiceRunnable { headers: [HTTPHeader]? = nil, uploadData: Data, queryParameters _: [HTTPQueryParameters]? = nil - ) -> AnyPublisher { + ) async throws -> RestRequest { guard let url = URL(string: url) else { - return Fail(error: URLError(.badURL)) - .eraseToAnyPublisher() + throw URLError(.badURL) } let request = RestRequest( url: url, @@ -178,9 +171,7 @@ extension ServiceRunnable { headers: headers, body: uploadData ) - return Just(request) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return request } private func createRestRequest( @@ -189,10 +180,10 @@ extension ServiceRunnable { headers: [HTTPHeader]? = nil, queryParameters: [HTTPQueryParameters]? = nil, body: T - ) -> AnyPublisher { + ) async throws -> RestRequest { let path = String(describing: path) guard let url = baseURL?.appendingPathComponent(path) else { - return Fail(error: URLError(.badURL)).eraseToAnyPublisher() + throw URLError(.badURL) } do { @@ -203,11 +194,9 @@ extension ServiceRunnable { queryParameters: queryParameters, body: body ) - return Just(request) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return request } catch { - return Fail(error: error).eraseToAnyPublisher() + throw error } } @@ -215,10 +204,10 @@ extension ServiceRunnable { path: PathType, method: RestMethod, queryParameters: [HTTPQueryParameters]? = nil - ) -> AnyPublisher { + ) throws -> RestRequest { let path = String(describing: path) guard let url = baseURL?.appendingPathComponent(path) else { - return Fail(error: URLError(.badURL)).eraseToAnyPublisher() + throw URLError(.badURL) } let request = RestRequest( @@ -226,9 +215,7 @@ extension ServiceRunnable { method: method, queryParameters: queryParameters ) - return Just(request) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + return request } func generateBoundary() -> String { @@ -236,7 +223,7 @@ extension ServiceRunnable { } // swiftlint:disable line_length cyclomatic_complexity - func createMultiPartRequest( + func createMultiPartRequestData( selfieImage: MultipartBody, livenessImages: [MultipartBody], userId: String?, @@ -306,7 +293,7 @@ extension ServiceRunnable { body.append(item.data) body.append(lineBreak.data(using: .utf8)!) } - + // Append selfie media file body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"\("selfie_image")\"; filename=\"\(selfieImage.filename)\"\(lineBreak)".data(using: .utf8)!) diff --git a/Sources/SmileID/Classes/Networking/SmileIDService.swift b/Sources/SmileID/Classes/Networking/SmileIDService.swift index 406190f00..d40598553 100644 --- a/Sources/SmileID/Classes/Networking/SmileIDService.swift +++ b/Sources/SmileID/Classes/Networking/SmileIDService.swift @@ -1,17 +1,16 @@ -import Combine import Foundation public protocol SmileIDServiceable { /// Returns a signature and timestamp that can be used to authenticate future requests. This is /// necessary only when using the authToken and *not* using the API key. - func authenticate(request: AuthenticationRequest) -> AnyPublisher + func authenticate(request: AuthenticationRequest) async throws -> AuthenticationResponse /// Used by Job Types that need to upload a file to the server. The response contains the URL /// that the file should eventually be uploaded to (via upload). - func prepUpload(request: PrepUploadRequest) -> AnyPublisher + func prepUpload(request: PrepUploadRequest) async throws -> PrepUploadResponse /// Uploads files to S3. The URL should be the one returned by `prepUpload`. - func upload(zip: Data, to url: String) -> AnyPublisher + func upload(zip: Data, to url: String) async throws -> AsyncThrowingStream /// Perform a synchronous SmartSelfie Enrollment. The response will include the final result of /// the enrollment. @@ -25,7 +24,7 @@ public protocol SmileIDServiceable { callbackUrl: String?, sandboxResult: Int?, allowNewEnroll: Bool? - ) -> AnyPublisher + ) async throws -> SmartSelfieResponse /// Perform a synchronous SmartSelfie Authentication. The response will include the final result /// of the authentication. @@ -38,7 +37,7 @@ public protocol SmileIDServiceable { partnerParams: [String: String]?, callbackUrl: String?, sandboxResult: Int? - ) -> AnyPublisher + ) async throws -> SmartSelfieResponse /// Query the Identity Information of an individual using their ID number from a supported ID /// Type. Return the personal information of the individual found in the database of the ID @@ -47,43 +46,43 @@ public protocol SmileIDServiceable { /// - Requires: The `callbackUrl` must be set on the `request` func doEnhancedKycAsync( request: EnhancedKycRequest - ) -> AnyPublisher + ) async throws -> EnhancedKycAsyncResponse /// Query the Identity Information of an individual using their ID number from a supported ID /// Type. Return the personal information of the individual found in the database of the ID authority. /// This will be done synchronously, and the result will be returned in the response. If the ID /// provider is unavailable, the response will be an error. func doEnhancedKyc( request: EnhancedKycRequest - ) -> AnyPublisher + ) async throws -> EnhancedKycResponse /// Fetches the status of a Job. This can be used to check if a Job is complete, and if so, /// whether it was successful. func getJobStatus( request: JobStatusRequest - ) -> AnyPublisher, Error> + ) async throws -> JobStatusResponse /// Returns supported products and metadata - func getServices() -> AnyPublisher + func getServices() async throws -> ServicesResponse /// Returns the ID types that are enabled for authenticated partner and which of those require /// consent func getProductsConfig( request: ProductsConfigRequest - ) -> AnyPublisher + ) async throws -> ProductsConfigResponse /// Gets supported documents and metadata for Document Verification func getValidDocuments( request: ProductsConfigRequest - ) -> AnyPublisher + ) async throws -> ValidDocumentsResponse /// Returns the different modes of getting the BVN OTP, either via sms or email - func requestBvnTotpMode(request: BvnTotpRequest) -> AnyPublisher + func requestBvnTotpMode(request: BvnTotpRequest) async throws -> BvnTotpResponse /// Returns the BVN OTP via the selected mode - func requestBvnOtp(request: BvnTotpModeRequest) -> AnyPublisher + func requestBvnOtp(request: BvnTotpModeRequest) async throws -> BvnTotpModeResponse /// Submits the BVN OTP for verification - func submitBvnOtp(request: SubmitBvnTotpRequest) -> AnyPublisher + func submitBvnOtp(request: SubmitBvnTotpRequest) async throws -> SubmitBvnTotpResponse } public extension SmileIDServiceable { @@ -100,39 +99,33 @@ public extension SmileIDServiceable { request: JobStatusRequest, interval _: TimeInterval, numAttempts: Int - ) -> AnyPublisher, Error> { + ) async throws -> JobStatusResponse { var lastError: Error? var attemptCount = 0 - func makeRequest() -> AnyPublisher, Error> { + func makeRequest() async throws -> JobStatusResponse { attemptCount += 1 - return SmileID.api.getJobStatus(request: request) - // swiftlint:disable force_cast - .map { response in response as! JobStatusResponse } - // swiftlint:enable force_cast - .flatMap { response -> AnyPublisher, Error> in - if response.jobComplete { - return Just(response).setFailureType(to: Error.self) - .eraseToAnyPublisher() - } else if attemptCount < numAttempts { - return makeRequest() - } else { - return Fail(error: SmileIDError.jobStatusTimeOut).eraseToAnyPublisher() - } + do { + let response: JobStatusResponse = try await SmileID.api.getJobStatus(request: request) + if response.jobComplete { + return response + } else if attemptCount < numAttempts { + return try await makeRequest() + } else { + throw SmileIDError.jobStatusTimeOut } - .catch { error -> AnyPublisher, Error> in - lastError = error - if attemptCount < numAttempts { - return makeRequest() - } else { - return Fail(error: lastError ?? error).eraseToAnyPublisher() - } + } catch { + lastError = error + if attemptCount < numAttempts { + return try await makeRequest() + } else { + throw lastError ?? error } - .eraseToAnyPublisher() + } } - return makeRequest() + return try await makeRequest() } /// Polls the server for the status of a SmartSelfie Job until it is complete. This should be called after @@ -148,8 +141,8 @@ public extension SmileIDServiceable { request: JobStatusRequest, interval: TimeInterval, numAttempts: Int - ) -> AnyPublisher { - pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) + ) async throws -> SmartSelfieJobStatusResponse { + try await pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) } /// Polls the server for the status of a Document Verification Job until it is complete. This should be called after @@ -165,8 +158,8 @@ public extension SmileIDServiceable { request: JobStatusRequest, interval: TimeInterval, numAttempts: Int - ) -> AnyPublisher { - pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) + ) async throws -> DocumentVerificationJobStatusResponse { + try await pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) } /// Polls the server for the status of a Biometric KYC Job until it is complete. This should be called after @@ -182,8 +175,8 @@ public extension SmileIDServiceable { request: JobStatusRequest, interval: TimeInterval, numAttempts: Int - ) -> AnyPublisher { - pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) + ) async throws -> BiometricKycJobStatusResponse { + try await pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) } /// Polls the server for the status of a Enhanced Document Verification Job until it is complete. @@ -200,8 +193,8 @@ public extension SmileIDServiceable { request: JobStatusRequest, interval: TimeInterval, numAttempts: Int - ) -> AnyPublisher { - pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) + ) async throws -> EnhancedDocumentVerificationJobStatusResponse { + try await pollJobStatus(request: request, interval: interval, numAttempts: numAttempts) } } @@ -211,12 +204,12 @@ public class SmileIDService: SmileIDServiceable, ServiceRunnable { public func authenticate( request: AuthenticationRequest - ) -> AnyPublisher { - post(to: "auth_smile", with: request) + ) async throws -> AuthenticationResponse { + try await post(to: "auth_smile", with: request) } - public func prepUpload(request: PrepUploadRequest) -> AnyPublisher { - post(to: "upload", with: request) + public func prepUpload(request: PrepUploadRequest) async throws -> PrepUploadResponse { + try await post(to: "upload", with: request) } public func doSmartSelfieEnrollment( @@ -229,8 +222,8 @@ public class SmileIDService: SmileIDServiceable, ServiceRunnable { callbackUrl: String? = SmileID.callbackUrl, sandboxResult: Int? = nil, allowNewEnroll: Bool? = nil - ) -> AnyPublisher { - multipart( + ) async throws -> SmartSelfieResponse { + try await multipart( to: "/v2/smart-selfie-enroll", signature: signature, timestamp: timestamp, @@ -253,8 +246,8 @@ public class SmileIDService: SmileIDServiceable, ServiceRunnable { partnerParams: [String: String]? = nil, callbackUrl: String? = SmileID.callbackUrl, sandboxResult: Int? = nil - ) -> AnyPublisher { - multipart( + ) async throws -> SmartSelfieResponse { + try await multipart( to: "/v2/smart-selfie-authentication", signature: signature, timestamp: timestamp, @@ -267,59 +260,59 @@ public class SmileIDService: SmileIDServiceable, ServiceRunnable { ) } - public func upload(zip: Data, to url: String) -> AnyPublisher { - upload(data: zip, to: url, with: .put) + public func upload(zip: Data, to url: String) async throws -> AsyncThrowingStream { + try await upload(data: zip, to: url, with: .put) } public func doEnhancedKycAsync( request: EnhancedKycRequest - ) -> AnyPublisher { - post(to: "async_id_verification", with: request) + ) async throws -> EnhancedKycAsyncResponse { + try await post(to: "async_id_verification", with: request) } public func doEnhancedKyc( request: EnhancedKycRequest - ) -> AnyPublisher { - post(to: "id_verification", with: request) + ) async throws -> EnhancedKycResponse { + try await post(to: "id_verification", with: request) } public func getJobStatus( request: JobStatusRequest - ) -> AnyPublisher, Error> { - post(to: "job_status", with: request) + ) async throws -> JobStatusResponse { + try await post(to: "job_status", with: request) } - public func getServices() -> AnyPublisher { - get(to: "services") + public func getServices() async throws -> ServicesResponse { + try await get(to: "services") } public func getProductsConfig( request: ProductsConfigRequest - ) -> AnyPublisher { - post(to: "products_config", with: request) + ) async throws -> ProductsConfigResponse { + try await post(to: "products_config", with: request) } public func getValidDocuments( request: ProductsConfigRequest - ) -> AnyPublisher { - post(to: "valid_documents", with: request) + ) async throws -> ValidDocumentsResponse { + try await post(to: "valid_documents", with: request) } public func requestBvnTotpMode( request: BvnTotpRequest - ) -> AnyPublisher { - post(to: "totp_consent", with: request) + ) async throws -> BvnTotpResponse { + try await post(to: "totp_consent", with: request) } public func requestBvnOtp( request: BvnTotpModeRequest - ) -> AnyPublisher { - post(to: "totp_consent/mode", with: request) + ) async throws -> BvnTotpModeResponse { + try await post(to: "totp_consent/mode", with: request) } public func submitBvnOtp( request: SubmitBvnTotpRequest - ) -> AnyPublisher { - post(to: "totp_consent/otp", with: request) + ) async throws -> SubmitBvnTotpResponse { + try await post(to: "totp_consent/otp", with: request) } } diff --git a/Sources/SmileID/Classes/Networking/URLSession/URLSessionPublisher.swift b/Sources/SmileID/Classes/Networking/URLSession/URLSessionPublisher.swift index fd42fc15c..01b4190b9 100644 --- a/Sources/SmileID/Classes/Networking/URLSession/URLSessionPublisher.swift +++ b/Sources/SmileID/Classes/Networking/URLSession/URLSessionPublisher.swift @@ -2,11 +2,11 @@ import Foundation import Combine protocol URLSessionPublisher { - func send(request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> + func send(request: URLRequest) async throws -> (data: Data, response: URLResponse) } extension URLSession: URLSessionPublisher { - func send(request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { - dataTaskPublisher(for: request).eraseToAnyPublisher() + func send(request: URLRequest) async throws -> (data: Data, response: URLResponse) { + try await data(for: request) } } diff --git a/Sources/SmileID/Classes/Networking/URLSession/URLSessionRestServiceClient.swift b/Sources/SmileID/Classes/Networking/URLSession/URLSessionRestServiceClient.swift index 6f3ae3572..795841020 100644 --- a/Sources/SmileID/Classes/Networking/URLSession/URLSessionRestServiceClient.swift +++ b/Sources/SmileID/Classes/Networking/URLSession/URLSessionRestServiceClient.swift @@ -1,5 +1,4 @@ import Foundation -import Combine public protocol URLUploadSessionPublisher { var delegate: URLDelegate { get } @@ -11,71 +10,63 @@ public protocol URLUploadSessionPublisher { } class URLSessionRestServiceClient: NSObject, RestServiceClient { + typealias URLSessionResponse = (data: Data, response: URLResponse) let session: URLSessionPublisher - let uploadSession: URLUploadSessionPublisher let decoder = JSONDecoder() public init( - session: URLSessionPublisher = URLSession.shared, - uploadSession: URLUploadSessionPublisher = URLUploadSessionPublisherImplementation() + session: URLSessionPublisher = URLSession.shared ) { self.session = session - self.uploadSession = uploadSession } - func send(request: RestRequest) -> AnyPublisher { + func send(request: RestRequest) async throws -> T { do { let urlRequest = try request.getURLRequest() - return session.send(request: urlRequest) - .tryMap(checkStatusCode) - .decode(type: T.self, decoder: decoder) - .mapError(mapToAPIError) - .eraseToAnyPublisher() + let urlSessionResponse = try await session.send(request: urlRequest) + let data = try checkStatusCode(urlSessionResponse) + return try decoder.decode(T.self, from: data) } catch { - return Fail(error: error).eraseToAnyPublisher() + throw mapToAPIError(error) } } - public func upload(request: RestRequest) -> AnyPublisher { - do { - let urlRequest = try request.getUploadRequest() - let subject = PassthroughSubject() - uploadSession.upload(request: urlRequest, data: request.body) { data, response, error in - if let error = error { - print(error.localizedDescription) - subject.send(completion: .failure(error)) - return - } - if (response as? HTTPURLResponse)?.statusCode == 200 { - subject.send(.response(data: data)) - return - } + public func upload(request: RestRequest) async throws -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + do { + let urlRequest = try request.getUploadRequest() + let delegate = URLDelegate(continuation: continuation) + let uploadSession = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + uploadSession.uploadTask(with: urlRequest, from: request.body) { data, response, error in + if let error = error { + continuation.finish(throwing: error) + return + } + if (response as? HTTPURLResponse)?.statusCode == 200 { + continuation.yield(.response(data: data)) + } + }.resume() + } catch { + continuation.finish(throwing: error) } - - let uploadProgress = uploadSession.delegate.subject - return uploadProgress - .merge(with: subject) - .eraseToAnyPublisher() - } catch { - return Fail(error: error).eraseToAnyPublisher() } } - public func multipart(request: RestRequest) -> AnyPublisher { + public func multipart(request: RestRequest) async throws -> T { do { let urlRequest = try request.getURLRequest() - return session.send(request: urlRequest) - .tryMap(checkStatusCode) - .decode(type: T.self, decoder: decoder) - .mapError(mapToAPIError) - .eraseToAnyPublisher() + let urlSessionResponse = try await session.send(request: urlRequest) + let data = try checkStatusCode(urlSessionResponse) + return try decoder.decode(T.self, from: data) } catch { - return Fail(error: error).eraseToAnyPublisher() + throw mapToAPIError(error) } } private func mapToAPIError(_ error: Error) -> SmileIDError { - if let decodingError = error as? DecodingError { + if let requestError = error as? URLError { + return .request(requestError) + } else if let decodingError = error as? DecodingError { return .decode(decodingError) } else if let error = error as? SmileIDError { return error @@ -84,20 +75,25 @@ class URLSessionRestServiceClient: NSObject, RestServiceClient { } } - private func checkStatusCode(_ element: URLSession.DataTaskPublisher.Output) throws -> Data { - guard let httpResponse = element.response as? HTTPURLResponse, + private func checkStatusCode(_ urlSessionResponse: URLSessionResponse) throws -> Data { + guard let httpResponse = urlSessionResponse.response as? HTTPURLResponse, httpResponse.isSuccess else { if let decodedError = try? JSONDecoder().decode( SmileIDErrorResponse.self, - from: element.data + from: urlSessionResponse.data ) { throw SmileIDError.api(decodedError.code, decodedError.message) } - throw SmileIDError.httpError((element.response as? HTTPURLResponse)?.statusCode ?? 500, element.data) + throw SmileIDError.httpError( + ( + urlSessionResponse.response as? HTTPURLResponse + )?.statusCode ?? 500, + urlSessionResponse.data + ) } - return element.data + return urlSessionResponse.data } } @@ -110,10 +106,10 @@ extension HTTPURLResponse { public class URLDelegate: NSObject, URLSessionTaskDelegate { - var subject: PassthroughSubject + let continuation: AsyncThrowingStream.Continuation - public init(subject: PassthroughSubject = .init()) { - self.subject = subject + public init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation } public func urlSession( @@ -123,30 +119,11 @@ public class URLDelegate: NSObject, URLSessionTaskDelegate { totalBytesSent: Int64, totalBytesExpectedToSend: Int64 ) { - subject.send(.progress(percentage: task.progress.fractionCompleted)) + self.continuation.yield(.progress(percentage: task.progress.fractionCompleted)) } -} - -public final class URLUploadSessionPublisherImplementation: URLUploadSessionPublisher { - - public let delegate: URLDelegate = URLDelegate() - lazy var session = { - URLSession(configuration: .default, - delegate: delegate, - delegateQueue: nil) - }() - public init() {} - - public func upload( - request: URLRequest, - data: Data?, - _ callback: @escaping (Data?, URLResponse?, Error?) -> Void - ) { - session.uploadTask(with: request, from: data) { data, response, error in - callback(data, response, error) - } - .resume() + deinit { + continuation.finish() } } diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift index c785ccc3c..71bd8c08f 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift @@ -308,7 +308,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { partnerParams: extraPartnerParams ) } - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) var smartSelfieLivenessImages = [MultipartBody]() var smartSelfieImage: MultipartBody? @@ -348,7 +348,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { callbackUrl: SmileID.callbackUrl, sandboxResult: nil, allowNewEnroll: allowNewEnroll - ).async() + ) } else { try await SmileID.api.doSmartSelfieAuthentication( signature: authResponse.signature, @@ -359,7 +359,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { partnerParams: extraPartnerParams, callbackUrl: SmileID.callbackUrl, sandboxResult: nil - ).async() + ) } apiResponse = response do { diff --git a/Sources/SmileID/Classes/SmileID.swift b/Sources/SmileID/Classes/SmileID.swift index 346186799..49743cae3 100644 --- a/Sources/SmileID/Classes/SmileID.swift +++ b/Sources/SmileID/Classes/SmileID.swift @@ -3,7 +3,7 @@ import SwiftUI import UIKit public class SmileID { - public static let version = "10.1.6" + public static let version = "10.2.0" @Injected var injectedApi: SmileIDServiceable public static var configuration: Config { config } @@ -139,7 +139,7 @@ public class SmileID { jobId: authRequestFile.jobId, userId: authRequestFile.userId ) - let authResponse = try await SmileID.api.authenticate(request: authRequest).async() + let authResponse = try await SmileID.api.authenticate(request: authRequest) let prepUploadRequest = PrepUploadRequest( partnerParams: authResponse.partnerParams.copy(extras: prepUploadFile.partnerParams.extras), // TODO: - Fix when Michael changes this to boolean @@ -147,7 +147,7 @@ public class SmileID { timestamp: authResponse.timestamp, signature: authResponse.signature ) - let prepUploadResponse = try await SmileID.api.prepUpload(request: prepUploadRequest).async() + let prepUploadResponse = try await SmileID.api.prepUpload(request: prepUploadRequest) let allFiles = try LocalStorage.getFilesByType(jobId: jobId, fileType: FileType.liveness)! + [ LocalStorage.getFileByType(jobId: jobId, fileType: FileType.selfie), LocalStorage.getFileByType(jobId: jobId, fileType: FileType.documentFront), @@ -159,7 +159,7 @@ public class SmileID { _ = try await SmileID.api.upload( zip: zip, to: prepUploadResponse.uploadUrl - ).async() + ) if deleteFilesOnSuccess { do { try LocalStorage.delete(at: [jobId]) diff --git a/Sources/SmileID/Classes/Views/JobSubmittable.swift b/Sources/SmileID/Classes/Views/JobSubmittable.swift index f45768a17..c3611c4b7 100644 --- a/Sources/SmileID/Classes/Views/JobSubmittable.swift +++ b/Sources/SmileID/Classes/Views/JobSubmittable.swift @@ -1,18 +1,17 @@ -import Combine import Foundation protocol JobSubmittable { func getJobStatus( _ authResponse: AuthenticationResponse - ) -> AnyPublisher, Error> + ) async throws -> JobStatusResponse func upload( _ prepUploadResponse: PrepUploadResponse, zip: Data - ) -> AnyPublisher + ) async throws -> UploadResponse func prepUpload( authResponse: AuthenticationResponse, allowNewEnroll: Bool, extraPartnerParams: [String: String] - ) -> AnyPublisher + ) async throws -> PrepUploadResponse func handleRetry() func handleClose() } @@ -22,27 +21,26 @@ extension JobSubmittable { authResponse: AuthenticationResponse, allowNewEnroll: Bool, extraPartnerParams: [String: String] - ) -> AnyPublisher { + ) async throws -> PrepUploadResponse { let prepUploadRequest = PrepUploadRequest( partnerParams: authResponse.partnerParams.copy(extras: extraPartnerParams), allowNewEnroll: String(allowNewEnroll), // TODO - Fix when Michael changes this to boolean timestamp: authResponse.timestamp, signature: authResponse.signature ) - return SmileID.api.prepUpload(request: prepUploadRequest) + return try await SmileID.api.prepUpload(request: prepUploadRequest) } func upload( _ prepUploadResponse: PrepUploadResponse, zip: Data - ) -> AnyPublisher { - SmileID.api.upload(zip: zip, to: prepUploadResponse.uploadUrl) - .eraseToAnyPublisher() + ) async throws -> AsyncThrowingStream { + try await SmileID.api.upload(zip: zip, to: prepUploadResponse.uploadUrl) } func getJobStatus( _ authResponse: AuthenticationResponse - ) -> AnyPublisher, Error> { + ) async throws -> JobStatusResponse { let jobStatusRequest = JobStatusRequest( userId: authResponse.partnerParams.userId, jobId: authResponse.partnerParams.jobId, @@ -51,6 +49,6 @@ extension JobSubmittable { timestamp: authResponse.timestamp, signature: authResponse.signature ) - return SmileID.api.getJobStatus(request: jobStatusRequest) + return try await SmileID.api.getJobStatus(request: jobStatusRequest) } } diff --git a/Tests/Mocks/NetworkingMocks.swift b/Tests/Mocks/NetworkingMocks.swift index 9747bbf2e..5aa9caa71 100644 --- a/Tests/Mocks/NetworkingMocks.swift +++ b/Tests/Mocks/NetworkingMocks.swift @@ -1,5 +1,4 @@ // swiftlint:disable force_cast -import Combine import Foundation @testable import SmileID import XCTest @@ -12,20 +11,20 @@ class MockServiceHeaderProvider: ServiceHeaderProvider { } } -class MockURLSessionPublisher: URLSessionPublisher { +class MockURLSession: URLSessionPublisher { + var expectedData = Data() var expectedResponse = URLResponse() func send( request _: URLRequest - ) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { - Result.Publisher((expectedData, expectedResponse)) - .eraseToAnyPublisher() + ) async throws -> (data: Data, response: URLResponse) { + return (expectedData, expectedResponse) } } class MockSmileIdentityService: SmileIDServiceable { - func authenticate(request _: AuthenticationRequest) -> AnyPublisher { + func authenticate(request _: AuthenticationRequest) async throws -> AuthenticationResponse { let params = PartnerParams( jobId: "jobid", userId: "userid", @@ -39,15 +38,13 @@ class MockSmileIdentityService: SmileIDServiceable { partnerParams: params ) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } - func prepUpload(request _: PrepUploadRequest) -> AnyPublisher { + func prepUpload(request _: PrepUploadRequest) async throws -> PrepUploadResponse { let response = PrepUploadResponse( code: "code", refId: "refid", @@ -55,46 +52,41 @@ class MockSmileIdentityService: SmileIDServiceable { smileJobId: "8950" ) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } - func upload(zip _: Data = Data(), to _: String = "") -> AnyPublisher { - let response = UploadResponse.response(data: Data()) - if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() - } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + func upload(zip _: Data = Data(), to _: String = "") async throws -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let response = UploadResponse.response(data: Data()) + if MockHelper.shouldFail { + continuation.finish(throwing: SmileIDError.request(URLError(.resourceUnavailable))) + } else { + continuation.yield(response) + } } } func doEnhancedKycAsync( request _: EnhancedKycRequest - ) -> AnyPublisher { + ) async throws -> EnhancedKycAsyncResponse { if MockHelper.shouldFail { let error = SmileIDError.request(URLError(.resourceUnavailable)) - return Fail(error: error) - .eraseToAnyPublisher() + throw error } else { let response = EnhancedKycAsyncResponse(success: true) - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } func doEnhancedKyc( request _: EnhancedKycRequest - ) -> AnyPublisher { + ) async throws -> EnhancedKycResponse { if MockHelper.shouldFail { let error = SmileIDError.request(URLError(.resourceUnavailable)) - return Fail(error: error) - .eraseToAnyPublisher() + throw error } else { let response = EnhancedKycResponse( smileJobId: "", @@ -121,8 +113,7 @@ class MockSmileIdentityService: SmileIDServiceable { idType: "", idNumber: "" ) - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } @@ -136,11 +127,10 @@ class MockSmileIdentityService: SmileIDServiceable { callbackUrl _: String?, sandboxResult _: Int?, allowNewEnroll _: Bool? - ) -> AnyPublisher { + ) async throws -> SmartSelfieResponse { if MockHelper.shouldFail { let error = SmileIDError.request(URLError(.resourceUnavailable)) - return Fail(error: error) - .eraseToAnyPublisher() + throw error } else { let response = SmartSelfieResponse( code: "", @@ -153,8 +143,7 @@ class MockSmileIdentityService: SmileIDServiceable { updatedAt: "", userId: "" ) - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } @@ -167,11 +156,10 @@ class MockSmileIdentityService: SmileIDServiceable { partnerParams _: [String: String]?, callbackUrl _: String?, sandboxResult _: Int? - ) -> AnyPublisher { + ) async throws -> SmartSelfieResponse { if MockHelper.shouldFail { let error = SmileIDError.request(URLError(.resourceUnavailable)) - return Fail(error: error) - .eraseToAnyPublisher() + throw error } else { let response = SmartSelfieResponse( code: "", @@ -184,25 +172,22 @@ class MockSmileIdentityService: SmileIDServiceable { updatedAt: "", userId: "" ) - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } func getJobStatus( request _: JobStatusRequest - ) -> AnyPublisher, Error> { + ) async throws -> JobStatusResponse { let response = JobStatusResponse(jobComplete: MockHelper.jobComplete) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } - func getServices() -> AnyPublisher { + func getServices() async throws -> ServicesResponse { var response: ServicesResponse do { response = try ServicesResponse( @@ -210,63 +195,51 @@ class MockSmileIdentityService: SmileIDServiceable { hostedWeb: HostedWeb(from: JSONDecoder() as! Decoder) ) } catch { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } func getProductsConfig( request _: ProductsConfigRequest - ) -> AnyPublisher { + ) async throws -> ProductsConfigResponse { var response: ProductsConfigResponse - do { - response = try ProductsConfigResponse( - consentRequired: [:], - idSelection: IdSelection( - basicKyc: [:], - biometricKyc: [:], - enhancedKyc: [:], - documentVerification: [:] - ) + response = ProductsConfigResponse( + consentRequired: [:], + idSelection: IdSelection( + basicKyc: [:], + biometricKyc: [:], + enhancedKyc: [:], + documentVerification: [:] ) - } catch { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() - } + ) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } func getValidDocuments( request _: ProductsConfigRequest - ) -> AnyPublisher { + ) async throws -> ValidDocumentsResponse { let response = ValidDocumentsResponse(validDocuments: [ValidDocument]()) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } public func requestBvnTotpMode( request _: BvnTotpRequest - ) -> AnyPublisher { + ) async throws -> BvnTotpResponse { let response = BvnTotpResponse( success: true, message: "success", @@ -276,17 +249,15 @@ class MockSmileIdentityService: SmileIDServiceable { signature: "signature" ) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } public func requestBvnOtp( request _: BvnTotpModeRequest - ) -> AnyPublisher { + ) async throws -> BvnTotpModeResponse { let response = BvnTotpModeResponse( success: true, message: "success", @@ -294,17 +265,15 @@ class MockSmileIdentityService: SmileIDServiceable { signature: "signature" ) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } public func submitBvnOtp( request _: SubmitBvnTotpRequest - ) -> AnyPublisher { + ) async throws -> SubmitBvnTotpResponse { let response = SubmitBvnTotpResponse( success: true, message: "success", @@ -312,11 +281,9 @@ class MockSmileIdentityService: SmileIDServiceable { signature: "signature" ) if MockHelper.shouldFail { - return Fail(error: SmileIDError.request(URLError(.resourceUnavailable))) - .eraseToAnyPublisher() + throw SmileIDError.request(URLError(.resourceUnavailable)) } else { - return Result.Publisher(response) - .eraseToAnyPublisher() + return response } } } diff --git a/Tests/Networking/EnhancedKycTest.swift b/Tests/Networking/EnhancedKycTest.swift index e6f2f937a..d9f834e3a 100644 --- a/Tests/Networking/EnhancedKycTest.swift +++ b/Tests/Networking/EnhancedKycTest.swift @@ -1,6 +1,5 @@ import Foundation import XCTest -import Combine @testable import SmileID class EnhancedKycTest: BaseTestCase { diff --git a/Tests/Networking/PollingTests.swift b/Tests/Networking/PollingTests.swift index 62e7814ff..6e82b62f5 100644 --- a/Tests/Networking/PollingTests.swift +++ b/Tests/Networking/PollingTests.swift @@ -1,9 +1,7 @@ -import Combine @testable import SmileID import XCTest final class PollingTests: XCTestCase { - var cancellables: Set = [] let mockDependency = DependencyContainer() let mockService = MockSmileIdentityService() @@ -29,64 +27,49 @@ final class PollingTests: XCTestCase { } func testPollJobStatus_Success( - pollFunction: (JobStatusRequest, TimeInterval, Int) -> AnyPublisher, Error>, + pollFunction: (JobStatusRequest, TimeInterval, Int) async throws -> JobStatusResponse, expectedResponse: JobStatusResponse, requestBuilder: () -> JobStatusRequest - ) { + ) async { let request = requestBuilder() let interval: TimeInterval = 1.0 let numAttempts = 3 - let expectation = XCTestExpectation(description: "Poll Job Status Success") - pollFunction(request, interval, numAttempts) - .sink(receiveCompletion: { completion in - switch completion { - case let .failure(error): - XCTFail("Unexpected error: \(error)") - case .finished: - break - } - expectation.fulfill() - }, receiveValue: { response in - XCTAssertEqual(response.jobComplete, expectedResponse.jobComplete) - }) - .store(in: &cancellables) - - wait(for: [expectation], timeout: 5.0) + do { + let response = try await pollFunction(request, interval, numAttempts) + XCTAssertEqual(response.jobComplete, expectedResponse.jobComplete) + } catch { + XCTFail("Unexpected error: \(error)") + } } func testPollingFunction_ErrorDuringPolling( - pollFunction: (JobStatusRequest, TimeInterval, Int) -> AnyPublisher, Error>, + pollFunction: (JobStatusRequest, TimeInterval, Int) async throws -> JobStatusResponse, requestBuilder: () -> JobStatusRequest - ) { + ) async { let request = requestBuilder() let interval: TimeInterval = 1.0 let numAttempts = 3 let expectation = XCTestExpectation(description: "Polling fails due to an error") + MockHelper.shouldFail = true MockHelper.jobComplete = false - pollFunction(request, interval, numAttempts) - .sink(receiveCompletion: { completion in - switch completion { - case .failure: - expectation.fulfill() - case .finished: - XCTFail("Polling should have failed due to an error") - } - }, receiveValue: { _ in - XCTFail("No response should be received b/c an error occurs at first attempt") - }) - .store(in: &cancellables) - - wait(for: [expectation], timeout: 2.0) + do { + _ = try await pollFunction(request, interval, numAttempts) + XCTFail("No response should be received b/c an error occurs at first attempt") + } catch { + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 2.0) } func testPollingFunction_MaxAttemptsReached( - pollFunction: (JobStatusRequest, TimeInterval, Int) -> AnyPublisher, Error>, + pollFunction: (JobStatusRequest, TimeInterval, Int) async throws -> JobStatusResponse, requestBuilder: () -> JobStatusRequest - ) { + ) async { let request = requestBuilder() let interval: TimeInterval = 1.0 let numAttempts = 3 @@ -97,28 +80,17 @@ final class PollingTests: XCTestCase { MockHelper.shouldFail = false MockHelper.jobComplete = false - pollFunction(request, interval, numAttempts) - .sink( - receiveCompletion: { completion in - switch completion { - case let .failure(error): - if error.localizedDescription == SmileIDError.jobStatusTimeOut.localizedDescription { - expectation.fulfill() - } - case .finished: - XCTFail("Polling should have failed due to reaching the maximum number of attempts") - } - }, - receiveValue: { response in - XCTAssertFalse(response.jobComplete, "Job is not complete") - } - ) - .store(in: &cancellables) - - wait(for: [expectation], timeout: 2.0) + do { + let response = try await pollFunction(request, interval, numAttempts) + XCTAssertFalse(response.jobComplete, "Job is not complete") + } catch { + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 2.0) } - func testPollSmartSelfieJobStatus() { + func testPollSmartSelfieJobStatus() async throws { let expectedResponse = SmartSelfieJobStatusResponse(jobComplete: true) let requestBuilder = { JobStatusRequest( userId: "", @@ -131,24 +103,24 @@ final class PollingTests: XCTestCase { ) } - testPollJobStatus_Success( + await testPollJobStatus_Success( pollFunction: mockService.pollSmartSelfieJobStatus, expectedResponse: expectedResponse, requestBuilder: requestBuilder ) - testPollingFunction_ErrorDuringPolling( + await testPollingFunction_ErrorDuringPolling( pollFunction: mockService.pollSmartSelfieJobStatus, requestBuilder: requestBuilder ) - testPollingFunction_MaxAttemptsReached( + await testPollingFunction_MaxAttemptsReached( pollFunction: mockService.pollSmartSelfieJobStatus, requestBuilder: requestBuilder ) } - func testPollDocumentVerificationJobStatus() { + func testPollDocumentVerificationJobStatus() async { let expectedResponse = DocumentVerificationJobStatusResponse(jobComplete: true) let requestBuilder = { JobStatusRequest( userId: "", @@ -161,24 +133,24 @@ final class PollingTests: XCTestCase { ) } - testPollJobStatus_Success( + await testPollJobStatus_Success( pollFunction: mockService.pollDocumentVerificationJobStatus, expectedResponse: expectedResponse, requestBuilder: requestBuilder ) - testPollingFunction_ErrorDuringPolling( + await testPollingFunction_ErrorDuringPolling( pollFunction: mockService.pollDocumentVerificationJobStatus, requestBuilder: requestBuilder ) - testPollingFunction_MaxAttemptsReached( + await testPollingFunction_MaxAttemptsReached( pollFunction: mockService.pollDocumentVerificationJobStatus, requestBuilder: requestBuilder ) } - func testPollBiometricKycJobStatus() { + func testPollBiometricKycJobStatus() async { let expectedResponse = BiometricKycJobStatusResponse(jobComplete: true) let requestBuilder = { JobStatusRequest( userId: "", @@ -191,24 +163,24 @@ final class PollingTests: XCTestCase { ) } - testPollJobStatus_Success( + await testPollJobStatus_Success( pollFunction: mockService.pollBiometricKycJobStatus, expectedResponse: expectedResponse, requestBuilder: requestBuilder ) - testPollingFunction_ErrorDuringPolling( + await testPollingFunction_ErrorDuringPolling( pollFunction: mockService.pollBiometricKycJobStatus, requestBuilder: requestBuilder ) - testPollingFunction_MaxAttemptsReached( + await testPollingFunction_MaxAttemptsReached( pollFunction: mockService.pollBiometricKycJobStatus, requestBuilder: requestBuilder ) } - func testPollEnhancedDocumentVerificationJobStatus() { + func testPollEnhancedDocumentVerificationJobStatus() async { let expectedResponse = EnhancedDocumentVerificationJobStatusResponse(jobComplete: true) let requestBuilder = { JobStatusRequest( userId: "", @@ -221,18 +193,18 @@ final class PollingTests: XCTestCase { ) } - testPollJobStatus_Success( + await testPollJobStatus_Success( pollFunction: mockService.pollEnhancedDocumentVerificationJobStatus, expectedResponse: expectedResponse, requestBuilder: requestBuilder ) - testPollingFunction_ErrorDuringPolling( + await testPollingFunction_ErrorDuringPolling( pollFunction: mockService.pollEnhancedDocumentVerificationJobStatus, requestBuilder: requestBuilder ) - testPollingFunction_MaxAttemptsReached( + await testPollingFunction_MaxAttemptsReached( pollFunction: mockService.pollEnhancedDocumentVerificationJobStatus, requestBuilder: requestBuilder ) diff --git a/Tests/Networking/URLSessionRestServiceClientTests.swift b/Tests/Networking/URLSessionRestServiceClientTests.swift index 4418b9558..5e23a4f32 100644 --- a/Tests/Networking/URLSessionRestServiceClientTests.swift +++ b/Tests/Networking/URLSessionRestServiceClientTests.swift @@ -1,26 +1,25 @@ import Foundation import XCTest -import Combine @testable import SmileID class URLSessionRestServiceClientTests: BaseTestCase { var mockURL: URL! var mockServiceHeaderProvider: MockServiceHeaderProvider! - var mockSessionPublisher: MockURLSessionPublisher! + var mockSession: MockURLSession! var serviceUnderTest: URLSessionRestServiceClient! override func setUpWithError() throws { try super.setUpWithError() mockServiceHeaderProvider = MockServiceHeaderProvider() - mockSessionPublisher = MockURLSessionPublisher() + mockSession = MockURLSession() mockDependencyContainer.register(ServiceHeaderProvider.self) { self.mockServiceHeaderProvider } - serviceUnderTest = URLSessionRestServiceClient(session: mockSessionPublisher) + serviceUnderTest = URLSessionRestServiceClient(session: mockSession) } - func testSendReturnsPublisherWithSuccessResponse() throws { + func testSendReturnsPublisherWithSuccessResponse() async throws { let expectedURL = URL(string: "https://example.com")! let expectedData = try JSONEncoder().encode(TestResponse(status: true, message: "Success")) let expectedResponse: URLResponse = HTTPURLResponse( @@ -29,15 +28,14 @@ class URLSessionRestServiceClientTests: BaseTestCase { httpVersion: nil, headerFields: nil )! - mockSessionPublisher.expectedResponse = expectedResponse - mockSessionPublisher.expectedData = expectedData + mockSession.expectedResponse = expectedResponse + mockSession.expectedData = expectedData let request = RestRequest(url: expectedURL, method: .get) - let result: AnyPublisher = serviceUnderTest.send(request: request) - let response = try `await`(result) + let response: TestResponse = try await serviceUnderTest.send(request: request) XCTAssert(response.status) } - func testSendReturnsPublisherWithSuccessResponseAnd201ResponseCode() throws { + func testSendReturnsPublisherWithSuccessResponseAnd201ResponseCode() async throws { let expectedURL = URL(string: "https://example.com")! let expectedData = try JSONEncoder().encode(TestResponse(status: true, message: "Success")) let expectedResponse: URLResponse = HTTPURLResponse( @@ -47,11 +45,10 @@ class URLSessionRestServiceClientTests: BaseTestCase { headerFields: nil )! - mockSessionPublisher.expectedResponse = expectedResponse - mockSessionPublisher.expectedData = expectedData + mockSession.expectedResponse = expectedResponse + mockSession.expectedData = expectedData let request = RestRequest(url: expectedURL, method: .get) - let result: AnyPublisher = serviceUnderTest.send(request: request) - let response = try `await`(result) + let response: TestResponse = try await serviceUnderTest.send(request: request) XCTAssert(response.status) } @@ -74,7 +71,7 @@ class URLSessionRestServiceClientTests: BaseTestCase { XCTAssertEqual(urlRequest.url!, expectedURL) } - func testSendReturnsPublisherWithFailureResponseWhenHttpResponseIsNotSuccessful() throws { + func testSendReturnsPublisherWithFailureResponseWhenHttpResponseIsNotSuccessful() async throws { let expectedURL = URL(string: "https://example.com")! let expectedData = try JSONEncoder().encode(TestResponse(status: true, message: "Success")) let expectedResponse: URLResponse = HTTPURLResponse( @@ -83,13 +80,12 @@ class URLSessionRestServiceClientTests: BaseTestCase { httpVersion: nil, headerFields: nil )! - mockSessionPublisher.expectedResponse = expectedResponse - mockSessionPublisher.expectedData = expectedData + mockSession.expectedResponse = expectedResponse + mockSession.expectedData = expectedData let request = RestRequest(url: expectedURL, method: .get) - let result: AnyPublisher = serviceUnderTest.send(request: request) do { - _ = try `await`(result) + let _: TestResponse = try await serviceUnderTest.send(request: request) XCTFail("Send should have not succeeded") } catch { XCTAssert(error is SmileIDError)