From 8073e57a353615fef4f51cbf7e4f8cab269c8bb3 Mon Sep 17 00:00:00 2001 From: Matus Klasovity Date: Tue, 6 Aug 2024 11:43:03 +0200 Subject: [PATCH 1/4] feat: Request Deduplication --- .../xcschemes/GoodNetworking-Sample.xcscheme | 78 +++++ .../RequestManager/RequestManager.swift | 3 +- .../Screens/Home/HomeViewModel.swift | 19 +- .../Executor/ExecutorTask.swift | 22 ++ .../Executor/RequestExecutor.swift | 77 +++++ .../Extensions/DataRequestExtensions.swift | 43 +++ .../Extensions/FutureExtensions.swift | 47 +++ .../Session/NetworkSession.swift | 302 +++++++++++++++++- 8 files changed, 580 insertions(+), 11 deletions(-) create mode 100644 GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/xcshareddata/xcschemes/GoodNetworking-Sample.xcscheme create mode 100644 Sources/GoodNetworking/Executor/ExecutorTask.swift create mode 100644 Sources/GoodNetworking/Executor/RequestExecutor.swift create mode 100644 Sources/GoodNetworking/Extensions/FutureExtensions.swift diff --git a/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/xcshareddata/xcschemes/GoodNetworking-Sample.xcscheme b/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/xcshareddata/xcschemes/GoodNetworking-Sample.xcscheme new file mode 100644 index 0000000..8be713f --- /dev/null +++ b/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/xcshareddata/xcschemes/GoodNetworking-Sample.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GoodNetworking-Sample/GoodNetworking-Sample/Managers/RequestManager/RequestManager.swift b/GoodNetworking-Sample/GoodNetworking-Sample/Managers/RequestManager/RequestManager.swift index 2f5d3ad..8f492de 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample/Managers/RequestManager/RequestManager.swift +++ b/GoodNetworking-Sample/GoodNetworking-Sample/Managers/RequestManager/RequestManager.swift @@ -24,8 +24,7 @@ final class RequestManager: RequestManagerType { } func fetchHero(heroId: Int) -> RequestPublisher { - return session.request(endpoint: Endpoint.hero(id: heroId)) - .goodify() + return session.execute(endpoint: Endpoint.hero(id: heroId)) .eraseToAnyPublisher() } diff --git a/GoodNetworking-Sample/GoodNetworking-Sample/Screens/Home/HomeViewModel.swift b/GoodNetworking-Sample/GoodNetworking-Sample/Screens/Home/HomeViewModel.swift index 170dc40..ef1b614 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample/Screens/Home/HomeViewModel.swift +++ b/GoodNetworking-Sample/GoodNetworking-Sample/Screens/Home/HomeViewModel.swift @@ -49,13 +49,18 @@ final class HomeViewModel { extension HomeViewModel { func fetchHero() { - di.requestManager.fetchHero(heroId: Int.random(in: Constants.Hero.range)) - .map{ HeroFetchingResultState.success($0) } - .catch { Just(HeroFetchingResultState.error($0)) } - .prepend(HeroFetchingResultState.loading) - .eraseToAnyPublisher() - .sink { [weak self] result in self?.heroResult.send(result) } - .store(in: &cancellables) +// di.requestManager.fetchHero(heroId: Int.random(in: Constants.Hero.range)) + let heroID = Int.random(in: Constants.Hero.range) + return Publishers.Merge( + di.requestManager.fetchHero(heroId: heroID), + di.requestManager.fetchHero(heroId: heroID) + ) + .map{ HeroFetchingResultState.success($0) } + .catch { Just(HeroFetchingResultState.error($0)) } + .prepend(HeroFetchingResultState.loading) + .eraseToAnyPublisher() + .sink { [weak self] result in self?.heroResult.send(result) } + .store(in: &cancellables) } func goToAbout() { diff --git a/Sources/GoodNetworking/Executor/ExecutorTask.swift b/Sources/GoodNetworking/Executor/ExecutorTask.swift new file mode 100644 index 0000000..4056eff --- /dev/null +++ b/Sources/GoodNetworking/Executor/ExecutorTask.swift @@ -0,0 +1,22 @@ +// +// ExecutorTask.swift +// +// +// Created by Matus Klasovity on 06/08/2024. +// + +import Foundation +import Alamofire + +final class ExecutorTask { + + var finishDate: Date? + let taskID: String + let task: Task, Never> + + init(taskID: String, task: Task, Never>) { + self.taskID = taskID + self.task = task + } + +} diff --git a/Sources/GoodNetworking/Executor/RequestExecutor.swift b/Sources/GoodNetworking/Executor/RequestExecutor.swift new file mode 100644 index 0000000..a878c94 --- /dev/null +++ b/Sources/GoodNetworking/Executor/RequestExecutor.swift @@ -0,0 +1,77 @@ +// +// RequestExecutor.swift +// +// +// Created by Matus Klasovity on 06/08/2024. +// + +import Foundation +import Alamofire + +actor RequestExecutor { + + private let logger: SessionLogger = { + if #available(iOS 14, *) { + return OSLogLogger() + } else { + return PrintLogger() + } + }() + + private var runningRequestTasks: [String: Any] = [:] + + func execute( + _ request: DataRequest, + taskID: String, + deduplicate: Bool, + validResponseCodes: Set, + emptyResponseCodes: Set, + emptyResponseMethods: Set + ) async -> DataResponse { + let randomUUID = UUID().uuidString + return await execute( + request, + taskID: deduplicate ? taskID : randomUUID, + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) + } + + private func execute( + _ request: DataRequest, + taskID: String, + validResponseCodes: Set, + emptyResponseCodes: Set, + emptyResponseMethods: Set + ) async -> DataResponse { + if let runningTask = runningRequestTasks[taskID] { + let executorTask = runningTask as! ExecutorTask + logger.log(level: .info, message: "🚀 taskID: \(taskID) Cached value used") + return await executorTask.task.value + } else { + let requestTask = Task { + return await request.goodifyAsync( + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) as DataResponse + } + + logger.log(level: .info, message: "🚀 taskID: \(taskID): Task created") + let executorTask: ExecutorTask = ExecutorTask( + taskID: taskID, + task: requestTask + ) + + runningRequestTasks[taskID] = executorTask + + let result = await requestTask.value + + logger.log(level: .info, message: "🚀 taskID: \(taskID): Task finished successfully") + return result + } + } + +} + diff --git a/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift b/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift index 34a4f69..d407019 100644 --- a/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift +++ b/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift @@ -81,6 +81,49 @@ public extension DataRequest { .value() } + func goodifyAsync( + type: T.Type = T.self, + validResponseCodes: Set = Set(200..<299), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) async throws -> T { + do { + return try await self + .validate(statusCode: validResponseCodes) + .serializingDecodable( + T.self, + automaticallyCancelling: true, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyResponseMethods + ) + .value + } catch let afError as AFError { + // potentionally the place for alamofire non fatal crashes logger + throw afError + } catch { + throw error + } + } + + + func goodifyAsync( + type: T.Type = T.self, + validResponseCodes: Set = Set(200..<299), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) async -> DataResponse { + await self + .validate(statusCode: validResponseCodes) + .serializingDecodable( + T.self, + automaticallyCancelling: true, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyResponseMethods + ) + .response + } + + } // MARK: - Private diff --git a/Sources/GoodNetworking/Extensions/FutureExtensions.swift b/Sources/GoodNetworking/Extensions/FutureExtensions.swift new file mode 100644 index 0000000..344e472 --- /dev/null +++ b/Sources/GoodNetworking/Extensions/FutureExtensions.swift @@ -0,0 +1,47 @@ +// +// FutureExtensions.swift +// +// +// Created by Matus Klasovity on 06/08/2024. +// + +import Foundation +import Combine + +extension Future where Failure == Error { + + static func create(asyncThrowableFunc: @Sendable @escaping () async throws -> Output) -> Self { + Self.init { promise in + nonisolated(unsafe) let promise = promise + Task { + do { + let result = try await asyncThrowableFunc() + await MainActor.run { + promise(.success(result)) + } + } catch { + await MainActor.run { + promise(.failure(error)) + } + } + } + } + } + +} + +extension Future where Failure == Never { + + static func create(asyncFunc: @Sendable @escaping () async -> Output) -> Self { + Self.init { promise in + nonisolated(unsafe) let promise = promise + Task { + let result = await asyncFunc() + await MainActor.run { + promise(.success(result)) + } + } + } + } + +} diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index d050216..2abc4c8 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -7,6 +7,7 @@ import Alamofire import Foundation +import Combine /// Executes network requests for the client app. public class NetworkSession { @@ -22,6 +23,8 @@ public class NetworkSession { public let baseUrl: String? + private let requestExecutor = RequestExecutor() + // MARK: - Initialization /// A public initializer that sets the baseURL and configuration properties, and initializes the underlying `Session` object. @@ -42,16 +45,17 @@ public class NetworkSession { } -// MARK: - Request +// MARK: - Build Request public extension NetworkSession { /// Builds a DataRequest object by constructing URL and Body parameters. /// /// - Parameters: - /// - endpoint: A GREndpoint instance representing the endpoint. + /// - endpoint: A Endpoint instance representing the endpoint. /// - base: An optional BaseURL instance representing the base URL. If not provided, the default `baseUrl` property will be used. /// - Returns: A DataRequest object that is ready to be executed. + @available(*, deprecated, renamed: "buildRequest", message: "Request method is deprecated, use buildRequest instead.") func request(endpoint: Endpoint, base: String? = nil) -> DataRequest { let baseUrl = base ?? baseUrl ?? "" @@ -64,6 +68,300 @@ public extension NetworkSession { ) } + /// Builds a DataRequest object by constructing URL and Body parameters. + /// + /// - Parameters: + /// - endpoint: A Endpoint instance representing the endpoint. + /// - base: An optional BaseURL instance representing the base URL. If not provided, the default `baseUrl` property will be used. + /// - Returns: A DataRequest object that is ready to be executed. + func buildRequest(endpoint: Endpoint, base: String? = nil) -> DataRequest { + let baseUrl = base ?? baseUrl ?? "" + + return session.request( + try? endpoint.url(on: baseUrl), + method: endpoint.method, + parameters: endpoint.parameters?.dictionary, + encoding: endpoint.encoding, + headers: endpoint.headers + ) + } + +} + +// MARK: - Execute request - Async + +public extension NetworkSession { + + /// Executes a data request and returns a `DataResponse` containing the result of the request. + /// + /// This method allows you to specify various parameters to control the request execution, + /// including deduplication, valid response codes, and empty response handling. + /// + /// - Parameters: + /// - request: The `DataRequest` to be executed. + /// - deduplicate: A boolean value indicating whether to deduplicate the request. Default is `true`. + /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. + /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. + /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - Returns: A `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. + func execute( + request: DataRequest, + deduplicate: Bool = true, + validResponseCodes: Set = Set(200..<300), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) async -> DataResponse { + let taskID = request.convertible.urlRequest?.url?.absoluteString ?? UUID().uuidString + + return await requestExecutor.execute( + request, + taskID: taskID, + deduplicate: deduplicate, + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) + } + + /// Executes a request to the specified endpoint and returns a `DataResponse` containing the result. + /// + /// This method allows you to specify various parameters to control the request execution, + /// including the base URL, deduplication, valid response codes, and empty response handling. + /// + /// - Parameters: + /// - endpoint: The `Endpoint` representing the API endpoint to be requested. + /// - base: An optional base URL to be used for the request. If not provided, the default base URL is used. + /// - deduplicate: A boolean value indicating whether to deduplicate the request. Default is `true`. + /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. + /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. + /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - Returns: A `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. + func execute( + endpoint: Endpoint, + base: String? = nil, + deduplicate: Bool = true, + validResponseCodes: Set = Set(200..<300), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) async -> DataResponse { + let baseUrl = base ?? baseUrl ?? "" + let taskID = (try? endpoint.url(on: baseUrl).absoluteString) ?? UUID().uuidString + let request = self.buildRequest(endpoint: endpoint, base: base) + + return await requestExecutor.execute( + request, + taskID: taskID, + deduplicate: deduplicate, + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) + } + +} + +// MARK: - Execute Request - Publisher DataResponse + +public extension NetworkSession { + + /// Executes a request to the specified endpoint and returns a `DataResponse` containing the result. + /// + /// This method allows you to specify various parameters to control the request execution, + /// including the base URL, deduplication, valid response codes, and empty response handling. + /// + /// - Parameters: + /// - endpoint: The `Endpoint` representing the API endpoint to be requested. + /// - base: An optional base URL to be used for the request. If not provided, the default base URL is used. + /// - deduplicate: A boolean value indicating whether to deduplicate the request. Default is `true`. + /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. + /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. + /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - Returns: A Publisher of `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. + func execute( + request: DataRequest, + deduplicate: Bool = true, + validResponseCodes: Set = Set(200..<300), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) -> AnyPublisher, Never> { + let taskID = request.convertible.urlRequest?.url?.absoluteString ?? UUID().uuidString + + return Future.create { [weak self] in + guard let self else { + return DataResponse( + request: nil, + response: nil, + data: nil, + metrics: nil, + serializationDuration: .nan, + result: .failure(AFError.sessionDeinitialized) + ) + } + + return await requestExecutor.execute( + request, + taskID: taskID, + deduplicate: deduplicate, + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) + } + .eraseToAnyPublisher() + } + + /// Executes a request to the specified endpoint and returns a `DataResponse` containing the result. + /// + /// This method allows you to specify various parameters to control the request execution, + /// including the base URL, deduplication, valid response codes, and empty response handling. + /// + /// - Parameters: + /// - endpoint: The `Endpoint` representing the API endpoint to be requested. + /// - base: An optional base URL to be used for the request. If not provided, the default base URL is used. + /// - deduplicate: A boolean value indicating whether to deduplicate the request. Default is `true`. + /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. + /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. + /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - Returns: A Publisher of `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. + func execute( + endpoint: Endpoint, + base: String? = nil, + deduplicate: Bool = true, + validResponseCodes: Set = Set(200..<300), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) -> AnyPublisher, Never> { + let baseUrl = base ?? baseUrl ?? "" + let taskID = (try? endpoint.url(on: baseUrl).absoluteString) ?? UUID().uuidString + let request = self.buildRequest(endpoint: endpoint, base: base) + + return Future.create { [weak self] in + guard let self else { + return DataResponse( + request: nil, + response: nil, + data: nil, + metrics: nil, + serializationDuration: .nan, + result: .failure(AFError.sessionDeinitialized) + ) + } + + return await requestExecutor.execute( + request, + taskID: taskID, + deduplicate: deduplicate, + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) + } + .eraseToAnyPublisher() + } + +} + +// MARK: - Execute Request - Publisher Response + +public extension NetworkSession { + + /// Executes a request to the specified endpoint and returns a `DataResponse` containing the result. + /// + /// This method allows you to specify various parameters to control the request execution, + /// including the base URL, deduplication, valid response codes, and empty response handling. + /// + /// - Parameters: + /// - endpoint: The `Endpoint` representing the API endpoint to be requested. + /// - base: An optional base URL to be used for the request. If not provided, the default base URL is used. + /// - deduplicate: A boolean value indicating whether to deduplicate the request. Default is `true`. + /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. + /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. + /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - Returns: A Publisher of `ResultType` or an `AFError`. + func execute( + endpoint: Endpoint, + base: String? = nil, + deduplicate: Bool = true, + validResponseCodes: Set = Set(200..<300), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) -> AnyPublisher { + let baseUrl = base ?? baseUrl ?? "" + let taskID = (try? endpoint.url(on: baseUrl).absoluteString) ?? UUID().uuidString + let request = self.buildRequest(endpoint: endpoint, base: base) + + return Future.create { [weak self] in + guard let self else { + throw AFError.sessionDeinitialized + } + + let dataResponse: DataResponse = await requestExecutor.execute( + request, + taskID: taskID, + deduplicate: deduplicate, + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) + + switch dataResponse.result { + case .success(let success): + return success + case .failure(let failure): + throw failure + } + } + .mapError { $0.asAFError(orFailWith: "") } + .eraseToAnyPublisher() + } + + /// Executes a request to the specified endpoint and returns a `DataResponse` containing the result. + /// + /// This method allows you to specify various parameters to control the request execution, + /// including the base URL, deduplication, valid response codes, and empty response handling. + /// + /// - Parameters: + /// - endpoint: The `Endpoint` representing the API endpoint to be requested. + /// - base: An optional base URL to be used for the request. If not provided, the default base URL is used. + /// - deduplicate: A boolean value indicating whether to deduplicate the request. Default is `true`. + /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. + /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. + /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - Returns: A Publisher of `ResultType` or an `AFError`. + func execute( + request: DataRequest, + deduplicate: Bool = true, + validResponseCodes: Set = Set(200..<300), + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + ) -> AnyPublisher { + let taskID = request.convertible.urlRequest?.url?.absoluteString ?? UUID().uuidString + + return Future.create { [weak self] in + guard let self else { + throw AFError.sessionDeinitialized + } + + let dataResponse: DataResponse = await requestExecutor.execute( + request, + taskID: taskID, + deduplicate: deduplicate, + validResponseCodes: validResponseCodes, + emptyResponseCodes: emptyResponseCodes, + emptyResponseMethods: emptyResponseMethods + ) + + switch dataResponse.result { + case .success(let success): + return success + case .failure(let failure): + throw failure + } + } + .mapError { $0.asAFError(orFailWith: "") } + .eraseToAnyPublisher() + } + } // MARK: - Download From ddfd15567e79e282707af683171d573459301141 Mon Sep 17 00:00:00 2001 From: Matus Klasovity Date: Wed, 7 Aug 2024 11:23:53 +0200 Subject: [PATCH 2/4] feat: Remove the task from the cache when finished --- Sources/GoodNetworking/Executor/RequestExecutor.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/GoodNetworking/Executor/RequestExecutor.swift b/Sources/GoodNetworking/Executor/RequestExecutor.swift index a878c94..41a687d 100644 --- a/Sources/GoodNetworking/Executor/RequestExecutor.swift +++ b/Sources/GoodNetworking/Executor/RequestExecutor.swift @@ -67,6 +67,7 @@ actor RequestExecutor { runningRequestTasks[taskID] = executorTask let result = await requestTask.value + runningRequestTasks[taskID] = nil logger.log(level: .info, message: "🚀 taskID: \(taskID): Task finished successfully") return result From 9516487ac5bcd5f3a2892bae211291524ba5c0d4 Mon Sep 17 00:00:00 2001 From: Matus Klasovity Date: Wed, 7 Aug 2024 11:24:14 +0200 Subject: [PATCH 3/4] feat: GoodifyAsync - support for custom decoder --- .../Extensions/DataRequestExtensions.swift | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift b/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift index d407019..b90c1c2 100644 --- a/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift +++ b/Sources/GoodNetworking/Extensions/DataRequestExtensions.swift @@ -81,11 +81,26 @@ public extension DataRequest { .value() } + /// Returns a `T` response or thrown an error for this instance and uses a ``serializingDecodable`` method to serialize the + /// response. + /// + /// - Parameters: + /// - type: `Decodable` type to which to decode response `Data`. Inferred from the context by default. + /// - validResponseCodes: `Set` of acceptable HTTP status code in the default acceptable range of 200…299. + /// - emptyResponseCodes: `Set` of HTTP status codes for which empty responses are allowed. `[204, 205]` by + /// default. + /// - emptyRequestMethods: `Set` of `HTTPMethod`s for which empty responses are allowed, regardless of + /// status code. `[.head]` by default. + /// - decoder: `JSONDecoder` instance used to decode response `Data`. For `Decodable` `JSONDecoder()` by default. + /// For `Decodable & WithCustomDecoder` custom `decoder` used by default. + /// + /// - Returns: The `DataResponsePublisher`. func goodifyAsync( type: T.Type = T.self, validResponseCodes: Set = Set(200..<299), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() ) async throws -> T { do { return try await self @@ -93,6 +108,7 @@ public extension DataRequest { .serializingDecodable( T.self, automaticallyCancelling: true, + decoder: decoder, emptyResponseCodes: emptyResponseCodes, emptyRequestMethods: emptyResponseMethods ) @@ -106,17 +122,33 @@ public extension DataRequest { } + /// Returns a `DataResponse` for this instance and uses a ``serializingDecodable`` method to serialize the + /// response. + /// + /// - Parameters: + /// - type: `Decodable` type to which to decode response `Data`. Inferred from the context by default. + /// - validResponseCodes: `Set` of acceptable HTTP status code in the default acceptable range of 200…299. + /// - emptyResponseCodes: `Set` of HTTP status codes for which empty responses are allowed. `[204, 205]` by + /// default. + /// - emptyRequestMethods: `Set` of `HTTPMethod`s for which empty responses are allowed, regardless of + /// status code. `[.head]` by default. + /// - decoder: `JSONDecoder` instance used to decode response `Data`. For `Decodable` `JSONDecoder()` by default. + /// For `Decodable & WithCustomDecoder` custom `decoder` used by default. + /// + /// - Returns: The `DataResponsePublisher`. func goodifyAsync( type: T.Type = T.self, validResponseCodes: Set = Set(200..<299), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() ) async -> DataResponse { await self .validate(statusCode: validResponseCodes) .serializingDecodable( T.self, automaticallyCancelling: true, + decoder: decoder, emptyResponseCodes: emptyResponseCodes, emptyRequestMethods: emptyResponseMethods ) From b9a1f232134ce9d8c028496f205e236996315828 Mon Sep 17 00:00:00 2001 From: Matus Klasovity Date: Wed, 7 Aug 2024 14:59:00 +0200 Subject: [PATCH 4/4] feat: Add caching mechanism --- .../Executor/ExecutorTask.swift | 14 ++++-- .../Executor/RequestExecutor.swift | 48 +++++++++++++------ .../Session/NetworkSession.swift | 42 +++++++++++----- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/Sources/GoodNetworking/Executor/ExecutorTask.swift b/Sources/GoodNetworking/Executor/ExecutorTask.swift index 4056eff..546aa00 100644 --- a/Sources/GoodNetworking/Executor/ExecutorTask.swift +++ b/Sources/GoodNetworking/Executor/ExecutorTask.swift @@ -8,15 +8,23 @@ import Foundation import Alamofire -final class ExecutorTask { +final class ExecutorTask { var finishDate: Date? let taskID: String - let task: Task, Never> + let task: Task, Never> - init(taskID: String, task: Task, Never>) { + private let cacheTimeout: TimeInterval + + var exceedsTimeout: Bool { + guard let finishDate else { return false } + return Date().timeIntervalSince(finishDate) > cacheTimeout + } + + init(taskID: String, task: Task, Never>, cacheTimeout: TimeInterval) { self.taskID = taskID self.task = task + self.cacheTimeout = cacheTimeout } } diff --git a/Sources/GoodNetworking/Executor/RequestExecutor.swift b/Sources/GoodNetworking/Executor/RequestExecutor.swift index 41a687d..4f8a73c 100644 --- a/Sources/GoodNetworking/Executor/RequestExecutor.swift +++ b/Sources/GoodNetworking/Executor/RequestExecutor.swift @@ -18,7 +18,7 @@ actor RequestExecutor { } }() - private var runningRequestTasks: [String: Any] = [:] + private var runningRequestTasks: [String: ExecutorTask] = [:] func execute( _ request: DataRequest, @@ -26,7 +26,8 @@ actor RequestExecutor { deduplicate: Bool, validResponseCodes: Set, emptyResponseCodes: Set, - emptyResponseMethods: Set + emptyResponseMethods: Set, + cacheTimeout: TimeInterval ) async -> DataResponse { let randomUUID = UUID().uuidString return await execute( @@ -34,43 +35,60 @@ actor RequestExecutor { taskID: deduplicate ? taskID : randomUUID, validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, - emptyResponseMethods: emptyResponseMethods + emptyResponseMethods: emptyResponseMethods, + cacheTimeout: cacheTimeout ) } - private func execute( + private func execute( _ request: DataRequest, taskID: String, validResponseCodes: Set, emptyResponseCodes: Set, - emptyResponseMethods: Set + emptyResponseMethods: Set, + cacheTimeout: TimeInterval ) async -> DataResponse { + runningRequestTasks = runningRequestTasks.filter { !$0.value.exceedsTimeout } + if let runningTask = runningRequestTasks[taskID] { - let executorTask = runningTask as! ExecutorTask logger.log(level: .info, message: "🚀 taskID: \(taskID) Cached value used") - return await executorTask.task.value + return await runningTask.task.value.map { $0 as! SuccessType } } else { - let requestTask = Task { - return await request.goodifyAsync( + let requestTask = Task, Never> { + let result: DataResponse = await request.goodifyAsync( validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, emptyResponseMethods: emptyResponseMethods - ) as DataResponse + ) + + return result.map { $0 as Decodable } } logger.log(level: .info, message: "🚀 taskID: \(taskID): Task created") let executorTask: ExecutorTask = ExecutorTask( taskID: taskID, - task: requestTask + task: requestTask, + cacheTimeout: cacheTimeout ) runningRequestTasks[taskID] = executorTask - let result = await requestTask.value - runningRequestTasks[taskID] = nil + let dataResponse = await requestTask.value + switch dataResponse.result { + case .success: + logger.log(level: .info, message: "🚀 taskID: \(taskID): Task finished successfully") + if cacheTimeout > 0 { + runningRequestTasks[taskID]?.finishDate = Date() + } else { + runningRequestTasks[taskID] = nil + } + + case .failure: + logger.log(level: .error, message: "🚀 taskID: \(taskID): Task finished with error") + runningRequestTasks[taskID] = nil + } - logger.log(level: .info, message: "🚀 taskID: \(taskID): Task finished successfully") - return result + return dataResponse.map { $0 as! SuccessType } } } diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 2abc4c8..e68cdfb 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -103,13 +103,15 @@ public extension NetworkSession { /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - cacheTimeout: The time interval to cache the successful response. Default is `0` (no cache). /// - Returns: A `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. func execute( request: DataRequest, deduplicate: Bool = true, validResponseCodes: Set = Set(200..<300), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + cacheTimeout: TimeInterval = 0 ) async -> DataResponse { let taskID = request.convertible.urlRequest?.url?.absoluteString ?? UUID().uuidString @@ -119,7 +121,8 @@ public extension NetworkSession { deduplicate: deduplicate, validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, - emptyResponseMethods: emptyResponseMethods + emptyResponseMethods: emptyResponseMethods, + cacheTimeout: cacheTimeout ) } @@ -135,6 +138,7 @@ public extension NetworkSession { /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - cacheTimeout: The time interval to cache the successful response. Default is `0` (no cache). /// - Returns: A `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. func execute( endpoint: Endpoint, @@ -142,7 +146,8 @@ public extension NetworkSession { deduplicate: Bool = true, validResponseCodes: Set = Set(200..<300), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + cacheTimeout: TimeInterval = 0 ) async -> DataResponse { let baseUrl = base ?? baseUrl ?? "" let taskID = (try? endpoint.url(on: baseUrl).absoluteString) ?? UUID().uuidString @@ -154,7 +159,8 @@ public extension NetworkSession { deduplicate: deduplicate, validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, - emptyResponseMethods: emptyResponseMethods + emptyResponseMethods: emptyResponseMethods, + cacheTimeout: cacheTimeout ) } @@ -176,13 +182,15 @@ public extension NetworkSession { /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - cacheTimeout: The time interval to cache the successful response. Default is `0` (no cache). /// - Returns: A Publisher of `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. func execute( request: DataRequest, deduplicate: Bool = true, validResponseCodes: Set = Set(200..<300), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + cacheTimeout: TimeInterval = 0 ) -> AnyPublisher, Never> { let taskID = request.convertible.urlRequest?.url?.absoluteString ?? UUID().uuidString @@ -204,7 +212,8 @@ public extension NetworkSession { deduplicate: deduplicate, validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, - emptyResponseMethods: emptyResponseMethods + emptyResponseMethods: emptyResponseMethods, + cacheTimeout: cacheTimeout ) } .eraseToAnyPublisher() @@ -222,6 +231,7 @@ public extension NetworkSession { /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - cacheTimeout: The time interval to cache the successful response. Default is `0` (no cache). /// - Returns: A Publisher of `DataResponse` containing the result of the request, which includes either the decoded result of type `ResultType` or an `AFError`. func execute( endpoint: Endpoint, @@ -229,7 +239,8 @@ public extension NetworkSession { deduplicate: Bool = true, validResponseCodes: Set = Set(200..<300), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + cacheTimeout: TimeInterval = 0 ) -> AnyPublisher, Never> { let baseUrl = base ?? baseUrl ?? "" let taskID = (try? endpoint.url(on: baseUrl).absoluteString) ?? UUID().uuidString @@ -253,7 +264,8 @@ public extension NetworkSession { deduplicate: deduplicate, validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, - emptyResponseMethods: emptyResponseMethods + emptyResponseMethods: emptyResponseMethods, + cacheTimeout: cacheTimeout ) } .eraseToAnyPublisher() @@ -277,6 +289,7 @@ public extension NetworkSession { /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - cacheTimeout: The time interval to cache the successful response. Default is `0` (no cache). /// - Returns: A Publisher of `ResultType` or an `AFError`. func execute( endpoint: Endpoint, @@ -284,7 +297,8 @@ public extension NetworkSession { deduplicate: Bool = true, validResponseCodes: Set = Set(200..<300), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + cacheTimeout: TimeInterval = 0 ) -> AnyPublisher { let baseUrl = base ?? baseUrl ?? "" let taskID = (try? endpoint.url(on: baseUrl).absoluteString) ?? UUID().uuidString @@ -301,7 +315,8 @@ public extension NetworkSession { deduplicate: deduplicate, validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, - emptyResponseMethods: emptyResponseMethods + emptyResponseMethods: emptyResponseMethods, + cacheTimeout: cacheTimeout ) switch dataResponse.result { @@ -327,13 +342,15 @@ public extension NetworkSession { /// - validResponseCodes: A set of valid HTTP response codes. Default is all codes from 200 to 299. /// - emptyResponseCodes: A set of HTTP response codes that indicate an empty response. Default is the `DecodableResponseSerializer` default empty response codes for the specified `ResultType`. /// - emptyResponseMethods: A set of HTTP methods that indicate an empty response. Default is the `DecodableResponseSerializer` default empty request methods for the specified `ResultType`. + /// - cacheTimeout: The time interval to cache the successful response. Default is `0` (no cache). /// - Returns: A Publisher of `ResultType` or an `AFError`. func execute( request: DataRequest, deduplicate: Bool = true, validResponseCodes: Set = Set(200..<300), emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + cacheTimeout: TimeInterval = 0 ) -> AnyPublisher { let taskID = request.convertible.urlRequest?.url?.absoluteString ?? UUID().uuidString @@ -348,7 +365,8 @@ public extension NetworkSession { deduplicate: deduplicate, validResponseCodes: validResponseCodes, emptyResponseCodes: emptyResponseCodes, - emptyResponseMethods: emptyResponseMethods + emptyResponseMethods: emptyResponseMethods, + cacheTimeout: cacheTimeout ) switch dataResponse.result {