From 007fd559439d1bc1a72c0b5f71e7abcb26589b7e Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:05:14 +0200 Subject: [PATCH 01/13] Revert "feat: Network Session Modulatity" This reverts commit 094c89aa1e10cce33fceda2af8c508245edf4f3e. --- .../DeduplicatingRequestExecutor.swift | 17 +- .../GoodNetworking/Extensions/Goodify.swift | 270 ++++++++++++++++++ .../GoodNetworking/Models/EmptyResponse.swift | 18 -- .../Providers/DefaultSessionProvider.swift | 16 +- .../Session/LoggingEventMonitor.swift | 6 +- .../Session/NetworkSession.swift | 213 +++++--------- Sources/GoodNetworking/Wrapper/Resource.swift | 72 +---- .../Models/MockResponse.swift | 8 +- .../Tests/ArrayEncodingTests.swift | 20 +- .../Tests/DownloadTests.swift | 127 -------- .../Executor/DeduplicatingExecutorTests.swift | 30 +- .../DefaultRequestExecutorTests.swift | 133 +++++---- .../Tests/UploadTests.swift | 97 ------- 13 files changed, 467 insertions(+), 560 deletions(-) create mode 100644 Sources/GoodNetworking/Extensions/Goodify.swift delete mode 100644 Sources/GoodNetworking/Models/EmptyResponse.swift delete mode 100644 Tests/GoodNetworkingTests/Tests/DownloadTests.swift delete mode 100644 Tests/GoodNetworkingTests/Tests/UploadTests.swift diff --git a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift index 62498ce..665dc81 100644 --- a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift +++ b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift @@ -28,7 +28,7 @@ import GoodLogger public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Identifiable { /// A unique identifier used to track and deduplicate requests - private let taskId: String? + private let taskId: String #warning("Timeout should be configurable based on taskId") /// The duration in seconds for which successful responses are cached @@ -48,7 +48,7 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide /// - taskId: A unique identifier for deduplicating requests /// - cacheTimeout: The duration in seconds for which successful responses are cached. Defaults to 6 seconds. /// Set to 0 to disable caching. - public init(taskId: String? = nil, cacheTimeout: TimeInterval = 6, logger: GoodLogger? = nil) { + public init(taskId: String, cacheTimeout: TimeInterval = 6, logger: GoodLogger? = nil) { if let logger { self.logger = logger } else { @@ -85,17 +85,6 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide DeduplicatingRequestExecutor.runningRequestTasks = DeduplicatingRequestExecutor.runningRequestTasks .filter { !$0.value.exceedsTimeout } - guard let taskId = self.taskId ?? (try? endpoint.url(on: baseURL).absoluteString) else { - return DataResponse( - request: nil, - response: nil, - data: nil, - metrics: nil, - serializationDuration: 0.0, - result: .failure(.invalidURL(url: URL(string: "\(baseURL)/\(endpoint.path)"))) - ) - } - if let runningTask = DeduplicatingRequestExecutor.runningRequestTasks[taskId] { logger.log(message: "πŸš€ taskId: \(taskId) Cached value used", level: .info) return await runningTask.task.value @@ -128,7 +117,7 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide let dataResponse = await requestTask.value switch dataResponse.result { - case .success: + case .success(let value): logger.log(message: "πŸš€ taskId: \(taskId): Task finished successfully", level: .info) if cacheTimeout > 0 { diff --git a/Sources/GoodNetworking/Extensions/Goodify.swift b/Sources/GoodNetworking/Extensions/Goodify.swift new file mode 100644 index 0000000..2095fb7 --- /dev/null +++ b/Sources/GoodNetworking/Extensions/Goodify.swift @@ -0,0 +1,270 @@ +// +// Goodify.swift +// GoodNetworking +// +// Created by Dominik PethΓΆ on 4/30/19. +// + +@preconcurrency import Alamofire +import Combine +import Foundation + +@available(iOS 13, *) +public extension DataRequest { + + /// Processes the network request and decodes the response into the specified type. + /// + /// This method validates the response using the provided `ValidationProviding` instance and then decodes the response data + /// into the specified type `T`. The decoding process is customizable with parameters such as data preprocessor, + /// JSON decoder, and sets of HTTP methods and status codes to consider as "empty" responses. + /// + /// - Parameters: + /// - type: The expected type of the response data, defaulting to `T.self`. + /// - validator: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. + /// - preprocessor: The preprocessor for manipulating the response data before decoding. + /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. + /// - emptyRequestMethods: The HTTP methods that indicate an empty response. + /// - decoder: The JSON decoder used for decoding the response data. If the type conforms to `WithCustomDecoder`, the custom decoder is used. + /// - Returns: A `DataTask` that contains the decoded result. + func goodify( + type: T.Type = T.self, + validator: any ValidationProviding = DefaultValidationProvider(), + preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyRequestMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() + ) -> DataTask { + return self + .validate { + self.goodifyValidation( + request: $0, + response: $1, + data: $2, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyRequestMethods, + validator: validator + ) + } + .serializingDecodable( + T.self, + automaticallyCancelling: true, + dataPreprocessor: preprocessor, + decoder: decoder, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyRequestMethods + ) + } + + /// Processes the network request and decodes the response into the specified type using Combine. + /// + /// This is a legacy implementation using Combine for handling network responses. It validates the response, + /// then publishes the decoded result or an error. This version of the method is deprecated. + /// + /// - Parameters: + /// - type: The expected type of the response data, defaulting to `T.self`. + /// - queue: The queue on which the response is published. + /// - preprocessor: The preprocessor for manipulating the response data before decoding. + /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. + /// - emptyResponseMethods: The HTTP methods that indicate an empty response. + /// - decoder: The JSON decoder used for decoding the response data. + /// - Returns: A `Publisher` that publishes the decoded result or an `AFError`. + @available(*, deprecated, message: "Legacy Combine implementation") + func goodify( + type: T.Type = T.self, + queue: DispatchQueue = .main, + preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() + ) -> AnyPublisher where T: Sendable { + let serializer = DecodableResponseSerializer( + dataPreprocessor: preprocessor, + decoder: decoder, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyResponseMethods + ) + return self.validate() + .publishResponse(using: serializer, on: queue) + .value() + } + + /// Processes the network request and decodes the response into an array of the specified type. + /// + /// This method validates the response using the provided `ValidationProviding` instance and then decodes the response data + /// into an array of the specified type `[T]`. The decoding process is customizable with parameters such as data preprocessor, + /// JSON decoder, and sets of HTTP methods and status codes to consider as "empty" responses. + /// + /// - Parameters: + /// - type: The expected type of the response data, defaulting to `[T].self`. + /// - validator: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. + /// - preprocessor: The preprocessor for manipulating the response data before decoding. + /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. + /// - emptyRequestMethods: The HTTP methods that indicate an empty response. + /// - decoder: The JSON decoder used for decoding the response data. If the type conforms to `WithCustomDecoder`, the custom decoder is used. + /// - Returns: A `DataTask` that contains the decoded array result. + func goodify( + type: [T].Type = [T].self, + validator: any ValidationProviding = DefaultValidationProvider(), + preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyRequestMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() + ) -> DataTask<[T]> { + return self + .validate { + self.goodifyValidation( + request: $0, + response: $1, + data: $2, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyRequestMethods, + validator: validator + ) + } + .serializingDecodable( + [T].self, + automaticallyCancelling: true, + dataPreprocessor: preprocessor, + decoder: decoder, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyRequestMethods + ) + } + + /// Processes the network request and decodes the response into an array of the specified type using Combine. + /// + /// This is a legacy implementation using Combine for handling network responses. It validates the response, + /// then publishes the decoded result or an error. This version of the method is deprecated. + /// + /// - Parameters: + /// - type: The expected type of the response data, defaulting to `[T].self`. + /// - queue: The queue on which the response is published. + /// - preprocessor: The preprocessor for manipulating the response data before decoding. + /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. + /// - emptyResponseMethods: The HTTP methods that indicate an empty response. + /// - decoder: The JSON decoder used for decoding the response data. + /// - Returns: A `Publisher` that publishes the decoded result or an `AFError`. + @available(*, deprecated, message: "Legacy Combine implementation") + func goodify( + type: [T].Type = [T].self, + queue: DispatchQueue = .main, + preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, + emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, + emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, + decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() + ) -> AnyPublisher<[T], AFError> where T: Sendable { + let serializer = DecodableResponseSerializer<[T]>( + dataPreprocessor: preprocessor, + decoder: decoder, + emptyResponseCodes: emptyResponseCodes, + emptyRequestMethods: emptyResponseMethods + ) + return self.validate() + .publishResponse(using: serializer, on: queue) + .value() + } + +} + +public extension DataRequest { + + /// Creates a `DataResponse` with the specified success value. + /// + /// - Parameter value: The value to set as the success result. + /// - Returns: A `DataResponse` object with the success value. + func response(withValue value: T) -> DataResponse { + return DataResponse( + request: request, + response: response, + data: data, + metrics: .none, + serializationDuration: 30, + result: AFResult.success(value) + ) + } + + /// Creates a `DataResponse` with the specified error. + /// + /// - Parameter error: The error to set as the failure result. + /// - Returns: A `DataResponse` object with the failure error. + func response(withError error: AFError) -> DataResponse { + return DataResponse( + request: request, + response: response, + data: data, + metrics: .none, + serializationDuration: 30, + result: AFResult.failure(error) + ) + } + +} + +// MARK: - Validation + +extension DataRequest { + + /// Validates the response using a custom validator. + /// + /// This method checks if the response data is valid according to the provided `ValidationProviding` instance. + /// If the validation fails, an error is returned. + /// + /// - Parameters: + /// - request: The original URL request. + /// - response: The HTTP response received. + /// - data: The response data. + /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. + /// - emptyRequestMethods: The HTTP methods that indicate an empty response. + /// - validator: The validation provider used to validate the response. + /// - Returns: A `ValidationResult` indicating whether the validation succeeded or failed. + private func goodifyValidation( + request: URLRequest?, + response: HTTPURLResponse, + data: Data?, + emptyResponseCodes: Set, + emptyRequestMethods: Set, + validator: any ValidationProviding + ) -> ValidationResult { + guard let data else { + let emptyResponseAllowed = requestAllowsEmptyResponseData(request, emptyRequestMethods: emptyRequestMethods) + || responseAllowsEmptyResponseData(response, emptyResponseCodes: emptyResponseCodes) + + return emptyResponseAllowed + ? .success(()) + : .failure(AFError.responseSerializationFailed(reason: .inputDataNilOrZeroLength)) + } + + do { + try validator.validate(statusCode: response.statusCode, data: data) + return .success(()) + } catch let error { + return .failure(error) + } + } + + /// Determines whether the `request` allows empty response bodies, if `request` exists. + /// + ///- Parameters: + /// - request: `URLRequest` to evaluate. + /// - emptyRequestMethods: The HTTP methods that indicate an empty response. + /// + /// - Returns: `Bool` representing the outcome of the evaluation, or `nil` if `request` was `nil`. + private func requestAllowsEmptyResponseData(_ request: URLRequest?, emptyRequestMethods: Set) -> Bool { + guard let httpMethodString = request?.httpMethod else { return false } + + let httpMethod = HTTPMethod(rawValue: httpMethodString) + return emptyRequestMethods.contains(httpMethod) + } + + /// Determines whether the `response` allows empty response bodies, if `response` exists. + /// + ///- Parameters: + /// - request: `HTTPURLResponse` to evaluate. + /// - emptyRequestMethods: The HTTP status codes that indicate an empty response. + /// + /// - Returns: `Bool` representing the outcome of the evaluation, or `nil` if `response` was `nil`. + private func responseAllowsEmptyResponseData(_ response: HTTPURLResponse, emptyResponseCodes: Set) -> Bool { + emptyResponseCodes.contains(response.statusCode) + } + +} diff --git a/Sources/GoodNetworking/Models/EmptyResponse.swift b/Sources/GoodNetworking/Models/EmptyResponse.swift deleted file mode 100644 index 18b7e4f..0000000 --- a/Sources/GoodNetworking/Models/EmptyResponse.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// EmptyResponse.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -public struct EmptyResponse: Decodable { - - public init(from decoder: any Decoder) throws {} - - public init() {} - -} - -public protocol EmptyResponseCreatable { - static var emptyInstance: Self { get } -} diff --git a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift index 4a63ee0..deba235 100644 --- a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift @@ -46,7 +46,17 @@ public actor DefaultSessionProvider: NetworkSessionProviding { eventMonitors: configuration.eventMonitors ) - self.logger = logger + if let logger { + self.logger = logger + } + } + + var defaultLogger: GoodLogger { + if #available(iOS 14, *) { + return OSLogLogger(logMetaData: false) + } else { + return PrintLogger(logMetaData: false) + } } /// Initializes the session provider with an existing `Alamofire.Session`. @@ -61,7 +71,9 @@ public actor DefaultSessionProvider: NetworkSessionProviding { eventMonitors: [session.eventMonitor] ) - self.logger = logger + if let logger { + self.logger = logger + } } /// A Boolean value indicating that the session is always valid. diff --git a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift index 64e89a6..3b8cb69 100644 --- a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift +++ b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift @@ -34,7 +34,7 @@ /// let config = NetworkSessionConfiguration(eventMonitors: [monitor]) /// let session = NetworkSession(configuration: config) /// ``` -import Alamofire +@preconcurrency import Alamofire import Combine import GoodLogger import Foundation @@ -52,7 +52,7 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { maxVerboseLogSizeBytes: Int = 100_000, slowRequestThreshold: TimeInterval = 1.0, prefixes: Prefixes = Prefixes(), - mimeTypeWhilelistConfiguration: MimeTypeWhitelistConfiguration? = MimeTypeWhitelistConfiguration() + mimeTypeWhilelistConfiguration: MimeTypeWhitelistConfiguration? = nil ) { self.verbose = verbose self.prettyPrinted = prettyPrinted @@ -149,7 +149,7 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { self.configuration = configuration } - public func request(_ request: DataRequest, didParseResponse response: DataResponse) { + public func request(_ request: DataRequest, didParseResponse response: DataResponse) { let requestSize = request.request?.httpBody?.count ?? 0 let responseSize = response.data?.count ?? 0 diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 0e98112..0195966 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -124,50 +124,6 @@ public actor NetworkSession: Hashable { public extension NetworkSession { - /// Performs a network request that returns a decoded response. - /// - /// This method handles the complete lifecycle of a network request, including: - /// - Base URL resolution - /// - Session validation - /// - Request execution - /// - Response validation - /// - Error transformation - /// - Response decoding - /// - /// - Parameters: - /// - endpoint: The endpoint to request, containing URL, method, parameters, and headers - /// - baseUrlProvider: Optional override for the base URL provider - /// - validationProvider: Provider for custom response validation logic - /// - resultProvider: Optional provider for resolving results without network calls - /// - requestExecutor: The component responsible for executing the network request - /// - Returns: A decoded instance of the specified Result type - /// - Throws: A Failure error if any step in the request process fails - func request( - endpoint: Endpoint, - baseUrlProvider: BaseUrlProviding? = nil, - requestExecutor: RequestExecuting = DefaultRequestExecutor(), - validationProvider: any ValidationProviding = DefaultValidationProvider() - ) async throws(Failure) { - try await catchingFailureEmpty(validationProvider: validationProvider) { - let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - // If not call request executor to use the API - let response = await requestExecutor.executeRequest( - endpoint: endpoint, - session: resolvedSession, - baseURL: resolvedBaseUrl - ) - - guard let statusCode = response.response?.statusCode else { - throw response.error ?? NetworkError.sessionError - } - - // Validate API result from executor - try validationProvider.validate(statusCode: statusCode, data: response.data) - } - } - /// Performs a network request that returns a decoded response. /// /// This method handles the complete lifecycle of a network request, including: @@ -209,15 +165,26 @@ public extension NetworkSession { baseURL: resolvedBaseUrl ) - guard let statusCode = response.response?.statusCode else { - throw response.error ?? NetworkError.sessionError - } - // Validate API result from executor - try validationProvider.validate(statusCode: statusCode, data: response.data) + let validationResult = goodifyValidation( + request: response.request, + response: response.response!, + data: response.data, + validator: validationProvider + ) + + // If validation fails throw + if case Alamofire.DataRequest.ValidationResult.failure(let error) = validationResult { throw error } // Decode - return try decodeResponse(response) + let decoder = (Result.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() + + do { + let result = try decoder.decode(Result.self, from: response.data ?? Data()) + return result + } catch { + throw NetworkError.missingRemoteData + } } } } @@ -253,11 +220,15 @@ public extension NetworkSession { baseURL: resolvedBaseUrl ) - guard let statusCode = response.response?.statusCode else { - throw response.error ?? NetworkError.sessionError - } + let validationResult = goodifyValidation( + request: response.request, + response: response.response!, + data: response.data, + validator: validationProvider + ) - try validationProvider.validate(statusCode: statusCode, data: response.data) + // If validation fails throw + if case Alamofire.DataRequest.ValidationResult.failure(let error) = validationResult { throw error } return response.data ?? Data() } @@ -294,83 +265,43 @@ public extension NetworkSession { public extension NetworkSession { - /// Creates a download request that saves the response to a file and provides progress updates. + /// Creates a download request that saves the response to a file. /// /// This method handles downloading files from a network endpoint and saving them /// to the app's documents directory. It supports: /// - Custom file naming /// - Automatic directory creation /// - Previous file removal - /// - Progress tracking via AsyncStream /// /// - Parameters: /// - endpoint: The endpoint to download from /// - baseUrlProvider: Optional override for the base URL provider /// - customFileName: The name to use for the saved file - /// - Returns: An AsyncStream that emits download progress and final URL + /// - Returns: An Alamofire DownloadRequest instance /// - Throws: A NetworkError if the download setup fails - func download( + func download( endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, - customFileName: String, - validationProvider: any ValidationProviding = DefaultValidationProvider() - ) -> AsyncThrowingStream<(progress: Double, url: URL?), Error> { - return AsyncThrowingStream { continuation in - Task { - do { - // Resolve the base URL and session before starting the stream - let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - // Ensure we can create a valid URL - guard let downloadURL = try? endpoint.url(on: resolvedBaseUrl) else { - continuation.finish(throwing: validationProvider.transformError(NetworkError.invalidBaseURL)) - return - } - - // Set up file destination - let destination: DownloadRequest.Destination = { temporaryURL, _ in - let directoryURLs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - let url = directoryURLs.first?.appendingPathComponent(customFileName) ?? temporaryURL - return (url, [.removePreviousFile, .createIntermediateDirectories]) - } - - // Start the download - let request = resolvedSession.download( - downloadURL, - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers, - to: destination - ) - - // Monitor progress - request.downloadProgress { progress in - continuation.yield((progress: progress.fractionCompleted, url: nil)) - } - - // Handle response - request.response { response in - switch response.result { - case .success: - if let destinationURL = response.fileURL { - continuation.yield((progress: 1.0, url: destinationURL)) - } else { - continuation.finish(throwing: validationProvider.transformError(.missingRemoteData)) - } - case .failure(let error): - continuation.finish(throwing: error) - } - - continuation.finish() - } + customFileName: String + ) async throws(NetworkError) -> DownloadRequest { + let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) + let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - } catch { - continuation.finish(throwing: validationProvider.transformError(.sessionError)) - } - } + let destination: DownloadRequest.Destination = { temporaryURL, _ in + let directoryURLs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + let url = directoryURLs.first?.appendingPathComponent(customFileName) ?? temporaryURL + + return (url, [.removePreviousFile, .createIntermediateDirectories]) } + + return resolvedSession.download( + try? endpoint.url(on: resolvedBaseUrl), + method: endpoint.method, + parameters: endpoint.parameters?.dictionary, + encoding: endpoint.encoding, + headers: endpoint.headers, + to: destination + ) } } @@ -515,45 +446,29 @@ extension NetworkSession { } } - /// Executes code with standardized error handling. + /// Validates the response using a custom validator. /// - /// This method provides consistent error handling by: - /// - Catching and transforming network errors - /// - Handling Alamofire-specific errors - /// - Converting errors to the expected failure type + /// This method checks if the response data is valid according to the provided `ValidationProviding` instance. + /// If the validation fails, an error is returned. /// /// - Parameters: - /// - validationProvider: Provider for error transformation - /// - body: The code to execute - /// - Returns: The result of type Result - /// - Throws: A transformed error matching the Failure type - func catchingFailureEmpty( - validationProvider: any ValidationProviding, - body: () async throws -> Void - ) async throws(Failure) { + /// - request: The original URL request. + /// - response: The HTTP response received. + /// - data: The response data. + /// - validator: The validation provider used to validate the response. + /// - Returns: A `ValidationResult` indicating whether the validation succeeded or failed. + private func goodifyValidation( + request: URLRequest?, + response: HTTPURLResponse, + data: Data?, + validator: any ValidationProviding + ) -> Alamofire.Request.ValidationResult { do { - return try await body() - } catch let networkError as NetworkError { - throw validationProvider.transformError(networkError) - } catch let error as AFError { - if let underlyingError = error.underlyingError as? Failure { - throw underlyingError - } else if let underlyingError = error.underlyingError as? NetworkError { - throw validationProvider.transformError(underlyingError) - } else { - throw validationProvider.transformError(NetworkError.sessionError) - } - } catch { - throw validationProvider.transformError(NetworkError.sessionError) + try validator.validate(statusCode: response.statusCode, data: data) + return .success(()) + } catch let error { + return .failure(error) } } - func decodeResponse( - _ response: DataResponse, - defaultDecoder: JSONDecoder = JSONDecoder() - ) throws -> Result { - let decoder = (Result.self as? WithCustomDecoder.Type)?.decoder ?? defaultDecoder - return try decoder.decode(Result.self, from: response.data ?? Data()) - } - } diff --git a/Sources/GoodNetworking/Wrapper/Resource.swift b/Sources/GoodNetworking/Wrapper/Resource.swift index c1bd4ca..9025234 100644 --- a/Sources/GoodNetworking/Wrapper/Resource.swift +++ b/Sources/GoodNetworking/Wrapper/Resource.swift @@ -115,59 +115,29 @@ extension Resource { } public func create() async throws { - logger - .log( - message: "CREATE operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + logger.log(level: .error, message: "CREATE operation not defined for resource \(String(describing: R.self))", privacy: .auto) } public func read(forceReload: Bool = false) async throws { - logger - .log( - message: "READ operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + logger.log(level: .error, message: "READ operation not defined for resource \(String(describing: R.self))", privacy: .auto) } public func updateRemote() async throws { - logger - .log( - message: "UPDATE operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + logger.log(level: .error, message: "UPDATE operation not defined for resource \(String(describing: R.self))", privacy: .auto) } public func delete() async throws { - logger - .log( - message: "DELETE operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + logger.log(level: .error, message: "DELETE operation not defined for resource \(String(describing: R.self))", privacy: .auto) } public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { - logger - .log( - message: "LIST operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) - logger.log(message: "Check type of parameters passed to this resource.", level: .error, privacy: .auto) - logger.log(message: "Current parameters type: \(type(of: parameters))", level: .error, privacy: .auto) + logger.log(level: .error, message: "LIST operation not defined for resource \(String(describing: R.self))", privacy: .auto) + logger.log(level: .error, message: "Check type of parameters passed to this resource.", privacy: .auto) + logger.log(level: .error, message: "Current parameters type: \(type(of: parameters))", privacy: .auto) } public func nextPage() async throws { - logger - .log( - message: "LIST operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + logger.log(level: .error, message: "LIST operation not defined for resource \(String(describing: R.self))", privacy: .auto) } } @@ -179,11 +149,7 @@ extension Resource where R: Creatable { public func create() async throws { guard let request = try R.request(from: state.value) else { - return logger - .log( - message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", - level: .error - ) + return logger.log(level: .error, message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", privacy: .auto) } try await create(request: request) } @@ -237,11 +203,7 @@ extension Resource where R: Readable { let resource = state.value guard let request = try R.request(from: resource) else { self.state = .idle - return logger - .log( - message: "Requesting nil resource always fails! Use read(request:forceReload:) with a custom request or supply a resource to read.", - level: .error - ) + return logger.log(level: .error, message: "Requesting nil resource always fails! Use read(request:forceReload:) with a custom request or supply a resource to read.", privacy: .auto) } try await read(request: request, forceReload: forceReload) @@ -249,7 +211,7 @@ extension Resource where R: Readable { public func read(request: R.ReadRequest, forceReload: Bool = false) async throws { guard !state.isAvailable || forceReload else { - return logger.log(message: "Skipping read - value already exists", level: .info, privacy: .auto) + return logger.log(level: .info, message: "Skipping read - value already exists", privacy: .auto) } let resource = state.value @@ -292,11 +254,7 @@ extension Resource where R: Updatable { public func updateRemote() async throws { guard let request = try R.request(from: state.value) else { - return logger - .log( - message: "Updating resource to nil always fails! Use DELETE instead.", - level: .error - ) + return logger.log(level: .error, message: "Updating resource to nil always fails! Use DELETE instead.", privacy: .auto) } try await updateRemote(request: request) } @@ -347,11 +305,7 @@ extension Resource where R: Deletable { public func delete() async throws { guard let request = try R.request(from: state.value) else { - return logger - .log( - message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", - level: .error - ) + return logger.log(level: .error, message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", privacy: .auto) } try await delete(request: request) } diff --git a/Tests/GoodNetworkingTests/Models/MockResponse.swift b/Tests/GoodNetworkingTests/Models/MockResponse.swift index ff23b27..abba094 100644 --- a/Tests/GoodNetworkingTests/Models/MockResponse.swift +++ b/Tests/GoodNetworkingTests/Models/MockResponse.swift @@ -5,15 +5,11 @@ // Created by Andrej Jasso on 07/02/2025. // -import GoodNetworking +import Foundation -struct MockResponse: Codable, EmptyResponseCreatable { +struct MockResponse: Codable { let code: Int let description: String - static var emptyInstance: MockResponse { - return MockResponse(code: 204, description: "No Content") - } - } diff --git a/Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift b/Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift index e5806b9..cf3036f 100644 --- a/Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift +++ b/Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift @@ -54,9 +54,25 @@ final class ArrayEncodingTests: XCTestCase { var testCancellable: AnyCancellable? - func testGRSessionPostWithTopArrayJSON() async throws { + func testGRSessionPostWithTopArrayJSON() async { let session = NetworkSession(baseUrl: Base.base.rawValue, configuration: .default) - try await session.request(endpoint: TestEndpoint.unkeyedTopLevelList(MyStruct.sample)) + let request: AnyPublisher = await session.request(endpoint: TestEndpoint.unkeyedTopLevelList(MyStruct.sample)) + .goodify(type: EmptyResponse.self) + let requestExpectation = expectation(description: "Request Expectation") + + testCancellable = request + .sink(receiveCompletion: { completion in + switch completion { + case .finished: + break + case .failure(let error): + XCTFail("Request failed with error: \(error)") + } + requestExpectation.fulfill() + + }, receiveValue: { _ in } + ) + await fulfillment(of: [requestExpectation], timeout: 5.0, enforceOrder: true) } func testEndpointBuilder() { diff --git a/Tests/GoodNetworkingTests/Tests/DownloadTests.swift b/Tests/GoodNetworkingTests/Tests/DownloadTests.swift deleted file mode 100644 index 0c3be90..0000000 --- a/Tests/GoodNetworkingTests/Tests/DownloadTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// DownloadTests.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -import XCTest -@testable import GoodNetworking -import Alamofire -import GoodLogger - -final class DownloadTests: XCTestCase { - - // MARK: - Properties - - private var networkSession: NetworkSession! - private var baseURL = "https://pdfobject.com/pdf/sample.pdf" - private var baseURL2 = "https://cartographicperspectives.org/index.php/journal/article/download/cp13-full/pdf/4742" - private var baseURL3 = "https://invalid.com/pdf/invalid.pdf" - - // MARK: - Setup - - override func setUp() { - super.setUp() - networkSession = NetworkSession(baseUrlProvider: baseURL) - } - - override func tearDown() { - networkSession = nil - super.tearDown() - } - - // MARK: - Tests - - func testDownloadWithFast() async throws { - // Given - let downloadEndpoint = DownloadEndpoint() - var progressValues: [Double] = [] - var url: URL! - - // When - for try await completion in try await networkSession.download( - endpoint: downloadEndpoint, - baseUrlProvider: baseURL, - customFileName: "SamplePDFTest" - ) { - print((completion.progress * 100).rounded(), "/100%") - progressValues.append(completion.progress) - url = completion.url - } - - // Then - XCTAssertFalse(progressValues.isEmpty) - XCTAssertEqual(progressValues.last!, 1.0, accuracy: 0.01) - - // Verify file exists - XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) - - // Cleanup - try? FileManager.default.removeItem(at: url) - } - - func testDownloadWithProgressLong() async throws { - // Given - let downloadEndpoint = DownloadEndpoint() - var progressValues: [Double] = [] - var url: URL! - - // When - for try await completion in try await networkSession.download( - endpoint: downloadEndpoint, - baseUrlProvider: baseURL2, - customFileName: "SamplePDFTest" - ) { - print((completion.progress * 100).rounded(), "/100%") - progressValues.append(completion.progress) - url = completion.url - } - - // Then - XCTAssertFalse(progressValues.isEmpty) - XCTAssertEqual(progressValues.last!, 1.0, accuracy: 0.01) - - // Verify file exists - XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) - - // Cleanup - try? FileManager.default.removeItem(at: url) - } - - func testDownloadWithInvalidURL() async throws { - // Given - let downloadEndpoint = DownloadEndpoint() - var progressValues: [Double] = [] - var url: URL! - - // When/Then - do { - for try await completion in try await networkSession.download( - endpoint: downloadEndpoint, - baseUrlProvider: baseURL3, - customFileName: "SamplePDFTest" - ) { - print((completion.progress * 100).rounded(), "/100%") - progressValues.append(completion.progress) - url = completion.url - } - XCTFail("Expected download to fail for invalid URL") - } catch { - // Then - XCTAssertTrue( - error is URLError || error is AFError || error is NetworkError, - "Expected URLError or AFError, got \(type(of: error))" - ) - } - } - -} - -private struct DownloadEndpoint: Endpoint { - var path: String = "" - var method: HTTPMethod = .get - var parameters: Parameters? = nil - var headers: HTTPHeaders? = nil - var encoding: ParameterEncoding = URLEncoding.default -} diff --git a/Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift b/Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift index 33aa8b6..d0a992a 100644 --- a/Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift +++ b/Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift @@ -41,35 +41,7 @@ final class DeduplicatingExecutorTests: XCTestCase { XCTAssertEqual(result2.name, "Luke Skywalker") XCTAssertTrue(logger.messages.contains(where: { $0.contains("Cached value used") } )) } - - func testConcurrentRequestsAreDeduplicatedDefaultTaskID() async throws { - // Setup - let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(logger: logger) - let session: Session! = Session() - let baseURL = "https://swapi.dev/api" - let networkSession = NetworkSession(baseUrlProvider: baseURL, session: session) - // Given - let endpoint = SwapiEndpoint.luke - - // When - async let firstResult: SwapiPerson = networkSession.request( - endpoint: endpoint, - requestExecutor: executor - ) - - async let secondResult: SwapiPerson = networkSession.request( - endpoint: endpoint, - requestExecutor: executor - ) - - // Then - let (result1, result2) = try await (firstResult, secondResult) - XCTAssertEqual(result1.name, "Luke Skywalker") - XCTAssertEqual(result2.name, "Luke Skywalker") - XCTAssertTrue(logger.messages.contains(where: { $0.contains("Cached value used") } )) - } - + func testDifferentRequestsAreNotDeduplicated() async throws { // Setup let logger = TestingLogger() diff --git a/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift b/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift index 5c8dd17..eb21609 100644 --- a/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift +++ b/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift @@ -10,35 +10,13 @@ import XCTest import Alamofire import GoodLogger -final class DefaultRequestExecutorTestsGenerated: XCTestCase { - - // MARK: - Properties - - private var executor: DefaultRequestExecutor! - private var session: Session! - private var networkSession: NetworkSession! - - // MARK: - Setup +final class DefaultRequestExecutorTests: XCTestCase { - override func setUp() { - super.setUp() - executor = DefaultRequestExecutor() - session = Session() - let baseURL = "https://httpstat.us" - networkSession = NetworkSession(baseUrlProvider: baseURL) - } - - override func tearDown() { - executor = nil - session = nil - networkSession = nil - super.tearDown() - } - - // MARK: - Simple tests + // MARK: - Tests func testConcurrentRequests() async throws { // Setup + let logger = TestingLogger() let executor = DefaultRequestExecutor() let session: Session! = Session() let baseURL = "https://swapi.dev/api" @@ -65,8 +43,18 @@ final class DefaultRequestExecutorTestsGenerated: XCTestCase { func testErrorHandling() async throws { // Setup + let logger = TestingLogger() let executor = DefaultRequestExecutor() let baseURL = "https://swapi.dev/api" + let provider = DefaultSessionProvider( + configuration: NetworkSessionConfiguration( + eventMonitors: [LoggingEventMonitor(logger: logger)] + ) + ) + let networkSession = NetworkSession( + baseUrlProvider: baseURL, + sessionProvider: provider + ) // Given let invalidEndpoint = SwapiEndpoint.invalid @@ -80,6 +68,43 @@ final class DefaultRequestExecutorTestsGenerated: XCTestCase { XCTAssertTrue(error != nil) } } +} + +final class DefaultRequestExecutorTestsGenerated: XCTestCase { + + // MARK: - Properties + + private var executor: DefaultRequestExecutor! + private var session: Session! + private var networkSession: NetworkSession! + + /// A private property that provides the appropriate logger based on the iOS version. + /// + /// For iOS 14 and later, it uses `OSLogLogger`. For earlier versions, it defaults to `PrintLogger`. + private var logger: GoodLogger { + if #available(iOS 14, *) { + return OSLogLogger(logMetaData: true) + } else { + return PrintLogger(logMetaData: true) + } + } + + // MARK: - Setup + + override func setUp() { + super.setUp() + executor = DefaultRequestExecutor() + session = Session() + let baseURL = "https://httpstat.us" + networkSession = NetworkSession(baseUrlProvider: baseURL) + } + + override func tearDown() { + executor = nil + session = nil + networkSession = nil + super.tearDown() + } // MARK: - Success Responses (2xx) @@ -98,24 +123,17 @@ final class DefaultRequestExecutorTestsGenerated: XCTestCase { XCTAssertEqual(response.code, 202) } - func testNoContent204ExpectingNonEmptyCreatableType() async throws { - struct EmptyResponse: Decodable {} - do { - let _: EmptyResponse = try await networkSession.request( - endpoint: StatusAPI.status(204), - requestExecutor: executor - ) - XCTFail("Expected no content error") - } catch { - print("Success") - } - } - - func testNoContent204ExpectingEmpty() async throws { - try await networkSession.request( - endpoint: StatusAPI.status(204), - requestExecutor: executor - ) + func testNoContent204() async throws { + let response: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(204), requestExecutor: executor) + XCTAssertEqual(response.code, 204) + +// do { +// let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(204), requestExecutor: executor) +// XCTFail("Expected no content error") +// } catch { +// print(error.localizedDescription) +// XCTAssertTrue(error.statusCode == 204) +// } } // MARK: - Redirection Responses (3xx) @@ -123,29 +141,36 @@ final class DefaultRequestExecutorTestsGenerated: XCTestCase { func testPermanentRedirectToHTMLExpectingJSON301() async throws { do { let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(301), requestExecutor: executor) - XCTFail("Expected missingRemoteData") + XCTFail("Expected 301 error") } catch { - print("Success") + XCTAssert(error == NetworkError.missingRemoteData) } } - func testNotModifiedToHTMLExpectingJSON304() async throws { + func testTemporaryRedirect301() async throws { do { - struct EmptyResponse: Decodable {} - let _: EmptyResponse = try await networkSession.request(endpoint: StatusAPI.status(304), requestExecutor: executor) - XCTFail("Expected missingRemoteData") + let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(301), requestExecutor: executor) + XCTFail("Expected 301 error") } catch { - print(error.errorDescription) - XCTAssertEqual(error.statusCode, 304) + XCTAssert(error == NetworkError.missingRemoteData) } } - func testTemporaryRedirectToHTMLExpectingJSON307() async throws { + func testTemporaryRedirect307() async throws { do { let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(307), requestExecutor: executor) - XCTFail("Expected missingRemoteData") + XCTFail("Expected 307 error") + } catch { + XCTAssertTrue(error.statusCode == 200) + } + } + + func testNotModified304() async throws { + do { + let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(304), requestExecutor: executor) + XCTFail("Expected 304 error") } catch { - print("Success") + XCTAssertTrue(error.statusCode == 200) } } diff --git a/Tests/GoodNetworkingTests/Tests/UploadTests.swift b/Tests/GoodNetworkingTests/Tests/UploadTests.swift deleted file mode 100644 index 65448ad..0000000 --- a/Tests/GoodNetworkingTests/Tests/UploadTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// UploadTests.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -import XCTest -@testable import GoodNetworking -import Alamofire -import GoodLogger - -final class UploadTests: XCTestCase { - - // MARK: - Properties - - private var networkSession: NetworkSession! - private var baseURL = "https://pdfobject.com/pdf/sample.pdf" - private var baseURL2 = "https://cartographicperspectives.org/index.php/journal/article/download/cp13-full/pdf/4742" - // MARK: - Setup - - override func setUp() { - super.setUp() - networkSession = NetworkSession(baseUrlProvider: baseURL) - } - - override func tearDown() { - networkSession = nil - super.tearDown() - } - - // MARK: - Tests - - func testDownloadWithFast() async throws { - // Given - let downloadEndpoint = DownloadEndpoint() - var progressValues: [Double] = [] - var url: URL! - - // When - for try await completion in try await networkSession.download( - endpoint: downloadEndpoint, - baseUrlProvider: baseURL, - customFileName: "SamplePDFTest" - ) { - print((completion.progress * 100).rounded(), "/100%") - progressValues.append(completion.progress) - url = completion.url - } - - // Then - XCTAssertFalse(progressValues.isEmpty) - XCTAssertEqual(progressValues.last!, 1.0, accuracy: 0.01) - - // Verify file exists - XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) - - // Cleanup - try? FileManager.default.removeItem(at: url) - } - - func testDownloadWithProgressLong() async throws { - // Given - let downloadEndpoint = DownloadEndpoint() - var progressValues: [Double] = [] - var url: URL! - - // When - for try await completion in try await networkSession.download( - endpoint: downloadEndpoint, - baseUrlProvider: baseURL2, - customFileName: "SamplePDFTest" - ) { - print((completion.progress * 100).rounded(), "/100%") - progressValues.append(completion.progress) - url = completion.url - } - - // Then - XCTAssertFalse(progressValues.isEmpty) - XCTAssertEqual(progressValues.last!, 1.0, accuracy: 0.01) - - // Verify file exists - XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) - - // Cleanup - try? FileManager.default.removeItem(at: url) - } -} - -private struct DownloadEndpoint: Endpoint { - var path: String = "" - var method: HTTPMethod = .get - var parameters: Parameters? = nil - var headers: HTTPHeaders? = nil - var encoding: ParameterEncoding = URLEncoding.default -} From ddb772da92a5726d7b9b95c2a5b496db401cba72 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:15:19 +0200 Subject: [PATCH 02/13] Revert "Executor fixes" This reverts commit 5ebb52a9de14a5c871f3ba68a39905a5321d1ceb. --- .../DeduplicatingRequestExecutor.swift | 87 ++++-- .../Executor/DefaultRequestExecutor.swift | 48 +++- .../Executor/ExecutorTask.swift | 2 +- .../Executor/RequestExecuting.swift | 7 +- .../Protocols/ValidationProviding.swift | 2 +- .../Providers/DefaultSessionProvider.swift | 36 +-- .../Providers/DefaultValidationProvider.swift | 2 +- .../GoodNetworking/Session/NetworkError.swift | 4 +- .../Session/NetworkSession.swift | 91 +----- .../{Tests => }/ArrayEncodingTests.swift | 0 .../{Tests => }/DateDecodingTest.swift | 0 .../DeduplicatingExecutorTests.swift | 52 ++-- .../DefaultBaseUrlProviderTests.swift | 0 .../DefaultSessionProviderTests.swift | 0 .../DefaultValidationProviderTests.swift | 0 .../{Endpoints => }/Endpoint.swift | 0 .../Endpoints/StatusAPI.swift | 28 -- .../{Tests => }/FutureSessionTests.swift | 0 .../Models/MockResponse.swift | 15 - .../{Models => }/MyStruct.swift | 0 .../{Tests => }/NetworkSessionTests.swift | 0 .../{Endpoints => }/SwapiEndpoint.swift | 0 .../{Models => }/SwapiPerson.swift | 0 .../{Services => }/TestingLogger.swift | 0 .../DefaultRequestExecutorTests.swift | 262 ------------------ 25 files changed, 165 insertions(+), 471 deletions(-) rename Tests/GoodNetworkingTests/{Tests => }/ArrayEncodingTests.swift (100%) rename Tests/GoodNetworkingTests/{Tests => }/DateDecodingTest.swift (100%) rename Tests/GoodNetworkingTests/{Tests/Executor => }/DeduplicatingExecutorTests.swift (69%) rename Tests/GoodNetworkingTests/{Tests => }/DefaultBaseUrlProviderTests.swift (100%) rename Tests/GoodNetworkingTests/{Tests => }/DefaultSessionProviderTests.swift (100%) rename Tests/GoodNetworkingTests/{Tests => }/DefaultValidationProviderTests.swift (100%) rename Tests/GoodNetworkingTests/{Endpoints => }/Endpoint.swift (100%) delete mode 100644 Tests/GoodNetworkingTests/Endpoints/StatusAPI.swift rename Tests/GoodNetworkingTests/{Tests => }/FutureSessionTests.swift (100%) delete mode 100644 Tests/GoodNetworkingTests/Models/MockResponse.swift rename Tests/GoodNetworkingTests/{Models => }/MyStruct.swift (100%) rename Tests/GoodNetworkingTests/{Tests => }/NetworkSessionTests.swift (100%) rename Tests/GoodNetworkingTests/{Endpoints => }/SwapiEndpoint.swift (100%) rename Tests/GoodNetworkingTests/{Models => }/SwapiPerson.swift (100%) rename Tests/GoodNetworkingTests/{Services => }/TestingLogger.swift (100%) delete mode 100644 Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift diff --git a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift index 665dc81..931ac9e 100644 --- a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift +++ b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift @@ -77,32 +77,42 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide /// - validationProvider: Provider for response validation and error transformation /// - Returns: The decoded response of type Result /// - Throws: An error of type Failure if the request fails or validation fails - public func executeRequest( + public func executeRequest( endpoint: Endpoint, session: Session, - baseURL: String - ) async -> DataResponse { + baseURL: String, + validationProvider: any ValidationProviding = DefaultValidationProvider() + ) async throws(Failure) -> Result { DeduplicatingRequestExecutor.runningRequestTasks = DeduplicatingRequestExecutor.runningRequestTasks .filter { !$0.value.exceedsTimeout } + return try await catchingFailure(validationProvider: validationProvider) { if let runningTask = DeduplicatingRequestExecutor.runningRequestTasks[taskId] { logger.log(message: "πŸš€ taskId: \(taskId) Cached value used", level: .info) - return await runningTask.task.value + let dataResponse = await runningTask.task.value + switch dataResponse.result { + case .success(let value): + if let result = value as? Result { + return result + } else { + throw validationProvider.transformError(NetworkError.sessionError) + } + case .failure(let error): + throw error + } } else { let requestTask = ExecutorTask.TaskType { - return await withCheckedContinuation { continuation in - session.request( - try? endpoint.url(on: baseURL), - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers - ) - .validate() - .response { response in - continuation.resume(returning: response) - } - } + let result = await session.request( + try? endpoint.url(on: baseURL), + method: endpoint.method, + parameters: endpoint.parameters?.dictionary, + encoding: endpoint.encoding, + headers: endpoint.headers + ) + .goodify(type: Result.self, validator: validationProvider) + .response + + return result.map { $0 as Result } } logger.log(message: "πŸš€ taskId: \(taskId): Task created", level: .info) @@ -119,22 +129,57 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide switch dataResponse.result { case .success(let value): logger.log(message: "πŸš€ taskId: \(taskId): Task finished successfully", level: .info) - if cacheTimeout > 0 { DeduplicatingRequestExecutor.runningRequestTasks[taskId]?.finishDate = Date() } else { DeduplicatingRequestExecutor.runningRequestTasks[taskId] = nil } - return dataResponse + guard let result = value as? Result else { + throw validationProvider.transformError(NetworkError.sessionError) + } + return result - case .failure: + case .failure(let error): logger.log(message: "πŸš€ taskId: \(taskId): Task finished with error", level: .error) DeduplicatingRequestExecutor.runningRequestTasks[taskId] = nil - return dataResponse + throw error } } + } + } + /// Executes a closure while catching and transforming failures. + /// + /// This method provides standardized error handling by: + /// - Catching and transforming network errors + /// - Handling Alamofire-specific errors + /// - Converting errors to the expected failure type + /// + /// - Parameters: + /// - validationProvider: The provider used to transform any errors. + /// - body: The closure to execute. + /// - Returns: The result of type `Result`. + /// - Throws: A transformed error if the closure fails. + func catchingFailure( + validationProvider: any ValidationProviding, + body: () async throws -> Result + ) async throws(Failure) -> Result { + do { + return try await body() + } catch let networkError as NetworkError { + throw validationProvider.transformError(networkError) + } catch let error as AFError { + if let underlyingError = error.underlyingError as? Failure { + throw underlyingError + } else if let underlyingError = error.underlyingError as? NetworkError { + throw validationProvider.transformError(underlyingError) + } else { + throw validationProvider.transformError(NetworkError.sessionError) + } + } catch { + throw validationProvider.transformError(NetworkError.sessionError) + } } } diff --git a/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift b/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift index d4e0329..7826ae5 100644 --- a/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift +++ b/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift @@ -44,21 +44,55 @@ public final actor DefaultRequestExecutor: RequestExecuting, Sendable { /// - validationProvider: Provider for response validation and error transformation /// - Returns: The decoded response of type Result /// - Throws: An error of type Failure if the request fails or validation fails - public func executeRequest( + public func executeRequest( endpoint: Endpoint, session: Session, - baseURL: String - ) async -> DataResponse { - return await withCheckedContinuation { continuation in - session.request( + baseURL: String, + validationProvider: any ValidationProviding = DefaultValidationProvider() + ) async throws(Failure) -> Result { + return try await catchingFailure(validationProvider: validationProvider) { + return try await session.request( try? endpoint.url(on: baseURL), method: endpoint.method, parameters: endpoint.parameters?.dictionary, encoding: endpoint.encoding, headers: endpoint.headers - ).response { response in - continuation.resume(returning: response) + ) + .goodify(type: Result.self, validator: validationProvider) + .value + } + } + + /// Executes a closure while catching and transforming failures. + /// + /// This method provides standardized error handling by: + /// - Catching and transforming network errors + /// - Handling Alamofire-specific errors + /// - Converting errors to the expected failure type + /// + /// - Parameters: + /// - validationProvider: The provider used to transform any errors. + /// - body: The closure to execute. + /// - Returns: The result of type `Result`. + /// - Throws: A transformed error if the closure fails. + func catchingFailure( + validationProvider: any ValidationProviding, + body: () async throws -> Result + ) async throws(Failure) -> Result { + do { + return try await body() + } catch let networkError as NetworkError { + throw validationProvider.transformError(networkError) + } catch let error as AFError { + if let underlyingError = error.underlyingError as? Failure { + throw underlyingError + } else if let underlyingError = error.underlyingError as? NetworkError { + throw validationProvider.transformError(underlyingError) + } else { + throw validationProvider.transformError(NetworkError.sessionError) } + } catch { + throw validationProvider.transformError(NetworkError.sessionError) } } diff --git a/Sources/GoodNetworking/Executor/ExecutorTask.swift b/Sources/GoodNetworking/Executor/ExecutorTask.swift index 964855a..bd3f5c5 100644 --- a/Sources/GoodNetworking/Executor/ExecutorTask.swift +++ b/Sources/GoodNetworking/Executor/ExecutorTask.swift @@ -24,7 +24,7 @@ import Alamofire public final class ExecutorTask { /// Type alias for the underlying asynchronous task that handles network responses - typealias TaskType = Task, Never> + typealias TaskType = Task, Never> /// The date when the task completed execution. `nil` if the task hasn't finished. var finishDate: Date? diff --git a/Sources/GoodNetworking/Executor/RequestExecuting.swift b/Sources/GoodNetworking/Executor/RequestExecuting.swift index f693b57..d16371e 100644 --- a/Sources/GoodNetworking/Executor/RequestExecuting.swift +++ b/Sources/GoodNetworking/Executor/RequestExecuting.swift @@ -45,10 +45,11 @@ public protocol RequestExecuting: Sendable { /// - validationProvider: Provider for response validation and error transformation /// - Returns: The decoded response of type Result /// - Throws: An error of type Failure if the request fails or validation fails - func executeRequest( + func executeRequest( endpoint: Endpoint, session: Session, - baseURL: String - ) async -> DataResponse + baseURL: String, + validationProvider: any ValidationProviding + ) async throws(Failure) -> Result } diff --git a/Sources/GoodNetworking/Protocols/ValidationProviding.swift b/Sources/GoodNetworking/Protocols/ValidationProviding.swift index c3a5fc3..05bd26d 100644 --- a/Sources/GoodNetworking/Protocols/ValidationProviding.swift +++ b/Sources/GoodNetworking/Protocols/ValidationProviding.swift @@ -27,7 +27,7 @@ public protocol ValidationProviding: Sendable where Failure: Error { /// - statusCode: The HTTP status code from the network response. /// - data: The data received from the network response. /// - Throws: A `Failure` error if the validation fails. - func validate(statusCode: Int, data: Data?) throws(Failure) + func validate(statusCode: Int, data: Data) throws(Failure) /// Transforms a general `NetworkError` into a specific error of type `Failure`. /// diff --git a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift index deba235..1bda3c3 100644 --- a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift @@ -32,12 +32,18 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// A private property that provides the appropriate logger based on the iOS version. /// /// For iOS 14 and later, it uses `OSLogLogger`. For earlier versions, it defaults to `PrintLogger`. - var logger: GoodLogger? + private var logger: GoodLogger { + if #available(iOS 14, *) { + return OSLogLogger(logMetaData: false) + } else { + return PrintLogger(logMetaData: false) + } + } /// Initializes the session provider with a network session configuration. /// /// - Parameter configuration: The configuration used to create network sessions. - public init(configuration: NetworkSessionConfiguration, logger: GoodLogger? = nil) { + public init(configuration: NetworkSessionConfiguration) { self.configuration = configuration self.currentSession = Alamofire.Session( configuration: configuration.urlSessionConfiguration, @@ -45,24 +51,12 @@ public actor DefaultSessionProvider: NetworkSessionProviding { serverTrustManager: configuration.serverTrustManager, eventMonitors: configuration.eventMonitors ) - - if let logger { - self.logger = logger - } - } - - var defaultLogger: GoodLogger { - if #available(iOS 14, *) { - return OSLogLogger(logMetaData: false) - } else { - return PrintLogger(logMetaData: false) - } } /// Initializes the session provider with an existing `Alamofire.Session`. /// /// - Parameter session: An existing session that will be used by this provider. - public init(session: Alamofire.Session, logger: GoodLogger? = nil) { + public init(session: Alamofire.Session) { self.currentSession = session self.configuration = NetworkSessionConfiguration( urlSessionConfiguration: session.sessionConfiguration, @@ -70,10 +64,6 @@ public actor DefaultSessionProvider: NetworkSessionProviding { serverTrustManager: session.serverTrustManager, eventMonitors: [session.eventMonitor] ) - - if let logger { - self.logger = logger - } } /// A Boolean value indicating that the session is always valid. @@ -83,7 +73,7 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// - Returns: `true`, indicating the session is valid. public var isSessionValid: Bool { - logger?.log( + logger.log( message: "βœ… Default session is always valid", level: .debug ) @@ -94,7 +84,7 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// Since the default session does not support invalidation, this method simply logs a message without performing any action. public func invalidateSession() async { - logger?.log( + logger.log( message: "❌ Default session cannot be invalidated", level: .debug ) @@ -107,7 +97,7 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// - Returns: A new instance of `Alamofire.Session`. public func makeSession() async -> Alamofire.Session { - logger?.log( + logger.log( message: "❌ Default Session Provider cannot be create a new Session, it's setup in the initializer", level: .debug ) @@ -122,7 +112,7 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// - Returns: The current or newly created `Alamofire.Session`. public func resolveSession() async -> Alamofire.Session { - logger?.log( + logger.log( message: "❌ Default session provider always resolves current session which is setup in the initializer", level: .debug ) diff --git a/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift b/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift index 4a454f5..9c68d0f 100644 --- a/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift @@ -35,7 +35,7 @@ public struct DefaultValidationProvider: ValidationProviding { /// - statusCode: The HTTP status code from the network response. /// - data: The data received from the network response. /// - Throws: A `NetworkError.remote` if the status code indicates a failure (outside the 200-299 range). - public func validate(statusCode: Int, data: Data?) throws(Failure) { + public func validate(statusCode: Int, data: Data) throws(Failure) { if statusCode < 200 || statusCode >= 300 { throw NetworkError.remote(statusCode: statusCode, data: data) } diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index 4e18351..765ed26 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -10,7 +10,7 @@ import Foundation public enum NetworkError: LocalizedError, Hashable { case endpoint(EndpointError) - case remote(statusCode: Int, data: Data?) + case remote(statusCode: Int, data: Data) case paging(PagingError) case missingLocalData case missingRemoteData @@ -56,7 +56,7 @@ public enum NetworkError: LocalizedError, Hashable { func remoteError(as errorType: E.Type) -> E? { if case let .remote(_, data) = self { - return try? JSONDecoder().decode(errorType, from: data ?? Data()) + return try? JSONDecoder().decode(errorType, from: data) } else { return nil } diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 0195966..8d82597 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -145,46 +145,22 @@ public extension NetworkSession { func request( endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, + validationProvider: any ValidationProviding = DefaultValidationProvider(), resultProvider: ResultProviding? = nil, - requestExecutor: RequestExecuting = DefaultRequestExecutor(), - validationProvider: any ValidationProviding = DefaultValidationProvider() + requestExecutor: RequestExecuting = DefaultRequestExecutor() ) async throws(Failure) -> Result { return try await catchingFailure(validationProvider: validationProvider) { let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - // Try resolve provided data if let result: Result = await resultProvider?.resolveResult(endpoint: endpoint) { - // If available directly return them return result } else { - // If not call request executor to use the API - let response = await requestExecutor.executeRequest( + return try await requestExecutor.executeRequest( endpoint: endpoint, session: resolvedSession, - baseURL: resolvedBaseUrl - ) - - // Validate API result from executor - let validationResult = goodifyValidation( - request: response.request, - response: response.response!, - data: response.data, - validator: validationProvider + baseURL: resolvedBaseUrl, + validationProvider: validationProvider ) - - // If validation fails throw - if case Alamofire.DataRequest.ValidationResult.failure(let error) = validationResult { throw error } - - // Decode - let decoder = (Result.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() - - do { - let result = try decoder.decode(Result.self, from: response.data ?? Data()) - return result - } catch { - throw NetworkError.missingRemoteData - } } } } @@ -203,35 +179,21 @@ public extension NetworkSession { func requestRaw( endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, - resultProvider: ResultProviding? = nil, - requestExecutor: RequestExecuting = DefaultRequestExecutor(), validationProvider: any ValidationProviding = DefaultValidationProvider() ) async throws(Failure) -> Data { return try await catchingFailure(validationProvider: validationProvider) { let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - if let result: Data = await resultProvider?.resolveResult(endpoint: endpoint) { - return result - } else { - let response = await requestExecutor.executeRequest( - endpoint: endpoint, - session: resolvedSession, - baseURL: resolvedBaseUrl - ) - - let validationResult = goodifyValidation( - request: response.request, - response: response.response!, - data: response.data, - validator: validationProvider - ) - - // If validation fails throw - if case Alamofire.DataRequest.ValidationResult.failure(let error) = validationResult { throw error } - - return response.data ?? Data() - } + return try await resolvedSession.request( + try? endpoint.url(on: resolvedBaseUrl), + method: endpoint.method, + parameters: endpoint.parameters?.dictionary, + encoding: endpoint.encoding, + headers: endpoint.headers + ) + .serializingData() + .value } } @@ -446,29 +408,4 @@ extension NetworkSession { } } - /// Validates the response using a custom validator. - /// - /// This method checks if the response data is valid according to the provided `ValidationProviding` instance. - /// If the validation fails, an error is returned. - /// - /// - Parameters: - /// - request: The original URL request. - /// - response: The HTTP response received. - /// - data: The response data. - /// - validator: The validation provider used to validate the response. - /// - Returns: A `ValidationResult` indicating whether the validation succeeded or failed. - private func goodifyValidation( - request: URLRequest?, - response: HTTPURLResponse, - data: Data?, - validator: any ValidationProviding - ) -> Alamofire.Request.ValidationResult { - do { - try validator.validate(statusCode: response.statusCode, data: data) - return .success(()) - } catch let error { - return .failure(error) - } - } - } diff --git a/Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift b/Tests/GoodNetworkingTests/ArrayEncodingTests.swift similarity index 100% rename from Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift rename to Tests/GoodNetworkingTests/ArrayEncodingTests.swift diff --git a/Tests/GoodNetworkingTests/Tests/DateDecodingTest.swift b/Tests/GoodNetworkingTests/DateDecodingTest.swift similarity index 100% rename from Tests/GoodNetworkingTests/Tests/DateDecodingTest.swift rename to Tests/GoodNetworkingTests/DateDecodingTest.swift diff --git a/Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift b/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift similarity index 69% rename from Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift rename to Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift index d0a992a..7394a84 100644 --- a/Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift +++ b/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift @@ -8,7 +8,6 @@ import XCTest @testable import GoodNetworking import Alamofire -import GoodLogger final class DeduplicatingExecutorTests: XCTestCase { @@ -20,19 +19,20 @@ final class DeduplicatingExecutorTests: XCTestCase { let executor = DeduplicatingRequestExecutor(taskId: "People", logger: logger) let session: Session! = Session() let baseURL = "https://swapi.dev/api" - let networkSession = NetworkSession(baseUrlProvider: baseURL, session: session) + // Given let endpoint = SwapiEndpoint.luke // When - async let firstResult: SwapiPerson = networkSession.request( + async let firstResult: SwapiPerson = executor.executeRequest( endpoint: endpoint, - requestExecutor: executor + session: session, + baseURL: baseURL ) - - async let secondResult: SwapiPerson = networkSession.request( + async let secondResult: SwapiPerson = executor.executeRequest( endpoint: endpoint, - requestExecutor: executor + session: session, + baseURL: baseURL ) // Then @@ -49,21 +49,21 @@ final class DeduplicatingExecutorTests: XCTestCase { let executor2 = DeduplicatingRequestExecutor(taskId: "Vader", logger: logger) let session: Session! = Session() let baseURL = "https://swapi.dev/api" - let networkSession = NetworkSession(baseUrlProvider: baseURL, session: session) // Given let lukeEndpoint = SwapiEndpoint.luke let vaderEndpoint = SwapiEndpoint.vader // When - async let lukeResult: SwapiPerson = networkSession.request( + async let lukeResult: SwapiPerson = executor.executeRequest( endpoint: lukeEndpoint, - requestExecutor: executor + session: session, + baseURL: baseURL ) - - async let vaderResult: SwapiPerson = networkSession.request( + async let vaderResult: SwapiPerson = executor2.executeRequest( endpoint: vaderEndpoint, - requestExecutor: executor2 + session: session, + baseURL: baseURL ) // Then @@ -79,20 +79,20 @@ final class DeduplicatingExecutorTests: XCTestCase { let executor = DeduplicatingRequestExecutor(taskId: "People", logger: logger) let session: Session! = Session() let baseURL = "https://swapi.dev/api" - let networkSession = NetworkSession(baseUrlProvider: baseURL, session: session) // Given let endpoint = SwapiEndpoint.luke // When - async let firstResult: SwapiPerson = networkSession.request( + async let firstResult: SwapiPerson = executor.executeRequest( endpoint: endpoint, - requestExecutor: executor + session: session, + baseURL: baseURL ) - - async let secondResult: SwapiPerson = networkSession.request( + async let secondResult: SwapiPerson = executor.executeRequest( endpoint: endpoint, - requestExecutor: executor + session: session, + baseURL: baseURL ) let luke = try await firstResult @@ -108,27 +108,19 @@ final class DeduplicatingExecutorTests: XCTestCase { // Setup let logger = TestingLogger() let executor = DeduplicatingRequestExecutor(taskId: "Invalid", logger: logger) + let session: Session! = Session() let baseURL = "https://swapi.dev/api" - let provider = DefaultSessionProvider( - configuration: NetworkSessionConfiguration( - eventMonitors: [LoggingEventMonitor(logger: logger)] - ) - ) - let networkSession = NetworkSession( - baseUrlProvider: baseURL, - sessionProvider: provider - ) // Given let invalidEndpoint = SwapiEndpoint.invalid // When/Then do { - let _: SwapiPerson = try await networkSession.request(endpoint: invalidEndpoint, requestExecutor: executor) + let _: SwapiPerson = try await executor + .executeRequest(endpoint: invalidEndpoint, session: session, baseURL: baseURL) XCTFail("Expected error to be thrown") } catch { - print(logger.messages) XCTAssertTrue(logger.messages.contains(where: { $0.contains("Task finished with error") } )) } } diff --git a/Tests/GoodNetworkingTests/Tests/DefaultBaseUrlProviderTests.swift b/Tests/GoodNetworkingTests/DefaultBaseUrlProviderTests.swift similarity index 100% rename from Tests/GoodNetworkingTests/Tests/DefaultBaseUrlProviderTests.swift rename to Tests/GoodNetworkingTests/DefaultBaseUrlProviderTests.swift diff --git a/Tests/GoodNetworkingTests/Tests/DefaultSessionProviderTests.swift b/Tests/GoodNetworkingTests/DefaultSessionProviderTests.swift similarity index 100% rename from Tests/GoodNetworkingTests/Tests/DefaultSessionProviderTests.swift rename to Tests/GoodNetworkingTests/DefaultSessionProviderTests.swift diff --git a/Tests/GoodNetworkingTests/Tests/DefaultValidationProviderTests.swift b/Tests/GoodNetworkingTests/DefaultValidationProviderTests.swift similarity index 100% rename from Tests/GoodNetworkingTests/Tests/DefaultValidationProviderTests.swift rename to Tests/GoodNetworkingTests/DefaultValidationProviderTests.swift diff --git a/Tests/GoodNetworkingTests/Endpoints/Endpoint.swift b/Tests/GoodNetworkingTests/Endpoint.swift similarity index 100% rename from Tests/GoodNetworkingTests/Endpoints/Endpoint.swift rename to Tests/GoodNetworkingTests/Endpoint.swift diff --git a/Tests/GoodNetworkingTests/Endpoints/StatusAPI.swift b/Tests/GoodNetworkingTests/Endpoints/StatusAPI.swift deleted file mode 100644 index 68612cc..0000000 --- a/Tests/GoodNetworkingTests/Endpoints/StatusAPI.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// StatusAPI.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -import GoodNetworking -import Alamofire - -enum StatusAPI: Endpoint { - - case status(Int) - - var path: String { - switch self { - case .status(let code): return "\(code)" - } - } - - var headers: HTTPHeaders? { - return [ - "Content-Type": "application/json", - "Accept": "application/json" - ] - } - -} diff --git a/Tests/GoodNetworkingTests/Tests/FutureSessionTests.swift b/Tests/GoodNetworkingTests/FutureSessionTests.swift similarity index 100% rename from Tests/GoodNetworkingTests/Tests/FutureSessionTests.swift rename to Tests/GoodNetworkingTests/FutureSessionTests.swift diff --git a/Tests/GoodNetworkingTests/Models/MockResponse.swift b/Tests/GoodNetworkingTests/Models/MockResponse.swift deleted file mode 100644 index abba094..0000000 --- a/Tests/GoodNetworkingTests/Models/MockResponse.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// MockResponse.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -import Foundation - -struct MockResponse: Codable { - - let code: Int - let description: String - -} diff --git a/Tests/GoodNetworkingTests/Models/MyStruct.swift b/Tests/GoodNetworkingTests/MyStruct.swift similarity index 100% rename from Tests/GoodNetworkingTests/Models/MyStruct.swift rename to Tests/GoodNetworkingTests/MyStruct.swift diff --git a/Tests/GoodNetworkingTests/Tests/NetworkSessionTests.swift b/Tests/GoodNetworkingTests/NetworkSessionTests.swift similarity index 100% rename from Tests/GoodNetworkingTests/Tests/NetworkSessionTests.swift rename to Tests/GoodNetworkingTests/NetworkSessionTests.swift diff --git a/Tests/GoodNetworkingTests/Endpoints/SwapiEndpoint.swift b/Tests/GoodNetworkingTests/SwapiEndpoint.swift similarity index 100% rename from Tests/GoodNetworkingTests/Endpoints/SwapiEndpoint.swift rename to Tests/GoodNetworkingTests/SwapiEndpoint.swift diff --git a/Tests/GoodNetworkingTests/Models/SwapiPerson.swift b/Tests/GoodNetworkingTests/SwapiPerson.swift similarity index 100% rename from Tests/GoodNetworkingTests/Models/SwapiPerson.swift rename to Tests/GoodNetworkingTests/SwapiPerson.swift diff --git a/Tests/GoodNetworkingTests/Services/TestingLogger.swift b/Tests/GoodNetworkingTests/TestingLogger.swift similarity index 100% rename from Tests/GoodNetworkingTests/Services/TestingLogger.swift rename to Tests/GoodNetworkingTests/TestingLogger.swift diff --git a/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift b/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift deleted file mode 100644 index eb21609..0000000 --- a/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift +++ /dev/null @@ -1,262 +0,0 @@ -// -// DefaultRequestExecutorTests.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -import XCTest -@testable import GoodNetworking -import Alamofire -import GoodLogger - -final class DefaultRequestExecutorTests: XCTestCase { - - // MARK: - Tests - - func testConcurrentRequests() async throws { - // Setup - let logger = TestingLogger() - let executor = DefaultRequestExecutor() - let session: Session! = Session() - let baseURL = "https://swapi.dev/api" - let networkSession = NetworkSession(baseUrlProvider: baseURL, session: session) - // Given - let endpoint = SwapiEndpoint.luke - - // When - async let firstResult: SwapiPerson = networkSession.request( - endpoint: endpoint, - requestExecutor: executor - ) - - async let secondResult: SwapiPerson = networkSession.request( - endpoint: endpoint, - requestExecutor: executor - ) - - // Then - let (result1, result2) = try await (firstResult, secondResult) - XCTAssertEqual(result1.name, "Luke Skywalker") - XCTAssertEqual(result2.name, "Luke Skywalker") - } - - func testErrorHandling() async throws { - // Setup - let logger = TestingLogger() - let executor = DefaultRequestExecutor() - let baseURL = "https://swapi.dev/api" - let provider = DefaultSessionProvider( - configuration: NetworkSessionConfiguration( - eventMonitors: [LoggingEventMonitor(logger: logger)] - ) - ) - let networkSession = NetworkSession( - baseUrlProvider: baseURL, - sessionProvider: provider - ) - - // Given - let invalidEndpoint = SwapiEndpoint.invalid - - // When/Then - do { - let _: SwapiPerson = try await networkSession.request(endpoint: invalidEndpoint, requestExecutor: executor) - - XCTFail("Expected error to be thrown") - } catch { - XCTAssertTrue(error != nil) - } - } -} - -final class DefaultRequestExecutorTestsGenerated: XCTestCase { - - // MARK: - Properties - - private var executor: DefaultRequestExecutor! - private var session: Session! - private var networkSession: NetworkSession! - - /// A private property that provides the appropriate logger based on the iOS version. - /// - /// For iOS 14 and later, it uses `OSLogLogger`. For earlier versions, it defaults to `PrintLogger`. - private var logger: GoodLogger { - if #available(iOS 14, *) { - return OSLogLogger(logMetaData: true) - } else { - return PrintLogger(logMetaData: true) - } - } - - // MARK: - Setup - - override func setUp() { - super.setUp() - executor = DefaultRequestExecutor() - session = Session() - let baseURL = "https://httpstat.us" - networkSession = NetworkSession(baseUrlProvider: baseURL) - } - - override func tearDown() { - executor = nil - session = nil - networkSession = nil - super.tearDown() - } - - // MARK: - Success Responses (2xx) - - func testSuccess200OK() async throws { - let response: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(200), requestExecutor: executor) - XCTAssertEqual(response.code, 200) - } - - func testCreated201() async throws { - let response: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(201), requestExecutor: executor) - XCTAssertEqual(response.code, 201) - } - - func testAccepted202() async throws { - let response: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(202), requestExecutor: executor) - XCTAssertEqual(response.code, 202) - } - - func testNoContent204() async throws { - let response: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(204), requestExecutor: executor) - XCTAssertEqual(response.code, 204) - -// do { -// let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(204), requestExecutor: executor) -// XCTFail("Expected no content error") -// } catch { -// print(error.localizedDescription) -// XCTAssertTrue(error.statusCode == 204) -// } - } - - // MARK: - Redirection Responses (3xx) - - func testPermanentRedirectToHTMLExpectingJSON301() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(301), requestExecutor: executor) - XCTFail("Expected 301 error") - } catch { - XCTAssert(error == NetworkError.missingRemoteData) - } - } - - func testTemporaryRedirect301() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(301), requestExecutor: executor) - XCTFail("Expected 301 error") - } catch { - XCTAssert(error == NetworkError.missingRemoteData) - } - } - - func testTemporaryRedirect307() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(307), requestExecutor: executor) - XCTFail("Expected 307 error") - } catch { - XCTAssertTrue(error.statusCode == 200) - } - } - - func testNotModified304() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(304), requestExecutor: executor) - XCTFail("Expected 304 error") - } catch { - XCTAssertTrue(error.statusCode == 200) - } - } - - // MARK: - Client Errors (4xx) - - func testBadRequest400() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(400), requestExecutor: executor) - XCTFail("Expected 400 error") - } catch { - XCTAssertTrue(error.statusCode == 400) - } - } - - func testUnauthorized401() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(401), requestExecutor: executor) - XCTFail("Expected 401 error") - } catch { - XCTAssertTrue(error.statusCode == 401) - } - } - - func testForbidden403() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(403), requestExecutor: executor) - XCTFail("Expected 403 error") - } catch { - XCTAssertTrue(error.statusCode == 403) - } - } - - func testNotFound404() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(404), requestExecutor: executor) - XCTFail("Expected 404 error") - } catch { - XCTAssertTrue(error.statusCode == 404) - } - } - - func testTooManyRequests429() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(429), requestExecutor: executor) - XCTFail("Expected 429 error") - } catch { - XCTAssertTrue(error.statusCode == 429) - } - } - - // MARK: - Server Errors (5xx) - - func testInternalServerError500() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(500), requestExecutor: executor) - XCTFail("Expected 500 error") - } catch { - XCTAssertTrue(error.statusCode == 500) - } - } - - func testBadGateway502() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(502), requestExecutor: executor) - XCTFail("Expected 502 error") - } catch { - XCTAssertTrue(error.statusCode == 502) - } - } - - func testServiceUnavailable503() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(503), requestExecutor: executor) - XCTFail("Expected 503 error") - } catch { - XCTAssertTrue(error.statusCode == 503) - } - } - - func testGatewayTimeout504() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(504), requestExecutor: executor) - XCTFail("Expected 504 error") - } catch { - XCTAssertTrue(error.statusCode == 504) - } - } - -} From 549aa079d042231f1e4a52c68c3cc70f366331d8 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:15:23 +0200 Subject: [PATCH 03/13] Revert "fix: unsafe sendableconfigs" This reverts commit 496fbe577ebf418185f6a17783ec73c6951807f1. --- .../SampleDeduplicatingResultProvider.swift | 12 +- .../DeduplicatingRequestExecutor.swift | 46 +++-- .../Executor/ExecutorTask.swift | 10 +- .../DeduplicatingResultProvider.swift | 10 +- .../Session/LoggingEventMonitor.swift | 166 ++++++++---------- .../DeduplicatingExecutorTests.swift | 12 +- 6 files changed, 119 insertions(+), 137 deletions(-) diff --git a/GoodNetworking-Sample/GoodNetworking-Sample/Provider/SampleDeduplicatingResultProvider.swift b/GoodNetworking-Sample/GoodNetworking-Sample/Provider/SampleDeduplicatingResultProvider.swift index 7cf4434..82cbc91 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample/Provider/SampleDeduplicatingResultProvider.swift +++ b/GoodNetworking-Sample/GoodNetworking-Sample/Provider/SampleDeduplicatingResultProvider.swift @@ -27,7 +27,7 @@ public actor SampleDeduplicatingResultProvider: ResultProviding, Sendable { // Sample in-memory cache - not recommended for production use private static var cache: [String: (value: Sendable, finishDate: Date)] = [:] - private let taskId: String + private let taskID: String private let cacheTimeout: TimeInterval private var shouldUpdateOnStore: Bool = false @@ -45,17 +45,17 @@ public actor SampleDeduplicatingResultProvider: ResultProviding, Sendable { /// Creates a new instance of the sample deduplicating provider /// - Parameters: - /// - taskId: A unique identifier for the task + /// - taskID: A unique identifier for the task /// - cacheTimeout: How long cached values remain valid (in seconds) - public init(taskId: String, cacheTimeout: TimeInterval = 6) { - self.taskId = taskId + public init(taskID: String, cacheTimeout: TimeInterval = 6) { + self.taskID = taskID self.cacheTimeout = cacheTimeout } - /// Generates a unique cache key using the endpoint and taskId. + /// Generates a unique cache key using the endpoint and taskID. /// This is a simple implementation for demonstration purposes. private func cacheKey(for endpoint: Endpoint) -> String { - return "\(taskId)_\(endpoint.path)" + return "\(taskID)_\(endpoint.path)" } /// Checks if the cached response has expired. diff --git a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift index 931ac9e..200115f 100644 --- a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift +++ b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift @@ -18,24 +18,23 @@ import GoodLogger /// /// Example usage: /// ```swift -/// let executor = DeduplicatingRequestExecutor(taskId: "user_profile", cacheTimeout: 300) +/// let executor = DeduplicatingRequestExecutor(taskID: "user_profile", cacheTimeout: 300) /// let result: UserProfile = try await executor.executeRequest( /// endpoint: endpoint, /// session: session, /// baseURL: "https://api.example.com" /// ) /// ``` -public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Identifiable { +public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable { /// A unique identifier used to track and deduplicate requests - private let taskId: String - - #warning("Timeout should be configurable based on taskId") + private let taskID: String + /// The duration in seconds for which successful responses are cached private let cacheTimeout: TimeInterval - + /// A dictionary storing currently running or cached request tasks - public static var runningRequestTasks: [String: ExecutorTask] = [:] + public var runningRequestTasks: [String: ExecutorTask] = [:] /// A private property that provides the appropriate logger based on the iOS version. /// @@ -45,10 +44,10 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide /// Creates a new deduplicating request executor. /// /// - Parameters: - /// - taskId: A unique identifier for deduplicating requests + /// - taskID: A unique identifier for deduplicating requests /// - cacheTimeout: The duration in seconds for which successful responses are cached. Defaults to 6 seconds. /// Set to 0 to disable caching. - public init(taskId: String, cacheTimeout: TimeInterval = 6, logger: GoodLogger? = nil) { + public init(taskID: String, cacheTimeout: TimeInterval = 6, logger: GoodLogger? = nil) { if let logger { self.logger = logger } else { @@ -58,7 +57,7 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide self.logger = PrintLogger(logMetaData: false) } } - self.taskId = taskId + self.taskID = taskID self.cacheTimeout = cacheTimeout } @@ -83,12 +82,11 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide baseURL: String, validationProvider: any ValidationProviding = DefaultValidationProvider() ) async throws(Failure) -> Result { - DeduplicatingRequestExecutor.runningRequestTasks = DeduplicatingRequestExecutor.runningRequestTasks - .filter { !$0.value.exceedsTimeout } + runningRequestTasks = runningRequestTasks.filter { !$0.value.exceedsTimeout } return try await catchingFailure(validationProvider: validationProvider) { - if let runningTask = DeduplicatingRequestExecutor.runningRequestTasks[taskId] { - logger.log(message: "πŸš€ taskId: \(taskId) Cached value used", level: .info) + if let runningTask = runningRequestTasks[taskID] { + logger.log(message: "πŸš€ taskID: \(taskID) Cached value used", level: .info) let dataResponse = await runningTask.task.value switch dataResponse.result { case .success(let value): @@ -109,30 +107,30 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide encoding: endpoint.encoding, headers: endpoint.headers ) - .goodify(type: Result.self, validator: validationProvider) - .response + .goodify(type: Result.self, validator: validationProvider) + .response return result.map { $0 as Result } } - logger.log(message: "πŸš€ taskId: \(taskId): Task created", level: .info) + logger.log(message: "πŸš€ taskID: \(taskID): Task created", level: .info) let executorTask: ExecutorTask = ExecutorTask( - taskId: taskId, + taskID: taskID, task: requestTask as ExecutorTask.TaskType, cacheTimeout: cacheTimeout ) - DeduplicatingRequestExecutor.runningRequestTasks[taskId] = executorTask + runningRequestTasks[taskID] = executorTask let dataResponse = await requestTask.value switch dataResponse.result { case .success(let value): - logger.log(message: "πŸš€ taskId: \(taskId): Task finished successfully", level: .info) + logger.log(message: "πŸš€ taskID: \(taskID): Task finished successfully", level: .info) if cacheTimeout > 0 { - DeduplicatingRequestExecutor.runningRequestTasks[taskId]?.finishDate = Date() + runningRequestTasks[taskID]?.finishDate = Date() } else { - DeduplicatingRequestExecutor.runningRequestTasks[taskId] = nil + runningRequestTasks[taskID] = nil } guard let result = value as? Result else { @@ -141,8 +139,8 @@ public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable, Ide return result case .failure(let error): - logger.log(message: "πŸš€ taskId: \(taskId): Task finished with error", level: .error) - DeduplicatingRequestExecutor.runningRequestTasks[taskId] = nil + logger.log(message: "πŸš€ taskID: \(taskID): Task finished with error", level: .error) + runningRequestTasks[taskID] = nil throw error } } diff --git a/Sources/GoodNetworking/Executor/ExecutorTask.swift b/Sources/GoodNetworking/Executor/ExecutorTask.swift index bd3f5c5..7ba5bb2 100644 --- a/Sources/GoodNetworking/Executor/ExecutorTask.swift +++ b/Sources/GoodNetworking/Executor/ExecutorTask.swift @@ -16,7 +16,7 @@ import Alamofire /// Example usage: /// ```swift /// let task = ExecutorTask( -/// taskId: "fetch_user", +/// taskID: "fetch_user", /// task: Task { ... }, /// cacheTimeout: 300 // 5 minutes /// ) @@ -30,7 +30,7 @@ public final class ExecutorTask { var finishDate: Date? /// A unique identifier for the task - let taskId: String + let taskID: String /// The underlying asynchronous task let task: TaskType @@ -51,11 +51,11 @@ public final class ExecutorTask { /// Creates a new executor task /// /// - Parameters: - /// - taskId: A unique identifier for the task + /// - taskID: A unique identifier for the task /// - task: The underlying asynchronous task /// - cacheTimeout: The duration in seconds after which cached results are considered stale - init(taskId: String, task: TaskType, cacheTimeout: TimeInterval) { - self.taskId = taskId + init(taskID: String, task: TaskType, cacheTimeout: TimeInterval) { + self.taskID = taskID self.task = task self.cacheTimeout = cacheTimeout } diff --git a/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift b/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift index 7ee711d..617c536 100644 --- a/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift +++ b/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift @@ -17,7 +17,7 @@ public actor DeduplicatingResultProvider: ResultProviding, Sendable { // Shared in-memory cache private static var cache: [String: (value: Sendable, finishDate: Date)] = [:] - private let taskId: String + private let taskID: String private let cacheTimeout: TimeInterval private var shouldUpdateOnStore: Bool = false @@ -32,14 +32,14 @@ public actor DeduplicatingResultProvider: ResultProviding, Sendable { } } - public init(taskId: String, cacheTimeout: TimeInterval = 6) { - self.taskId = taskId + public init(taskID: String, cacheTimeout: TimeInterval = 6) { + self.taskID = taskID self.cacheTimeout = cacheTimeout } - /// Generates a unique cache key using the endpoint and taskId + /// Generates a unique cache key using the endpoint and taskID private func cacheKey(for endpoint: Endpoint) -> String { - return "\(taskId)_\(endpoint.path)" + return "\(taskID)_\(endpoint.path)" } /// Checks if the cached response has expired diff --git a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift index 3b8cb69..d67210b 100644 --- a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift +++ b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift @@ -41,27 +41,10 @@ import Foundation public struct LoggingEventMonitor: EventMonitor, Sendable { - public let configuration: Configuration + nonisolated(unsafe) private static var configuration = Configuration() /// Configuration options for the logging monitor. - public struct Configuration: Sendable { - - public init( - verbose: Bool = true, - prettyPrinted: Bool = true, - maxVerboseLogSizeBytes: Int = 100_000, - slowRequestThreshold: TimeInterval = 1.0, - prefixes: Prefixes = Prefixes(), - mimeTypeWhilelistConfiguration: MimeTypeWhitelistConfiguration? = nil - ) { - self.verbose = verbose - self.prettyPrinted = prettyPrinted - self.maxVerboseLogSizeBytes = maxVerboseLogSizeBytes - self.slowRequestThreshold = slowRequestThreshold - self.prefixes = prefixes - self.mimeTypeWhilelistConfiguration = mimeTypeWhilelistConfiguration - } - + public struct Configuration { /// Whether to log detailed request/response information. Defaults to `true`. var verbose: Bool = true @@ -76,52 +59,12 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { /// Emoji prefixes used in log messages. var prefixes = Prefixes() - - public struct MimeTypeWhitelistConfiguration : Sendable { - - public init(responseTypeWhiteList: [String]? = nil) { - self.responseTypeWhiteList = responseTypeWhiteList ?? [ - "application/json", - "application/ld+json", - "application/xml", - "text/plain", - "text/csv", - "text/html", - "text/javascript", - "application/rtf" - ] - } - - var responseTypeWhiteList: [String] - - } - - var mimeTypeWhilelistConfiguration: MimeTypeWhitelistConfiguration? - - /// List of MIME types that will be logged when `useMimeTypeWhitelist` is enabled. - + + /// Whether to only log responses with whitelisted MIME types. Defaults to `true`. + var useMimeTypeWhitelist: Bool = true /// Emoji prefixes used to categorize different types of log messages. - public struct Prefixes: Sendable { - - public init( - request: String = "πŸš€", - response: String = "⬇️", - error: String = "🚨", - headers: String = "🏷", - metrics: String = "βŒ›οΈ", - success: String = "βœ…", - failure: String = "❌" - ) { - self.request = request - self.response = response - self.error = error - self.headers = headers - self.metrics = metrics - self.success = success - self.failure = failure - } - + public struct Prefixes { var request = "πŸš€" var response = "⬇️" var error = "🚨" @@ -132,6 +75,15 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { } } + /// Updates the monitor's configuration. + /// + /// - Parameter updates: A closure that modifies the configuration. + public static func configure(_ updates: (inout Configuration) -> Void) { + var config = configuration + updates(&config) + configuration = config + } + /// The queue on which logging events are dispatched. public let queue = DispatchQueue(label: C.queueLabel, qos: .background) @@ -144,9 +96,8 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { /// Creates a new logging monitor. /// /// - Parameter logger: The logger instance to use for output. If nil, no logging occurs. - public init(logger: (any GoodLogger)?, configuration: Configuration = .init()) { + public init(logger: (any GoodLogger)?) { self.logger = logger - self.configuration = configuration } public func request(_ request: DataRequest, didParseResponse response: DataResponse) { @@ -158,23 +109,22 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { let requestBodyMessage = parse( data: request.request?.httpBody, error: response.error as NSError?, - prefix: "\(configuration.prefixes.request) Request body (\(formatBytes(requestSize))):" + prefix: "\(Self.configuration.prefixes.request) Request body (\(formatBytes(requestSize))):" ) let errorMessage: String? = if let afError = response.error { - "\(configuration.prefixes.error) Error:\n\(afError)" + "\(Self.configuration.prefixes.error) Error:\n\(afError)" } else { nil } let responseBodyMessage = if - let mimeTypeWhilelistConfiguration = configuration.mimeTypeWhilelistConfiguration, - mimeTypeWhilelistConfiguration.responseTypeWhiteList - .contains(where: { $0 == response.response?.mimeType }) + Self.configuration.useMimeTypeWhitelist, + Self.responseTypeWhiteList.contains(where: { $0 == response.response?.mimeType }) { parse( data: response.data, error: response.error as NSError?, - prefix: "\(configuration.prefixes.response) Response body (\(formatBytes(responseSize))):" + prefix: "\(Self.configuration.prefixes.response) Response body (\(formatBytes(responseSize))):" ) } else { "❓❓❓ Response MIME type not whitelisted (\(response.response?.mimeType ?? "❓"))" @@ -208,42 +158,42 @@ private extension LoggingEventMonitor { else { return nil } - guard configuration.verbose else { - return "\(configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)" + guard Self.configuration.verbose else { + return "\(Self.configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)" } if let headers = request.allHTTPHeaderFields, !headers.isEmpty, let headersData = try? JSONSerialization.data(withJSONObject: headers, options: [.prettyPrinted]), - let headersPrettyMessage = parse(data: headersData, error: nil, prefix: "\(configuration.prefixes.headers) Headers:") { + let headersPrettyMessage = parse(data: headersData, error: nil, prefix: "\(Self.configuration.prefixes.headers) Headers:") { - return "\(configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage + return "\(Self.configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage } else { let headers = if let allHTTPHeaderFields = request.allHTTPHeaderFields, !allHTTPHeaderFields.isEmpty { allHTTPHeaderFields.description } else { "empty headers" } - return "\(configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n\(configuration.prefixes.headers) Headers: \(headers)" + return "\(Self.configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n\(Self.configuration.prefixes.headers) Headers: \(headers)" } } func parse(data: Data?, error: NSError?, prefix: String) -> String? { - guard configuration.verbose else { return nil } + guard Self.configuration.verbose else { return nil } if let data = data, !data.isEmpty { - guard data.count < configuration.maxVerboseLogSizeBytes else { + guard data.count < Self.configuration.maxVerboseLogSizeBytes else { return [ prefix, "Data size is too big!", - "Max size is: \(configuration.maxVerboseLogSizeBytes) bytes.", + "Max size is: \(Self.configuration.maxVerboseLogSizeBytes) bytes.", "Data size is: \(data.count) bytes", "πŸ’‘Tip: Change LoggingEventMonitor.maxVerboseLogSizeBytes = \(data.count)" ].joined(separator: "\n") } if let string = String(data: data, encoding: .utf8) { if let jsonData = try? JSONSerialization.jsonObject(with: data, options: []), - let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: configuration.prettyPrinted ? [.prettyPrinted, .withoutEscapingSlashes] : [.withoutEscapingSlashes]), + let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: Self.configuration.prettyPrinted ? [.prettyPrinted, .withoutEscapingSlashes] : [.withoutEscapingSlashes]), let prettyPrintedString = String(data: prettyPrintedData, encoding: .utf8) { return "\(prefix) \n\(prettyPrintedString)" } else { @@ -256,16 +206,16 @@ private extension LoggingEventMonitor { } func parse(metrics: URLSessionTaskMetrics?) -> String? { - guard let metrics, configuration.verbose else { + guard let metrics, Self.configuration.verbose else { return nil } let duration = metrics.taskInterval.duration - let warning = duration > configuration.slowRequestThreshold ? " ⚠️ Slow Request!" : "" + let warning = duration > Self.configuration.slowRequestThreshold ? " ⚠️ Slow Request!" : "" return [ "↗️ Start: \(metrics.taskInterval.start)", - "\(configuration.prefixes.metrics) Duration: \(String(format: "%.3f", duration))s\(warning)" + "\(Self.configuration.prefixes.metrics) Duration: \(String(format: "%.3f", duration))s\(warning)" ].joined(separator: "\n") } @@ -273,19 +223,53 @@ private extension LoggingEventMonitor { func parseResponseStatus(response: HTTPURLResponse) -> String { let statusCode = response.statusCode let logMessage = (200 ..< 300).contains(statusCode) - ? "\(configuration.prefixes.success) \(statusCode)" - : "\(configuration.prefixes.failure) \(statusCode)" + ? "\(Self.configuration.prefixes.success) \(statusCode)" + : "\(Self.configuration.prefixes.failure) \(statusCode)" return logMessage } private func formatBytes(_ bytes: Int) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .binary // Uses 1024 as the base, appropriate for data sizes - formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] - formatter.includesUnit = true - formatter.isAdaptive = true - return formatter.string(fromByteCount: Int64(bytes)) + let units = ["B", "KB", "MB"] + var size = Double(bytes) + var unitIndex = 0 + + while size > 1024 && unitIndex < units.count - 1 { + size /= 1024 + unitIndex += 1 + } + + return String(format: "%.1f %@", size, units[unitIndex]) + } + +} + +public extension LoggingEventMonitor { + + /// List of MIME types that will be logged when `useMimeTypeWhitelist` is enabled. + nonisolated(unsafe) private(set) static var responseTypeWhiteList: [String] = [ + "application/json", + "application/ld+json", + "application/xml", + "text/plain", + "text/csv", + "text/html", + "text/javascript", + "application/rtf" + ] + + /// Adds a MIME type to the whitelist for response logging. + /// + /// - Parameter mimeType: The MIME type to whitelist + nonisolated(unsafe) static func logMimeType(_ mimeType: String) { + responseTypeWhiteList.append(mimeType) + } + + /// Removes a MIME type from the whitelist for response logging. + /// + /// - Parameter mimeType: The MIME type to remove + nonisolated(unsafe) static func stopLoggingMimeType(_ mimeType: String) { + responseTypeWhiteList.removeAll{ $0 == mimeType } } } diff --git a/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift b/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift index 7394a84..52a0b6d 100644 --- a/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift +++ b/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift @@ -16,7 +16,7 @@ final class DeduplicatingExecutorTests: XCTestCase { func testConcurrentRequestsAreDeduplicated() async throws { // Setup let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskId: "People", logger: logger) + let executor = DeduplicatingRequestExecutor(taskID: "People", logger: logger) let session: Session! = Session() let baseURL = "https://swapi.dev/api" @@ -45,8 +45,8 @@ final class DeduplicatingExecutorTests: XCTestCase { func testDifferentRequestsAreNotDeduplicated() async throws { // Setup let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskId: "Luke", logger: logger) - let executor2 = DeduplicatingRequestExecutor(taskId: "Vader", logger: logger) + let executor = DeduplicatingRequestExecutor(taskID: "Luke", logger: logger) + let executor2 = DeduplicatingRequestExecutor(taskID: "Vader", logger: logger) let session: Session! = Session() let baseURL = "https://swapi.dev/api" @@ -76,7 +76,7 @@ final class DeduplicatingExecutorTests: XCTestCase { func testSequentialRequestsAreNotDeduplicated() async throws { // Setup let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskId: "People", logger: logger) + let executor = DeduplicatingRequestExecutor(taskID: "People", logger: logger) let session: Session! = Session() let baseURL = "https://swapi.dev/api" @@ -107,7 +107,7 @@ final class DeduplicatingExecutorTests: XCTestCase { func testErrorHandling() async throws { // Setup let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskId: "Invalid", logger: logger) + let executor = DeduplicatingRequestExecutor(taskID: "People", logger: logger) let session: Session! = Session() let baseURL = "https://swapi.dev/api" @@ -116,7 +116,7 @@ final class DeduplicatingExecutorTests: XCTestCase { // When/Then do { - let _: SwapiPerson = try await executor + let result: SwapiPerson = try await executor .executeRequest(endpoint: invalidEndpoint, session: session, baseURL: baseURL) XCTFail("Expected error to be thrown") From 99d6b5d183c360649c92187e9744d9be4f3fe7b6 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:15:30 +0200 Subject: [PATCH 04/13] Revert "feat: Request Deduplication" This reverts commit 81837644b8cba3d7307953c0c72831b028501862. --- .../xcschemes/GoodNetworking.xcscheme | 2 +- .../xcschemes/GoodNetworkingTests.xcscheme | 2 +- .../project.pbxproj | 22 +- .../xcshareddata/swiftpm/Package.resolved | 12 +- .../xcschemes/GoodNetworking-Sample.xcscheme | 2 +- .../GoodNetworking-Sample/Models/User.swift | 2 +- .../SampleDeduplicatingResultProvider.swift | 100 ------- .../Screens/UserScreen.swift | 4 +- .../DeduplicatingRequestExecutor.swift | 183 ------------- .../Executor/DefaultRequestExecutor.swift | 99 ------- .../Executor/ExecutorTask.swift | 63 ----- .../Executor/RequestExecuting.swift | 55 ---- .../GoodNetworking/Protocols/Endpoint.swift | 2 +- .../Protocols/ResourceOperations.swift | 10 +- .../Protocols/ResultProviding.swift | 47 ---- .../DeduplicatingResultProvider.swift | 82 ------ .../Providers/DefaultSessionProvider.swift | 4 +- .../Session/LoggingEventMonitor.swift | 173 +++--------- .../Session/NetworkSession.swift | 248 ++++++------------ .../Session/NetworkSessionConfiguration.swift | 4 +- .../DeduplicatingExecutorTests.swift | 127 --------- Tests/GoodNetworkingTests/SwapiEndpoint.swift | 25 -- Tests/GoodNetworkingTests/SwapiPerson.swift | 20 -- Tests/GoodNetworkingTests/TestingLogger.swift | 25 -- 24 files changed, 136 insertions(+), 1177 deletions(-) delete mode 100644 GoodNetworking-Sample/GoodNetworking-Sample/Provider/SampleDeduplicatingResultProvider.swift delete mode 100644 Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift delete mode 100644 Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift delete mode 100644 Sources/GoodNetworking/Executor/ExecutorTask.swift delete mode 100644 Sources/GoodNetworking/Executor/RequestExecuting.swift delete mode 100644 Sources/GoodNetworking/Protocols/ResultProviding.swift delete mode 100644 Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift delete mode 100644 Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift delete mode 100644 Tests/GoodNetworkingTests/SwapiEndpoint.swift delete mode 100644 Tests/GoodNetworkingTests/SwapiPerson.swift delete mode 100644 Tests/GoodNetworkingTests/TestingLogger.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GoodNetworking.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GoodNetworking.xcscheme index a23c386..c3d48c5 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/GoodNetworking.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GoodNetworking.xcscheme @@ -1,6 +1,6 @@ String { - return "\(taskID)_\(endpoint.path)" - } - - /// Checks if the cached response has expired. - /// This is a basic timeout-based implementation for demonstration. - private func isCacheValid(for key: String) -> Bool { - guard let cachedEntry = Self.cache[key] else { return false } - return Date().timeIntervalSince(cachedEntry.finishDate) < cacheTimeout - } - - /// Stores a result in the cache manually (Can be called externally). - /// This is a simplified implementation for demonstration purposes. - public func storeResult(_ result: Result, for endpoint: Endpoint) async { - let key = cacheKey(for: endpoint) - - if shouldUpdateOnStore { - shouldUpdateOnStore = false - Self.cache[key] = (value: result, finishDate: Date()) - logger.log(message: "Value updated for \(key)") - } else { - logger.log(message: print("Already cached \(key)")) - } - } - - /// Resolves and returns the result asynchronously. - /// This is a sample implementation showing basic caching behavior. - /// - /// - Parameter endpoint: The endpoint to resolve the result for - /// - Returns: The cached result if available and valid, nil otherwise - public func resolveResult(endpoint: Endpoint) async -> Result? { - let key = cacheKey(for: endpoint) - - // Return cached response if available and valid - if isCacheValid(for: key), let cachedValue = Self.cache[key]?.value as? Result { - logger.log(message: "Cache hit for \(key)") - return cachedValue - } else { - shouldUpdateOnStore = true - return nil - } - } - -} diff --git a/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserScreen.swift b/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserScreen.swift index 948f57d..a9072b3 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserScreen.swift +++ b/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserScreen.swift @@ -10,7 +10,7 @@ import GRAsyncImage import SwiftUI struct UserScreen: View { - + // MARK: - State @State private var user = Resource(session: .sampleSession, remote: RemoteUser.self) @@ -96,5 +96,5 @@ struct UserScreen: View { #Preview { UserScreen(userId: 1) - + } diff --git a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift deleted file mode 100644 index 200115f..0000000 --- a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// DeduplicatingRequestExecutor.swift -// GoodNetworking -// -// Created by Andrej Jasso on 04/02/2025. -// - -import Alamofire -import Foundation -import GoodLogger - -/// A request executor that deduplicates concurrent requests and provides caching capabilities. -/// -/// `DeduplicatingRequestExecutor` implements the `RequestExecuting` protocol and provides additional functionality to: -/// - Deduplicate concurrent requests to the same endpoint by reusing in-flight requests -/// - Cache successful responses for a configurable duration -/// - Clean up expired cache entries automatically -/// -/// Example usage: -/// ```swift -/// let executor = DeduplicatingRequestExecutor(taskID: "user_profile", cacheTimeout: 300) -/// let result: UserProfile = try await executor.executeRequest( -/// endpoint: endpoint, -/// session: session, -/// baseURL: "https://api.example.com" -/// ) -/// ``` -public final actor DeduplicatingRequestExecutor: RequestExecuting, Sendable { - - /// A unique identifier used to track and deduplicate requests - private let taskID: String - - /// The duration in seconds for which successful responses are cached - private let cacheTimeout: TimeInterval - - /// A dictionary storing currently running or cached request tasks - public var runningRequestTasks: [String: ExecutorTask] = [:] - - /// A private property that provides the appropriate logger based on the iOS version. - /// - /// For iOS 14 and later, it uses `OSLogLogger`. For earlier versions, it defaults to `PrintLogger`. - private var logger: GoodLogger - - /// Creates a new deduplicating request executor. - /// - /// - Parameters: - /// - taskID: A unique identifier for deduplicating requests - /// - cacheTimeout: The duration in seconds for which successful responses are cached. Defaults to 6 seconds. - /// Set to 0 to disable caching. - public init(taskID: String, cacheTimeout: TimeInterval = 6, logger: GoodLogger? = nil) { - if let logger { - self.logger = logger - } else { - if #available(iOS 14, *) { - self.logger = OSLogLogger(logMetaData: false) - } else { - self.logger = PrintLogger(logMetaData: false) - } - } - self.taskID = taskID - self.cacheTimeout = cacheTimeout - } - - /// Executes a network request with deduplication and caching support. - /// - /// This method extends the base `RequestExecuting` functionality by: - /// - Returning cached responses if available and not expired - /// - Reusing in-flight requests to the same endpoint - /// - Caching successful responses for the configured timeout duration - /// - Automatically cleaning up expired cache entries - /// - /// - Parameters: - /// - endpoint: The endpoint configuration for the request - /// - session: The Alamofire session to use for the request - /// - baseURL: The base URL to prepend to the endpoint's path - /// - validationProvider: Provider for response validation and error transformation - /// - Returns: The decoded response of type Result - /// - Throws: An error of type Failure if the request fails or validation fails - public func executeRequest( - endpoint: Endpoint, - session: Session, - baseURL: String, - validationProvider: any ValidationProviding = DefaultValidationProvider() - ) async throws(Failure) -> Result { - runningRequestTasks = runningRequestTasks.filter { !$0.value.exceedsTimeout } - - return try await catchingFailure(validationProvider: validationProvider) { - if let runningTask = runningRequestTasks[taskID] { - logger.log(message: "πŸš€ taskID: \(taskID) Cached value used", level: .info) - let dataResponse = await runningTask.task.value - switch dataResponse.result { - case .success(let value): - if let result = value as? Result { - return result - } else { - throw validationProvider.transformError(NetworkError.sessionError) - } - case .failure(let error): - throw error - } - } else { - let requestTask = ExecutorTask.TaskType { - let result = await session.request( - try? endpoint.url(on: baseURL), - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers - ) - .goodify(type: Result.self, validator: validationProvider) - .response - - return result.map { $0 as Result } - } - - logger.log(message: "πŸš€ taskID: \(taskID): Task created", level: .info) - - let executorTask: ExecutorTask = ExecutorTask( - taskID: taskID, - task: requestTask as ExecutorTask.TaskType, - cacheTimeout: cacheTimeout - ) - - runningRequestTasks[taskID] = executorTask - - let dataResponse = await requestTask.value - switch dataResponse.result { - case .success(let value): - logger.log(message: "πŸš€ taskID: \(taskID): Task finished successfully", level: .info) - if cacheTimeout > 0 { - runningRequestTasks[taskID]?.finishDate = Date() - } else { - runningRequestTasks[taskID] = nil - } - - guard let result = value as? Result else { - throw validationProvider.transformError(NetworkError.sessionError) - } - return result - - case .failure(let error): - logger.log(message: "πŸš€ taskID: \(taskID): Task finished with error", level: .error) - runningRequestTasks[taskID] = nil - throw error - } - } - } - } - - /// Executes a closure while catching and transforming failures. - /// - /// This method provides standardized error handling by: - /// - Catching and transforming network errors - /// - Handling Alamofire-specific errors - /// - Converting errors to the expected failure type - /// - /// - Parameters: - /// - validationProvider: The provider used to transform any errors. - /// - body: The closure to execute. - /// - Returns: The result of type `Result`. - /// - Throws: A transformed error if the closure fails. - func catchingFailure( - validationProvider: any ValidationProviding, - body: () async throws -> Result - ) async throws(Failure) -> Result { - do { - return try await body() - } catch let networkError as NetworkError { - throw validationProvider.transformError(networkError) - } catch let error as AFError { - if let underlyingError = error.underlyingError as? Failure { - throw underlyingError - } else if let underlyingError = error.underlyingError as? NetworkError { - throw validationProvider.transformError(underlyingError) - } else { - throw validationProvider.transformError(NetworkError.sessionError) - } - } catch { - throw validationProvider.transformError(NetworkError.sessionError) - } - } - -} diff --git a/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift b/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift deleted file mode 100644 index 7826ae5..0000000 --- a/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// DefaultRequestExecutor.swift -// GoodNetworking -// -// Created by Andrej Jasso on 04/02/2025. -// - -import Alamofire -import Foundation - -/// A default implementation of the `RequestExecuting` protocol that handles network request execution. -/// -/// `DefaultRequestExecutor` provides a concrete implementation for executing network requests with proper error handling -/// and response validation. It is designed to work with Alamofire's Session and supports custom validation through -/// the `ValidationProviding` protocol. -/// -/// Example usage: -/// ```swift -/// let executor = DefaultRequestExecutor() -/// let result: MyModel = try await executor.executeRequest( -/// endpoint: endpoint, -/// session: session, -/// baseURL: "https://api.example.com", -/// validationProvider: CustomValidationProvider() -/// ) -/// ``` -public final actor DefaultRequestExecutor: RequestExecuting, Sendable { - - /// Creates a new instance of `DefaultRequestExecutor`. - public init() {} - - /// Executes a network request and returns the decoded result. - /// - /// This method handles the complete lifecycle of a network request, including: - /// - Building the request URL using the base URL and endpoint - /// - Setting up request parameters, headers, and encoding - /// - Executing the request using Alamofire - /// - Validating and decoding the response - /// - /// - Parameters: - /// - endpoint: The endpoint configuration for the request - /// - session: The Alamofire session to use for the request - /// - baseURL: The base URL to prepend to the endpoint's path - /// - validationProvider: Provider for response validation and error transformation - /// - Returns: The decoded response of type Result - /// - Throws: An error of type Failure if the request fails or validation fails - public func executeRequest( - endpoint: Endpoint, - session: Session, - baseURL: String, - validationProvider: any ValidationProviding = DefaultValidationProvider() - ) async throws(Failure) -> Result { - return try await catchingFailure(validationProvider: validationProvider) { - return try await session.request( - try? endpoint.url(on: baseURL), - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers - ) - .goodify(type: Result.self, validator: validationProvider) - .value - } - } - - /// Executes a closure while catching and transforming failures. - /// - /// This method provides standardized error handling by: - /// - Catching and transforming network errors - /// - Handling Alamofire-specific errors - /// - Converting errors to the expected failure type - /// - /// - Parameters: - /// - validationProvider: The provider used to transform any errors. - /// - body: The closure to execute. - /// - Returns: The result of type `Result`. - /// - Throws: A transformed error if the closure fails. - func catchingFailure( - validationProvider: any ValidationProviding, - body: () async throws -> Result - ) async throws(Failure) -> Result { - do { - return try await body() - } catch let networkError as NetworkError { - throw validationProvider.transformError(networkError) - } catch let error as AFError { - if let underlyingError = error.underlyingError as? Failure { - throw underlyingError - } else if let underlyingError = error.underlyingError as? NetworkError { - throw validationProvider.transformError(underlyingError) - } else { - throw validationProvider.transformError(NetworkError.sessionError) - } - } catch { - throw validationProvider.transformError(NetworkError.sessionError) - } - } - -} diff --git a/Sources/GoodNetworking/Executor/ExecutorTask.swift b/Sources/GoodNetworking/Executor/ExecutorTask.swift deleted file mode 100644 index 7ba5bb2..0000000 --- a/Sources/GoodNetworking/Executor/ExecutorTask.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// ExecutorTask.swift -// -// -// Created by Matus Klasovity on 06/08/2024. -// - -import Foundation -import Alamofire - -/// A class that represents an asynchronous network task with caching capabilities. -/// -/// `ExecutorTask` encapsulates a network request task along with metadata for caching and timeout management. -/// It provides functionality to track task completion time and determine if cached results have expired. -/// -/// Example usage: -/// ```swift -/// let task = ExecutorTask( -/// taskID: "fetch_user", -/// task: Task { ... }, -/// cacheTimeout: 300 // 5 minutes -/// ) -/// ``` -public final class ExecutorTask { - - /// Type alias for the underlying asynchronous task that handles network responses - typealias TaskType = Task, Never> - - /// The date when the task completed execution. `nil` if the task hasn't finished. - var finishDate: Date? - - /// A unique identifier for the task - let taskID: String - - /// The underlying asynchronous task - let task: TaskType - - /// The duration in seconds after which cached results are considered stale - private let cacheTimeout: TimeInterval - - /// Indicates whether the cached result has exceeded its timeout period - /// - /// Returns `true` if the task has finished and the time since completion exceeds - /// the cache timeout. Returns `false` if the task hasn't finished or is within - /// the timeout period. - var exceedsTimeout: Bool { - guard let finishDate else { return false } - return Date().timeIntervalSince(finishDate) > cacheTimeout - } - - /// Creates a new executor task - /// - /// - Parameters: - /// - taskID: A unique identifier for the task - /// - task: The underlying asynchronous task - /// - cacheTimeout: The duration in seconds after which cached results are considered stale - init(taskID: String, task: TaskType, cacheTimeout: TimeInterval) { - self.taskID = taskID - self.task = task - self.cacheTimeout = cacheTimeout - } - -} diff --git a/Sources/GoodNetworking/Executor/RequestExecuting.swift b/Sources/GoodNetworking/Executor/RequestExecuting.swift deleted file mode 100644 index d16371e..0000000 --- a/Sources/GoodNetworking/Executor/RequestExecuting.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// RequestExecuting.swift -// GoodNetworking -// -// Created by Andrej Jasso on 03/02/2025. -// - -import Alamofire -import Foundation -import GoodLogger - -/// A protocol defining the interface for executing network requests. -/// -/// `RequestExecuting` provides a standardized way to execute network requests with proper error handling, -/// validation, and type-safe responses. It is designed to work with Alamofire's Session and supports -/// custom validation and error handling through the ValidationProviding protocol. -/// -/// Example usage: -/// ```swift -/// struct RequestExecutor: RequestExecuting { -/// func executeRequest( -/// endpoint: Endpoint, -/// session: Session, -/// baseURL: String, -/// validationProvider: ValidationProviding -/// ) async throws -> Result { -/// // Implementation details -/// } -/// } -/// ``` -public protocol RequestExecuting: Sendable { - - /// Executes a network request and returns the decoded result. - /// - /// This method handles the complete lifecycle of a network request, including: - /// - Request execution using the provided Alamofire session - /// - Response validation using the validation provider - /// - Error handling and transformation - /// - Response decoding into the specified Result type - /// - /// - Parameters: - /// - endpoint: The endpoint configuration for the request - /// - session: The Alamofire session to use for the request - /// - baseURL: The base URL to prepend to the endpoint's path - /// - validationProvider: Provider for response validation and error transformation - /// - Returns: The decoded response of type Result - /// - Throws: An error of type Failure if the request fails or validation fails - func executeRequest( - endpoint: Endpoint, - session: Session, - baseURL: String, - validationProvider: any ValidationProviding - ) async throws(Failure) -> Result - -} diff --git a/Sources/GoodNetworking/Protocols/Endpoint.swift b/Sources/GoodNetworking/Protocols/Endpoint.swift index 012c3e9..a25e4da 100644 --- a/Sources/GoodNetworking/Protocols/Endpoint.swift +++ b/Sources/GoodNetworking/Protocols/Endpoint.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - Endpoint /// `GREndpoint` protocol defines a set of requirements for an endpoint. -public protocol Endpoint: Sendable { +public protocol Endpoint { /// The path to be appended to the base URL. var path: String { get } diff --git a/Sources/GoodNetworking/Protocols/ResourceOperations.swift b/Sources/GoodNetworking/Protocols/ResourceOperations.swift index 6a8b770..10a5878 100644 --- a/Sources/GoodNetworking/Protocols/ResourceOperations.swift +++ b/Sources/GoodNetworking/Protocols/ResourceOperations.swift @@ -22,7 +22,7 @@ public protocol Creatable: RemoteResource { associatedtype CreateRequest: Sendable /// The type of response returned after the resource is created. - associatedtype CreateResponse: NetworkSession.DataType + associatedtype CreateResponse: Decodable & Sendable /// Creates a new resource on the remote server using the provided session and request data. /// @@ -130,7 +130,7 @@ public protocol Readable: RemoteResource { associatedtype ReadRequest: Sendable /// The type of response returned after reading the resource. - associatedtype ReadResponse: NetworkSession.DataType + associatedtype ReadResponse: Decodable & Sendable /// Reads the resource from the remote server using the provided session and request data. /// @@ -295,7 +295,7 @@ public protocol Updatable: Readable { associatedtype UpdateRequest: Sendable /// The type of response returned after updating the resource. - associatedtype UpdateResponse: NetworkSession.DataType + associatedtype UpdateResponse: Decodable & Sendable /// Updates an existing resource on the remote server using the provided session and request data. /// @@ -385,7 +385,7 @@ public protocol Deletable: Readable { associatedtype DeleteRequest: Sendable /// The type of response returned after deleting the resource. - associatedtype DeleteResponse: NetworkSession.DataType + associatedtype DeleteResponse: Decodable & Sendable /// Deletes the resource on the remote server using the provided session and request data. /// @@ -483,7 +483,7 @@ public protocol Listable: RemoteResource { associatedtype ListRequest: Sendable /// The type of response returned after listing the resources. - associatedtype ListResponse: NetworkSession.DataType + associatedtype ListResponse: Decodable & Sendable /// Lists the resources from the remote server using the provided session and request data. /// diff --git a/Sources/GoodNetworking/Protocols/ResultProviding.swift b/Sources/GoodNetworking/Protocols/ResultProviding.swift deleted file mode 100644 index e37fa05..0000000 --- a/Sources/GoodNetworking/Protocols/ResultProviding.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// ResultProviding.swift -// GoodNetworking -// -// Created by Andrej Jasso on 30/01/2025. -// - -import Foundation -import Alamofire - -/// A protocol for providing results asynchronously from network requests. -/// -/// `ResultProviding` defines a method that asynchronously resolves a result of a generic type. -/// Classes or structures that conform to this protocol can implement their own logic for determining and returning the result. -/// -/// This protocol is particularly useful for: -/// - Caching network responses -/// - Transforming raw network data into domain models -/// - Implementing custom result resolution strategies -/// - Handling offline-first scenarios -/// -/// Example usage: -/// ```swift -/// struct CacheProvider: ResultProviding { -/// func resolveResult(endpoint: Endpoint) async -> Result? { -/// // Check cache and return cached value if available -/// return try? await cache.object(for: endpoint.cacheKey) -/// } -/// } -/// ``` -public protocol ResultProviding: Sendable { - - /// Resolves and returns the result asynchronously for a given endpoint. - /// - /// This method fetches or computes the result, potentially involving asynchronous operations such as: - /// - Retrieving data from a local cache - /// - Transforming network responses - /// - Applying business logic to raw data - /// - Combining multiple data sources - /// - /// - Parameters: - /// - endpoint: The endpoint configuration for which to resolve the result - /// - Returns: The resolved result of type `Result`, or `nil` if no result could be resolved - /// - Note: The implementation should be thread-safe and handle errors appropriately - func resolveResult(endpoint: Endpoint) async -> Result? - -} diff --git a/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift b/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift deleted file mode 100644 index 617c536..0000000 --- a/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// DeduplicatingResultProvider.swift -// GoodNetworking -// -// Created by Andrej Jasso on 03/02/2025. -// - -import Foundation -import GoodLogger - -/// A protocol for providing results asynchronously. -/// -/// `ResultProviding` defines a method that asynchronously resolves a result of a generic type. -/// Classes or structures that conform to this protocol can implement their own logic for determining and returning the result. -public actor DeduplicatingResultProvider: ResultProviding, Sendable { - - // Shared in-memory cache - private static var cache: [String: (value: Sendable, finishDate: Date)] = [:] - - private let taskID: String - private let cacheTimeout: TimeInterval - private var shouldUpdateOnStore: Bool = false - - /// A private property that provides the appropriate logger based on the iOS version. - /// - /// For iOS 14 and later, it uses `OSLogLogger`. For earlier versions, it defaults to `PrintLogger`. - private var logger: GoodLogger { - if #available(iOS 14, *) { - return OSLogLogger(logMetaData: false) - } else { - return PrintLogger(logMetaData: false) - } - } - - public init(taskID: String, cacheTimeout: TimeInterval = 6) { - self.taskID = taskID - self.cacheTimeout = cacheTimeout - } - - /// Generates a unique cache key using the endpoint and taskID - private func cacheKey(for endpoint: Endpoint) -> String { - return "\(taskID)_\(endpoint.path)" - } - - /// Checks if the cached response has expired - private func isCacheValid(for key: String) -> Bool { - guard let cachedEntry = Self.cache[key] else { return false } - return Date().timeIntervalSince(cachedEntry.finishDate) < cacheTimeout - } - - /// Stores a result in the cache manually (Can be called externally) - public func storeResult(_ result: Result, for endpoint: Endpoint) async { - let key = cacheKey(for: endpoint) - - if shouldUpdateOnStore { - shouldUpdateOnStore = false - Self.cache[key] = (value: result, finishDate: Date()) - logger.log(message: "Value updated for \(key)") - } else { - logger.log(message: print("Already cached \(key)")) - } - } - - /// Resolves and returns the result asynchronously. - /// - /// This method fetches or computes the result, potentially involving asynchronous operations. - /// - /// - Returns: The resolved result. - public func resolveResult(endpoint: Endpoint) async -> Result? { - let key = cacheKey(for: endpoint) - - // Return cached response if available and valid - if isCacheValid(for: key), let cachedValue = Self.cache[key]?.value as? Result { - logger.log(message: "Cache hit for \(key)") - return cachedValue - } else { - shouldUpdateOnStore = true - return nil - } - } - -} diff --git a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift index 1bda3c3..0cee5ff 100644 --- a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift @@ -34,9 +34,9 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// For iOS 14 and later, it uses `OSLogLogger`. For earlier versions, it defaults to `PrintLogger`. private var logger: GoodLogger { if #available(iOS 14, *) { - return OSLogLogger(logMetaData: false) + return OSLogLogger() } else { - return PrintLogger(logMetaData: false) + return PrintLogger() } } diff --git a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift index d67210b..53837a7 100644 --- a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift +++ b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift @@ -5,35 +5,6 @@ // Created by Matus Klasovity on 30/01/2024. // -/// A network event monitor that provides detailed logging of network requests and responses. -/// -/// `LoggingEventMonitor` implements Alamofire's `EventMonitor` protocol to log network activity in a structured -/// and configurable way. It supports: -/// -/// - Request/response body logging with size limits and pretty printing -/// - Header logging -/// - Performance metrics and slow request warnings -/// - MIME type whitelisting for response logging -/// - Configurable log prefixes and formatting -/// -/// Example usage: -/// ```swift -/// let logger = ConsoleLogger() -/// let monitor = LoggingEventMonitor(logger: logger) -/// -/// // Configure logging options -/// LoggingEventMonitor.configure { config in -/// config.verbose = true -/// config.prettyPrinted = true -/// config.maxVerboseLogSizeBytes = 200_000 -/// } -/// ``` -/// -/// The monitor can be added to a network session configuration: -/// ```swift -/// let config = NetworkSessionConfiguration(eventMonitors: [monitor]) -/// let session = NetworkSession(configuration: config) -/// ``` @preconcurrency import Alamofire import Combine import GoodLogger @@ -41,93 +12,39 @@ import Foundation public struct LoggingEventMonitor: EventMonitor, Sendable { - nonisolated(unsafe) private static var configuration = Configuration() + nonisolated(unsafe) public static var verbose: Bool = true + nonisolated(unsafe) public static var prettyPrinted: Bool = true + nonisolated(unsafe) public static var maxVerboseLogSizeBytes: Int = 100_000 - /// Configuration options for the logging monitor. - public struct Configuration { - /// Whether to log detailed request/response information. Defaults to `true`. - var verbose: Bool = true - - /// Whether to pretty print JSON responses. Defaults to `true`. - var prettyPrinted: Bool = true - - /// Maximum size in bytes for verbose logging of request/response bodies. Defaults to 100KB. - var maxVerboseLogSizeBytes: Int = 100_000 - - /// Threshold in seconds above which requests are marked as slow. Defaults to 1 second. - var slowRequestThreshold: TimeInterval = 1.0 - - /// Emoji prefixes used in log messages. - var prefixes = Prefixes() - - /// Whether to only log responses with whitelisted MIME types. Defaults to `true`. - var useMimeTypeWhitelist: Bool = true - - /// Emoji prefixes used to categorize different types of log messages. - public struct Prefixes { - var request = "πŸš€" - var response = "⬇️" - var error = "🚨" - var headers = "🏷" - var metrics = "βŒ›οΈ" - var success = "βœ…" - var failure = "❌" - } - } - - /// Updates the monitor's configuration. - /// - /// - Parameter updates: A closure that modifies the configuration. - public static func configure(_ updates: (inout Configuration) -> Void) { - var config = configuration - updates(&config) - configuration = config - } - - /// The queue on which logging events are dispatched. + /// The `DispatchQueue` onto which Alamofire's root `CompositeEventMonitor` will dispatch events. `.main` by default. public let queue = DispatchQueue(label: C.queueLabel, qos: .background) private enum C { + static let queueLabel = "com.goodrequest.networklogger" + } private let logger: (any GoodLogger)? - /// Creates a new logging monitor. - /// - /// - Parameter logger: The logger instance to use for output. If nil, no logging occurs. public init(logger: (any GoodLogger)?) { self.logger = logger } public func request(_ request: DataRequest, didParseResponse response: DataResponse) { - let requestSize = request.request?.httpBody?.count ?? 0 - let responseSize = response.data?.count ?? 0 - let requestInfoMessage = parseRequestInfo(response: response) let metricsMessage = parse(metrics: response.metrics) - let requestBodyMessage = parse( - data: request.request?.httpBody, - error: response.error as NSError?, - prefix: "\(Self.configuration.prefixes.request) Request body (\(formatBytes(requestSize))):" - ) + let requestBodyMessage = parse(data: request.request?.httpBody, error: response.error as NSError?, prefix: "⬆️ Request body:") let errorMessage: String? = if let afError = response.error { - "\(Self.configuration.prefixes.error) Error:\n\(afError)" + "🚨 Error:\n\(afError)" } else { nil } - - let responseBodyMessage = if - Self.configuration.useMimeTypeWhitelist, - Self.responseTypeWhiteList.contains(where: { $0 == response.response?.mimeType }) - { - parse( - data: response.data, - error: response.error as NSError?, - prefix: "\(Self.configuration.prefixes.response) Response body (\(formatBytes(responseSize))):" - ) + + let responseBodyMessage = if Self.useMimeTypeWhitelist, Self.responseTypeWhiteList.contains(where: { $0 == response.response?.mimeType }) { + parse(data: response.data, error: response.error as NSError?, prefix: "⬇️ Response body:") } else { - "❓❓❓ Response MIME type not whitelisted (\(response.response?.mimeType ?? "❓"))" + "❓❓❓ Response MIME type not whitelisted (\(response.response?.mimeType ?? "❓")). You can try adding it to whitelist using logMimeType(_ mimeType:)." } let logMessage = [ @@ -158,42 +75,42 @@ private extension LoggingEventMonitor { else { return nil } - guard Self.configuration.verbose else { - return "\(Self.configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)" + guard Self.verbose else { + return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)" } if let headers = request.allHTTPHeaderFields, !headers.isEmpty, let headersData = try? JSONSerialization.data(withJSONObject: headers, options: [.prettyPrinted]), - let headersPrettyMessage = parse(data: headersData, error: nil, prefix: "\(Self.configuration.prefixes.headers) Headers:") { + let headersPrettyMessage = parse(data: headersData, error: nil, prefix: "🏷 Headers:") { - return "\(Self.configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage + return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage } else { let headers = if let allHTTPHeaderFields = request.allHTTPHeaderFields, !allHTTPHeaderFields.isEmpty { allHTTPHeaderFields.description } else { "empty headers" } - return "\(Self.configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n\(Self.configuration.prefixes.headers) Headers: \(headers)" + return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n🏷 Headers: \(headers)" } } func parse(data: Data?, error: NSError?, prefix: String) -> String? { - guard Self.configuration.verbose else { return nil } + guard Self.verbose else { return nil } if let data = data, !data.isEmpty { - guard data.count < Self.configuration.maxVerboseLogSizeBytes else { + guard data.count < Self.maxVerboseLogSizeBytes else { return [ prefix, "Data size is too big!", - "Max size is: \(Self.configuration.maxVerboseLogSizeBytes) bytes.", + "Max size is: \(Self.maxVerboseLogSizeBytes) bytes.", "Data size is: \(data.count) bytes", "πŸ’‘Tip: Change LoggingEventMonitor.maxVerboseLogSizeBytes = \(data.count)" ].joined(separator: "\n") } if let string = String(data: data, encoding: .utf8) { if let jsonData = try? JSONSerialization.jsonObject(with: data, options: []), - let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: Self.configuration.prettyPrinted ? [.prettyPrinted, .withoutEscapingSlashes] : [.withoutEscapingSlashes]), + let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: Self.prettyPrinted ? [.prettyPrinted, .withoutEscapingSlashes] : [.withoutEscapingSlashes]), let prettyPrintedString = String(data: prettyPrintedData, encoding: .utf8) { return "\(prefix) \n\(prettyPrintedString)" } else { @@ -206,47 +123,23 @@ private extension LoggingEventMonitor { } func parse(metrics: URLSessionTaskMetrics?) -> String? { - guard let metrics, Self.configuration.verbose else { + guard let metrics, Self.verbose else { return nil } - - let duration = metrics.taskInterval.duration - let warning = duration > Self.configuration.slowRequestThreshold ? " ⚠️ Slow Request!" : "" - - return [ - "↗️ Start: \(metrics.taskInterval.start)", - "\(Self.configuration.prefixes.metrics) Duration: \(String(format: "%.3f", duration))s\(warning)" - ].joined(separator: "\n") + return "↗️ Start: \(metrics.taskInterval.start)" + "\n" + "βŒ›οΈ Duration: \(metrics.taskInterval.duration)s" } func parseResponseStatus(response: HTTPURLResponse) -> String { let statusCode = response.statusCode - let logMessage = (200 ..< 300).contains(statusCode) - ? "\(Self.configuration.prefixes.success) \(statusCode)" - : "\(Self.configuration.prefixes.failure) \(statusCode)" - + let logMessage = (200 ..< 300).contains(statusCode) ? "βœ… \(statusCode)" : "❌ \(statusCode)" return logMessage } - private func formatBytes(_ bytes: Int) -> String { - let units = ["B", "KB", "MB"] - var size = Double(bytes) - var unitIndex = 0 - - while size > 1024 && unitIndex < units.count - 1 { - size /= 1024 - unitIndex += 1 - } - - return String(format: "%.1f %@", size, units[unitIndex]) - } - } public extension LoggingEventMonitor { - - /// List of MIME types that will be logged when `useMimeTypeWhitelist` is enabled. + nonisolated(unsafe) private(set) static var responseTypeWhiteList: [String] = [ "application/json", "application/ld+json", @@ -257,19 +150,17 @@ public extension LoggingEventMonitor { "text/javascript", "application/rtf" ] + + nonisolated(unsafe) static var useMimeTypeWhitelist: Bool = true - /// Adds a MIME type to the whitelist for response logging. - /// - /// - Parameter mimeType: The MIME type to whitelist nonisolated(unsafe) static func logMimeType(_ mimeType: String) { responseTypeWhiteList.append(mimeType) } - - /// Removes a MIME type from the whitelist for response logging. - /// - /// - Parameter mimeType: The MIME type to remove + nonisolated(unsafe) static func stopLoggingMimeType(_ mimeType: String) { - responseTypeWhiteList.removeAll{ $0 == mimeType } + responseTypeWhiteList.removeAll(where: { + $0 == mimeType + }) } - + } diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 8d82597..5273bfa 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -8,78 +8,39 @@ @preconcurrency import Alamofire import Foundation -/// A type responsible for executing network requests in a client application. +/// Executes network requests for the client app. /// -/// `NetworkSession` provides a high-level interface for making HTTP requests, handling downloads, -/// and managing file uploads. It uses a combination of base URL providers and session providers -/// to ensure proper configuration and session management. -/// -/// Key features: -/// - Supports typed network requests with automatic decoding -/// - Handles file downloads with customizable destinations -/// - Provides multipart form data upload capabilities -/// - Manages session lifecycle and validation -/// - Supports custom base URL resolution -/// -/// Example usage: -/// ```swift -/// let session = NetworkSession() -/// let result: MyResponse = try await session.request(endpoint: myEndpoint) -/// ``` +/// `NetworkSession` is responsible for sending, downloading, and uploading data through a network session. +/// It uses a base URL provider and a session provider to manage the configuration and ensure the session's validity. public actor NetworkSession: Hashable { - /// A type constraint requiring that network response types are both decodable and sendable. - public typealias DataType = Decodable & Sendable - - /// Compares two NetworkSession instances for equality based on their session IDs. - /// - /// - Parameters: - /// - lhs: The first NetworkSession to compare - /// - rhs: The second NetworkSession to compare - /// - Returns: `true` if both sessions have the same ID, `false` otherwise public static func == (lhs: NetworkSession, rhs: NetworkSession) -> Bool { lhs.sessionId == rhs.sessionId } - /// Hashes the essential components of the NetworkSession. - /// - /// - Parameter hasher: The hasher to use for combining the session's components nonisolated public func hash(into hasher: inout Hasher) { hasher.combine(sessionId) } // MARK: - ID - /// A unique identifier for this network session instance. nonisolated private let sessionId: UUID = UUID() // MARK: - Properties - /// The provider that manages the underlying network session. - /// - /// This provider is responsible for: - /// - Creating new sessions when needed - /// - Validating existing sessions - /// - Resolving session configurations + /// The provider responsible for managing the network session, ensuring it is created, resolved, and validated. public let sessionProvider: NetworkSessionProviding - /// A provider that resolves the base URL for network requests. - /// - /// The base URL provider allows for dynamic URL resolution, which is useful for: - /// - Environment-specific URLs (staging, production) - /// - Multi-tenant applications - /// - A/B testing different API endpoints + /// The optional provider for resolving the base URL to be used in network requests. public let baseUrlProvider: BaseUrlProviding? // MARK: - Initialization - /// Creates a new NetworkSession with custom providers. - /// - /// This initializer offers the most flexibility in configuring the session's behavior. + /// Initializes the `NetworkSession` with an optional base URL provider and a session provider. /// /// - Parameters: - /// - baseUrlProvider: A provider for resolving base URLs. Pass `nil` to disable base URL resolution. - /// - sessionProvider: A provider for managing the network session. Defaults to a standard configuration. + /// - baseUrlProvider: An optional provider for the base URL. Defaults to `nil`. + /// - sessionProvider: The session provider to be used. Defaults to `DefaultSessionProvider` with a default configuration. public init( baseUrlProvider: BaseUrlProviding? = nil, sessionProvider: NetworkSessionProviding = DefaultSessionProvider(configuration: .default) @@ -88,13 +49,11 @@ public actor NetworkSession: Hashable { self.sessionProvider = sessionProvider } - /// Creates a new NetworkSession with a base URL provider and configuration. - /// - /// This initializer is convenient when you need custom configuration but want to use the default session provider. + /// Initializes the `NetworkSession` with an optional base URL provider and a network session configuration. /// /// - Parameters: - /// - baseUrl: A provider for resolving base URLs. Pass `nil` to disable base URL resolution. - /// - configuration: The configuration to use for the session. Defaults to `.default`. + /// - baseUrl: An optional provider for the base URL. Defaults to `nil`. + /// - configuration: The configuration to be used for creating the session. Defaults to `.default`. public init( baseUrl: BaseUrlProviding? = nil, configuration: NetworkSessionConfiguration = .default @@ -103,13 +62,11 @@ public actor NetworkSession: Hashable { self.sessionProvider = DefaultSessionProvider(configuration: configuration) } - /// Creates a new NetworkSession with an existing Alamofire session. - /// - /// This initializer is useful when you need to integrate with existing Alamofire configurations. + /// Initializes the `NetworkSession` with an optional base URL provider and an existing session. /// /// - Parameters: - /// - baseUrlProvider: A provider for resolving base URLs. Pass `nil` to disable base URL resolution. - /// - session: An existing Alamofire session to use. + /// - baseUrlProvider: An optional provider for the base URL. Defaults to `nil`. + /// - session: An existing session to be used by this provider. public init( baseUrlProvider: BaseUrlProviding? = nil, session: Alamofire.Session @@ -124,58 +81,43 @@ public actor NetworkSession: Hashable { public extension NetworkSession { - /// Performs a network request that returns a decoded response. - /// - /// This method handles the complete lifecycle of a network request, including: - /// - Base URL resolution - /// - Session validation - /// - Request execution - /// - Response validation - /// - Error transformation - /// - Response decoding + /// Sends a network request to an endpoint using the resolved base URL and session. /// /// - Parameters: - /// - endpoint: The endpoint to request, containing URL, method, parameters, and headers - /// - baseUrlProvider: Optional override for the base URL provider - /// - validationProvider: Provider for custom response validation logic - /// - resultProvider: Optional provider for resolving results without network calls - /// - requestExecutor: The component responsible for executing the network request - /// - Returns: A decoded instance of the specified Result type - /// - Throws: A Failure error if any step in the request process fails - func request( + /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. + /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. + /// - validationProvider: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. + /// - Returns: The decoded result of type `Result`. + /// - Throws: A `Failure` error if validation or the request fails. + func request( endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, - validationProvider: any ValidationProviding = DefaultValidationProvider(), - resultProvider: ResultProviding? = nil, - requestExecutor: RequestExecuting = DefaultRequestExecutor() + validationProvider: any ValidationProviding = DefaultValidationProvider() ) async throws(Failure) -> Result { return try await catchingFailure(validationProvider: validationProvider) { let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - if let result: Result = await resultProvider?.resolveResult(endpoint: endpoint) { - return result - } else { - return try await requestExecutor.executeRequest( - endpoint: endpoint, - session: resolvedSession, - baseURL: resolvedBaseUrl, - validationProvider: validationProvider - ) - } + + return try await resolvedSession.request( + try? endpoint.url(on: resolvedBaseUrl), + method: endpoint.method, + parameters: endpoint.parameters?.dictionary, + encoding: endpoint.encoding, + headers: endpoint.headers + ) + .goodify(type: Result.self, validator: validationProvider) + .value } } - /// Performs a network request that returns raw response data. - /// - /// This method is useful when you need access to the raw response data without any decoding, - /// such as when handling binary data or implementing custom decoding logic. + /// Sends a raw network request and returns the response data. /// /// - Parameters: - /// - endpoint: The endpoint to request, containing URL, method, parameters, and headers - /// - baseUrlProvider: Optional override for the base URL provider - /// - validationProvider: Provider for custom response validation logic - /// - Returns: The raw response data - /// - Throws: A Failure error if the request or validation fails + /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. + /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. + /// - validationProvider: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. + /// - Returns: The raw response data. + /// - Throws: A `Failure` error if validation or the request fails. func requestRaw( endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, @@ -197,17 +139,12 @@ public extension NetworkSession { } } - /// Creates and returns an unprocessed Alamofire DataRequest. - /// - /// This method provides low-level access to the underlying Alamofire request object, - /// allowing for custom request handling and response processing. - /// - /// - Warning: This is a disfavored overload. Consider using the typed request methods instead. + /// Sends a request and returns an unprocessed `DataRequest` object. /// /// - Parameters: - /// - endpoint: The endpoint to request, containing URL, method, parameters, and headers - /// - baseUrlProvider: Optional override for the base URL provider - /// - Returns: An Alamofire DataRequest instance + /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. + /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. + /// - Returns: A `DataRequest` object representing the raw request. @_disfavoredOverload func request(endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil) async -> DataRequest { let resolvedBaseUrl = try? await resolveBaseUrl(baseUrlProvider: baseUrlProvider) let resolvedSession = await resolveSession(sessionProvider: sessionProvider) @@ -227,25 +164,15 @@ public extension NetworkSession { public extension NetworkSession { - /// Creates a download request that saves the response to a file. - /// - /// This method handles downloading files from a network endpoint and saving them - /// to the app's documents directory. It supports: - /// - Custom file naming - /// - Automatic directory creation - /// - Previous file removal + /// Creates a download request for the given `endpoint` and saves the result to the specified file. /// /// - Parameters: - /// - endpoint: The endpoint to download from - /// - baseUrlProvider: Optional override for the base URL provider - /// - customFileName: The name to use for the saved file - /// - Returns: An Alamofire DownloadRequest instance - /// - Throws: A NetworkError if the download setup fails - func download( - endpoint: Endpoint, - baseUrlProvider: BaseUrlProviding? = nil, - customFileName: String - ) async throws(NetworkError) -> DownloadRequest { + /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. + /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. + /// - customFileName: The name of the file to which the downloaded content will be saved. + /// - Returns: A `DownloadRequest` for the file download. + /// - Throws: A `NetworkError` if the request fails. + func download(endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, customFileName: String) async throws(NetworkError) -> DownloadRequest { let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) let resolvedSession = await resolveSession(sessionProvider: sessionProvider) @@ -272,20 +199,17 @@ public extension NetworkSession { public extension NetworkSession { - /// Uploads data as multipart form data with a single file. - /// - /// This method simplifies uploading a single file as part of a multipart form request. - /// It automatically handles the multipart form data construction. + /// Uploads data to the specified `endpoint` using multipart form data. /// /// - Parameters: - /// - endpoint: The endpoint to upload to - /// - data: The file data to upload - /// - fileHeader: The form field name for the file. Defaults to "file" - /// - filename: The name of the file being uploaded - /// - mimeType: The MIME type of the file - /// - baseUrlProvider: Optional override for the base URL provider - /// - Returns: An Alamofire UploadRequest instance - /// - Throws: A NetworkError if the upload setup fails + /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. + /// - data: The data to be uploaded. + /// - fileHeader: The header to use for the uploaded file in the form data. Defaults to "file". + /// - filename: The name of the file to be uploaded. + /// - mimeType: The MIME type of the file. + /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. + /// - Returns: An `UploadRequest` representing the upload. + /// - Throws: A `NetworkError` if the upload fails. func uploadWithMultipart( endpoint: Endpoint, data: Data, @@ -307,17 +231,14 @@ public extension NetworkSession { ) } - /// Uploads custom multipart form data. - /// - /// This method provides full control over the multipart form data construction, - /// allowing for complex form data with multiple files and fields. + /// Uploads multipart form data to the specified `endpoint`. /// /// - Parameters: - /// - endpoint: The endpoint to upload to - /// - multipartFormData: The pre-constructed multipart form data - /// - baseUrlProvider: Optional override for the base URL provider - /// - Returns: An Alamofire UploadRequest instance - /// - Throws: A NetworkError if the upload setup fails + /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. + /// - multipartFormData: The multipart form data to upload. + /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. + /// - Returns: An `UploadRequest` representing the upload. + /// - Throws: A `NetworkError` if the upload fails. func uploadWithMultipart( endpoint: Endpoint, multipartFormData: MultipartFormData, @@ -340,15 +261,10 @@ public extension NetworkSession { extension NetworkSession { - /// Ensures a valid session is available for use. - /// - /// This method manages the session lifecycle by: - /// - Checking the current session's validity - /// - Creating a new session if needed - /// - Resolving the current session state + /// Resolves the network session, creating a new one if necessary. /// - /// - Parameter sessionProvider: The provider managing the session - /// - Returns: A valid Alamofire Session instance + /// - Parameter sessionProvider: The provider managing the session. + /// - Returns: The resolved or newly created `Alamofire.Session`. func resolveSession(sessionProvider: NetworkSessionProviding) async -> Alamofire.Session { if await !sessionProvider.isSessionValid { await sessionProvider.makeSession() @@ -357,16 +273,11 @@ extension NetworkSession { } } - /// Resolves the base URL for a request. + /// Resolves the base URL using the provided or default base URL provider. /// - /// This method handles the base URL resolution process by: - /// - Using the provided override if available - /// - Falling back to the session's base URL provider - /// - Validating the resolved URL - /// - /// - Parameter baseUrlProvider: Optional override provider for the base URL - /// - Returns: The resolved base URL as a string - /// - Throws: NetworkError.invalidBaseURL if URL resolution fails + /// - Parameter baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. + /// - Returns: The resolved base URL as a `String`. + /// - Throws: A `NetworkError.invalidBaseURL` if the base URL cannot be resolved. func resolveBaseUrl(baseUrlProvider: BaseUrlProviding?) async throws(NetworkError) -> String { let baseUrlProvider = baseUrlProvider ?? self.baseUrlProvider guard let resolvedBaseUrl = await baseUrlProvider?.resolveBaseUrl() else { @@ -375,19 +286,14 @@ extension NetworkSession { return resolvedBaseUrl } - /// Executes code with standardized error handling. - /// - /// This method provides consistent error handling by: - /// - Catching and transforming network errors - /// - Handling Alamofire-specific errors - /// - Converting errors to the expected failure type + /// Executes a closure while catching and transforming failures. /// /// - Parameters: - /// - validationProvider: Provider for error transformation - /// - body: The code to execute - /// - Returns: The result of type Result - /// - Throws: A transformed error matching the Failure type - func catchingFailure( + /// - validationProvider: The provider used to transform any errors. + /// - body: The closure to execute. + /// - Returns: The result of type `Result`. + /// - Throws: A transformed error if the closure fails. + func catchingFailure( validationProvider: any ValidationProviding, body: () async throws -> Result ) async throws(Failure) -> Result { diff --git a/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift b/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift index 00eeda2..2cb3f23 100644 --- a/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift +++ b/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift @@ -54,9 +54,9 @@ public struct NetworkSessionConfiguration: Sendable { var eventMonitors: [EventMonitor] = [] if #available(iOS 14, *) { - eventMonitors.append(LoggingEventMonitor(logger: OSLogLogger(logMetaData: false))) + eventMonitors.append(LoggingEventMonitor(logger: OSLogLogger())) } else { - eventMonitors.append(LoggingEventMonitor(logger: PrintLogger(logMetaData: false))) + eventMonitors.append(LoggingEventMonitor(logger: PrintLogger())) } return NetworkSessionConfiguration( diff --git a/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift b/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift deleted file mode 100644 index 52a0b6d..0000000 --- a/Tests/GoodNetworkingTests/DeduplicatingExecutorTests.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// DeduplicatingExecutorTests.swift -// GoodNetworking -// -// Created by Assistant on 16/10/2024. -// - -import XCTest -@testable import GoodNetworking -import Alamofire - -final class DeduplicatingExecutorTests: XCTestCase { - - // MARK: - Tests - - func testConcurrentRequestsAreDeduplicated() async throws { - // Setup - let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskID: "People", logger: logger) - let session: Session! = Session() - let baseURL = "https://swapi.dev/api" - - // Given - let endpoint = SwapiEndpoint.luke - - // When - async let firstResult: SwapiPerson = executor.executeRequest( - endpoint: endpoint, - session: session, - baseURL: baseURL - ) - async let secondResult: SwapiPerson = executor.executeRequest( - endpoint: endpoint, - session: session, - baseURL: baseURL - ) - - // Then - let (result1, result2) = try await (firstResult, secondResult) - XCTAssertEqual(result1.name, "Luke Skywalker") - XCTAssertEqual(result2.name, "Luke Skywalker") - XCTAssertTrue(logger.messages.contains(where: { $0.contains("Cached value used") } )) - } - - func testDifferentRequestsAreNotDeduplicated() async throws { - // Setup - let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskID: "Luke", logger: logger) - let executor2 = DeduplicatingRequestExecutor(taskID: "Vader", logger: logger) - let session: Session! = Session() - let baseURL = "https://swapi.dev/api" - - // Given - let lukeEndpoint = SwapiEndpoint.luke - let vaderEndpoint = SwapiEndpoint.vader - - // When - async let lukeResult: SwapiPerson = executor.executeRequest( - endpoint: lukeEndpoint, - session: session, - baseURL: baseURL - ) - async let vaderResult: SwapiPerson = executor2.executeRequest( - endpoint: vaderEndpoint, - session: session, - baseURL: baseURL - ) - - // Then - let (luke, vader) = try await (lukeResult, vaderResult) - XCTAssertEqual(luke.name, "Luke Skywalker") - XCTAssertEqual(vader.name, "Darth Vader") - XCTAssertFalse(logger.messages.contains(where: { $0.contains("Cached value used") } )) - } - - func testSequentialRequestsAreNotDeduplicated() async throws { - // Setup - let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskID: "People", logger: logger) - let session: Session! = Session() - let baseURL = "https://swapi.dev/api" - - // Given - let endpoint = SwapiEndpoint.luke - - // When - async let firstResult: SwapiPerson = executor.executeRequest( - endpoint: endpoint, - session: session, - baseURL: baseURL - ) - async let secondResult: SwapiPerson = executor.executeRequest( - endpoint: endpoint, - session: session, - baseURL: baseURL - ) - - let luke = try await firstResult - let luke2 = try await secondResult - - // Then - XCTAssertEqual(luke.name, "Luke Skywalker") - XCTAssertEqual(luke2.name, "Luke Skywalker") - XCTAssertTrue(logger.messages.contains(where: { $0.contains("Cached value used") } )) - } - - func testErrorHandling() async throws { - // Setup - let logger = TestingLogger() - let executor = DeduplicatingRequestExecutor(taskID: "People", logger: logger) - let session: Session! = Session() - let baseURL = "https://swapi.dev/api" - - // Given - let invalidEndpoint = SwapiEndpoint.invalid - - // When/Then - do { - let result: SwapiPerson = try await executor - .executeRequest(endpoint: invalidEndpoint, session: session, baseURL: baseURL) - - XCTFail("Expected error to be thrown") - } catch { - XCTAssertTrue(logger.messages.contains(where: { $0.contains("Task finished with error") } )) - } - } -} diff --git a/Tests/GoodNetworkingTests/SwapiEndpoint.swift b/Tests/GoodNetworkingTests/SwapiEndpoint.swift deleted file mode 100644 index 4297ddd..0000000 --- a/Tests/GoodNetworkingTests/SwapiEndpoint.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// SwapiEndpoint.swift -// GoodNetworking -// -// Created by Andrej Jasso on 05/02/2025. -// - -import GoodNetworking -import Alamofire - -enum SwapiEndpoint: Endpoint { - - case luke - case vader - case invalid - - var path: String { - switch self { - case .luke: "/people/1" - case .vader: "/people/4" - case .invalid: "/invalid/path" - } - } - -} diff --git a/Tests/GoodNetworkingTests/SwapiPerson.swift b/Tests/GoodNetworkingTests/SwapiPerson.swift deleted file mode 100644 index 836b018..0000000 --- a/Tests/GoodNetworkingTests/SwapiPerson.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// SwapiPerson.swift -// GoodNetworking -// -// Created by Andrej Jasso on 05/02/2025. -// - -struct SwapiPerson: Decodable { - let name: String - let height: String - let mass: String - let birthYear: String - let gender: String - - enum CodingKeys: String, CodingKey { - case name, height, mass - case birthYear = "birth_year" - case gender - } -} diff --git a/Tests/GoodNetworkingTests/TestingLogger.swift b/Tests/GoodNetworkingTests/TestingLogger.swift deleted file mode 100644 index 3cf4580..0000000 --- a/Tests/GoodNetworkingTests/TestingLogger.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// TestingLogger.swift -// GoodNetworking -// -// Created by Andrej Jasso on 05/02/2025. -// - -import GoodLogger - -final class TestingLogger: GoodLogger { - - nonisolated(unsafe) var messages = [String]() - - nonisolated func log( - message: Any, - level: LogLevel?, - privacy: PrivacyType?, - fileName: String?, - lineNumber: Int? - ) { - print(message) - messages.append(String(describing: message)) - } - -} From eb1f711c460dc8c60c7fd70bc18fbafe346fbe5e Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:41:52 +0200 Subject: [PATCH 05/13] Buildable in Swift 6.2 --- Package.swift | 4 +--- Sources/GoodNetworking/GRImageDownloader.swift | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 12368af..ab5d39f 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,6 @@ let package = Package( .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.10.0")), .package(url: "https://github.com/Alamofire/AlamofireImage.git", .upToNextMajor(from: "4.2.0")), .package(url: "https://github.com/KittyMac/Sextant.git", .upToNextMinor(from: "0.4.31")), - .package(url: "https://github.com/GoodRequest/GoodLogger.git", .upToNextMajor(from: "1.2.4")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -34,8 +33,7 @@ let package = Package( dependencies: [ .product(name: "Alamofire", package: "Alamofire"), .product(name: "AlamofireImage", package: "AlamofireImage"), - .product(name: "Sextant", package: "Sextant"), - .product(name: "GoodLogger", package: "GoodLogger") + .product(name: "Sextant", package: "Sextant") ], path: "./Sources/GoodNetworking", resources: [.copy("PrivacyInfo.xcprivacy")], diff --git a/Sources/GoodNetworking/GRImageDownloader.swift b/Sources/GoodNetworking/GRImageDownloader.swift index 93084d4..1687389 100644 --- a/Sources/GoodNetworking/GRImageDownloader.swift +++ b/Sources/GoodNetworking/GRImageDownloader.swift @@ -9,6 +9,7 @@ import Foundation import AlamofireImage import Alamofire +#if swift(<6.2) /// The GRImageDownloader class provides a setup method for configuring and creating an instance of an ImageDownloader. public actor GRImageDownloader { @@ -78,3 +79,4 @@ public actor GRImageDownloader { } } +#endif // swift(<6.2) From eff57089416a6aa9e3960074c361c6d9ac8cefd8 Mon Sep 17 00:00:00 2001 From: Matus Klasovity Date: Mon, 9 Jun 2025 13:45:18 +0200 Subject: [PATCH 06/13] feat: Remove GoodLogger dependency # Conflicts: # Package.swift # Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift # Sources/GoodNetworking/Executor/RequestExecuting.swift # Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift # Sources/GoodNetworking/Providers/DefaultSessionProvider.swift # Sources/GoodNetworking/Session/LoggingEventMonitor.swift # Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift # Sources/GoodNetworking/Wrapper/Resource.swift --- .../project.pbxproj | 4 ++ .../xcshareddata/swiftpm/Package.resolved | 11 +--- .../Managers/SampleLogger.swift | 26 +++++++++ .../Managers/SampleNetworkSessions.swift | 6 ++- .../Screens/UserListScreen.swift | 6 ++- Package.resolved | 11 +--- .../GoodNetworking/GRImageDownloader.swift | 2 +- .../GoodNetworking/Logger/NetworkLogger.swift | 26 +++++++++ .../Providers/DefaultSessionProvider.swift | 43 ++++++++------- .../Session/LoggingEventMonitor.swift | 12 +++-- .../Session/NetworkSession.swift | 7 +-- .../Session/NetworkSessionConfiguration.swift | 11 ++-- Sources/GoodNetworking/Wrapper/Resource.swift | 54 ++++++++++--------- 13 files changed, 135 insertions(+), 84 deletions(-) create mode 100644 GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleLogger.swift create mode 100644 Sources/GoodNetworking/Logger/NetworkLogger.swift diff --git a/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.pbxproj b/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.pbxproj index 3bfb67b..1610dc9 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.pbxproj +++ b/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 09A9ECA12C48115A0032C359 /* GoodSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 09A9ECA02C48115A0032C359 /* GoodSwiftUI */; }; 09A9ECA32C4811B00032C359 /* UserListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9ECA22C4811B00032C359 /* UserListScreen.swift */; }; 09A9ECAE2C4AC9810032C359 /* JobUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A9ECAD2C4AC9810032C359 /* JobUser.swift */; }; + 3F8799A12DF703B000B4286B /* SampleLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8799A02DF703B000B4286B /* SampleLogger.swift */; }; 5D4200142CBEE7ED006C4292 /* UserDefaultsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D4200132CBEE7ED006C4292 /* UserDefaultsExtensions.swift */; }; 5D4200192CBF97CC006C4292 /* ServerPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D4200182CBF97CC006C4292 /* ServerPickerView.swift */; }; 5D4A967C299C190B00DFAEAE /* GoodNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 5D4A967B299C190B00DFAEAE /* GoodNetworking */; }; @@ -35,6 +36,7 @@ 09A9EC992C48015C0032C359 /* SampleEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleEndpoint.swift; sourceTree = ""; }; 09A9ECA22C4811B00032C359 /* UserListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListScreen.swift; sourceTree = ""; }; 09A9ECAD2C4AC9810032C359 /* JobUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobUser.swift; sourceTree = ""; }; + 3F8799A02DF703B000B4286B /* SampleLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleLogger.swift; sourceTree = ""; }; 5D4200132CBEE7ED006C4292 /* UserDefaultsExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtensions.swift; sourceTree = ""; }; 5D4200182CBF97CC006C4292 /* ServerPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerPickerView.swift; sourceTree = ""; }; 5D4A967A299C18FB00DFAEAE /* GoodNetworking */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = GoodNetworking; path = ..; sourceTree = ""; }; @@ -91,6 +93,7 @@ 09A9EC942C4800B90032C359 /* SampleNetworkSessions.swift */, 09A9EC992C48015C0032C359 /* SampleEndpoint.swift */, 5D7C80642CA2CDB900116E10 /* SampleSelectableBaseUrlProvider.swift */, + 3F8799A02DF703B000B4286B /* SampleLogger.swift */, ); path = Managers; sourceTree = ""; @@ -240,6 +243,7 @@ buildActionMask = 2147483647; files = ( 09A9ECA32C4811B00032C359 /* UserListScreen.swift in Sources */, + 3F8799A12DF703B000B4286B /* SampleLogger.swift in Sources */, 09A9EC9A2C48015C0032C359 /* SampleEndpoint.swift in Sources */, 09A9EC8F2C47FF8E0032C359 /* UserScreen.swift in Sources */, EACEC3FA29953DCB008242AA /* AppDelegate.swift in Sources */, diff --git a/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6d7a5d7..9abd788 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/GoodNetworking-Sample/GoodNetworking-Sample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0ec82abc5ca42058b5ceb3245d83f0d5427683fe77d944747a80f766426a155d", + "originHash" : "b3c20239e5f6e3400b6353d827fd101408abfdebe127fc574a3024afac1d4f96", "pins" : [ { "identity" : "alamofire", @@ -46,15 +46,6 @@ "version" : "2.0.0" } }, - { - "identity" : "goodlogger", - "kind" : "remoteSourceControl", - "location" : "https://github.com/GoodRequest/GoodLogger.git", - "state" : { - "revision" : "14318cd3a38a08ac49e2b1dee56386904fedecc5", - "version" : "1.3.0" - } - }, { "identity" : "goodswiftui", "kind" : "remoteSourceControl", diff --git a/GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleLogger.swift b/GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleLogger.swift new file mode 100644 index 0000000..ba42ada --- /dev/null +++ b/GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleLogger.swift @@ -0,0 +1,26 @@ +// +// SampleLogger.swift +// GoodNetworking-Sample +// +// Created by Matus Klasovity on 09/06/2025. +// + +import Foundation +import GoodNetworking + +struct SampleLogger: NetworkLogger { + + func logNetworkEvent(message: Any, level: LogLevel, fileName: String, lineNumber: Int) { + switch level { + case .debug: + print("[DEBUG] \(fileName):\(lineNumber) - \(message)") + case .info: + print("[INFO] \(fileName):\(lineNumber) - \(message)") + case .warning: + print("[WARNING] \(fileName):\(lineNumber) - \(message)") + case .error: + print("[ERROR] \(fileName):\(lineNumber) - \(message)") + } + } + +} diff --git a/GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleNetworkSessions.swift b/GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleNetworkSessions.swift index ad15857..fbacd2d 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleNetworkSessions.swift +++ b/GoodNetworking-Sample/GoodNetworking-Sample/Managers/SampleNetworkSessions.swift @@ -35,7 +35,11 @@ extension NetworkSession { let urlProvider = CustomBaseUrlProvider(serverCollection: prodServerCollection) #endif baseURLProvider = urlProvider - NetworkSession.sampleSession = NetworkSession(baseUrl: urlProvider) + NetworkSession.sampleSession = NetworkSession( + baseUrl: urlProvider, + configuration: .default(logger: SampleLogger()), + logger: SampleLogger() + ) } } diff --git a/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserListScreen.swift b/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserListScreen.swift index 9f3f8f6..e4a9a8e 100644 --- a/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserListScreen.swift +++ b/GoodNetworking-Sample/GoodNetworking-Sample/Screens/UserListScreen.swift @@ -13,7 +13,11 @@ struct UserListScreen: View { // MARK: - State - @State private var users = Resource(session: .sampleSession, remote: RemoteUser.self) + @State private var users = Resource( + session: .sampleSession, + remote: RemoteUser.self, + logger: SampleLogger() + ) @State private var didLoadList = false @State private var presentServerSettings = false diff --git a/Package.resolved b/Package.resolved index c1546ad..4e494dc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "886644daa6f3bcf0e06b25d6f55a2d8e5f9fcd6c28b069e7970f478683a82860", + "originHash" : "40119152ad89596efc6986f44ce1dc0a1bc0e0eb765f23d639911bd31163169e", "pins" : [ { "identity" : "alamofire", @@ -28,15 +28,6 @@ "version" : "0.1.12" } }, - { - "identity" : "goodlogger", - "kind" : "remoteSourceControl", - "location" : "https://github.com/GoodRequest/GoodLogger.git", - "state" : { - "revision" : "14318cd3a38a08ac49e2b1dee56386904fedecc5", - "version" : "1.3.0" - } - }, { "identity" : "hitch", "kind" : "remoteSourceControl", diff --git a/Sources/GoodNetworking/GRImageDownloader.swift b/Sources/GoodNetworking/GRImageDownloader.swift index 1687389..1482d24 100644 --- a/Sources/GoodNetworking/GRImageDownloader.swift +++ b/Sources/GoodNetworking/GRImageDownloader.swift @@ -41,7 +41,7 @@ public actor GRImageDownloader { /// - sessionConfiguration: The GRSessionConfiguration used to create the URLSession and Session. (default: .default) /// - downloaderConfiguration: The GRImageDownloaderConfiguration used to set the max concurrent operation count and max active downloads. static func setupAuthorizedImageDownloader( - sessionConfiguration: NetworkSessionConfiguration = .default, + sessionConfiguration: NetworkSessionConfiguration = .default(), downloaderConfiguration: GRImageDownloaderConfiguration ) { let imageDownloaderQueue = DispatchQueue(label: C.imageDownloaderDispatchQueueKey) diff --git a/Sources/GoodNetworking/Logger/NetworkLogger.swift b/Sources/GoodNetworking/Logger/NetworkLogger.swift new file mode 100644 index 0000000..432e162 --- /dev/null +++ b/Sources/GoodNetworking/Logger/NetworkLogger.swift @@ -0,0 +1,26 @@ +// +// NetworkLogger.swift +// GoodNetworking +// +// Created by Matus Klasovity on 09/06/2025. +// + +import Foundation + +public enum LogLevel: String, CaseIterable { + case debug + case info + case warning + case error +} + +public protocol NetworkLogger: Sendable { + /// Logs the given message with a specific log level, file name, and line number. + func logNetworkEvent( + message: Any, + level: LogLevel, + fileName: String, + lineNumber: Int + ) +} + diff --git a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift index 0cee5ff..16313f7 100644 --- a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift @@ -6,7 +6,6 @@ // @preconcurrency import Alamofire -import GoodLogger /// An actor that provides a default network session using Alamofire. /// @@ -14,7 +13,7 @@ import GoodLogger /// of default network sessions. This provider assumes that the session is always valid, and does not support session invalidation. /// It logs session-related activities using a logger, and allows sessions to be created or resolved based on a given configuration or existing session. /// -/// - Note: This provider uses `GoodLogger` for logging session-related messages. +/// - Note: This provider uses `NetworkLogger` for logging session-related messages. /// If available, it uses `OSLogLogger`, otherwise it falls back to `PrintLogger`. public actor DefaultSessionProvider: NetworkSessionProviding { @@ -29,21 +28,13 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// If a session has already been created, it is stored here. If not, the `makeSession()` function can be called to create one. nonisolated public let currentSession: Alamofire.Session - /// A private property that provides the appropriate logger based on the iOS version. - /// - /// For iOS 14 and later, it uses `OSLogLogger`. For earlier versions, it defaults to `PrintLogger`. - private var logger: GoodLogger { - if #available(iOS 14, *) { - return OSLogLogger() - } else { - return PrintLogger() - } - } + /// A private property that provides the logger + var logger: NetworkLogger? /// Initializes the session provider with a network session configuration. /// /// - Parameter configuration: The configuration used to create network sessions. - public init(configuration: NetworkSessionConfiguration) { + public init(configuration: NetworkSessionConfiguration, logger: NetworkLogger? = nil) { self.configuration = configuration self.currentSession = Alamofire.Session( configuration: configuration.urlSessionConfiguration, @@ -56,7 +47,7 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// Initializes the session provider with an existing `Alamofire.Session`. /// /// - Parameter session: An existing session that will be used by this provider. - public init(session: Alamofire.Session) { + public init(session: Alamofire.Session, logger: NetworkLogger? = nil) { self.currentSession = session self.configuration = NetworkSessionConfiguration( urlSessionConfiguration: session.sessionConfiguration, @@ -73,9 +64,11 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// - Returns: `true`, indicating the session is valid. public var isSessionValid: Bool { - logger.log( + logger?.logNetworkEvent( message: "βœ… Default session is always valid", - level: .debug + level: .debug, + fileName: #file, + lineNumber: #line ) return true } @@ -84,9 +77,11 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// Since the default session does not support invalidation, this method simply logs a message without performing any action. public func invalidateSession() async { - logger.log( + logger?.logNetworkEvent( message: "❌ Default session cannot be invalidated", - level: .debug + level: .debug, + fileName: #file, + lineNumber: #line ) } @@ -97,9 +92,11 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// - Returns: A new instance of `Alamofire.Session`. public func makeSession() async -> Alamofire.Session { - logger.log( + logger?.logNetworkEvent( message: "❌ Default Session Provider cannot be create a new Session, it's setup in the initializer", - level: .debug + level: .debug, + fileName: #file, + lineNumber: #line ) return currentSession @@ -112,9 +109,11 @@ public actor DefaultSessionProvider: NetworkSessionProviding { /// /// - Returns: The current or newly created `Alamofire.Session`. public func resolveSession() async -> Alamofire.Session { - logger.log( + logger?.logNetworkEvent( message: "❌ Default session provider always resolves current session which is setup in the initializer", - level: .debug + level: .debug, + fileName: #file, + lineNumber: #line ) return currentSession } diff --git a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift index 53837a7..ab47a7b 100644 --- a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift +++ b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift @@ -7,7 +7,6 @@ @preconcurrency import Alamofire import Combine -import GoodLogger import Foundation public struct LoggingEventMonitor: EventMonitor, Sendable { @@ -25,9 +24,12 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { } - private let logger: (any GoodLogger)? + private let logger: NetworkLogger - public init(logger: (any GoodLogger)?) { + /// Creates a new logging monitor. + /// + /// - Parameter logger: The logger instance to use for output. If nil, no logging occurs. + public init(logger: NetworkLogger, configuration: Configuration = .init()) { self.logger = logger } @@ -57,9 +59,9 @@ public struct LoggingEventMonitor: EventMonitor, Sendable { switch response.result { case .success: - logger?.log(message: logMessage, level: .debug) + logger.logNetworkEvent(message: logMessage, level: .debug, fileName: #file, lineNumber: #line) case .failure: - logger?.log(message: logMessage, level: .fault) + logger.logNetworkEvent(message: logMessage, level: .error, fileName: #file, lineNumber: #line) } } diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 5273bfa..9c5ee6b 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -43,7 +43,7 @@ public actor NetworkSession: Hashable { /// - sessionProvider: The session provider to be used. Defaults to `DefaultSessionProvider` with a default configuration. public init( baseUrlProvider: BaseUrlProviding? = nil, - sessionProvider: NetworkSessionProviding = DefaultSessionProvider(configuration: .default) + sessionProvider: NetworkSessionProviding = DefaultSessionProvider(configuration: .default()) ) { self.baseUrlProvider = baseUrlProvider self.sessionProvider = sessionProvider @@ -56,10 +56,11 @@ public actor NetworkSession: Hashable { /// - configuration: The configuration to be used for creating the session. Defaults to `.default`. public init( baseUrl: BaseUrlProviding? = nil, - configuration: NetworkSessionConfiguration = .default + configuration: NetworkSessionConfiguration = .default(), + logger: NetworkLogger? = nil ) { self.baseUrlProvider = baseUrl - self.sessionProvider = DefaultSessionProvider(configuration: configuration) + self.sessionProvider = DefaultSessionProvider(configuration: configuration, logger: logger) } /// Initializes the `NetworkSession` with an optional base URL provider and an existing session. diff --git a/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift b/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift index 2cb3f23..a3637ec 100644 --- a/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift +++ b/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift @@ -7,7 +7,6 @@ @preconcurrency import Alamofire import Foundation -import GoodLogger /// NetworkSessionConfiguration represents the configuration used to create a NetworkSession object. public struct NetworkSessionConfiguration: Sendable { @@ -50,15 +49,13 @@ public struct NetworkSessionConfiguration: Sendable { // MARK: - Static /// The default configuration for a `GRSession` object. - public static var `default`: NetworkSessionConfiguration { + public static func `default`(logger: (any NetworkLogger)? = nil) -> NetworkSessionConfiguration { var eventMonitors: [EventMonitor] = [] - if #available(iOS 14, *) { - eventMonitors.append(LoggingEventMonitor(logger: OSLogLogger())) - } else { - eventMonitors.append(LoggingEventMonitor(logger: PrintLogger())) + if let logger { + eventMonitors.append(LoggingEventMonitor(logger: logger)) } - + return NetworkSessionConfiguration( urlSessionConfiguration: .default, interceptor: nil, diff --git a/Sources/GoodNetworking/Wrapper/Resource.swift b/Sources/GoodNetworking/Wrapper/Resource.swift index 9025234..fe8a05c 100644 --- a/Sources/GoodNetworking/Wrapper/Resource.swift +++ b/Sources/GoodNetworking/Wrapper/Resource.swift @@ -7,7 +7,6 @@ import Alamofire import SwiftUI -import GoodLogger // MARK: - Resource @@ -27,6 +26,7 @@ public struct RawResponse: Sendable { private var session: FutureSession private var rawResponse: RawResponse = RawResponse() private var remote: R.Type + private let logger: NetworkLogger? private(set) public var state: ResourceState private var listState: ResourceState<[R.Resource], NetworkError> @@ -50,10 +50,12 @@ public struct RawResponse: Sendable { public init( wrappedValue: R.Resource? = nil, session: NetworkSession, - remote: R.Type + remote: R.Type, + logger: NetworkLogger? = nil ) { self.session = FutureSession { session } self.remote = remote + self.logger = logger if let wrappedValue { self.state = .available(wrappedValue) @@ -66,10 +68,12 @@ public struct RawResponse: Sendable { public init( session: FutureSession? = nil, - remote: R.Type + remote: R.Type, + logger: NetworkLogger? = nil ) { self.session = session ?? .placeholder self.remote = remote + self.logger = logger self.state = .idle self.listState = .idle } @@ -106,38 +110,30 @@ public struct RawResponse: Sendable { @available(iOS 17.0, *) extension Resource { - private var logger: GoodLogger { - if #available(iOS 14, *) { - return OSLogLogger() - } else { - return PrintLogger() - } - } - public func create() async throws { - logger.log(level: .error, message: "CREATE operation not defined for resource \(String(describing: R.self))", privacy: .auto) + logger?.logNetworkEvent(message: "CREATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) } public func read(forceReload: Bool = false) async throws { - logger.log(level: .error, message: "READ operation not defined for resource \(String(describing: R.self))", privacy: .auto) + logger?.logNetworkEvent(message: "READ operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) } public func updateRemote() async throws { - logger.log(level: .error, message: "UPDATE operation not defined for resource \(String(describing: R.self))", privacy: .auto) + logger?.logNetworkEvent(message: "UPDATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) } public func delete() async throws { - logger.log(level: .error, message: "DELETE operation not defined for resource \(String(describing: R.self))", privacy: .auto) + logger?.logNetworkEvent(message: "DELETE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) } public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { - logger.log(level: .error, message: "LIST operation not defined for resource \(String(describing: R.self))", privacy: .auto) - logger.log(level: .error, message: "Check type of parameters passed to this resource.", privacy: .auto) - logger.log(level: .error, message: "Current parameters type: \(type(of: parameters))", privacy: .auto) + logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) + logger?.logNetworkEvent(message: "Check type of parameters passed to this resource.", level: .error, fileName: #file, lineNumber: #line) + logger?.logNetworkEvent(message: "Current parameters type: \(type(of: parameters))", level: .error, fileName: #file, lineNumber: #line) } public func nextPage() async throws { - logger.log(level: .error, message: "LIST operation not defined for resource \(String(describing: R.self))", privacy: .auto) + logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) } } @@ -149,7 +145,8 @@ extension Resource where R: Creatable { public func create() async throws { guard let request = try R.request(from: state.value) else { - return logger.log(level: .error, message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", privacy: .auto) + logger?.logNetworkEvent(message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", level: .error, fileName: #file, lineNumber: #line) + return } try await create(request: request) } @@ -203,7 +200,13 @@ extension Resource where R: Readable { let resource = state.value guard let request = try R.request(from: resource) else { self.state = .idle - return logger.log(level: .error, message: "Requesting nil resource always fails! Use read(request:forceReload:) with a custom request or supply a resource to read.", privacy: .auto) + logger?.logNetworkEvent( + message: "Reading nil resource always fails! Use read(request:) with a custom request or supply a resource to read from.", + level: .error, + fileName: #file, + lineNumber: #line + ) + return } try await read(request: request, forceReload: forceReload) @@ -211,7 +214,8 @@ extension Resource where R: Readable { public func read(request: R.ReadRequest, forceReload: Bool = false) async throws { guard !state.isAvailable || forceReload else { - return logger.log(level: .info, message: "Skipping read - value already exists", privacy: .auto) + logger?.logNetworkEvent(message: "Skipping read - value already exists", level: .info, fileName: #file, lineNumber: #line) + return } let resource = state.value @@ -254,7 +258,8 @@ extension Resource where R: Updatable { public func updateRemote() async throws { guard let request = try R.request(from: state.value) else { - return logger.log(level: .error, message: "Updating resource to nil always fails! Use DELETE instead.", privacy: .auto) + logger?.logNetworkEvent(message: "Updating resource to nil always fails! Use DELETE instead.", level: .error, fileName: #file, lineNumber: #line) + return } try await updateRemote(request: request) } @@ -305,7 +310,8 @@ extension Resource where R: Deletable { public func delete() async throws { guard let request = try R.request(from: state.value) else { - return logger.log(level: .error, message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", privacy: .auto) + logger?.logNetworkEvent(message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", level: .error, fileName: #file, lineNumber: #line) + return } try await delete(request: request) } From dfb69f47a4e61c5a9fba12dff838f50c15058967 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:23:31 +0200 Subject: [PATCH 07/13] feat: Remove Alamofire vol.1 --- Package.resolved | 20 +- Package.swift | 6 +- .../Extensions/AFErrorExtensions.swift | 27 - .../Extensions/ArrayEncoding.swift | 61 +- .../GoodNetworking/Extensions/Goodify.swift | 524 ++++---- .../Extensions/URLExtensions.swift | 30 - .../GoodNetworking/GRImageDownloader.swift | 82 -- .../GoodNetworking/Logger/NetworkLogger.swift | 11 +- .../Logger/PrintNetworkLogger.swift | 21 + Sources/GoodNetworking/NetworkActor.swift | 46 + .../Protocols/BaseUrlProviding.swift | 35 - .../GoodNetworking/Protocols/Endpoint.swift | 34 +- .../GoodNetworking/Protocols/HTTPMethod.swift | 60 + Sources/GoodNetworking/Protocols/Header.swift | 134 ++ .../Protocols/NetworkSessionProviding.swift | 8 +- .../Protocols/ResourceOperations.swift | 1142 ++++++++--------- .../Protocols/URLConvertible.swift | 50 + .../Providers/DefaultBaseUrlProvider.swift | 41 - .../Providers/DefaultSessionProvider.swift | 224 ++-- .../Session/FutureSession.swift | 104 +- .../GoodNetworking/Session/GRSession.swift | 100 ++ .../Session/LoggingEventMonitor.swift | 315 +++-- .../Session/NetworkSession.swift | 633 +++++---- .../Session/NetworkSessionConfiguration.swift | 114 +- .../Session/TransientSession.swift | 52 +- Sources/GoodNetworking/Wrapper/Pager.swift | 70 +- Sources/GoodNetworking/Wrapper/Resource.swift | 853 ++++++------ 27 files changed, 2497 insertions(+), 2300 deletions(-) delete mode 100644 Sources/GoodNetworking/Extensions/AFErrorExtensions.swift delete mode 100644 Sources/GoodNetworking/Extensions/URLExtensions.swift delete mode 100644 Sources/GoodNetworking/GRImageDownloader.swift create mode 100644 Sources/GoodNetworking/Logger/PrintNetworkLogger.swift create mode 100644 Sources/GoodNetworking/NetworkActor.swift delete mode 100644 Sources/GoodNetworking/Protocols/BaseUrlProviding.swift create mode 100644 Sources/GoodNetworking/Protocols/HTTPMethod.swift create mode 100644 Sources/GoodNetworking/Protocols/Header.swift create mode 100644 Sources/GoodNetworking/Protocols/URLConvertible.swift delete mode 100644 Sources/GoodNetworking/Providers/DefaultBaseUrlProvider.swift create mode 100644 Sources/GoodNetworking/Session/GRSession.swift diff --git a/Package.resolved b/Package.resolved index 4e494dc..13f164c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,24 +1,6 @@ { - "originHash" : "40119152ad89596efc6986f44ce1dc0a1bc0e0eb765f23d639911bd31163169e", + "originHash" : "8eab421ee15e57c7b5a7c27243f361840c5e4ea94e8da3381f3024ef5ec78977", "pins" : [ - { - "identity" : "alamofire", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/Alamofire.git", - "state" : { - "revision" : "ea6a94b7dddffd0ca4d0f29252d95310b84dec84", - "version" : "5.10.0" - } - }, - { - "identity" : "alamofireimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Alamofire/AlamofireImage.git", - "state" : { - "revision" : "1eaf3b6c6882bed10f6e7b119665599dd2329aa1", - "version" : "4.3.0" - } - }, { "identity" : "chronometer", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ab5d39f..fe014bc 100644 --- a/Package.swift +++ b/Package.swift @@ -21,9 +21,7 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.10.0")), - .package(url: "https://github.com/Alamofire/AlamofireImage.git", .upToNextMajor(from: "4.2.0")), - .package(url: "https://github.com/KittyMac/Sextant.git", .upToNextMinor(from: "0.4.31")), + .package(url: "https://github.com/KittyMac/Sextant.git", .upToNextMinor(from: "0.4.31")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -31,8 +29,6 @@ let package = Package( .target( name: "GoodNetworking", dependencies: [ - .product(name: "Alamofire", package: "Alamofire"), - .product(name: "AlamofireImage", package: "AlamofireImage"), .product(name: "Sextant", package: "Sextant") ], path: "./Sources/GoodNetworking", diff --git a/Sources/GoodNetworking/Extensions/AFErrorExtensions.swift b/Sources/GoodNetworking/Extensions/AFErrorExtensions.swift deleted file mode 100644 index e029dd8..0000000 --- a/Sources/GoodNetworking/Extensions/AFErrorExtensions.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// AFErrorExtensions.swift -// GoodNetworking -// -// Created by Filip Ε aΕ‘ala on 03/01/2024. -// - -import Alamofire -import Foundation - -extension AFError: @unchecked Sendable {} - -extension AFError: @retroactive Equatable { - - public static func == (lhs: AFError, rhs: AFError) -> Bool { - lhs.localizedDescription == rhs.localizedDescription - } - -} - -extension AFError: @retroactive Hashable { - - public func hash(into hasher: inout Hasher) { - hasher.combine(self.localizedDescription) - } - -} diff --git a/Sources/GoodNetworking/Extensions/ArrayEncoding.swift b/Sources/GoodNetworking/Extensions/ArrayEncoding.swift index 6245e09..8450842 100644 --- a/Sources/GoodNetworking/Extensions/ArrayEncoding.swift +++ b/Sources/GoodNetworking/Extensions/ArrayEncoding.swift @@ -5,37 +5,36 @@ // Created by Andrej Jasso on 18/10/2023. // -@preconcurrency import Alamofire import Foundation /// Extension that allows an array be sent as a request parameters -public extension Array where Element: Sendable { - - /// Convert the receiver array to a `Parameters` object. - func asParameters() -> Parameters { - return [ArrayEncoding.arrayParametersKey: self] - } - -} - -/// Convert the parameters into a json array, and it is added as the request body. -/// The array must be sent as parameters using its `asParameters` method. -public struct ArrayEncoding: ParameterEncoding { - - public static let arrayParametersKey = "arrayParametersKey" - - public let defaultEncoder: ParameterEncoding - - public init(defaultEncoder: ParameterEncoding) { - self.defaultEncoder = defaultEncoder - } - - public func encode(_ urlRequest: URLRequestConvertible, with parameters: Parameters?) throws -> URLRequest { - guard let array = parameters?[Self.arrayParametersKey] else { - return try defaultEncoder.encode(urlRequest, with: parameters) - } - - return try JSONEncoding.default.encode(urlRequest, withJSONObject: array) - } - -} +//public extension Array where Element: Sendable { +// +// /// Convert the receiver array to a `Parameters` object. +// func asParameters() -> Parameters { +// return [ArrayEncoding.arrayParametersKey: self] +// } +// +//} +// +///// Convert the parameters into a json array, and it is added as the request body. +///// The array must be sent as parameters using its `asParameters` method. +//public struct ArrayEncoding: ParameterEncoding { +// +// public static let arrayParametersKey = "arrayParametersKey" +// +// public let defaultEncoder: ParameterEncoding +// +// public init(defaultEncoder: ParameterEncoding) { +// self.defaultEncoder = defaultEncoder +// } +// +// public func encode(_ urlRequest: URLRequestConvertible, with parameters: Parameters?) throws -> URLRequest { +// guard let array = parameters?[Self.arrayParametersKey] else { +// return try defaultEncoder.encode(urlRequest, with: parameters) +// } +// +// return try JSONEncoding.default.encode(urlRequest, withJSONObject: array) +// } +// +//} diff --git a/Sources/GoodNetworking/Extensions/Goodify.swift b/Sources/GoodNetworking/Extensions/Goodify.swift index 2095fb7..f9df897 100644 --- a/Sources/GoodNetworking/Extensions/Goodify.swift +++ b/Sources/GoodNetworking/Extensions/Goodify.swift @@ -5,266 +5,266 @@ // Created by Dominik PethΓΆ on 4/30/19. // -@preconcurrency import Alamofire -import Combine -import Foundation +//import Combine +//import Foundation -@available(iOS 13, *) -public extension DataRequest { - - /// Processes the network request and decodes the response into the specified type. - /// - /// This method validates the response using the provided `ValidationProviding` instance and then decodes the response data - /// into the specified type `T`. The decoding process is customizable with parameters such as data preprocessor, - /// JSON decoder, and sets of HTTP methods and status codes to consider as "empty" responses. - /// - /// - Parameters: - /// - type: The expected type of the response data, defaulting to `T.self`. - /// - validator: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. - /// - preprocessor: The preprocessor for manipulating the response data before decoding. - /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. - /// - emptyRequestMethods: The HTTP methods that indicate an empty response. - /// - decoder: The JSON decoder used for decoding the response data. If the type conforms to `WithCustomDecoder`, the custom decoder is used. - /// - Returns: A `DataTask` that contains the decoded result. - func goodify( - type: T.Type = T.self, - validator: any ValidationProviding = DefaultValidationProvider(), - preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, - emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyRequestMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, - decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() - ) -> DataTask { - return self - .validate { - self.goodifyValidation( - request: $0, - response: $1, - data: $2, - emptyResponseCodes: emptyResponseCodes, - emptyRequestMethods: emptyRequestMethods, - validator: validator - ) - } - .serializingDecodable( - T.self, - automaticallyCancelling: true, - dataPreprocessor: preprocessor, - decoder: decoder, - emptyResponseCodes: emptyResponseCodes, - emptyRequestMethods: emptyRequestMethods - ) - } - - /// Processes the network request and decodes the response into the specified type using Combine. - /// - /// This is a legacy implementation using Combine for handling network responses. It validates the response, - /// then publishes the decoded result or an error. This version of the method is deprecated. - /// - /// - Parameters: - /// - type: The expected type of the response data, defaulting to `T.self`. - /// - queue: The queue on which the response is published. - /// - preprocessor: The preprocessor for manipulating the response data before decoding. - /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. - /// - emptyResponseMethods: The HTTP methods that indicate an empty response. - /// - decoder: The JSON decoder used for decoding the response data. - /// - Returns: A `Publisher` that publishes the decoded result or an `AFError`. - @available(*, deprecated, message: "Legacy Combine implementation") - func goodify( - type: T.Type = T.self, - queue: DispatchQueue = .main, - preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, - emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, - decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() - ) -> AnyPublisher where T: Sendable { - let serializer = DecodableResponseSerializer( - dataPreprocessor: preprocessor, - decoder: decoder, - emptyResponseCodes: emptyResponseCodes, - emptyRequestMethods: emptyResponseMethods - ) - return self.validate() - .publishResponse(using: serializer, on: queue) - .value() - } - - /// Processes the network request and decodes the response into an array of the specified type. - /// - /// This method validates the response using the provided `ValidationProviding` instance and then decodes the response data - /// into an array of the specified type `[T]`. The decoding process is customizable with parameters such as data preprocessor, - /// JSON decoder, and sets of HTTP methods and status codes to consider as "empty" responses. - /// - /// - Parameters: - /// - type: The expected type of the response data, defaulting to `[T].self`. - /// - validator: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. - /// - preprocessor: The preprocessor for manipulating the response data before decoding. - /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. - /// - emptyRequestMethods: The HTTP methods that indicate an empty response. - /// - decoder: The JSON decoder used for decoding the response data. If the type conforms to `WithCustomDecoder`, the custom decoder is used. - /// - Returns: A `DataTask` that contains the decoded array result. - func goodify( - type: [T].Type = [T].self, - validator: any ValidationProviding = DefaultValidationProvider(), - preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, - emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyRequestMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, - decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() - ) -> DataTask<[T]> { - return self - .validate { - self.goodifyValidation( - request: $0, - response: $1, - data: $2, - emptyResponseCodes: emptyResponseCodes, - emptyRequestMethods: emptyRequestMethods, - validator: validator - ) - } - .serializingDecodable( - [T].self, - automaticallyCancelling: true, - dataPreprocessor: preprocessor, - decoder: decoder, - emptyResponseCodes: emptyResponseCodes, - emptyRequestMethods: emptyRequestMethods - ) - } - - /// Processes the network request and decodes the response into an array of the specified type using Combine. - /// - /// This is a legacy implementation using Combine for handling network responses. It validates the response, - /// then publishes the decoded result or an error. This version of the method is deprecated. - /// - /// - Parameters: - /// - type: The expected type of the response data, defaulting to `[T].self`. - /// - queue: The queue on which the response is published. - /// - preprocessor: The preprocessor for manipulating the response data before decoding. - /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. - /// - emptyResponseMethods: The HTTP methods that indicate an empty response. - /// - decoder: The JSON decoder used for decoding the response data. - /// - Returns: A `Publisher` that publishes the decoded result or an `AFError`. - @available(*, deprecated, message: "Legacy Combine implementation") - func goodify( - type: [T].Type = [T].self, - queue: DispatchQueue = .main, - preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, - emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, - emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, - decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() - ) -> AnyPublisher<[T], AFError> where T: Sendable { - let serializer = DecodableResponseSerializer<[T]>( - dataPreprocessor: preprocessor, - decoder: decoder, - emptyResponseCodes: emptyResponseCodes, - emptyRequestMethods: emptyResponseMethods - ) - return self.validate() - .publishResponse(using: serializer, on: queue) - .value() - } - -} - -public extension DataRequest { - - /// Creates a `DataResponse` with the specified success value. - /// - /// - Parameter value: The value to set as the success result. - /// - Returns: A `DataResponse` object with the success value. - func response(withValue value: T) -> DataResponse { - return DataResponse( - request: request, - response: response, - data: data, - metrics: .none, - serializationDuration: 30, - result: AFResult.success(value) - ) - } - - /// Creates a `DataResponse` with the specified error. - /// - /// - Parameter error: The error to set as the failure result. - /// - Returns: A `DataResponse` object with the failure error. - func response(withError error: AFError) -> DataResponse { - return DataResponse( - request: request, - response: response, - data: data, - metrics: .none, - serializationDuration: 30, - result: AFResult.failure(error) - ) - } - -} - -// MARK: - Validation - -extension DataRequest { - - /// Validates the response using a custom validator. - /// - /// This method checks if the response data is valid according to the provided `ValidationProviding` instance. - /// If the validation fails, an error is returned. - /// - /// - Parameters: - /// - request: The original URL request. - /// - response: The HTTP response received. - /// - data: The response data. - /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. - /// - emptyRequestMethods: The HTTP methods that indicate an empty response. - /// - validator: The validation provider used to validate the response. - /// - Returns: A `ValidationResult` indicating whether the validation succeeded or failed. - private func goodifyValidation( - request: URLRequest?, - response: HTTPURLResponse, - data: Data?, - emptyResponseCodes: Set, - emptyRequestMethods: Set, - validator: any ValidationProviding - ) -> ValidationResult { - guard let data else { - let emptyResponseAllowed = requestAllowsEmptyResponseData(request, emptyRequestMethods: emptyRequestMethods) - || responseAllowsEmptyResponseData(response, emptyResponseCodes: emptyResponseCodes) - - return emptyResponseAllowed - ? .success(()) - : .failure(AFError.responseSerializationFailed(reason: .inputDataNilOrZeroLength)) - } - - do { - try validator.validate(statusCode: response.statusCode, data: data) - return .success(()) - } catch let error { - return .failure(error) - } - } - - /// Determines whether the `request` allows empty response bodies, if `request` exists. - /// - ///- Parameters: - /// - request: `URLRequest` to evaluate. - /// - emptyRequestMethods: The HTTP methods that indicate an empty response. - /// - /// - Returns: `Bool` representing the outcome of the evaluation, or `nil` if `request` was `nil`. - private func requestAllowsEmptyResponseData(_ request: URLRequest?, emptyRequestMethods: Set) -> Bool { - guard let httpMethodString = request?.httpMethod else { return false } - - let httpMethod = HTTPMethod(rawValue: httpMethodString) - return emptyRequestMethods.contains(httpMethod) - } - - /// Determines whether the `response` allows empty response bodies, if `response` exists. - /// - ///- Parameters: - /// - request: `HTTPURLResponse` to evaluate. - /// - emptyRequestMethods: The HTTP status codes that indicate an empty response. - /// - /// - Returns: `Bool` representing the outcome of the evaluation, or `nil` if `response` was `nil`. - private func responseAllowsEmptyResponseData(_ response: HTTPURLResponse, emptyResponseCodes: Set) -> Bool { - emptyResponseCodes.contains(response.statusCode) - } - -} +//@available(iOS 13, *) +//public extension DataRequest { +// +// /// Processes the network request and decodes the response into the specified type. +// /// +// /// This method validates the response using the provided `ValidationProviding` instance and then decodes the response data +// /// into the specified type `T`. The decoding process is customizable with parameters such as data preprocessor, +// /// JSON decoder, and sets of HTTP methods and status codes to consider as "empty" responses. +// /// +// /// - Parameters: +// /// - type: The expected type of the response data, defaulting to `T.self`. +// /// - validator: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. +// /// - preprocessor: The preprocessor for manipulating the response data before decoding. +// /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. +// /// - emptyRequestMethods: The HTTP methods that indicate an empty response. +// /// - decoder: The JSON decoder used for decoding the response data. If the type conforms to `WithCustomDecoder`, the custom decoder is used. +// /// - Returns: A `DataTask` that contains the decoded result. +// func goodify( +// type: T.Type = T.self, +// validator: any ValidationProviding = DefaultValidationProvider(), +// preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, +// emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, +// emptyRequestMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, +// decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() +// ) -> DataTask { +// return self +// .validate { +// self.goodifyValidation( +// request: $0, +// response: $1, +// data: $2, +// emptyResponseCodes: emptyResponseCodes, +// emptyRequestMethods: emptyRequestMethods, +// validator: validator +// ) +// } +// .serializingDecodable( +// T.self, +// automaticallyCancelling: true, +// dataPreprocessor: preprocessor, +// decoder: decoder, +// emptyResponseCodes: emptyResponseCodes, +// emptyRequestMethods: emptyRequestMethods +// ) +// } +// +// /// Processes the network request and decodes the response into the specified type using Combine. +// /// +// /// This is a legacy implementation using Combine for handling network responses. It validates the response, +// /// then publishes the decoded result or an error. This version of the method is deprecated. +// /// +// /// - Parameters: +// /// - type: The expected type of the response data, defaulting to `T.self`. +// /// - queue: The queue on which the response is published. +// /// - preprocessor: The preprocessor for manipulating the response data before decoding. +// /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. +// /// - emptyResponseMethods: The HTTP methods that indicate an empty response. +// /// - decoder: The JSON decoder used for decoding the response data. +// /// - Returns: A `Publisher` that publishes the decoded result or an `AFError`. +// @available(*, deprecated, message: "Legacy Combine implementation") +// func goodify( +// type: T.Type = T.self, +// queue: DispatchQueue = .main, +// preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, +// emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, +// emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, +// decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() +// ) -> AnyPublisher where T: Sendable { +// let serializer = DecodableResponseSerializer( +// dataPreprocessor: preprocessor, +// decoder: decoder, +// emptyResponseCodes: emptyResponseCodes, +// emptyRequestMethods: emptyResponseMethods +// ) +// return self.validate() +// .publishResponse(using: serializer, on: queue) +// .value() +// } +// +// /// Processes the network request and decodes the response into an array of the specified type. +// /// +// /// This method validates the response using the provided `ValidationProviding` instance and then decodes the response data +// /// into an array of the specified type `[T]`. The decoding process is customizable with parameters such as data preprocessor, +// /// JSON decoder, and sets of HTTP methods and status codes to consider as "empty" responses. +// /// +// /// - Parameters: +// /// - type: The expected type of the response data, defaulting to `[T].self`. +// /// - validator: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. +// /// - preprocessor: The preprocessor for manipulating the response data before decoding. +// /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. +// /// - emptyRequestMethods: The HTTP methods that indicate an empty response. +// /// - decoder: The JSON decoder used for decoding the response data. If the type conforms to `WithCustomDecoder`, the custom decoder is used. +// /// - Returns: A `DataTask` that contains the decoded array result. +// func goodify( +// type: [T].Type = [T].self, +// validator: any ValidationProviding = DefaultValidationProvider(), +// preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, +// emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, +// emptyRequestMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, +// decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() +// ) -> DataTask<[T]> { +// return self +// .validate { +// self.goodifyValidation( +// request: $0, +// response: $1, +// data: $2, +// emptyResponseCodes: emptyResponseCodes, +// emptyRequestMethods: emptyRequestMethods, +// validator: validator +// ) +// } +// .serializingDecodable( +// [T].self, +// automaticallyCancelling: true, +// dataPreprocessor: preprocessor, +// decoder: decoder, +// emptyResponseCodes: emptyResponseCodes, +// emptyRequestMethods: emptyRequestMethods +// ) +// } +// +// /// Processes the network request and decodes the response into an array of the specified type using Combine. +// /// +// /// This is a legacy implementation using Combine for handling network responses. It validates the response, +// /// then publishes the decoded result or an error. This version of the method is deprecated. +// /// +// /// - Parameters: +// /// - type: The expected type of the response data, defaulting to `[T].self`. +// /// - queue: The queue on which the response is published. +// /// - preprocessor: The preprocessor for manipulating the response data before decoding. +// /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. +// /// - emptyResponseMethods: The HTTP methods that indicate an empty response. +// /// - decoder: The JSON decoder used for decoding the response data. +// /// - Returns: A `Publisher` that publishes the decoded result or an `AFError`. +// @available(*, deprecated, message: "Legacy Combine implementation") +// func goodify( +// type: [T].Type = [T].self, +// queue: DispatchQueue = .main, +// preprocessor: DataPreprocessor = DecodableResponseSerializer.defaultDataPreprocessor, +// emptyResponseCodes: Set = DecodableResponseSerializer.defaultEmptyResponseCodes, +// emptyResponseMethods: Set = DecodableResponseSerializer.defaultEmptyRequestMethods, +// decoder: JSONDecoder = (T.self as? WithCustomDecoder.Type)?.decoder ?? JSONDecoder() +// ) -> AnyPublisher<[T], AFError> where T: Sendable { +// let serializer = DecodableResponseSerializer<[T]>( +// dataPreprocessor: preprocessor, +// decoder: decoder, +// emptyResponseCodes: emptyResponseCodes, +// emptyRequestMethods: emptyResponseMethods +// ) +// return self.validate() +// .publishResponse(using: serializer, on: queue) +// .value() +// } +// +//} +// +//public extension DataRequest { +// +// /// Creates a `DataResponse` with the specified success value. +// /// +// /// - Parameter value: The value to set as the success result. +// /// - Returns: A `DataResponse` object with the success value. +// func response(withValue value: T) -> DataResponse { +// return DataResponse( +// request: request, +// response: response, +// data: data, +// metrics: .none, +// serializationDuration: 30, +// result: AFResult.success(value) +// ) +// } +// +// /// Creates a `DataResponse` with the specified error. +// /// +// /// - Parameter error: The error to set as the failure result. +// /// - Returns: A `DataResponse` object with the failure error. +// func response(withError error: AFError) -> DataResponse { +// return DataResponse( +// request: request, +// response: response, +// data: data, +// metrics: .none, +// serializationDuration: 30, +// result: AFResult.failure(error) +// ) +// } +// +//} +// +//// MARK: - Validation +// +//extension DataRequest { +// +// /// Validates the response using a custom validator. +// /// +// /// This method checks if the response data is valid according to the provided `ValidationProviding` instance. +// /// If the validation fails, an error is returned. +// /// +// /// - Parameters: +// /// - request: The original URL request. +// /// - response: The HTTP response received. +// /// - data: The response data. +// /// - emptyResponseCodes: The HTTP status codes that indicate an empty response. +// /// - emptyRequestMethods: The HTTP methods that indicate an empty response. +// /// - validator: The validation provider used to validate the response. +// /// - Returns: A `ValidationResult` indicating whether the validation succeeded or failed. +// private func goodifyValidation( +// request: URLRequest?, +// response: HTTPURLResponse, +// data: Data?, +// emptyResponseCodes: Set, +// emptyRequestMethods: Set, +// validator: any ValidationProviding +// ) -> ValidationResult { +// guard let data else { +// let emptyResponseAllowed = requestAllowsEmptyResponseData(request, emptyRequestMethods: emptyRequestMethods) +// || responseAllowsEmptyResponseData(response, emptyResponseCodes: emptyResponseCodes) +// +// return emptyResponseAllowed +// ? .success(()) +// : .failure(AFError.responseSerializationFailed(reason: .inputDataNilOrZeroLength)) +// } +// +// do { +// try validator.validate(statusCode: response.statusCode, data: data) +// return .success(()) +// } catch let error { +// return .failure(error) +// } +// } +// +// /// Determines whether the `request` allows empty response bodies, if `request` exists. +// /// +// ///- Parameters: +// /// - request: `URLRequest` to evaluate. +// /// - emptyRequestMethods: The HTTP methods that indicate an empty response. +// /// +// /// - Returns: `Bool` representing the outcome of the evaluation, or `nil` if `request` was `nil`. +// private func requestAllowsEmptyResponseData(_ request: URLRequest?, emptyRequestMethods: Set) -> Bool { +// guard let httpMethodString = request?.httpMethod else { return false } +// +// let httpMethod = HTTPMethod(rawValue: httpMethodString) +// return emptyRequestMethods.contains(httpMethod) +// } +// +// /// Determines whether the `response` allows empty response bodies, if `response` exists. +// /// +// ///- Parameters: +// /// - request: `HTTPURLResponse` to evaluate. +// /// - emptyRequestMethods: The HTTP status codes that indicate an empty response. +// /// +// /// - Returns: `Bool` representing the outcome of the evaluation, or `nil` if `response` was `nil`. +// private func responseAllowsEmptyResponseData(_ response: HTTPURLResponse, emptyResponseCodes: Set) -> Bool { +// emptyResponseCodes.contains(response.statusCode) +// } +// +//} +// \ No newline at end of file diff --git a/Sources/GoodNetworking/Extensions/URLExtensions.swift b/Sources/GoodNetworking/Extensions/URLExtensions.swift deleted file mode 100644 index cba773a..0000000 --- a/Sources/GoodNetworking/Extensions/URLExtensions.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// URLExtensions.swift -// GoodNetworking -// -// Created by Filip Ε aΕ‘ala on 02/01/2024. -// - -@preconcurrency import Alamofire -import Foundation - -/// Extends `Optional` to conform to `Alamofire.URLConvertible`. -/// -/// This extension allows an optional `URL` to be used where `Alamofire.URLConvertible` is required. -/// If the optional `URL` contains a value, it is returned. If the optional is `nil`, a `URLError` with the -/// `badURL` code is thrown. -extension Optional: Alamofire.URLConvertible { - - /// Converts the optional `URL` to a non-optional `URL`. - /// - /// If the optional contains a `URL`, it is returned. If the optional is `nil`, a `URLError` with the - /// `badURL` code is thrown, indicating that the URL could not be converted. - /// - /// - Throws: A `URLError(.badURL)` if the optional URL is `nil`. - /// - Returns: The unwrapped `URL` if available. - public func asURL() throws -> URL { - guard let self else { throw URLError(.badURL) } - return self - } - -} diff --git a/Sources/GoodNetworking/GRImageDownloader.swift b/Sources/GoodNetworking/GRImageDownloader.swift deleted file mode 100644 index 1482d24..0000000 --- a/Sources/GoodNetworking/GRImageDownloader.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// GRImageDownloader.swift -// GoodNetworking -// -// Created by Andrej Jasso on 24/05/2022. -// - -import Foundation -import AlamofireImage -import Alamofire - -#if swift(<6.2) -/// The GRImageDownloader class provides a setup method for configuring and creating an instance of an ImageDownloader. -public actor GRImageDownloader { - - /// The GRImageDownloaderConfiguration holds a single variable maxActiveDownloads, which represents the maximum number of concurrent downloads. - public struct GRImageDownloaderConfiguration { - - let maxActiveDownloads: Int - - } - - // MARK: - Constants - - enum C { - - static let imageDownloaderDispatchQueueKey = "ImageDownloaderDispatchQueue" - static let imageDownloaderOperationQueueKey = "ImageDownloaderOperationQueue" - - } - - // MARK: - Variables - - static var shared: ImageDownloader? - - // MARK: - Public - - /// Sets up an authorized image downloader with the given session configuration and downloader configuration. - /// - /// - Parameters: - /// - sessionConfiguration: The GRSessionConfiguration used to create the URLSession and Session. (default: .default) - /// - downloaderConfiguration: The GRImageDownloaderConfiguration used to set the max concurrent operation count and max active downloads. - static func setupAuthorizedImageDownloader( - sessionConfiguration: NetworkSessionConfiguration = .default(), - downloaderConfiguration: GRImageDownloaderConfiguration - ) { - let imageDownloaderQueue = DispatchQueue(label: C.imageDownloaderDispatchQueueKey) - let operationQueue = OperationQueue() - - operationQueue.name = C.imageDownloaderOperationQueueKey - operationQueue.underlyingQueue = imageDownloaderQueue - operationQueue.maxConcurrentOperationCount = downloaderConfiguration.maxActiveDownloads - operationQueue.qualityOfService = .default - - - let sessionDelegate = SessionDelegate() - - let urlSession = URLSession( - configuration: sessionConfiguration.urlSessionConfiguration, - delegate: sessionDelegate, - delegateQueue: operationQueue - ) - - let session = Session( - session: urlSession, - delegate: sessionDelegate, - rootQueue: imageDownloaderQueue, - interceptor: sessionConfiguration.interceptor, - serverTrustManager: sessionConfiguration.serverTrustManager, - eventMonitors: sessionConfiguration.eventMonitors - ) - - shared = ImageDownloader( - session: session, - downloadPrioritization: .fifo, - maximumActiveDownloads: downloaderConfiguration.maxActiveDownloads, - imageCache: AutoPurgingImageCache() - ) - } - -} -#endif // swift(<6.2) diff --git a/Sources/GoodNetworking/Logger/NetworkLogger.swift b/Sources/GoodNetworking/Logger/NetworkLogger.swift index 432e162..c30103a 100644 --- a/Sources/GoodNetworking/Logger/NetworkLogger.swift +++ b/Sources/GoodNetworking/Logger/NetworkLogger.swift @@ -8,19 +8,22 @@ import Foundation public enum LogLevel: String, CaseIterable { + case debug case info case warning case error + } public protocol NetworkLogger: Sendable { + /// Logs the given message with a specific log level, file name, and line number. - func logNetworkEvent( + nonisolated func logNetworkEvent( message: Any, level: LogLevel, - fileName: String, - lineNumber: Int + file: String, + line: Int ) + } - diff --git a/Sources/GoodNetworking/Logger/PrintNetworkLogger.swift b/Sources/GoodNetworking/Logger/PrintNetworkLogger.swift new file mode 100644 index 0000000..2dce02c --- /dev/null +++ b/Sources/GoodNetworking/Logger/PrintNetworkLogger.swift @@ -0,0 +1,21 @@ +// +// PrintNetworkLogger.swift +// GoodNetworking +// +// Created by Filip Ε aΕ‘ala on 02/07/2025. +// + +public struct PrintNetworkLogger: NetworkLogger { + + public init() {} + + nonisolated public func logNetworkEvent( + message: Any, + level: LogLevel, + file: String, + line: Int + ) { + print(message) + } + +} diff --git a/Sources/GoodNetworking/NetworkActor.swift b/Sources/GoodNetworking/NetworkActor.swift new file mode 100644 index 0000000..8201f6a --- /dev/null +++ b/Sources/GoodNetworking/NetworkActor.swift @@ -0,0 +1,46 @@ +// +// NetworkActor.swift +// GoodNetworking +// +// Created by Filip Ε aΕ‘ala on 02/07/2025. +// + +import Foundation + +@globalActor public actor NetworkActor { + + public static let shared: NetworkActor = NetworkActor() + public static let queue: DispatchQueue = DispatchQueue(label: "goodnetworking.queue") + + private let executor: any SerialExecutor + + public nonisolated var unownedExecutor: UnownedSerialExecutor { + UnownedSerialExecutor(ordinary: executor) + } + + public init() { + self.executor = NetworkActorSerialExecutor(queue: NetworkActor.queue) + } + +} + +final class NetworkActorSerialExecutor: SerialExecutor { + + private let queue: DispatchQueue + + init(queue: DispatchQueue) { + self.queue = queue + } + + func enqueue(_ job: UnownedJob) { + let executor = self.asUnownedSerialExecutor() + queue.async { + job.runSynchronously(on: executor) + } + } + + func asUnownedSerialExecutor() -> UnownedSerialExecutor { + UnownedSerialExecutor(ordinary: self) + } + +} diff --git a/Sources/GoodNetworking/Protocols/BaseUrlProviding.swift b/Sources/GoodNetworking/Protocols/BaseUrlProviding.swift deleted file mode 100644 index 4fbc050..0000000 --- a/Sources/GoodNetworking/Protocols/BaseUrlProviding.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// BaseUrlProviding.swift -// GoodNetworking -// -// Created by Andrej Jasso on 20/09/2024. -// - -import Foundation - -/// A protocol for providing the base URL for network requests. -/// -/// `BaseUrlProviding` defines a method that asynchronously resolves the base URL used for network requests. -/// Classes or structures that conform to this protocol can implement their own logic for determining and returning the base URL. -public protocol BaseUrlProviding: Sendable { - - /// Resolves and returns the base URL for network requests asynchronously. - /// - /// This method is used to fetch or compute the base URL, potentially involving asynchronous operations. - /// If the base URL cannot be resolved, the method returns `nil`. - /// - /// - Returns: The resolved base URL as a `String`, or `nil` if the URL could not be determined. - func resolveBaseUrl() async -> String? - -} - -extension String: BaseUrlProviding { - - /// Returns the string itself as the base URL. - /// - /// This extension allows any `String` instance to conform to `BaseUrlProviding`, returning itself as the base URL. - /// - /// - Returns: The string instance as the base URL. - public func resolveBaseUrl() async -> String? { self } - -} diff --git a/Sources/GoodNetworking/Protocols/Endpoint.swift b/Sources/GoodNetworking/Protocols/Endpoint.swift index a25e4da..d1dc6a1 100644 --- a/Sources/GoodNetworking/Protocols/Endpoint.swift +++ b/Sources/GoodNetworking/Protocols/Endpoint.swift @@ -5,7 +5,6 @@ // Created by Filip Ε aΕ‘ala on 10/12/2023. // -import Alamofire import Foundation // MARK: - Endpoint @@ -18,13 +17,13 @@ public protocol Endpoint { /// HTTP method to be used for the request. var method: HTTPMethod { get } - + /// Parameters to be sent with the request. var parameters: EndpointParameters? { get } /// HTTP headers to be added to the request. var headers: HTTPHeaders? { get } - + /// Encoding to be used for encoding the parameters. var encoding: ParameterEncoding { get } @@ -55,8 +54,10 @@ public extension Endpoint { // MARK: - Parameters /// Enum that represents the type of parameters to be sent with the request. +@available(*, deprecated) public enum EndpointParameters { + public typealias Parameters = [String: Any] public typealias CustomEncodable = (Encodable & Sendable & WithCustomEncoder) /// Case for sending `Parameters`. @@ -88,3 +89,30 @@ public enum EndpointParameters { } } + +// MARK: - Compatibility + +@available(*, deprecated) +public protocol ParameterEncoding {} + +@available(*, deprecated) +public enum URLEncoding: ParameterEncoding { + case `default` +} + +@available(*, deprecated) +public enum JSONEncoding: ParameterEncoding { + case `default` +} + +@available(*, deprecated) +extension String { + + func asURL() throws -> URL { + guard let url = URL(string: self) else { + throw NetworkError.invalidBaseURL + } + return url + } + +} diff --git a/Sources/GoodNetworking/Protocols/HTTPMethod.swift b/Sources/GoodNetworking/Protocols/HTTPMethod.swift new file mode 100644 index 0000000..cc4f5d1 --- /dev/null +++ b/Sources/GoodNetworking/Protocols/HTTPMethod.swift @@ -0,0 +1,60 @@ +// +// HTTPMethod.swift +// GoodNetworking +// +// Created by Filip Ε aΕ‘ala on 02/07/2025. +// + +@frozen public enum HTTPMethod: String { + + case get = "GET" + case head = "HEAD" + case post = "POST" + case put = "PUT" + case delete = "DELETE" + case connect = "CONNECT" + case options = "OPTIONS" + case trace = "TRACE" + case patch = "PATCH" + + var isSafe: Bool { + switch self { + case .get, .head, .options, .trace: + return true + + case .put, .delete, .post, .patch, .connect: + return false + } + } + + var isIdempotent: Bool { + switch self { + case .get, .head, .options, .trace, .put, .delete: + return true + + case .post, .patch, .connect: + return false + } + } + + var isCacheable: Bool { + switch self { + case .get, .head: + return true + + case .options, .trace, .put, .delete, .post, .patch, .connect: + return false + } + } + + var hasRequestBody: Bool { + switch self { + case .post, .put, .patch: + return true + + case .get, .head, .options, .trace, .delete, .connect: + return false + } + } + +} diff --git a/Sources/GoodNetworking/Protocols/Header.swift b/Sources/GoodNetworking/Protocols/Header.swift new file mode 100644 index 0000000..9a8358f --- /dev/null +++ b/Sources/GoodNetworking/Protocols/Header.swift @@ -0,0 +1,134 @@ +// +// Header.swift +// GoodNetworking +// +// Created by Filip Ε aΕ‘ala on 02/07/2025. +// + +// MARK: - HeaderConvertible + +public protocol HeaderConvertible: Sendable { + + func resolveHeader() -> HTTPHeader + +} + +// MARK: - HTTPHeader + +public struct HTTPHeader: Equatable, Hashable, HeaderConvertible { + + public let name: String + public let value: String + + public init(_ string: String) { + assert(!string.isEmpty) + + let split = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true) + + assert(split.count == 2, "Cannot parse header, missing colon: \(string)") + assert(split[0].isEmpty == false, "Invalid header name") + assert(split[1].isEmpty == false, "Invalid header value") + + self.name = String(split[0]) + self.value = String(split[1]) + } + + public init(name: String, value: String) { + self.name = name + self.value = value + } + + public func resolveHeader() -> HTTPHeader { + self + } + +} + +extension HTTPHeader: ExpressibleByStringLiteral, ExpressibleByStringInterpolation { + + public init(stringLiteral value: String) { + self.init(value) + } + +} + +extension HTTPHeader: CustomStringConvertible { + + public var description: String { + "\(name): \(value)" + } + +} + +// MARK: - HTTPHeaders + +public struct HTTPHeaders: Equatable, Hashable, Sendable { + + public let headers: [HTTPHeader] + + public init(_ headers: [String: String]) { + self.headers = headers.map(HTTPHeader.init).reduce(into: [], { $0.append($1) }) + } + + public subscript(_ name: String) -> String? { + value(for: name) + } + + public func value(for name: String) -> String? { + guard let index = headers.firstIndex(where: { $0.name == name }) else { return nil } + return headers[index].value + } + +} + +extension HTTPHeaders: ExpressibleByDictionaryLiteral { + + public init(dictionaryLiteral elements: (String, String)...) { + self.headers = elements.map(HTTPHeader.init) + } + +} + +extension HTTPHeaders: ExpressibleByArrayLiteral { + + public init(arrayLiteral elements: HeaderConvertible...) { + self.headers = elements.map { $0.resolveHeader() } + } + +} + +extension HTTPHeaders: Sequence { + + public func makeIterator() -> IndexingIterator<[HTTPHeader]> { + headers.makeIterator() + } + +} + +extension HTTPHeaders: Collection { + + public var startIndex: Int { + headers.startIndex + } + + public var endIndex: Int { + headers.endIndex + } + + public subscript(position: Int) -> HTTPHeader { + headers[position] + } + + public func index(after i: Int) -> Int { + headers.index(after: i) + } + +} + +extension HTTPHeaders: CustomStringConvertible { + + public var description: String { + headers.map(\.description).joined(separator: "\n") + } + +} diff --git a/Sources/GoodNetworking/Protocols/NetworkSessionProviding.swift b/Sources/GoodNetworking/Protocols/NetworkSessionProviding.swift index 7aa4ec8..41dfb5c 100644 --- a/Sources/GoodNetworking/Protocols/NetworkSessionProviding.swift +++ b/Sources/GoodNetworking/Protocols/NetworkSessionProviding.swift @@ -5,8 +5,6 @@ // Created by Andrej Jasso on 30/09/2024. // -import Alamofire - /// A protocol for managing network sessions used in network requests. /// /// `NetworkSessionProviding` defines the methods and properties required to handle session creation, validation, and invalidation @@ -30,7 +28,7 @@ public protocol NetworkSessionProviding: Sendable { /// This method is responsible for creating a fresh instance of `Alamofire.Session` to be used for future network requests. /// /// - Returns: A new instance of `Alamofire.Session`. - func makeSession() async -> Alamofire.Session + func makeSession() async -> NetworkSession /// Resolves and returns the current valid network session. /// @@ -38,6 +36,6 @@ public protocol NetworkSessionProviding: Sendable { /// of a new session by calling `makeSession()`. /// /// - Returns: The current or newly created `Alamofire.Session` instance. - func resolveSession() async -> Alamofire.Session - + func resolveSession() async -> NetworkSession + } diff --git a/Sources/GoodNetworking/Protocols/ResourceOperations.swift b/Sources/GoodNetworking/Protocols/ResourceOperations.swift index 10a5878..f81a6b5 100644 --- a/Sources/GoodNetworking/Protocols/ResourceOperations.swift +++ b/Sources/GoodNetworking/Protocols/ResourceOperations.swift @@ -16,574 +16,574 @@ import Sextant /// Types conforming to `Creatable` define the necessary types and functions for creating a resource on a server. /// This includes specifying the types for creation requests and responses, and providing methods for making the /// network request and transforming the responses. -public protocol Creatable: RemoteResource { - - /// The type of request used to create the resource. - associatedtype CreateRequest: Sendable - - /// The type of response returned after the resource is created. - associatedtype CreateResponse: Decodable & Sendable - - /// Creates a new resource on the remote server using the provided session and request data. - /// - /// This method performs an asynchronous network request to create a resource, using the specified session and request. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The creation request data. - /// - Returns: The response object containing the created resource data. - /// - Throws: A `NetworkError` if the request fails. - static func create( - using session: NetworkSession, - request: CreateRequest - ) async throws(NetworkError) -> CreateResponse - - /// Constructs an `Endpoint` for the creation request. - /// - /// This method is used to convert the creation request data into an `Endpoint` that represents the request details. - /// - /// - Parameter request: The creation request data. - /// - Returns: An `Endpoint` that represents the request. - /// - Throws: A `NetworkError` if the endpoint cannot be created. - nonisolated static func endpoint(_ request: CreateRequest) throws(NetworkError) -> Endpoint - - /// Transforms an optional `Resource` into a `CreateRequest`. - /// - /// This method can be used to generate a `CreateRequest` from a given `Resource`, if applicable. - /// - /// - Parameter resource: The optional resource to be transformed into a request. - /// - Returns: A `CreateRequest` derived from the resource, or `nil` if not applicable. - /// - Throws: A `NetworkError` if the transformation fails. - nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? - - /// Transforms the creation response into a `Resource`. - /// - /// This method is used to convert the response data from the creation request into a usable `Resource`. - /// - /// - Parameter response: The response received from the creation request. - /// - Returns: A `Resource` derived from the response. - /// - Throws: A `NetworkError` if the transformation fails. - nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource - -} - -public extension Creatable { - - /// Creates a new resource on the remote server using the provided session and request data. - /// - /// This default implementation performs the network request to create the resource by first obtaining the - /// `Endpoint` from the `CreateRequest`, then sending the request using the provided `NetworkSession`. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The creation request data. - /// - Returns: The response object containing the created resource data. - /// - Throws: A `NetworkError` if the request fails. - static func create( - using session: NetworkSession, - request: CreateRequest - ) async throws(NetworkError) -> CreateResponse { - let endpoint: Endpoint = try Self.endpoint(request) - let response: CreateResponse = try await session.request(endpoint: endpoint) - return response - } - - /// Provides a default implementation that throws an error indicating the request cannot be derived from the resource. - /// - /// This implementation can be overridden by conforming types to provide specific behavior. - /// - /// - Parameter resource: The optional resource to be transformed into a request. - /// - Returns: `nil` by default. - /// - Throws: A `NetworkError.missingLocalData` if the transformation is not supported. - nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? { - throw .missingLocalData - } - -} - -public extension Creatable where CreateResponse == Resource { - - /// Provides a default implementation that directly returns the response as the `Resource`. - /// - /// This implementation can be used when the `CreateResponse` type is the same as the `Resource` type, - /// allowing the response to be returned directly. - /// - /// - Parameter response: The response received from the creation request. - /// - Returns: The response as a `Resource`. - /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). - nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { - response - } - -} - -// MARK: - Readable - -/// Represents a resource that can be read from a remote server. -/// -/// Types conforming to `Readable` define the necessary types and functions for reading a resource from a server. -/// This includes specifying the types for read requests and responses, and providing methods for making the -/// network request and transforming the responses. -public protocol Readable: RemoteResource { - - /// The type of request used to read the resource. - associatedtype ReadRequest: Sendable - - /// The type of response returned after reading the resource. - associatedtype ReadResponse: Decodable & Sendable - - /// Reads the resource from the remote server using the provided session and request data. - /// - /// This method performs an asynchronous network request to read a resource, using the specified session and request. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The read request data. - /// - Returns: The response object containing the resource data. - /// - Throws: A `NetworkError` if the request fails. - static func read( - using session: NetworkSession, - request: ReadRequest - ) async throws(NetworkError) -> ReadResponse - - /// Constructs an `Endpoint` for the read request. - /// - /// This method is used to convert the read request data into an `Endpoint` that represents the request details. - /// - /// - Parameter request: The read request data. - /// - Returns: An `Endpoint` that represents the request. - /// - Throws: A `NetworkError` if the endpoint cannot be created. - nonisolated static func endpoint(_ request: ReadRequest) throws(NetworkError) -> Endpoint - - /// Transforms an optional `Resource` into a `ReadRequest`. - /// - /// This method can be used to generate a `ReadRequest` from a given `Resource`, if applicable. - /// - /// - Parameter resource: The optional resource to be transformed into a request. - /// - Returns: A `ReadRequest` derived from the resource, or `nil` if not applicable. - /// - Throws: A `NetworkError` if the transformation fails. - nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? - - /// Transforms the read response into a `Resource`. - /// - /// This method is used to convert the response data from the read request into a usable `Resource`. - /// - /// - Parameter response: The response received from the read request. - /// - Returns: A `Resource` derived from the response. - /// - Throws: A `NetworkError` if the transformation fails. - nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource - -} - -public extension Readable { - - /// Reads the resource from the remote server using the provided session and request data. - /// - /// This default implementation performs the network request to read the resource by first obtaining the - /// `Endpoint` from the `ReadRequest`, then sending the request using the provided `NetworkSession`. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The read request data. - /// - Returns: The response object containing the resource data. - /// - Throws: A `NetworkError` if the request fails. - static func read( - using session: NetworkSession, - request: ReadRequest - ) async throws(NetworkError) -> ReadResponse { - let endpoint: Endpoint = try Self.endpoint(request) - let response: ReadResponse = try await session.request(endpoint: endpoint) - return response - } - -} - -public extension Readable where ReadRequest == Void { - - /// Provides a default implementation that returns an empty `Void` request. - /// - /// This implementation can be used when the `ReadRequest` type is `Void`, indicating that no request data is needed. - /// - /// - Parameter resource: The optional resource to be transformed into a request. - /// - Returns: An empty `Void` request. - nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? { - () - } - -} - -public extension Readable where ReadResponse == Resource { - - /// Provides a default implementation that directly returns the response as the `Resource`. - /// - /// This implementation can be used when the `ReadResponse` type is the same as the `Resource` type, - /// allowing the response to be returned directly. - /// - /// - Parameter response: The response received from the read request. - /// - Returns: The response as a `Resource`. - /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). - nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { - response - } - -} - -// MARK: - Query - -/// Represents a resource that can be read as a query response from a remote server. -/// -/// `Query` extends the `Readable` protocol to add support for resources where the `ReadResponse` is of type `Data`. -/// It provides additional methods for querying and parsing the raw response data. -public protocol Query: Readable where ReadResponse == Data { - - /// Provides the query string for the request. - /// - /// This method is used to specify the query parameters for the request. - /// - /// - Returns: A string representing the query. - nonisolated static func query() -> String - -} - -public extension Query where Resource: Decodable { - - /// Provides a default implementation for parsing the raw response data into a `Resource` using the query. - /// - /// This method uses the specified query to extract and decode the data from the response. - /// - /// - Parameter response: The raw response data received from the server. - /// - Returns: The decoded `Resource` object. - /// - Throws: A `NetworkError` if the parsing or decoding fails. - nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { - Sextant.shared.query(response, values: Hitch(string: query())) ?? .placeholder - } - -} - -public extension Query { - - /// Reads the raw data from the remote server using the provided session and request data. - /// - /// This implementation performs a network request to read the resource as raw data. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The read request data. - /// - Returns: The raw response data. - /// - Throws: A `NetworkError` if the request fails. - static func read( - using session: NetworkSession, - request: ReadRequest - ) async throws(NetworkError) -> ReadResponse { - let endpoint: Endpoint = try Self.endpoint(request) - let response: ReadResponse = try await session.requestRaw(endpoint: endpoint) - return response - } - -} - -// MARK: - Updatable - -/// Represents a resource that can be updated on a remote server. -/// -/// Types conforming to `Updatable` define the necessary types and functions for updating a resource on a server. -/// This includes specifying the types for update requests and responses, and providing methods for making the -/// network request and transforming the responses. -public protocol Updatable: Readable { - - /// The type of request used to update the resource. - associatedtype UpdateRequest: Sendable - - /// The type of response returned after updating the resource. - associatedtype UpdateResponse: Decodable & Sendable - - /// Updates an existing resource on the remote server using the provided session and request data. - /// - /// This method performs an asynchronous network request to update a resource, using the specified session and request. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The update request data. - /// - Returns: The response object containing the updated resource data. - /// - Throws: A `NetworkError` if the request fails. - static func update( - using session: NetworkSession, - request: UpdateRequest - ) async throws(NetworkError) -> UpdateResponse - - /// Constructs an `Endpoint` for the update request. - /// - /// This method is used to convert the update request data into an `Endpoint` that represents the request details. - /// - /// - Parameter request: The update request data. - /// - Returns: An `Endpoint` that represents the request. - /// - Throws: A `NetworkError` if the endpoint cannot be created. - nonisolated static func endpoint(_ request: UpdateRequest) throws(NetworkError) -> Endpoint - - /// Transforms an optional `Resource` into an `UpdateRequest`. - /// - /// This method can be used to generate an `UpdateRequest` from a given `Resource`, if applicable. - /// - /// - Parameter resource: The optional resource to be transformed into a request. - /// - Returns: An `UpdateRequest` derived from the resource, or `nil` if not applicable. - /// - Throws: A `NetworkError` if the transformation fails. - nonisolated static func request(from resource: Resource?) throws(NetworkError) -> UpdateRequest? - - nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource - -} - -public extension Updatable { - - /// Updates an existing resource on the remote server using the provided session and request data. - /// - /// This default implementation performs the network request to update the resource by first obtaining the - /// `Endpoint` from the `UpdateRequest`, then sending the request using the provided `NetworkSession`. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The update request data. - /// - Returns: The response object containing the updated resource data. - /// - Throws: A `NetworkError` if the request fails. - static func update( - using session: NetworkSession, - request: UpdateRequest - ) async throws(NetworkError) -> UpdateResponse { - let endpoint: Endpoint = try Self.endpoint(request) - let response: UpdateResponse = try await session.request(endpoint: endpoint) - return response - } - -} - -public extension Updatable where UpdateResponse == Resource { - - /// Provides a default implementation that directly returns the response as the `Resource`. - /// - /// This implementation can be used when the `UpdateResponse` type is the same as the `Resource` type, - /// allowing the response to be returned directly. - /// - /// - Parameter response: The response received from the update request. - /// - Returns: The response as a `Resource`. - /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). - nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { - response - } - -} - -// MARK: - Deletable - -/// Represents a resource that can be deleted from a remote server. -/// -/// Types conforming to `Deletable` define the necessary types and functions for deleting a resource on a server. -/// This includes specifying the types for delete requests and responses, and providing methods for making the -/// network request and transforming the responses. -public protocol Deletable: Readable { - - /// The type of request used to delete the resource. - associatedtype DeleteRequest: Sendable - - /// The type of response returned after deleting the resource. - associatedtype DeleteResponse: Decodable & Sendable - - /// Deletes the resource on the remote server using the provided session and request data. - /// - /// This method performs an asynchronous network request to delete a resource, using the specified session and request. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The delete request data. - /// - Returns: The response object indicating the result of the deletion. - /// - Throws: A `NetworkError` if the request fails. - @discardableResult - static func delete( - using session: NetworkSession, - request: DeleteRequest - ) async throws(NetworkError) -> DeleteResponse - - /// Constructs an `Endpoint` for the delete request. - /// - /// This method is used to convert the delete request data into an `Endpoint` that represents the request details. - /// - /// - Parameter request: The delete request data. - /// - Returns: An `Endpoint` that represents the request. - /// - Throws: A `NetworkError` if the endpoint cannot be created. - nonisolated static func endpoint(_ request: DeleteRequest) throws(NetworkError) -> Endpoint - - /// Transforms an optional `Resource` into a `DeleteRequest`. - /// - /// This method can be used to generate a `DeleteRequest` from a given `Resource`, if applicable. - /// - /// - Parameter resource: The optional resource to be transformed into a request. - /// - Returns: A `DeleteRequest` derived from the resource, or `nil` if not applicable. - /// - Throws: A `NetworkError` if the transformation fails. - nonisolated static func request(from resource: Resource?) throws(NetworkError) -> DeleteRequest? - - /// Transforms the delete response into a `Resource`. - /// - /// This method is used to convert the response data from the delete request into a usable `Resource`. - /// - /// - Parameter response: The response received from the delete request. - /// - Returns: A `Resource` derived from the response. - /// - Throws: A `NetworkError` if the transformation fails. - nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? - -} - -public extension Deletable { - - /// Deletes the resource on the remote server using the provided session and request data. - /// - /// This default implementation performs the network request to delete the resource by first obtaining the - /// `Endpoint` from the `DeleteRequest`, then sending the request using the provided `NetworkSession`. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The delete request data. - /// - Returns: The response object indicating the result of the deletion. - /// - Throws: A `NetworkError` if the request fails. - @discardableResult - static func delete( - using session: NetworkSession, - request: DeleteRequest - ) async throws(NetworkError) -> DeleteResponse { - let endpoint: Endpoint = try Self.endpoint(request) - let response: DeleteResponse = try await session.request(endpoint: endpoint) - return response - } - -} - -public extension Deletable where DeleteResponse == Resource { - - /// Provides a default implementation that directly returns the response as the `Resource`. - /// - /// This implementation can be used when the `DeleteResponse` type is the same as the `Resource` type, - /// allowing the response to be returned directly. - /// - /// - Parameter response: The response received from the delete request. - /// - Returns: The response as a `Resource`. - /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). - nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? { - response - } - -} -// MARK: - Listable - -/// Represents a resource that can be listed (retrieved in bulk) from a remote server. -/// -/// Types conforming to `Listable` define the necessary types and functions for retrieving a list of resources from a server. -/// This includes specifying the types for list requests and responses, and providing methods for making the network request, -/// managing pagination, and transforming responses. -public protocol Listable: RemoteResource { - - /// The type of request used to list the resources. - associatedtype ListRequest: Sendable - - /// The type of response returned after listing the resources. - associatedtype ListResponse: Decodable & Sendable - - /// Lists the resources from the remote server using the provided session and request data. - /// - /// This method performs an asynchronous network request to retrieve a list of resources, using the specified session and request. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The list request data. - /// - Returns: The response object containing the list of resources. - /// - Throws: A `NetworkError` if the request fails. - static func list( - using session: NetworkSession, - request: ListRequest - ) async throws(NetworkError) -> ListResponse - - /// Constructs an `Endpoint` for the list request. - /// - /// This method is used to convert the list request data into an `Endpoint` that represents the request details. - /// - /// - Parameter request: The list request data. - /// - Returns: An `Endpoint` that represents the request. - /// - Throws: A `NetworkError` if the endpoint cannot be created. - nonisolated static func endpoint(_ request: ListRequest) throws(NetworkError) -> Endpoint - - /// Provides the first page request for listing resources. - /// - /// This method is used to define the initial request for retrieving the first page of resources. - /// - /// - Returns: The `ListRequest` representing the first page request. - nonisolated static func firstPageRequest(withParameters: Any?) -> ListRequest - - nonisolated static func nextPageRequest( - currentResource: [Resource], - parameters: Any?, - lastResponse: ListResponse - ) -> ListRequest? - - /// Combines the new response with the existing list of resources. - /// - /// This method is used to merge the response from the list request with an existing list of resources. - /// - /// - Parameters: - /// - response: The new response data. - /// - oldValue: The existing list of resources. - /// - Returns: A new array of `Resource` combining the old and new data. - nonisolated static func list(from response: ListResponse, oldValue: [Resource]) -> [Resource] - -} - -public extension Listable { - - /// Provides a default implementation for fetching the next page request. - /// - /// By default, this method returns `nil`, indicating that pagination is not supported. - /// - /// - Parameters: - /// - currentResource: The current list of resources. - /// - lastResponse: The last response received. - /// - Returns: `nil` by default, indicating no next page request. - nonisolated static func nextPageRequest( - currentResource: [Resource], - parameters: Any?, - lastResponse: ListResponse - ) -> ListRequest? { - return nil - } - - /// Lists the resources from the remote server using the provided session and request data. - /// - /// This default implementation performs the network request to list the resources by first obtaining the - /// `Endpoint` from the `ListRequest`, then sending the request using the provided `NetworkSession`. - /// - /// - Parameters: - /// - session: The network session used to perform the request. - /// - request: The list request data. - /// - Returns: The response object containing the list of resources. - /// - Throws: A `NetworkError` if the request fails. - static func list( - using session: NetworkSession, - request: ListRequest - ) async throws(NetworkError) -> ListResponse { - let endpoint: Endpoint = try Self.endpoint(request) - let response: ListResponse = try await session.request(endpoint: endpoint) - return response - } - -} - -// MARK: - All CRUD Operations - -/// Represents a resource that supports Create, Read, Update, and Delete operations. -/// -/// Types conforming to `CRUDable` define the necessary functions to perform all basic CRUD operations (Create, Read, Update, Delete). -/// This protocol combines the individual capabilities of `Creatable`, `Readable`, `Updatable`, and `Deletable`. -public protocol CRUDable: Creatable, Readable, Updatable, Deletable {} - -// MARK: - CRUD + Listable - -/// Represents a resource that supports full CRUD operations as well as bulk listing. -/// -/// Types conforming to `CRUDLable` support all basic CRUD operations (Create, Read, Update, Delete) and also provide -/// capabilities for retrieving lists of resources using the `Listable` protocol. -public protocol CRUDLable: CRUDable, Listable {} +//public protocol Creatable: RemoteResource { +// +// /// The type of request used to create the resource. +// associatedtype CreateRequest: Sendable +// +// /// The type of response returned after the resource is created. +// associatedtype CreateResponse: Decodable & Sendable +// +// /// Creates a new resource on the remote server using the provided session and request data. +// /// +// /// This method performs an asynchronous network request to create a resource, using the specified session and request. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The creation request data. +// /// - Returns: The response object containing the created resource data. +// /// - Throws: A `NetworkError` if the request fails. +// static func create( +// using session: NetworkSession, +// request: CreateRequest +// ) async throws(NetworkError) -> CreateResponse +// +// /// Constructs an `Endpoint` for the creation request. +// /// +// /// This method is used to convert the creation request data into an `Endpoint` that represents the request details. +// /// +// /// - Parameter request: The creation request data. +// /// - Returns: An `Endpoint` that represents the request. +// /// - Throws: A `NetworkError` if the endpoint cannot be created. +// nonisolated static func endpoint(_ request: CreateRequest) throws(NetworkError) -> Endpoint +// +// /// Transforms an optional `Resource` into a `CreateRequest`. +// /// +// /// This method can be used to generate a `CreateRequest` from a given `Resource`, if applicable. +// /// +// /// - Parameter resource: The optional resource to be transformed into a request. +// /// - Returns: A `CreateRequest` derived from the resource, or `nil` if not applicable. +// /// - Throws: A `NetworkError` if the transformation fails. +// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? +// +// /// Transforms the creation response into a `Resource`. +// /// +// /// This method is used to convert the response data from the creation request into a usable `Resource`. +// /// +// /// - Parameter response: The response received from the creation request. +// /// - Returns: A `Resource` derived from the response. +// /// - Throws: A `NetworkError` if the transformation fails. +// nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource +// +//} +// +//public extension Creatable { +// +// /// Creates a new resource on the remote server using the provided session and request data. +// /// +// /// This default implementation performs the network request to create the resource by first obtaining the +// /// `Endpoint` from the `CreateRequest`, then sending the request using the provided `NetworkSession`. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The creation request data. +// /// - Returns: The response object containing the created resource data. +// /// - Throws: A `NetworkError` if the request fails. +// static func create( +// using session: NetworkSession, +// request: CreateRequest +// ) async throws(NetworkError) -> CreateResponse { +// let endpoint: Endpoint = try Self.endpoint(request) +// let response: CreateResponse = try await session.request(endpoint: endpoint) +// return response +// } +// +// /// Provides a default implementation that throws an error indicating the request cannot be derived from the resource. +// /// +// /// This implementation can be overridden by conforming types to provide specific behavior. +// /// +// /// - Parameter resource: The optional resource to be transformed into a request. +// /// - Returns: `nil` by default. +// /// - Throws: A `NetworkError.missingLocalData` if the transformation is not supported. +// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? { +// throw .missingLocalData +// } +// +//} +// +//public extension Creatable where CreateResponse == Resource { +// +// /// Provides a default implementation that directly returns the response as the `Resource`. +// /// +// /// This implementation can be used when the `CreateResponse` type is the same as the `Resource` type, +// /// allowing the response to be returned directly. +// /// +// /// - Parameter response: The response received from the creation request. +// /// - Returns: The response as a `Resource`. +// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). +// nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { +// response +// } +// +//} +// +//// MARK: - Readable +// +///// Represents a resource that can be read from a remote server. +///// +///// Types conforming to `Readable` define the necessary types and functions for reading a resource from a server. +///// This includes specifying the types for read requests and responses, and providing methods for making the +///// network request and transforming the responses. +//public protocol Readable: RemoteResource { +// +// /// The type of request used to read the resource. +// associatedtype ReadRequest: Sendable +// +// /// The type of response returned after reading the resource. +// associatedtype ReadResponse: Decodable & Sendable +// +// /// Reads the resource from the remote server using the provided session and request data. +// /// +// /// This method performs an asynchronous network request to read a resource, using the specified session and request. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The read request data. +// /// - Returns: The response object containing the resource data. +// /// - Throws: A `NetworkError` if the request fails. +// static func read( +// using session: NetworkSession, +// request: ReadRequest +// ) async throws(NetworkError) -> ReadResponse +// +// /// Constructs an `Endpoint` for the read request. +// /// +// /// This method is used to convert the read request data into an `Endpoint` that represents the request details. +// /// +// /// - Parameter request: The read request data. +// /// - Returns: An `Endpoint` that represents the request. +// /// - Throws: A `NetworkError` if the endpoint cannot be created. +// nonisolated static func endpoint(_ request: ReadRequest) throws(NetworkError) -> Endpoint +// +// /// Transforms an optional `Resource` into a `ReadRequest`. +// /// +// /// This method can be used to generate a `ReadRequest` from a given `Resource`, if applicable. +// /// +// /// - Parameter resource: The optional resource to be transformed into a request. +// /// - Returns: A `ReadRequest` derived from the resource, or `nil` if not applicable. +// /// - Throws: A `NetworkError` if the transformation fails. +// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? +// +// /// Transforms the read response into a `Resource`. +// /// +// /// This method is used to convert the response data from the read request into a usable `Resource`. +// /// +// /// - Parameter response: The response received from the read request. +// /// - Returns: A `Resource` derived from the response. +// /// - Throws: A `NetworkError` if the transformation fails. +// nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource +// +//} +// +//public extension Readable { +// +// /// Reads the resource from the remote server using the provided session and request data. +// /// +// /// This default implementation performs the network request to read the resource by first obtaining the +// /// `Endpoint` from the `ReadRequest`, then sending the request using the provided `NetworkSession`. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The read request data. +// /// - Returns: The response object containing the resource data. +// /// - Throws: A `NetworkError` if the request fails. +// static func read( +// using session: NetworkSession, +// request: ReadRequest +// ) async throws(NetworkError) -> ReadResponse { +// let endpoint: Endpoint = try Self.endpoint(request) +// let response: ReadResponse = try await session.request(endpoint: endpoint) +// return response +// } +// +//} +// +//public extension Readable where ReadRequest == Void { +// +// /// Provides a default implementation that returns an empty `Void` request. +// /// +// /// This implementation can be used when the `ReadRequest` type is `Void`, indicating that no request data is needed. +// /// +// /// - Parameter resource: The optional resource to be transformed into a request. +// /// - Returns: An empty `Void` request. +// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? { +// () +// } +// +//} +// +//public extension Readable where ReadResponse == Resource { +// +// /// Provides a default implementation that directly returns the response as the `Resource`. +// /// +// /// This implementation can be used when the `ReadResponse` type is the same as the `Resource` type, +// /// allowing the response to be returned directly. +// /// +// /// - Parameter response: The response received from the read request. +// /// - Returns: The response as a `Resource`. +// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). +// nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { +// response +// } +// +//} +// +//// MARK: - Query +// +///// Represents a resource that can be read as a query response from a remote server. +///// +///// `Query` extends the `Readable` protocol to add support for resources where the `ReadResponse` is of type `Data`. +///// It provides additional methods for querying and parsing the raw response data. +//public protocol Query: Readable where ReadResponse == Data { +// +// /// Provides the query string for the request. +// /// +// /// This method is used to specify the query parameters for the request. +// /// +// /// - Returns: A string representing the query. +// nonisolated static func query() -> String +// +//} +// +//public extension Query where Resource: Decodable { +// +// /// Provides a default implementation for parsing the raw response data into a `Resource` using the query. +// /// +// /// This method uses the specified query to extract and decode the data from the response. +// /// +// /// - Parameter response: The raw response data received from the server. +// /// - Returns: The decoded `Resource` object. +// /// - Throws: A `NetworkError` if the parsing or decoding fails. +// nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { +// Sextant.shared.query(response, values: Hitch(string: query())) ?? .placeholder +// } +// +//} +// +//public extension Query { +// +// /// Reads the raw data from the remote server using the provided session and request data. +// /// +// /// This implementation performs a network request to read the resource as raw data. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The read request data. +// /// - Returns: The raw response data. +// /// - Throws: A `NetworkError` if the request fails. +// static func read( +// using session: NetworkSession, +// request: ReadRequest +// ) async throws(NetworkError) -> ReadResponse { +// let endpoint: Endpoint = try Self.endpoint(request) +// let response: ReadResponse = try await session.requestRaw(endpoint: endpoint) +// return response +// } +// +//} +// +//// MARK: - Updatable +// +///// Represents a resource that can be updated on a remote server. +///// +///// Types conforming to `Updatable` define the necessary types and functions for updating a resource on a server. +///// This includes specifying the types for update requests and responses, and providing methods for making the +///// network request and transforming the responses. +//public protocol Updatable: Readable { +// +// /// The type of request used to update the resource. +// associatedtype UpdateRequest: Sendable +// +// /// The type of response returned after updating the resource. +// associatedtype UpdateResponse: Decodable & Sendable +// +// /// Updates an existing resource on the remote server using the provided session and request data. +// /// +// /// This method performs an asynchronous network request to update a resource, using the specified session and request. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The update request data. +// /// - Returns: The response object containing the updated resource data. +// /// - Throws: A `NetworkError` if the request fails. +// static func update( +// using session: NetworkSession, +// request: UpdateRequest +// ) async throws(NetworkError) -> UpdateResponse +// +// /// Constructs an `Endpoint` for the update request. +// /// +// /// This method is used to convert the update request data into an `Endpoint` that represents the request details. +// /// +// /// - Parameter request: The update request data. +// /// - Returns: An `Endpoint` that represents the request. +// /// - Throws: A `NetworkError` if the endpoint cannot be created. +// nonisolated static func endpoint(_ request: UpdateRequest) throws(NetworkError) -> Endpoint +// +// /// Transforms an optional `Resource` into an `UpdateRequest`. +// /// +// /// This method can be used to generate an `UpdateRequest` from a given `Resource`, if applicable. +// /// +// /// - Parameter resource: The optional resource to be transformed into a request. +// /// - Returns: An `UpdateRequest` derived from the resource, or `nil` if not applicable. +// /// - Throws: A `NetworkError` if the transformation fails. +// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> UpdateRequest? +// +// nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource +// +//} +// +//public extension Updatable { +// +// /// Updates an existing resource on the remote server using the provided session and request data. +// /// +// /// This default implementation performs the network request to update the resource by first obtaining the +// /// `Endpoint` from the `UpdateRequest`, then sending the request using the provided `NetworkSession`. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The update request data. +// /// - Returns: The response object containing the updated resource data. +// /// - Throws: A `NetworkError` if the request fails. +// static func update( +// using session: NetworkSession, +// request: UpdateRequest +// ) async throws(NetworkError) -> UpdateResponse { +// let endpoint: Endpoint = try Self.endpoint(request) +// let response: UpdateResponse = try await session.request(endpoint: endpoint) +// return response +// } +// +//} +// +//public extension Updatable where UpdateResponse == Resource { +// +// /// Provides a default implementation that directly returns the response as the `Resource`. +// /// +// /// This implementation can be used when the `UpdateResponse` type is the same as the `Resource` type, +// /// allowing the response to be returned directly. +// /// +// /// - Parameter response: The response received from the update request. +// /// - Returns: The response as a `Resource`. +// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). +// nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { +// response +// } +// +//} +// +//// MARK: - Deletable +// +///// Represents a resource that can be deleted from a remote server. +///// +///// Types conforming to `Deletable` define the necessary types and functions for deleting a resource on a server. +///// This includes specifying the types for delete requests and responses, and providing methods for making the +///// network request and transforming the responses. +//public protocol Deletable: Readable { +// +// /// The type of request used to delete the resource. +// associatedtype DeleteRequest: Sendable +// +// /// The type of response returned after deleting the resource. +// associatedtype DeleteResponse: Decodable & Sendable +// +// /// Deletes the resource on the remote server using the provided session and request data. +// /// +// /// This method performs an asynchronous network request to delete a resource, using the specified session and request. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The delete request data. +// /// - Returns: The response object indicating the result of the deletion. +// /// - Throws: A `NetworkError` if the request fails. +// @discardableResult +// static func delete( +// using session: NetworkSession, +// request: DeleteRequest +// ) async throws(NetworkError) -> DeleteResponse +// +// /// Constructs an `Endpoint` for the delete request. +// /// +// /// This method is used to convert the delete request data into an `Endpoint` that represents the request details. +// /// +// /// - Parameter request: The delete request data. +// /// - Returns: An `Endpoint` that represents the request. +// /// - Throws: A `NetworkError` if the endpoint cannot be created. +// nonisolated static func endpoint(_ request: DeleteRequest) throws(NetworkError) -> Endpoint +// +// /// Transforms an optional `Resource` into a `DeleteRequest`. +// /// +// /// This method can be used to generate a `DeleteRequest` from a given `Resource`, if applicable. +// /// +// /// - Parameter resource: The optional resource to be transformed into a request. +// /// - Returns: A `DeleteRequest` derived from the resource, or `nil` if not applicable. +// /// - Throws: A `NetworkError` if the transformation fails. +// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> DeleteRequest? +// +// /// Transforms the delete response into a `Resource`. +// /// +// /// This method is used to convert the response data from the delete request into a usable `Resource`. +// /// +// /// - Parameter response: The response received from the delete request. +// /// - Returns: A `Resource` derived from the response. +// /// - Throws: A `NetworkError` if the transformation fails. +// nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? +// +//} +// +//public extension Deletable { +// +// /// Deletes the resource on the remote server using the provided session and request data. +// /// +// /// This default implementation performs the network request to delete the resource by first obtaining the +// /// `Endpoint` from the `DeleteRequest`, then sending the request using the provided `NetworkSession`. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The delete request data. +// /// - Returns: The response object indicating the result of the deletion. +// /// - Throws: A `NetworkError` if the request fails. +// @discardableResult +// static func delete( +// using session: NetworkSession, +// request: DeleteRequest +// ) async throws(NetworkError) -> DeleteResponse { +// let endpoint: Endpoint = try Self.endpoint(request) +// let response: DeleteResponse = try await session.request(endpoint: endpoint) +// return response +// } +// +//} +// +//public extension Deletable where DeleteResponse == Resource { +// +// /// Provides a default implementation that directly returns the response as the `Resource`. +// /// +// /// This implementation can be used when the `DeleteResponse` type is the same as the `Resource` type, +// /// allowing the response to be returned directly. +// /// +// /// - Parameter response: The response received from the delete request. +// /// - Returns: The response as a `Resource`. +// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). +// nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? { +// response +// } +// +//} +//// MARK: - Listable +// +///// Represents a resource that can be listed (retrieved in bulk) from a remote server. +///// +///// Types conforming to `Listable` define the necessary types and functions for retrieving a list of resources from a server. +///// This includes specifying the types for list requests and responses, and providing methods for making the network request, +///// managing pagination, and transforming responses. +//public protocol Listable: RemoteResource { +// +// /// The type of request used to list the resources. +// associatedtype ListRequest: Sendable +// +// /// The type of response returned after listing the resources. +// associatedtype ListResponse: Decodable & Sendable +// +// /// Lists the resources from the remote server using the provided session and request data. +// /// +// /// This method performs an asynchronous network request to retrieve a list of resources, using the specified session and request. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The list request data. +// /// - Returns: The response object containing the list of resources. +// /// - Throws: A `NetworkError` if the request fails. +// static func list( +// using session: NetworkSession, +// request: ListRequest +// ) async throws(NetworkError) -> ListResponse +// +// /// Constructs an `Endpoint` for the list request. +// /// +// /// This method is used to convert the list request data into an `Endpoint` that represents the request details. +// /// +// /// - Parameter request: The list request data. +// /// - Returns: An `Endpoint` that represents the request. +// /// - Throws: A `NetworkError` if the endpoint cannot be created. +// nonisolated static func endpoint(_ request: ListRequest) throws(NetworkError) -> Endpoint +// +// /// Provides the first page request for listing resources. +// /// +// /// This method is used to define the initial request for retrieving the first page of resources. +// /// +// /// - Returns: The `ListRequest` representing the first page request. +// nonisolated static func firstPageRequest(withParameters: Any?) -> ListRequest +// +// nonisolated static func nextPageRequest( +// currentResource: [Resource], +// parameters: Any?, +// lastResponse: ListResponse +// ) -> ListRequest? +// +// /// Combines the new response with the existing list of resources. +// /// +// /// This method is used to merge the response from the list request with an existing list of resources. +// /// +// /// - Parameters: +// /// - response: The new response data. +// /// - oldValue: The existing list of resources. +// /// - Returns: A new array of `Resource` combining the old and new data. +// nonisolated static func list(from response: ListResponse, oldValue: [Resource]) -> [Resource] +// +//} +// +//public extension Listable { +// +// /// Provides a default implementation for fetching the next page request. +// /// +// /// By default, this method returns `nil`, indicating that pagination is not supported. +// /// +// /// - Parameters: +// /// - currentResource: The current list of resources. +// /// - lastResponse: The last response received. +// /// - Returns: `nil` by default, indicating no next page request. +// nonisolated static func nextPageRequest( +// currentResource: [Resource], +// parameters: Any?, +// lastResponse: ListResponse +// ) -> ListRequest? { +// return nil +// } +// +// /// Lists the resources from the remote server using the provided session and request data. +// /// +// /// This default implementation performs the network request to list the resources by first obtaining the +// /// `Endpoint` from the `ListRequest`, then sending the request using the provided `NetworkSession`. +// /// +// /// - Parameters: +// /// - session: The network session used to perform the request. +// /// - request: The list request data. +// /// - Returns: The response object containing the list of resources. +// /// - Throws: A `NetworkError` if the request fails. +// static func list( +// using session: NetworkSession, +// request: ListRequest +// ) async throws(NetworkError) -> ListResponse { +// let endpoint: Endpoint = try Self.endpoint(request) +// let response: ListResponse = try await session.request(endpoint: endpoint) +// return response +// } +// +//} +// +//// MARK: - All CRUD Operations +// +///// Represents a resource that supports Create, Read, Update, and Delete operations. +///// +///// Types conforming to `CRUDable` define the necessary functions to perform all basic CRUD operations (Create, Read, Update, Delete). +///// This protocol combines the individual capabilities of `Creatable`, `Readable`, `Updatable`, and `Deletable`. +//public protocol CRUDable: Creatable, Readable, Updatable, Deletable {} +// +//// MARK: - CRUD + Listable +// +///// Represents a resource that supports full CRUD operations as well as bulk listing. +///// +///// Types conforming to `CRUDLable` support all basic CRUD operations (Create, Read, Update, Delete) and also provide +///// capabilities for retrieving lists of resources using the `Listable` protocol. +//public protocol CRUDLable: CRUDable, Listable {} diff --git a/Sources/GoodNetworking/Protocols/URLConvertible.swift b/Sources/GoodNetworking/Protocols/URLConvertible.swift new file mode 100644 index 0000000..90f95e8 --- /dev/null +++ b/Sources/GoodNetworking/Protocols/URLConvertible.swift @@ -0,0 +1,50 @@ +// +// URLConvertible.swift +// GoodNetworking +// +// Created by Filip Ε aΕ‘ala on 02/07/2025. +// + +import Foundation + +// MARK: - URLConvertible + +/// `URLConvertible` defines a function that asynchronously resolves the base URL used for network requests. +/// Classes or structs that conform to this protocol can implement their own logic for determining and returning the base URL. +public protocol URLConvertible: Sendable { + + /// Resolves and returns the base URL for network requests asynchronously. + /// + /// This method is used to fetch or compute the base URL, potentially involving asynchronous operations. + /// If the base URL cannot be resolved, the method returns `nil`. + /// + /// - Returns: The resolved URL or `nil` if the URL could not be constructed. + func resolveUrl() async -> URL? + +} + +// MARK: - Default implementations + +extension Optional: URLConvertible { + + public func resolveUrl() async -> URL? { + self + } + +} + +extension URL: URLConvertible { + + public func resolveUrl() async -> URL? { + self + } + +} + +extension String: URLConvertible { + + public func resolveUrl() async -> URL? { + URL(string: self) + } + +} diff --git a/Sources/GoodNetworking/Providers/DefaultBaseUrlProvider.swift b/Sources/GoodNetworking/Providers/DefaultBaseUrlProvider.swift deleted file mode 100644 index feddb23..0000000 --- a/Sources/GoodNetworking/Providers/DefaultBaseUrlProvider.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// DefaultBaseUrlProvider.swift -// GoodNetworking -// -// Created by Andrej Jasso on 15/10/2024. -// - -/// A simple URL provider that returns a predefined base URL. -/// -/// `DefaultBaseUrlProvider` conforms to `BaseUrlProviding` and is used to provide a static base URL for network requests. -/// This struct is initialized with a given base URL, and it returns that URL when resolved asynchronously. -/// -/// Example usage: -/// ``` -/// let urlProvider = DefaultBaseUrlProvider(baseUrl: "https://api.example.com") -/// let resolvedUrl = await urlProvider.resolveBaseUrl() -/// ``` -/// -/// - Note: The base URL is provided at initialization and remains constant. -public struct DefaultBaseUrlProvider: BaseUrlProviding { - - /// The base URL string to be used for network requests. - let baseUrl: String - - /// Initializes the provider with a given base URL. - /// - /// - Parameter baseUrl: The base URL that will be returned when resolving the URL. - init(baseUrl: String) { - self.baseUrl = baseUrl - } - - /// Resolves and returns the predefined base URL asynchronously. - /// - /// This method returns the base URL that was passed during initialization. - /// - /// - Returns: The base URL as a `String?`. - public func resolveBaseUrl() async -> String? { - return baseUrl - } - -} diff --git a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift index 16313f7..ca78b42 100644 --- a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift @@ -5,116 +5,114 @@ // Created by Andrej Jasso on 14/10/2024. // -@preconcurrency import Alamofire - -/// An actor that provides a default network session using Alamofire. -/// -/// `DefaultSessionProvider` conforms to `NetworkSessionProviding` and handles the creation, validation, and management -/// of default network sessions. This provider assumes that the session is always valid, and does not support session invalidation. -/// It logs session-related activities using a logger, and allows sessions to be created or resolved based on a given configuration or existing session. -/// -/// - Note: This provider uses `NetworkLogger` for logging session-related messages. -/// If available, it uses `OSLogLogger`, otherwise it falls back to `PrintLogger`. -public actor DefaultSessionProvider: NetworkSessionProviding { - - /// The configuration for the network session. - /// - /// This configuration contains details such as interceptors, server trust managers, and event monitors. - /// It is used to create a new instance of `Alamofire.Session`. - nonisolated public let configuration: NetworkSessionConfiguration - - /// The current session used for network requests. - /// - /// If a session has already been created, it is stored here. If not, the `makeSession()` function can be called to create one. - nonisolated public let currentSession: Alamofire.Session - - /// A private property that provides the logger - var logger: NetworkLogger? - - /// Initializes the session provider with a network session configuration. - /// - /// - Parameter configuration: The configuration used to create network sessions. - public init(configuration: NetworkSessionConfiguration, logger: NetworkLogger? = nil) { - self.configuration = configuration - self.currentSession = Alamofire.Session( - configuration: configuration.urlSessionConfiguration, - interceptor: configuration.interceptor, - serverTrustManager: configuration.serverTrustManager, - eventMonitors: configuration.eventMonitors - ) - } - - /// Initializes the session provider with an existing `Alamofire.Session`. - /// - /// - Parameter session: An existing session that will be used by this provider. - public init(session: Alamofire.Session, logger: NetworkLogger? = nil) { - self.currentSession = session - self.configuration = NetworkSessionConfiguration( - urlSessionConfiguration: session.sessionConfiguration, - interceptor: session.interceptor, - serverTrustManager: session.serverTrustManager, - eventMonitors: [session.eventMonitor] - ) - } - - /// A Boolean value indicating that the session is always valid. - /// - /// Since the default session does not rely on any special credentials or configuration, it is always considered valid. - /// This method logs a message indicating the session is valid. - /// - /// - Returns: `true`, indicating the session is valid. - public var isSessionValid: Bool { - logger?.logNetworkEvent( - message: "βœ… Default session is always valid", - level: .debug, - fileName: #file, - lineNumber: #line - ) - return true - } - - /// Logs a message indicating that the default session cannot be invalidated. - /// - /// Since the default session does not support invalidation, this method simply logs a message without performing any action. - public func invalidateSession() async { - logger?.logNetworkEvent( - message: "❌ Default session cannot be invalidated", - level: .debug, - fileName: #file, - lineNumber: #line - ) - } - - /// Creates and returns a new `Alamofire.Session` with the provided configuration. - /// - /// This method uses the stored `configuration` or falls back to a default configuration if none is provided. - /// It logs the session creation process and returns the newly created session, storing it as the current session. - /// - /// - Returns: A new instance of `Alamofire.Session`. - public func makeSession() async -> Alamofire.Session { - logger?.logNetworkEvent( - message: "❌ Default Session Provider cannot be create a new Session, it's setup in the initializer", - level: .debug, - fileName: #file, - lineNumber: #line - ) - - return currentSession - } - - /// Resolves and returns the current valid session. - /// - /// If a session has already been created (`currentSession` is non-nil), this method returns it. - /// Otherwise, it calls `makeSession()` to create and return a new session. - /// - /// - Returns: The current or newly created `Alamofire.Session`. - public func resolveSession() async -> Alamofire.Session { - logger?.logNetworkEvent( - message: "❌ Default session provider always resolves current session which is setup in the initializer", - level: .debug, - fileName: #file, - lineNumber: #line - ) - return currentSession - } -} +///// An actor that provides a default network session using Alamofire. +///// +///// `DefaultSessionProvider` conforms to `NetworkSessionProviding` and handles the creation, validation, and management +///// of default network sessions. This provider assumes that the session is always valid, and does not support session invalidation. +///// It logs session-related activities using a logger, and allows sessions to be created or resolved based on a given configuration or existing session. +///// +///// - Note: This provider uses `NetworkLogger` for logging session-related messages. +///// If available, it uses `OSLogLogger`, otherwise it falls back to `PrintLogger`. +//public actor DefaultSessionProvider: NetworkSessionProviding { +// +// /// The configuration for the network session. +// /// +// /// This configuration contains details such as interceptors, server trust managers, and event monitors. +// /// It is used to create a new instance of `Alamofire.Session`. +// nonisolated public let configuration: NetworkSessionConfiguration +// +// /// The current session used for network requests. +// /// +// /// If a session has already been created, it is stored here. If not, the `makeSession()` function can be called to create one. +// nonisolated public let currentSession: Alamofire.Session +// +// /// A private property that provides the logger +// var logger: NetworkLogger? +// +// /// Initializes the session provider with a network session configuration. +// /// +// /// - Parameter configuration: The configuration used to create network sessions. +// public init(configuration: NetworkSessionConfiguration, logger: NetworkLogger? = nil) { +// self.configuration = configuration +// self.currentSession = Alamofire.Session( +// configuration: configuration.urlSessionConfiguration, +// interceptor: configuration.interceptor, +// serverTrustManager: configuration.serverTrustManager, +// eventMonitors: configuration.eventMonitors +// ) +// } +// +// /// Initializes the session provider with an existing `Alamofire.Session`. +// /// +// /// - Parameter session: An existing session that will be used by this provider. +// public init(session: Alamofire.Session, logger: NetworkLogger? = nil) { +// self.currentSession = session +// self.configuration = NetworkSessionConfiguration( +// urlSessionConfiguration: session.sessionConfiguration, +// interceptor: session.interceptor, +// serverTrustManager: session.serverTrustManager, +// eventMonitors: [session.eventMonitor] +// ) +// } +// +// /// A Boolean value indicating that the session is always valid. +// /// +// /// Since the default session does not rely on any special credentials or configuration, it is always considered valid. +// /// This method logs a message indicating the session is valid. +// /// +// /// - Returns: `true`, indicating the session is valid. +// public var isSessionValid: Bool { +// logger?.logNetworkEvent( +// message: "βœ… Default session is always valid", +// level: .debug, +// fileName: #file, +// lineNumber: #line +// ) +// return true +// } +// +// /// Logs a message indicating that the default session cannot be invalidated. +// /// +// /// Since the default session does not support invalidation, this method simply logs a message without performing any action. +// public func invalidateSession() async { +// logger?.logNetworkEvent( +// message: "❌ Default session cannot be invalidated", +// level: .debug, +// fileName: #file, +// lineNumber: #line +// ) +// } +// +// /// Creates and returns a new `Alamofire.Session` with the provided configuration. +// /// +// /// This method uses the stored `configuration` or falls back to a default configuration if none is provided. +// /// It logs the session creation process and returns the newly created session, storing it as the current session. +// /// +// /// - Returns: A new instance of `Alamofire.Session`. +// public func makeSession() async -> Alamofire.Session { +// logger?.logNetworkEvent( +// message: "❌ Default Session Provider cannot be create a new Session, it's setup in the initializer", +// level: .debug, +// fileName: #file, +// lineNumber: #line +// ) +// +// return currentSession +// } +// +// /// Resolves and returns the current valid session. +// /// +// /// If a session has already been created (`currentSession` is non-nil), this method returns it. +// /// Otherwise, it calls `makeSession()` to create and return a new session. +// /// +// /// - Returns: The current or newly created `Alamofire.Session`. +// public func resolveSession() async -> Alamofire.Session { +// logger?.logNetworkEvent( +// message: "❌ Default session provider always resolves current session which is setup in the initializer", +// level: .debug, +// fileName: #file, +// lineNumber: #line +// ) +// return currentSession +// } +//} diff --git a/Sources/GoodNetworking/Session/FutureSession.swift b/Sources/GoodNetworking/Session/FutureSession.swift index 9a26bcb..73047d2 100644 --- a/Sources/GoodNetworking/Session/FutureSession.swift +++ b/Sources/GoodNetworking/Session/FutureSession.swift @@ -10,55 +10,55 @@ /// `FutureSession` acts as a wrapper around `NetworkSession`, providing a mechanism for lazily loading and caching the session. /// The session is supplied asynchronously via a `FutureSessionSupplier` and is cached upon first use, allowing subsequent /// requests to reuse the same session instance without needing to recreate it. -public actor FutureSession { - - /// The type alias for a supplier function that provides a `NetworkSession` asynchronously. - public typealias FutureSessionSupplier = (@Sendable () async -> NetworkSession) - - private var supplier: FutureSessionSupplier - private var sessionCache: NetworkSession? - - /// Provides access to the cached network session. - /// - /// If the session has already been cached, it returns the existing instance. If not, it uses the `supplier` - /// function to create a new session, caches it, and then returns the newly created session. - public var cachedSession: NetworkSession { - get async { - let session = sessionCache - if let session { - return session - } else { - // Resolve and cache the session for future use - sessionCache = await supplier() - return sessionCache! - } - } - } - - /// Initializes the `FutureSession` with a supplier function for the network session. - /// - /// - Parameter supplier: A function that provides a `NetworkSession` asynchronously. - public init(_ supplier: @escaping FutureSessionSupplier) { - self.supplier = supplier - } - - /// Allows the `FutureSession` to be called as a function to retrieve the cached network session. - /// - /// - Returns: The cached or newly resolved `NetworkSession`. - public func callAsFunction() async -> NetworkSession { - return await cachedSession - } - -} - -internal extension FutureSession { - - /// A placeholder `FutureSession` used as a fallback. - /// - /// This placeholder is intended for internal use only and serves as a default instance when no valid session is provided. - /// It will trigger a runtime error if accessed, indicating that a valid network session should be supplied using `Resource.session(:)`. - static let placeholder: FutureSession = FutureSession { - preconditionFailure("No session supplied. Use Resource.session(:) to provide a valid network session.") - } - -} +//public actor FutureSession { +// +// /// The type alias for a supplier function that provides a `NetworkSession` asynchronously. +// public typealias FutureSessionSupplier = (@Sendable () async -> NetworkSession) +// +// private var supplier: FutureSessionSupplier +// private var sessionCache: NetworkSession? +// +// /// Provides access to the cached network session. +// /// +// /// If the session has already been cached, it returns the existing instance. If not, it uses the `supplier` +// /// function to create a new session, caches it, and then returns the newly created session. +// public var cachedSession: NetworkSession { +// get async { +// let session = sessionCache +// if let session { +// return session +// } else { +// // Resolve and cache the session for future use +// sessionCache = await supplier() +// return sessionCache! +// } +// } +// } +// +// /// Initializes the `FutureSession` with a supplier function for the network session. +// /// +// /// - Parameter supplier: A function that provides a `NetworkSession` asynchronously. +// public init(_ supplier: @escaping FutureSessionSupplier) { +// self.supplier = supplier +// } +// +// /// Allows the `FutureSession` to be called as a function to retrieve the cached network session. +// /// +// /// - Returns: The cached or newly resolved `NetworkSession`. +// public func callAsFunction() async -> NetworkSession { +// return await cachedSession +// } +// +//} +// +//internal extension FutureSession { +// +// /// A placeholder `FutureSession` used as a fallback. +// /// +// /// This placeholder is intended for internal use only and serves as a default instance when no valid session is provided. +// /// It will trigger a runtime error if accessed, indicating that a valid network session should be supplied using `Resource.session(:)`. +// static let placeholder: FutureSession = FutureSession { +// preconditionFailure("No session supplied. Use Resource.session(:) to provide a valid network session.") +// } +// +//} diff --git a/Sources/GoodNetworking/Session/GRSession.swift b/Sources/GoodNetworking/Session/GRSession.swift new file mode 100644 index 0000000..18120f6 --- /dev/null +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -0,0 +1,100 @@ +// +// GRSession.swift +// GoodNetworking +// +// Created by Filip Ε aΕ‘ala on 02/07/2025. +// + +import Foundation + +// MARK: - Initialization + +public final class NetworkSession: NSObject, Sendable { + + private let baseUrl: any URLConvertible + private let session: URLSession + private let baseHeaders: HTTPHeaders + + public init( + baseUrl: any URLConvertible, + baseHeaders: HTTPHeaders = [], + logger: any NetworkLogger = PrintNetworkLogger() + ) { + self.baseUrl = baseUrl + self.baseHeaders = baseHeaders + + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = NetworkActor.queue + + let configuration = URLSessionConfiguration.ephemeral + configuration.httpAdditionalHeaders = baseHeaders.map { $0.resolveHeader() }.reduce(into: [:], { $0[$1.name] = $1.value }) + + self.session = URLSession( + configuration: configuration, + delegate: NetworkSessionDelegate(), + delegateQueue: operationQueue + ) + } + +} + +// MARK: - Network session delegate + +final class NetworkSessionDelegate: NSObject, Sendable {} + +extension NetworkSessionDelegate: URLSessionDelegate { + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { +#warning("TODO: Implement SSL pinning/certificate validation") + completionHandler(.performDefaultHandling, nil) + } + +} + +// MARK: - Request + +extension NetworkSession { + + public func request(endpoint: Endpoint) async -> Data { + return Data() + } + + public func get(_ path: URLConvertible) async -> Data { + return Data() + } + +} + + + +// MARK: - Sample + +func x() async { + + let session = NetworkSession( + baseUrl: "https://api.sampleapis.com/", + baseHeaders: [HTTPHeader("User-Agent: iOS app")] + ) + + await session.request(endpoint: CoffeeEndpoint.hot) + await session.get("/coffee/hot") + +} + +enum CoffeeEndpoint: Endpoint { + + case hot + + var method: HTTPMethod { .get } + var path: String { "/coffee/hot" } + +} + + +enum Endpoints { + static let hotCoffee = "/coffee/hot" +} diff --git a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift index ab47a7b..b9e74b4 100644 --- a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift +++ b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift @@ -5,164 +5,163 @@ // Created by Matus Klasovity on 30/01/2024. // -@preconcurrency import Alamofire import Combine import Foundation -public struct LoggingEventMonitor: EventMonitor, Sendable { - - nonisolated(unsafe) public static var verbose: Bool = true - nonisolated(unsafe) public static var prettyPrinted: Bool = true - nonisolated(unsafe) public static var maxVerboseLogSizeBytes: Int = 100_000 - - /// The `DispatchQueue` onto which Alamofire's root `CompositeEventMonitor` will dispatch events. `.main` by default. - public let queue = DispatchQueue(label: C.queueLabel, qos: .background) - - private enum C { - - static let queueLabel = "com.goodrequest.networklogger" - - } - - private let logger: NetworkLogger - - /// Creates a new logging monitor. - /// - /// - Parameter logger: The logger instance to use for output. If nil, no logging occurs. - public init(logger: NetworkLogger, configuration: Configuration = .init()) { - self.logger = logger - } - - public func request(_ request: DataRequest, didParseResponse response: DataResponse) { - let requestInfoMessage = parseRequestInfo(response: response) - let metricsMessage = parse(metrics: response.metrics) - let requestBodyMessage = parse(data: request.request?.httpBody, error: response.error as NSError?, prefix: "⬆️ Request body:") - let errorMessage: String? = if let afError = response.error { - "🚨 Error:\n\(afError)" - } else { - nil - } - - let responseBodyMessage = if Self.useMimeTypeWhitelist, Self.responseTypeWhiteList.contains(where: { $0 == response.response?.mimeType }) { - parse(data: response.data, error: response.error as NSError?, prefix: "⬇️ Response body:") - } else { - "❓❓❓ Response MIME type not whitelisted (\(response.response?.mimeType ?? "❓")). You can try adding it to whitelist using logMimeType(_ mimeType:)." - } - - let logMessage = [ - requestInfoMessage, - metricsMessage, - requestBodyMessage, - errorMessage, - responseBodyMessage - ].compactMap { $0 }.joined(separator: "\n") - - switch response.result { - case .success: - logger.logNetworkEvent(message: logMessage, level: .debug, fileName: #file, lineNumber: #line) - case .failure: - logger.logNetworkEvent(message: logMessage, level: .error, fileName: #file, lineNumber: #line) - } - } - -} - -private extension LoggingEventMonitor { - - func parseRequestInfo(response: DataResponse) -> String? { - guard let request = response.request, - let url = request.url?.absoluteString.removingPercentEncoding, - let method = request.httpMethod, - let response = response.response - else { - return nil - } - guard Self.verbose else { - return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)" - } - - if let headers = request.allHTTPHeaderFields, - !headers.isEmpty, - let headersData = try? JSONSerialization.data(withJSONObject: headers, options: [.prettyPrinted]), - let headersPrettyMessage = parse(data: headersData, error: nil, prefix: "🏷 Headers:") { - - return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage - } else { - let headers = if let allHTTPHeaderFields = request.allHTTPHeaderFields, !allHTTPHeaderFields.isEmpty { - allHTTPHeaderFields.description - } else { - "empty headers" - } - return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n🏷 Headers: \(headers)" - } - } - - func parse(data: Data?, error: NSError?, prefix: String) -> String? { - guard Self.verbose else { return nil } - - if let data = data, !data.isEmpty { - guard data.count < Self.maxVerboseLogSizeBytes else { - return [ - prefix, - "Data size is too big!", - "Max size is: \(Self.maxVerboseLogSizeBytes) bytes.", - "Data size is: \(data.count) bytes", - "πŸ’‘Tip: Change LoggingEventMonitor.maxVerboseLogSizeBytes = \(data.count)" - ].joined(separator: "\n") - } - if let string = String(data: data, encoding: .utf8) { - if let jsonData = try? JSONSerialization.jsonObject(with: data, options: []), - let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: Self.prettyPrinted ? [.prettyPrinted, .withoutEscapingSlashes] : [.withoutEscapingSlashes]), - let prettyPrintedString = String(data: prettyPrintedData, encoding: .utf8) { - return "\(prefix) \n\(prettyPrintedString)" - } else { - return "\(prefix)\(string)" - } - } - } - - return nil - } - - func parse(metrics: URLSessionTaskMetrics?) -> String? { - guard let metrics, Self.verbose else { - return nil - } - return "↗️ Start: \(metrics.taskInterval.start)" + "\n" + "βŒ›οΈ Duration: \(metrics.taskInterval.duration)s" - } - - - func parseResponseStatus(response: HTTPURLResponse) -> String { - let statusCode = response.statusCode - let logMessage = (200 ..< 300).contains(statusCode) ? "βœ… \(statusCode)" : "❌ \(statusCode)" - return logMessage - } - -} - -public extension LoggingEventMonitor { - - nonisolated(unsafe) private(set) static var responseTypeWhiteList: [String] = [ - "application/json", - "application/ld+json", - "application/xml", - "text/plain", - "text/csv", - "text/html", - "text/javascript", - "application/rtf" - ] - - nonisolated(unsafe) static var useMimeTypeWhitelist: Bool = true - - nonisolated(unsafe) static func logMimeType(_ mimeType: String) { - responseTypeWhiteList.append(mimeType) - } - - nonisolated(unsafe) static func stopLoggingMimeType(_ mimeType: String) { - responseTypeWhiteList.removeAll(where: { - $0 == mimeType - }) - } - -} +//public struct LoggingEventMonitor: EventMonitor, Sendable { +// +// nonisolated(unsafe) public static var verbose: Bool = true +// nonisolated(unsafe) public static var prettyPrinted: Bool = true +// nonisolated(unsafe) public static var maxVerboseLogSizeBytes: Int = 100_000 +// +// /// The `DispatchQueue` onto which Alamofire's root `CompositeEventMonitor` will dispatch events. `.main` by default. +// public let queue = DispatchQueue(label: C.queueLabel, qos: .background) +// +// private enum C { +// +// static let queueLabel = "com.goodrequest.networklogger" +// +// } +// +// private let logger: NetworkLogger +// +// /// Creates a new logging monitor. +// /// +// /// - Parameter logger: The logger instance to use for output. If nil, no logging occurs. +// public init(logger: NetworkLogger, configuration: Configuration = .init()) { +// self.logger = logger +// } +// +// public func request(_ request: DataRequest, didParseResponse response: DataResponse) { +// let requestInfoMessage = parseRequestInfo(response: response) +// let metricsMessage = parse(metrics: response.metrics) +// let requestBodyMessage = parse(data: request.request?.httpBody, error: response.error as NSError?, prefix: "⬆️ Request body:") +// let errorMessage: String? = if let afError = response.error { +// "🚨 Error:\n\(afError)" +// } else { +// nil +// } +// +// let responseBodyMessage = if Self.useMimeTypeWhitelist, Self.responseTypeWhiteList.contains(where: { $0 == response.response?.mimeType }) { +// parse(data: response.data, error: response.error as NSError?, prefix: "⬇️ Response body:") +// } else { +// "❓❓❓ Response MIME type not whitelisted (\(response.response?.mimeType ?? "❓")). You can try adding it to whitelist using logMimeType(_ mimeType:)." +// } +// +// let logMessage = [ +// requestInfoMessage, +// metricsMessage, +// requestBodyMessage, +// errorMessage, +// responseBodyMessage +// ].compactMap { $0 }.joined(separator: "\n") +// +// switch response.result { +// case .success: +// logger.logNetworkEvent(message: logMessage, level: .debug, fileName: #file, lineNumber: #line) +// case .failure: +// logger.logNetworkEvent(message: logMessage, level: .error, fileName: #file, lineNumber: #line) +// } +// } +// +//} +// +//private extension LoggingEventMonitor { +// +// func parseRequestInfo(response: DataResponse) -> String? { +// guard let request = response.request, +// let url = request.url?.absoluteString.removingPercentEncoding, +// let method = request.httpMethod, +// let response = response.response +// else { +// return nil +// } +// guard Self.verbose else { +// return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)" +// } +// +// if let headers = request.allHTTPHeaderFields, +// !headers.isEmpty, +// let headersData = try? JSONSerialization.data(withJSONObject: headers, options: [.prettyPrinted]), +// let headersPrettyMessage = parse(data: headersData, error: nil, prefix: "🏷 Headers:") { +// +// return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage +// } else { +// let headers = if let allHTTPHeaderFields = request.allHTTPHeaderFields, !allHTTPHeaderFields.isEmpty { +// allHTTPHeaderFields.description +// } else { +// "empty headers" +// } +// return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n🏷 Headers: \(headers)" +// } +// } +// +// func parse(data: Data?, error: NSError?, prefix: String) -> String? { +// guard Self.verbose else { return nil } +// +// if let data = data, !data.isEmpty { +// guard data.count < Self.maxVerboseLogSizeBytes else { +// return [ +// prefix, +// "Data size is too big!", +// "Max size is: \(Self.maxVerboseLogSizeBytes) bytes.", +// "Data size is: \(data.count) bytes", +// "πŸ’‘Tip: Change LoggingEventMonitor.maxVerboseLogSizeBytes = \(data.count)" +// ].joined(separator: "\n") +// } +// if let string = String(data: data, encoding: .utf8) { +// if let jsonData = try? JSONSerialization.jsonObject(with: data, options: []), +// let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: Self.prettyPrinted ? [.prettyPrinted, .withoutEscapingSlashes] : [.withoutEscapingSlashes]), +// let prettyPrintedString = String(data: prettyPrintedData, encoding: .utf8) { +// return "\(prefix) \n\(prettyPrintedString)" +// } else { +// return "\(prefix)\(string)" +// } +// } +// } +// +// return nil +// } +// +// func parse(metrics: URLSessionTaskMetrics?) -> String? { +// guard let metrics, Self.verbose else { +// return nil +// } +// return "↗️ Start: \(metrics.taskInterval.start)" + "\n" + "βŒ›οΈ Duration: \(metrics.taskInterval.duration)s" +// } +// +// +// func parseResponseStatus(response: HTTPURLResponse) -> String { +// let statusCode = response.statusCode +// let logMessage = (200 ..< 300).contains(statusCode) ? "βœ… \(statusCode)" : "❌ \(statusCode)" +// return logMessage +// } +// +//} +// +//public extension LoggingEventMonitor { +// +// nonisolated(unsafe) private(set) static var responseTypeWhiteList: [String] = [ +// "application/json", +// "application/ld+json", +// "application/xml", +// "text/plain", +// "text/csv", +// "text/html", +// "text/javascript", +// "application/rtf" +// ] +// +// nonisolated(unsafe) static var useMimeTypeWhitelist: Bool = true +// +// nonisolated(unsafe) static func logMimeType(_ mimeType: String) { +// responseTypeWhiteList.append(mimeType) +// } +// +// nonisolated(unsafe) static func stopLoggingMimeType(_ mimeType: String) { +// responseTypeWhiteList.removeAll(where: { +// $0 == mimeType +// }) +// } +// +//} diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 9c5ee6b..83fe6f8 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -1,318 +1,317 @@ +//// +//// NetworkSession.swift +//// GoodNetworking +//// +//// Created by Dominik PethΓΆ on 8/17/20. +//// // -// NetworkSession.swift -// GoodNetworking -// -// Created by Dominik PethΓΆ on 8/17/20. -// - -@preconcurrency import Alamofire -import Foundation - -/// Executes network requests for the client app. -/// -/// `NetworkSession` is responsible for sending, downloading, and uploading data through a network session. -/// It uses a base URL provider and a session provider to manage the configuration and ensure the session's validity. -public actor NetworkSession: Hashable { - - public static func == (lhs: NetworkSession, rhs: NetworkSession) -> Bool { - lhs.sessionId == rhs.sessionId - } - - nonisolated public func hash(into hasher: inout Hasher) { - hasher.combine(sessionId) - } - - // MARK: - ID - - nonisolated private let sessionId: UUID = UUID() - - // MARK: - Properties - - /// The provider responsible for managing the network session, ensuring it is created, resolved, and validated. - public let sessionProvider: NetworkSessionProviding - - /// The optional provider for resolving the base URL to be used in network requests. - public let baseUrlProvider: BaseUrlProviding? - - // MARK: - Initialization - - /// Initializes the `NetworkSession` with an optional base URL provider and a session provider. - /// - /// - Parameters: - /// - baseUrlProvider: An optional provider for the base URL. Defaults to `nil`. - /// - sessionProvider: The session provider to be used. Defaults to `DefaultSessionProvider` with a default configuration. - public init( - baseUrlProvider: BaseUrlProviding? = nil, - sessionProvider: NetworkSessionProviding = DefaultSessionProvider(configuration: .default()) - ) { - self.baseUrlProvider = baseUrlProvider - self.sessionProvider = sessionProvider - } - - /// Initializes the `NetworkSession` with an optional base URL provider and a network session configuration. - /// - /// - Parameters: - /// - baseUrl: An optional provider for the base URL. Defaults to `nil`. - /// - configuration: The configuration to be used for creating the session. Defaults to `.default`. - public init( - baseUrl: BaseUrlProviding? = nil, - configuration: NetworkSessionConfiguration = .default(), - logger: NetworkLogger? = nil - ) { - self.baseUrlProvider = baseUrl - self.sessionProvider = DefaultSessionProvider(configuration: configuration, logger: logger) - } - - /// Initializes the `NetworkSession` with an optional base URL provider and an existing session. - /// - /// - Parameters: - /// - baseUrlProvider: An optional provider for the base URL. Defaults to `nil`. - /// - session: An existing session to be used by this provider. - public init( - baseUrlProvider: BaseUrlProviding? = nil, - session: Alamofire.Session - ) { - self.baseUrlProvider = baseUrlProvider - self.sessionProvider = DefaultSessionProvider(session: session) - } - -} - -// MARK: - Request - -public extension NetworkSession { - - /// Sends a network request to an endpoint using the resolved base URL and session. - /// - /// - Parameters: - /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. - /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. - /// - validationProvider: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. - /// - Returns: The decoded result of type `Result`. - /// - Throws: A `Failure` error if validation or the request fails. - func request( - endpoint: Endpoint, - baseUrlProvider: BaseUrlProviding? = nil, - validationProvider: any ValidationProviding = DefaultValidationProvider() - ) async throws(Failure) -> Result { - return try await catchingFailure(validationProvider: validationProvider) { - let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - return try await resolvedSession.request( - try? endpoint.url(on: resolvedBaseUrl), - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers - ) - .goodify(type: Result.self, validator: validationProvider) - .value - } - } - - /// Sends a raw network request and returns the response data. - /// - /// - Parameters: - /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. - /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. - /// - validationProvider: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. - /// - Returns: The raw response data. - /// - Throws: A `Failure` error if validation or the request fails. - func requestRaw( - endpoint: Endpoint, - baseUrlProvider: BaseUrlProviding? = nil, - validationProvider: any ValidationProviding = DefaultValidationProvider() - ) async throws(Failure) -> Data { - return try await catchingFailure(validationProvider: validationProvider) { - let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - return try await resolvedSession.request( - try? endpoint.url(on: resolvedBaseUrl), - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers - ) - .serializingData() - .value - } - } - - /// Sends a request and returns an unprocessed `DataRequest` object. - /// - /// - Parameters: - /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. - /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. - /// - Returns: A `DataRequest` object representing the raw request. - @_disfavoredOverload func request(endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil) async -> DataRequest { - let resolvedBaseUrl = try? await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - return resolvedSession.request( - try? endpoint.url(on: resolvedBaseUrl ?? ""), - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers - ) - } - -} - -// MARK: - Download - -public extension NetworkSession { - - /// Creates a download request for the given `endpoint` and saves the result to the specified file. - /// - /// - Parameters: - /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. - /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. - /// - customFileName: The name of the file to which the downloaded content will be saved. - /// - Returns: A `DownloadRequest` for the file download. - /// - Throws: A `NetworkError` if the request fails. - func download(endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, customFileName: String) async throws(NetworkError) -> DownloadRequest { - let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - let destination: DownloadRequest.Destination = { temporaryURL, _ in - let directoryURLs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - let url = directoryURLs.first?.appendingPathComponent(customFileName) ?? temporaryURL - - return (url, [.removePreviousFile, .createIntermediateDirectories]) - } - - return resolvedSession.download( - try? endpoint.url(on: resolvedBaseUrl), - method: endpoint.method, - parameters: endpoint.parameters?.dictionary, - encoding: endpoint.encoding, - headers: endpoint.headers, - to: destination - ) - } - -} - -// MARK: - Upload - -public extension NetworkSession { - - /// Uploads data to the specified `endpoint` using multipart form data. - /// - /// - Parameters: - /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. - /// - data: The data to be uploaded. - /// - fileHeader: The header to use for the uploaded file in the form data. Defaults to "file". - /// - filename: The name of the file to be uploaded. - /// - mimeType: The MIME type of the file. - /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. - /// - Returns: An `UploadRequest` representing the upload. - /// - Throws: A `NetworkError` if the upload fails. - func uploadWithMultipart( - endpoint: Endpoint, - data: Data, - fileHeader: String = "file", - filename: String, - mimeType: String, - baseUrlProvider: BaseUrlProviding? = nil - ) async throws(NetworkError) -> UploadRequest { - let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - return resolvedSession.upload( - multipartFormData: { formData in - formData.append(data, withName: fileHeader, fileName: filename, mimeType: mimeType) - }, - to: try? endpoint.url(on: resolvedBaseUrl), - method: endpoint.method, - headers: endpoint.headers - ) - } - - /// Uploads multipart form data to the specified `endpoint`. - /// - /// - Parameters: - /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. - /// - multipartFormData: The multipart form data to upload. - /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. - /// - Returns: An `UploadRequest` representing the upload. - /// - Throws: A `NetworkError` if the upload fails. - func uploadWithMultipart( - endpoint: Endpoint, - multipartFormData: MultipartFormData, - baseUrlProvider: BaseUrlProviding? = nil - ) async throws(NetworkError) -> UploadRequest { - let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) - let resolvedSession = await resolveSession(sessionProvider: sessionProvider) - - return resolvedSession.upload( - multipartFormData: multipartFormData, - to: try? endpoint.url(on: resolvedBaseUrl), - method: endpoint.method, - headers: endpoint.headers - ) - } - -} - -// MARK: - Internal - -extension NetworkSession { - - /// Resolves the network session, creating a new one if necessary. - /// - /// - Parameter sessionProvider: The provider managing the session. - /// - Returns: The resolved or newly created `Alamofire.Session`. - func resolveSession(sessionProvider: NetworkSessionProviding) async -> Alamofire.Session { - if await !sessionProvider.isSessionValid { - await sessionProvider.makeSession() - } else { - await sessionProvider.resolveSession() - } - } - - /// Resolves the base URL using the provided or default base URL provider. - /// - /// - Parameter baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. - /// - Returns: The resolved base URL as a `String`. - /// - Throws: A `NetworkError.invalidBaseURL` if the base URL cannot be resolved. - func resolveBaseUrl(baseUrlProvider: BaseUrlProviding?) async throws(NetworkError) -> String { - let baseUrlProvider = baseUrlProvider ?? self.baseUrlProvider - guard let resolvedBaseUrl = await baseUrlProvider?.resolveBaseUrl() else { - throw .invalidBaseURL - } - return resolvedBaseUrl - } - - /// Executes a closure while catching and transforming failures. - /// - /// - Parameters: - /// - validationProvider: The provider used to transform any errors. - /// - body: The closure to execute. - /// - Returns: The result of type `Result`. - /// - Throws: A transformed error if the closure fails. - func catchingFailure( - validationProvider: any ValidationProviding, - body: () async throws -> Result - ) async throws(Failure) -> Result { - do { - return try await body() - } catch let networkError as NetworkError { - throw validationProvider.transformError(networkError) - } catch let error as AFError { - if let underlyingError = error.underlyingError as? Failure { - throw underlyingError - } else if let underlyingError = error.underlyingError as? NetworkError { - throw validationProvider.transformError(underlyingError) - } else { - throw validationProvider.transformError(NetworkError.sessionError) - } - } catch { - throw validationProvider.transformError(NetworkError.sessionError) - } - } - -} +//import Foundation +// +///// Executes network requests for the client app. +///// +///// `NetworkSession` is responsible for sending, downloading, and uploading data through a network session. +///// It uses a base URL provider and a session provider to manage the configuration and ensure the session's validity. +//public actor OLD_NetworkSession: Hashable { +// +// public static func == (lhs: OLD_NetworkSession, rhs: OLD_NetworkSession) -> Bool { +// lhs.sessionId == rhs.sessionId +// } +// +// nonisolated public func hash(into hasher: inout Hasher) { +// hasher.combine(sessionId) +// } +// +// // MARK: - ID +// +// nonisolated private let sessionId: UUID = UUID() +// +// // MARK: - Properties +// +// /// The provider responsible for managing the network session, ensuring it is created, resolved, and validated. +// public let sessionProvider: NetworkSessionProviding +// +// /// The optional provider for resolving the base URL to be used in network requests. +// public let baseUrlProvider: BaseUrlProviding? +// +// // MARK: - Initialization +// +// /// Initializes the `NetworkSession` with an optional base URL provider and a session provider. +// /// +// /// - Parameters: +// /// - baseUrlProvider: An optional provider for the base URL. Defaults to `nil`. +// /// - sessionProvider: The session provider to be used. Defaults to `DefaultSessionProvider` with a default configuration. +// public init( +// baseUrlProvider: BaseUrlProviding? = nil, +// sessionProvider: NetworkSessionProviding = DefaultSessionProvider(configuration: .default()) +// ) { +// self.baseUrlProvider = baseUrlProvider +// self.sessionProvider = sessionProvider +// } +// +// /// Initializes the `NetworkSession` with an optional base URL provider and a network session configuration. +// /// +// /// - Parameters: +// /// - baseUrl: An optional provider for the base URL. Defaults to `nil`. +// /// - configuration: The configuration to be used for creating the session. Defaults to `.default`. +// public init( +// baseUrl: BaseUrlProviding? = nil, +// configuration: NetworkSessionConfiguration = .default(), +// logger: NetworkLogger? = nil +// ) { +// self.baseUrlProvider = baseUrl +// self.sessionProvider = DefaultSessionProvider(configuration: configuration, logger: logger) +// } +// +// /// Initializes the `NetworkSession` with an optional base URL provider and an existing session. +// /// +// /// - Parameters: +// /// - baseUrlProvider: An optional provider for the base URL. Defaults to `nil`. +// /// - session: An existing session to be used by this provider. +// public init( +// baseUrlProvider: BaseUrlProviding? = nil, +// session: Alamofire.Session +// ) { +// self.baseUrlProvider = baseUrlProvider +// self.sessionProvider = DefaultSessionProvider(session: session) +// } +// +//} +// +//// MARK: - Request +// +//public extension OLD_NetworkSession { +// +// /// Sends a network request to an endpoint using the resolved base URL and session. +// /// +// /// - Parameters: +// /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. +// /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. +// /// - validationProvider: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. +// /// - Returns: The decoded result of type `Result`. +// /// - Throws: A `Failure` error if validation or the request fails. +// func request( +// endpoint: Endpoint, +// baseUrlProvider: BaseUrlProviding? = nil, +// validationProvider: any ValidationProviding = DefaultValidationProvider() +// ) async throws(Failure) -> Result { +// return try await catchingFailure(validationProvider: validationProvider) { +// let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) +// let resolvedSession = await resolveSession(sessionProvider: sessionProvider) +// +// return try await resolvedSession.request( +// try? endpoint.url(on: resolvedBaseUrl), +// method: endpoint.method, +// parameters: endpoint.parameters?.dictionary, +// encoding: endpoint.encoding, +// headers: endpoint.headers +// ) +// .goodify(type: Result.self, validator: validationProvider) +// .value +// } +// } +// +// /// Sends a raw network request and returns the response data. +// /// +// /// - Parameters: +// /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. +// /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. +// /// - validationProvider: The validation provider used to validate the response. Defaults to `DefaultValidationProvider`. +// /// - Returns: The raw response data. +// /// - Throws: A `Failure` error if validation or the request fails. +// func requestRaw( +// endpoint: Endpoint, +// baseUrlProvider: BaseUrlProviding? = nil, +// validationProvider: any ValidationProviding = DefaultValidationProvider() +// ) async throws(Failure) -> Data { +// return try await catchingFailure(validationProvider: validationProvider) { +// let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) +// let resolvedSession = await resolveSession(sessionProvider: sessionProvider) +// +// return try await resolvedSession.request( +// try? endpoint.url(on: resolvedBaseUrl), +// method: endpoint.method, +// parameters: endpoint.parameters?.dictionary, +// encoding: endpoint.encoding, +// headers: endpoint.headers +// ) +// .serializingData() +// .value +// } +// } +// +// /// Sends a request and returns an unprocessed `DataRequest` object. +// /// +// /// - Parameters: +// /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. +// /// - baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. +// /// - Returns: A `DataRequest` object representing the raw request. +// @_disfavoredOverload func request(endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil) async -> DataRequest { +// let resolvedBaseUrl = try? await resolveBaseUrl(baseUrlProvider: baseUrlProvider) +// let resolvedSession = await resolveSession(sessionProvider: sessionProvider) +// +// return resolvedSession.request( +// try? endpoint.url(on: resolvedBaseUrl ?? ""), +// method: endpoint.method, +// parameters: endpoint.parameters?.dictionary, +// encoding: endpoint.encoding, +// headers: endpoint.headers +// ) +// } +// +//} +// +//// MARK: - Download +// +//public extension OLD_NetworkSession { +// +// /// Creates a download request for the given `endpoint` and saves the result to the specified file. +// /// +// /// - Parameters: +// /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. +// /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. +// /// - customFileName: The name of the file to which the downloaded content will be saved. +// /// - Returns: A `DownloadRequest` for the file download. +// /// - Throws: A `NetworkError` if the request fails. +// func download(endpoint: Endpoint, baseUrlProvider: BaseUrlProviding? = nil, customFileName: String) async throws(NetworkError) -> DownloadRequest { +// let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) +// let resolvedSession = await resolveSession(sessionProvider: sessionProvider) +// +// let destination: DownloadRequest.Destination = { temporaryURL, _ in +// let directoryURLs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) +// let url = directoryURLs.first?.appendingPathComponent(customFileName) ?? temporaryURL +// +// return (url, [.removePreviousFile, .createIntermediateDirectories]) +// } +// +// return resolvedSession.download( +// try? endpoint.url(on: resolvedBaseUrl), +// method: endpoint.method, +// parameters: endpoint.parameters?.dictionary, +// encoding: endpoint.encoding, +// headers: endpoint.headers, +// to: destination +// ) +// } +// +//} +// +//// MARK: - Upload +// +//public extension OLD_NetworkSession { +// +// /// Uploads data to the specified `endpoint` using multipart form data. +// /// +// /// - Parameters: +// /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. +// /// - data: The data to be uploaded. +// /// - fileHeader: The header to use for the uploaded file in the form data. Defaults to "file". +// /// - filename: The name of the file to be uploaded. +// /// - mimeType: The MIME type of the file. +// /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. +// /// - Returns: An `UploadRequest` representing the upload. +// /// - Throws: A `NetworkError` if the upload fails. +// func uploadWithMultipart( +// endpoint: Endpoint, +// data: Data, +// fileHeader: String = "file", +// filename: String, +// mimeType: String, +// baseUrlProvider: BaseUrlProviding? = nil +// ) async throws(NetworkError) -> UploadRequest { +// let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) +// let resolvedSession = await resolveSession(sessionProvider: sessionProvider) +// +// return resolvedSession.upload( +// multipartFormData: { formData in +// formData.append(data, withName: fileHeader, fileName: filename, mimeType: mimeType) +// }, +// to: try? endpoint.url(on: resolvedBaseUrl), +// method: endpoint.method, +// headers: endpoint.headers +// ) +// } +// +// /// Uploads multipart form data to the specified `endpoint`. +// /// +// /// - Parameters: +// /// - endpoint: The endpoint instance representing the URL, method, parameters, and headers. +// /// - multipartFormData: The multipart form data to upload. +// /// - baseUrlProvider: An optional base URL provider. Defaults to `nil`. +// /// - Returns: An `UploadRequest` representing the upload. +// /// - Throws: A `NetworkError` if the upload fails. +// func uploadWithMultipart( +// endpoint: Endpoint, +// multipartFormData: MultipartFormData, +// baseUrlProvider: BaseUrlProviding? = nil +// ) async throws(NetworkError) -> UploadRequest { +// let resolvedBaseUrl = try await resolveBaseUrl(baseUrlProvider: baseUrlProvider) +// let resolvedSession = await resolveSession(sessionProvider: sessionProvider) +// +// return resolvedSession.upload( +// multipartFormData: multipartFormData, +// to: try? endpoint.url(on: resolvedBaseUrl), +// method: endpoint.method, +// headers: endpoint.headers +// ) +// } +// +//} +// +//// MARK: - Internal +// +//extension OLD_NetworkSession { +// +// /// Resolves the network session, creating a new one if necessary. +// /// +// /// - Parameter sessionProvider: The provider managing the session. +// /// - Returns: The resolved or newly created `Alamofire.Session`. +// func resolveSession(sessionProvider: NetworkSessionProviding) async -> Alamofire.Session { +// if await !sessionProvider.isSessionValid { +// await sessionProvider.makeSession() +// } else { +// await sessionProvider.resolveSession() +// } +// } +// +// /// Resolves the base URL using the provided or default base URL provider. +// /// +// /// - Parameter baseUrlProvider: An optional base URL provider. If `nil`, the default `baseUrlProvider` is used. +// /// - Returns: The resolved base URL as a `String`. +// /// - Throws: A `NetworkError.invalidBaseURL` if the base URL cannot be resolved. +// func resolveBaseUrl(baseUrlProvider: BaseUrlProviding?) async throws(NetworkError) -> String { +// let baseUrlProvider = baseUrlProvider ?? self.baseUrlProvider +// guard let resolvedBaseUrl = await baseUrlProvider?.resolveBaseUrl() else { +// throw .invalidBaseURL +// } +// return resolvedBaseUrl +// } +// +// /// Executes a closure while catching and transforming failures. +// /// +// /// - Parameters: +// /// - validationProvider: The provider used to transform any errors. +// /// - body: The closure to execute. +// /// - Returns: The result of type `Result`. +// /// - Throws: A transformed error if the closure fails. +// func catchingFailure( +// validationProvider: any ValidationProviding, +// body: () async throws -> Result +// ) async throws(Failure) -> Result { +// do { +// return try await body() +// } catch let networkError as NetworkError { +// throw validationProvider.transformError(networkError) +// } catch let error as AFError { +// if let underlyingError = error.underlyingError as? Failure { +// throw underlyingError +// } else if let underlyingError = error.underlyingError as? NetworkError { +// throw validationProvider.transformError(underlyingError) +// } else { +// throw validationProvider.transformError(NetworkError.sessionError) +// } +// } catch { +// throw validationProvider.transformError(NetworkError.sessionError) +// } +// } +// +//} diff --git a/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift b/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift index a3637ec..51ed6c4 100644 --- a/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift +++ b/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift @@ -5,63 +5,63 @@ // Created by Andrej Jasso on 15/11/2022. // -@preconcurrency import Alamofire +//@preconcurrency import Alamofire import Foundation /// NetworkSessionConfiguration represents the configuration used to create a NetworkSession object. -public struct NetworkSessionConfiguration: Sendable { - - // MARK: - Constants - - /// The `URLSessionConfiguration` used to configure the `Session` object. - public let urlSessionConfiguration: URLSessionConfiguration - - /// The `RequestInterceptor` used to intercept requests and modify them. - public let interceptor: RequestInterceptor? - - /// The `ServerTrustManager` used to validate the trust of a server. - public let serverTrustManager: ServerTrustManager? - - /// An array of `EventMonitor` objects used to monitor network events. - public let eventMonitors: [EventMonitor] - - // MARK: - Initialization - - /// Initializes a `NetworkSessionConfiguration` object with the provided parameters. - /// - /// - Parameters: - /// - urlSessionConfiguration: The `URLSessionConfiguration` used to configure the `Session` object. - /// - interceptor: The `RequestInterceptor` used to intercept requests and modify them. - /// - serverTrustManager: The `ServerTrustManager` used to validate the trust of a server. - /// - eventMonitors: An array of `EventMonitor` objects used to monitor network events. - public init( - urlSessionConfiguration: URLSessionConfiguration = .default, - interceptor: RequestInterceptor? = nil, - serverTrustManager: ServerTrustManager? = nil, - eventMonitors: [EventMonitor] = [] - ) { - self.urlSessionConfiguration = urlSessionConfiguration - self.interceptor = interceptor - self.serverTrustManager = serverTrustManager - self.eventMonitors = eventMonitors - } - - // MARK: - Static - - /// The default configuration for a `GRSession` object. - public static func `default`(logger: (any NetworkLogger)? = nil) -> NetworkSessionConfiguration { - var eventMonitors: [EventMonitor] = [] - - if let logger { - eventMonitors.append(LoggingEventMonitor(logger: logger)) - } - - return NetworkSessionConfiguration( - urlSessionConfiguration: .default, - interceptor: nil, - serverTrustManager: nil, - eventMonitors: eventMonitors - ) - } - -} +//public struct NetworkSessionConfiguration: Sendable { +// +// // MARK: - Constants +// +// /// The `URLSessionConfiguration` used to configure the `Session` object. +// public let urlSessionConfiguration: URLSessionConfiguration +// +// /// The `RequestInterceptor` used to intercept requests and modify them. +// public let interceptor: RequestInterceptor? +// +// /// The `ServerTrustManager` used to validate the trust of a server. +// public let serverTrustManager: ServerTrustManager? +// +// /// An array of `EventMonitor` objects used to monitor network events. +// public let eventMonitors: [EventMonitor] +// +// // MARK: - Initialization +// +// /// Initializes a `NetworkSessionConfiguration` object with the provided parameters. +// /// +// /// - Parameters: +// /// - urlSessionConfiguration: The `URLSessionConfiguration` used to configure the `Session` object. +// /// - interceptor: The `RequestInterceptor` used to intercept requests and modify them. +// /// - serverTrustManager: The `ServerTrustManager` used to validate the trust of a server. +// /// - eventMonitors: An array of `EventMonitor` objects used to monitor network events. +// public init( +// urlSessionConfiguration: URLSessionConfiguration = .default, +// interceptor: RequestInterceptor? = nil, +// serverTrustManager: ServerTrustManager? = nil, +// eventMonitors: [EventMonitor] = [] +// ) { +// self.urlSessionConfiguration = urlSessionConfiguration +// self.interceptor = interceptor +// self.serverTrustManager = serverTrustManager +// self.eventMonitors = eventMonitors +// } +// +// // MARK: - Static +// +// /// The default configuration for a `GRSession` object. +// public static func `default`(logger: (any NetworkLogger)? = nil) -> NetworkSessionConfiguration { +// var eventMonitors: [EventMonitor] = [] +// +// if let logger { +// eventMonitors.append(LoggingEventMonitor(logger: logger)) +// } +// +// return NetworkSessionConfiguration( +// urlSessionConfiguration: .default, +// interceptor: nil, +// serverTrustManager: nil, +// eventMonitors: eventMonitors +// ) +// } +// +//} diff --git a/Sources/GoodNetworking/Session/TransientSession.swift b/Sources/GoodNetworking/Session/TransientSession.swift index f5c0328..de8a065 100644 --- a/Sources/GoodNetworking/Session/TransientSession.swift +++ b/Sources/GoodNetworking/Session/TransientSession.swift @@ -14,29 +14,29 @@ /// a mechanism for deferring the resolution of a session. /// /// The session is supplied asynchronously via a `TransientSessionSupplier`. -public struct TransientSession { - - /// Function that will resolve a `NetworkSession` asynchronously when required. - public typealias TransientSessionSupplier = (@Sendable () async -> NetworkSession) - - private let supplier: TransientSessionSupplier - - /// Creates `TransientSession` with a supplier for network session resolution. - /// - /// - Parameter supplier: A function that will provide a `NetworkSession`. - public init(_ supplier: @escaping TransientSessionSupplier) { - self.supplier = supplier - } - - /// Resolves an appropriate session by calling the supplier. - /// - Returns: Network session resolved and returned from the supplier. - public func resolve() async -> NetworkSession { - return await supplier() - } - - /// Sugared resolution function. See ``resolve``. - public func callAsFunction() async -> NetworkSession { - return await resolve() - } - -} +//public struct TransientSession { +// +// /// Function that will resolve a `NetworkSession` asynchronously when required. +// public typealias TransientSessionSupplier = (@Sendable () async -> NetworkSession) +// +// private let supplier: TransientSessionSupplier +// +// /// Creates `TransientSession` with a supplier for network session resolution. +// /// +// /// - Parameter supplier: A function that will provide a `NetworkSession`. +// public init(_ supplier: @escaping TransientSessionSupplier) { +// self.supplier = supplier +// } +// +// /// Resolves an appropriate session by calling the supplier. +// /// - Returns: Network session resolved and returned from the supplier. +// public func resolve() async -> NetworkSession { +// return await supplier() +// } +// +// /// Sugared resolution function. See ``resolve``. +// public func callAsFunction() async -> NetworkSession { +// return await resolve() +// } +// +//} diff --git a/Sources/GoodNetworking/Wrapper/Pager.swift b/Sources/GoodNetworking/Wrapper/Pager.swift index e980ea3..78dde8a 100644 --- a/Sources/GoodNetworking/Wrapper/Pager.swift +++ b/Sources/GoodNetworking/Wrapper/Pager.swift @@ -7,38 +7,38 @@ import SwiftUI -@available(iOS 17.0, *) -public struct Pager: View { - - @State private var isFinished = false - private let resource: () -> Resource - - public init(resource: @escaping @autoclosure () -> Resource) { - self.resource = resource - } - - public var body: some View { - Group { - if !isFinished { - ProgressView() - } else { - Rectangle().frame(width: 0, height: 0).hidden() - } - } - .onAppear { Task.detached { await getNextPage() }} - } - - private func getNextPage() async { - if let nextPage = resource().nextPageRequest() { - isFinished = false - do { - try await resource().list(request: nextPage) - } catch { - isFinished = true - } - } else { - isFinished = true - } - } - -} +//@available(iOS 17.0, *) +//public struct Pager: View { +// +// @State private var isFinished = false +// private let resource: () -> Resource +// +// public init(resource: @escaping @autoclosure () -> Resource) { +// self.resource = resource +// } +// +// public var body: some View { +// Group { +// if !isFinished { +// ProgressView() +// } else { +// Rectangle().frame(width: 0, height: 0).hidden() +// } +// } +// .onAppear { Task.detached { await getNextPage() }} +// } +// +// private func getNextPage() async { +// if let nextPage = resource().nextPageRequest() { +// isFinished = false +// do { +// try await resource().list(request: nextPage) +// } catch { +// isFinished = true +// } +// } else { +// isFinished = true +// } +// } +// +//} diff --git a/Sources/GoodNetworking/Wrapper/Resource.swift b/Sources/GoodNetworking/Wrapper/Resource.swift index fe8a05c..ff67b52 100644 --- a/Sources/GoodNetworking/Wrapper/Resource.swift +++ b/Sources/GoodNetworking/Wrapper/Resource.swift @@ -5,430 +5,429 @@ // Created by Filip Ε aΕ‘ala on 08/12/2023. // -import Alamofire -import SwiftUI - -// MARK: - Resource - -public struct RawResponse: Sendable { - - var create: (Decodable & Sendable)? - var read: (Decodable & Sendable)? - var update: (Decodable & Sendable)? - var delete: (Decodable & Sendable)? - var list: (Decodable & Sendable)? - -} - -@available(iOS 17.0, *) -@MainActor @Observable public final class Resource { - - private var session: FutureSession - private var rawResponse: RawResponse = RawResponse() - private var remote: R.Type - private let logger: NetworkLogger? - - private(set) public var state: ResourceState - private var listState: ResourceState<[R.Resource], NetworkError> - private var listParameters: Any? - - public var value: R.Resource? { - get { - state.value - } - set { - if let newValue { - state = .pending(newValue) - listState = .pending([newValue]) - } else { - state = .idle - listState = .idle - } - } - } - - public init( - wrappedValue: R.Resource? = nil, - session: NetworkSession, - remote: R.Type, - logger: NetworkLogger? = nil - ) { - self.session = FutureSession { session } - self.remote = remote - self.logger = logger - - if let wrappedValue { - self.state = .available(wrappedValue) - self.listState = .available([wrappedValue]) - } else { - self.state = .idle - self.listState = .idle - } - } - - public init( - session: FutureSession? = nil, - remote: R.Type, - logger: NetworkLogger? = nil - ) { - self.session = session ?? .placeholder - self.remote = remote - self.logger = logger - self.state = .idle - self.listState = .idle - } - - @discardableResult - public func session(_ networkSession: NetworkSession) -> Self { - self.session = FutureSession { networkSession } - return self - } - - @discardableResult - public func session(_ futureSession: FutureSession) -> Self { - self.session = futureSession - return self - } - - @discardableResult - public func session(_ sessionSupplier: @escaping FutureSession.FutureSessionSupplier) -> Self { - self.session = FutureSession(sessionSupplier) - return self - } - - @discardableResult - public func initialResource(_ newValue: R.Resource) -> Self { - self.state = .available(newValue) - self.listState = .available([newValue]) - return self - } - -} - -// MARK: - Operations - -@available(iOS 17.0, *) -extension Resource { - - public func create() async throws { - logger?.logNetworkEvent(message: "CREATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) - } - - public func read(forceReload: Bool = false) async throws { - logger?.logNetworkEvent(message: "READ operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) - } - - public func updateRemote() async throws { - logger?.logNetworkEvent(message: "UPDATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) - } - - public func delete() async throws { - logger?.logNetworkEvent(message: "DELETE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) - } - - public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { - logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) - logger?.logNetworkEvent(message: "Check type of parameters passed to this resource.", level: .error, fileName: #file, lineNumber: #line) - logger?.logNetworkEvent(message: "Current parameters type: \(type(of: parameters))", level: .error, fileName: #file, lineNumber: #line) - } - - public func nextPage() async throws { - logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) - } - -} - -// MARK: - Create - -@available(iOS 17.0, *) -extension Resource where R: Creatable { - - public func create() async throws { - guard let request = try R.request(from: state.value) else { - logger?.logNetworkEvent(message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", level: .error, fileName: #file, lineNumber: #line) - return - } - try await create(request: request) - } - - public func create(request: R.CreateRequest) async throws { - let resource = state.value - if let resource { - self.state = .uploading(resource) - self.listState = .uploading([resource]) - } else { - self.state = .loading - self.listState = .loading - } - - do { - let response = try await remote.create( - using: session(), - request: request - ) - self.rawResponse.create = response - - let resource = try R.resource(from: response, updating: resource) - try Task.checkCancellation() - - self.state = .available(resource) - self.listState = .available([resource]) - } catch let error as NetworkError { - if let resource { - self.state = .stale(resource, error) - self.listState = .stale([resource], error) - } else { - self.state = .failure(error) - self.listState = .failure(error) - } - - throw error - } catch { - throw error - } - } - -} - -// MARK: - Read - -@available(iOS 17.0, *) -extension Resource where R: Readable { - - // forceReload is default true, when resource is already set, calling read() is expected to always reload the data - public func read(forceReload: Bool = true) async throws { - let resource = state.value - guard let request = try R.request(from: resource) else { - self.state = .idle - logger?.logNetworkEvent( - message: "Reading nil resource always fails! Use read(request:) with a custom request or supply a resource to read from.", - level: .error, - fileName: #file, - lineNumber: #line - ) - return - } - - try await read(request: request, forceReload: forceReload) - } - - public func read(request: R.ReadRequest, forceReload: Bool = false) async throws { - guard !state.isAvailable || forceReload else { - logger?.logNetworkEvent(message: "Skipping read - value already exists", level: .info, fileName: #file, lineNumber: #line) - return - } - - let resource = state.value - self.state = .loading - self.listState = .loading - - do { - let response = try await remote.read( - using: session(), - request: request - ) - self.rawResponse.read = response - - let resource = try R.resource(from: response, updating: resource) - try Task.checkCancellation() - - self.state = .available(resource) - self.listState = .available([resource]) - } catch let error as NetworkError { - if let resource { - self.state = .stale(resource, error) - self.listState = .stale([resource], error) - } else { - self.state = .failure(error) - self.listState = .failure(error) - } - - throw error - } catch { - throw error - } - } - -} - -// MARK: - Update - -@available(iOS 17.0, *) -extension Resource where R: Updatable { - - public func updateRemote() async throws { - guard let request = try R.request(from: state.value) else { - logger?.logNetworkEvent(message: "Updating resource to nil always fails! Use DELETE instead.", level: .error, fileName: #file, lineNumber: #line) - return - } - try await updateRemote(request: request) - } - - public func updateRemote(request: R.UpdateRequest) async throws { - let resource = state.value - if let resource { - self.state = .uploading(resource) - self.listState = .uploading([resource]) - } else { - self.state = .loading - self.listState = .loading - } - - do { - let response = try await remote.update( - using: session(), - request: request - ) - self.rawResponse.update = response - - let resource = try R.resource(from: response, updating: resource) - try Task.checkCancellation() - - self.state = .available(resource) - self.listState = .available([resource]) - } catch let error as NetworkError { - if let resource { - self.state = .stale(resource, error) - self.listState = .stale([resource], error) - } else { - self.state = .failure(error) - self.listState = .failure(error) - } - - throw error - } catch { - throw error - } - } - -} - -// MARK: - Delete - -@available(iOS 17.0, *) -extension Resource where R: Deletable { - - public func delete() async throws { - guard let request = try R.request(from: state.value) else { - logger?.logNetworkEvent(message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", level: .error, fileName: #file, lineNumber: #line) - return - } - try await delete(request: request) - } - - public func delete(request: R.DeleteRequest) async throws { - self.state = .loading - self.listState = .loading - - do { - let response = try await remote.delete( - using: session(), - request: request - ) - self.rawResponse.delete = response - - let resource = try R.resource(from: response, updating: state.value) - try Task.checkCancellation() - - if let resource { - // case with partial/soft delete only - self.state = .available(resource) - self.listState = .available([resource]) - } else { - self.state = .idle - #warning("TODO: vymazat z listu iba prave vymazovany element") - self.listState = .idle - } - } catch let error as NetworkError { - self.state = .failure(error) - self.listState = .failure(error) - - throw error - } catch { - throw error - } - } - -} - -// MARK: - List - -@available(iOS 17.0, *) -extension Resource where R: Listable { - - public var elements: [R.Resource] { - if let list = listState.value { - return list - } else { - return Array.init(repeating: .placeholder, count: 3) - } - } - - public var startIndex: Int { - elements.startIndex - } - - public var endIndex: Int { - elements.endIndex - } - - public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { - if !(listState.value?.isEmpty ?? true) || forceReload { - self.listState = .idle - self.state = .loading - } - self.listParameters = parameters - - let firstPageRequest = R.firstPageRequest(withParameters: parameters) - try await list(request: firstPageRequest) - } - - public func nextPage() async throws { - guard let nextPageRequest = nextPageRequest() else { return } - try await list(request: nextPageRequest) - } - - internal func nextPageRequest() -> R.ListRequest? { - guard let currentList = listState.value, - let lastResponse = rawResponse.list as? R.ListResponse - else { - return nil - } - - return R.nextPageRequest( - currentResource: currentList, - parameters: self.listParameters, - lastResponse: lastResponse - ) - } - - public func list(request: R.ListRequest) async throws { - if !state.isAvailable { - self.state = .loading - } - - do { - let response = try await remote.list( - using: session(), - request: request - ) - self.rawResponse.list = response - - let list = R.list(from: response, oldValue: listState.value ?? []) - self.listState = .available(list) - - let resource = list.first - if let resource { - self.state = .available(resource) - } else { - self.state = .idle - } - } catch let error { - self.state = .failure(error) - self.listState = .failure(error) - - throw error - } - } - -} +//import SwiftUI +// +//// MARK: - Resource +// +//public struct RawResponse: Sendable { +// +// var create: (Decodable & Sendable)? +// var read: (Decodable & Sendable)? +// var update: (Decodable & Sendable)? +// var delete: (Decodable & Sendable)? +// var list: (Decodable & Sendable)? +// +//} +// +//@available(iOS 17.0, *) +//@MainActor @Observable public final class Resource { +// +// private var session: FutureSession +// private var rawResponse: RawResponse = RawResponse() +// private var remote: R.Type +// private let logger: NetworkLogger? +// +// private(set) public var state: ResourceState +// private var listState: ResourceState<[R.Resource], NetworkError> +// private var listParameters: Any? +// +// public var value: R.Resource? { +// get { +// state.value +// } +// set { +// if let newValue { +// state = .pending(newValue) +// listState = .pending([newValue]) +// } else { +// state = .idle +// listState = .idle +// } +// } +// } +// +// public init( +// wrappedValue: R.Resource? = nil, +// session: NetworkSession, +// remote: R.Type, +// logger: NetworkLogger? = nil +// ) { +// self.session = FutureSession { session } +// self.remote = remote +// self.logger = logger +// +// if let wrappedValue { +// self.state = .available(wrappedValue) +// self.listState = .available([wrappedValue]) +// } else { +// self.state = .idle +// self.listState = .idle +// } +// } +// +// public init( +// session: FutureSession? = nil, +// remote: R.Type, +// logger: NetworkLogger? = nil +// ) { +// self.session = session ?? .placeholder +// self.remote = remote +// self.logger = logger +// self.state = .idle +// self.listState = .idle +// } +// +// @discardableResult +// public func session(_ networkSession: NetworkSession) -> Self { +// self.session = FutureSession { networkSession } +// return self +// } +// +// @discardableResult +// public func session(_ futureSession: FutureSession) -> Self { +// self.session = futureSession +// return self +// } +// +// @discardableResult +// public func session(_ sessionSupplier: @escaping FutureSession.FutureSessionSupplier) -> Self { +// self.session = FutureSession(sessionSupplier) +// return self +// } +// +// @discardableResult +// public func initialResource(_ newValue: R.Resource) -> Self { +// self.state = .available(newValue) +// self.listState = .available([newValue]) +// return self +// } +// +//} +// +//// MARK: - Operations +// +//@available(iOS 17.0, *) +//extension Resource { +// +// public func create() async throws { +// logger?.logNetworkEvent(message: "CREATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) +// } +// +// public func read(forceReload: Bool = false) async throws { +// logger?.logNetworkEvent(message: "READ operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) +// } +// +// public func updateRemote() async throws { +// logger?.logNetworkEvent(message: "UPDATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) +// } +// +// public func delete() async throws { +// logger?.logNetworkEvent(message: "DELETE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) +// } +// +// public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { +// logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) +// logger?.logNetworkEvent(message: "Check type of parameters passed to this resource.", level: .error, fileName: #file, lineNumber: #line) +// logger?.logNetworkEvent(message: "Current parameters type: \(type(of: parameters))", level: .error, fileName: #file, lineNumber: #line) +// } +// +// public func nextPage() async throws { +// logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) +// } +// +//} +// +//// MARK: - Create +// +//@available(iOS 17.0, *) +//extension Resource where R: Creatable { +// +// public func create() async throws { +// guard let request = try R.request(from: state.value) else { +// logger?.logNetworkEvent(message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", level: .error, fileName: #file, lineNumber: #line) +// return +// } +// try await create(request: request) +// } +// +// public func create(request: R.CreateRequest) async throws { +// let resource = state.value +// if let resource { +// self.state = .uploading(resource) +// self.listState = .uploading([resource]) +// } else { +// self.state = .loading +// self.listState = .loading +// } +// +// do { +// let response = try await remote.create( +// using: session(), +// request: request +// ) +// self.rawResponse.create = response +// +// let resource = try R.resource(from: response, updating: resource) +// try Task.checkCancellation() +// +// self.state = .available(resource) +// self.listState = .available([resource]) +// } catch let error as NetworkError { +// if let resource { +// self.state = .stale(resource, error) +// self.listState = .stale([resource], error) +// } else { +// self.state = .failure(error) +// self.listState = .failure(error) +// } +// +// throw error +// } catch { +// throw error +// } +// } +// +//} +// +//// MARK: - Read +// +//@available(iOS 17.0, *) +//extension Resource where R: Readable { +// +// // forceReload is default true, when resource is already set, calling read() is expected to always reload the data +// public func read(forceReload: Bool = true) async throws { +// let resource = state.value +// guard let request = try R.request(from: resource) else { +// self.state = .idle +// logger?.logNetworkEvent( +// message: "Reading nil resource always fails! Use read(request:) with a custom request or supply a resource to read from.", +// level: .error, +// fileName: #file, +// lineNumber: #line +// ) +// return +// } +// +// try await read(request: request, forceReload: forceReload) +// } +// +// public func read(request: R.ReadRequest, forceReload: Bool = false) async throws { +// guard !state.isAvailable || forceReload else { +// logger?.logNetworkEvent(message: "Skipping read - value already exists", level: .info, fileName: #file, lineNumber: #line) +// return +// } +// +// let resource = state.value +// self.state = .loading +// self.listState = .loading +// +// do { +// let response = try await remote.read( +// using: session(), +// request: request +// ) +// self.rawResponse.read = response +// +// let resource = try R.resource(from: response, updating: resource) +// try Task.checkCancellation() +// +// self.state = .available(resource) +// self.listState = .available([resource]) +// } catch let error as NetworkError { +// if let resource { +// self.state = .stale(resource, error) +// self.listState = .stale([resource], error) +// } else { +// self.state = .failure(error) +// self.listState = .failure(error) +// } +// +// throw error +// } catch { +// throw error +// } +// } +// +//} +// +//// MARK: - Update +// +//@available(iOS 17.0, *) +//extension Resource where R: Updatable { +// +// public func updateRemote() async throws { +// guard let request = try R.request(from: state.value) else { +// logger?.logNetworkEvent(message: "Updating resource to nil always fails! Use DELETE instead.", level: .error, fileName: #file, lineNumber: #line) +// return +// } +// try await updateRemote(request: request) +// } +// +// public func updateRemote(request: R.UpdateRequest) async throws { +// let resource = state.value +// if let resource { +// self.state = .uploading(resource) +// self.listState = .uploading([resource]) +// } else { +// self.state = .loading +// self.listState = .loading +// } +// +// do { +// let response = try await remote.update( +// using: session(), +// request: request +// ) +// self.rawResponse.update = response +// +// let resource = try R.resource(from: response, updating: resource) +// try Task.checkCancellation() +// +// self.state = .available(resource) +// self.listState = .available([resource]) +// } catch let error as NetworkError { +// if let resource { +// self.state = .stale(resource, error) +// self.listState = .stale([resource], error) +// } else { +// self.state = .failure(error) +// self.listState = .failure(error) +// } +// +// throw error +// } catch { +// throw error +// } +// } +// +//} +// +//// MARK: - Delete +// +//@available(iOS 17.0, *) +//extension Resource where R: Deletable { +// +// public func delete() async throws { +// guard let request = try R.request(from: state.value) else { +// logger?.logNetworkEvent(message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", level: .error, fileName: #file, lineNumber: #line) +// return +// } +// try await delete(request: request) +// } +// +// public func delete(request: R.DeleteRequest) async throws { +// self.state = .loading +// self.listState = .loading +// +// do { +// let response = try await remote.delete( +// using: session(), +// request: request +// ) +// self.rawResponse.delete = response +// +// let resource = try R.resource(from: response, updating: state.value) +// try Task.checkCancellation() +// +// if let resource { +// // case with partial/soft delete only +// self.state = .available(resource) +// self.listState = .available([resource]) +// } else { +// self.state = .idle +// #warning("TODO: vymazat z listu iba prave vymazovany element") +// self.listState = .idle +// } +// } catch let error as NetworkError { +// self.state = .failure(error) +// self.listState = .failure(error) +// +// throw error +// } catch { +// throw error +// } +// } +// +//} +// +//// MARK: - List +// +//@available(iOS 17.0, *) +//extension Resource where R: Listable { +// +// public var elements: [R.Resource] { +// if let list = listState.value { +// return list +// } else { +// return Array.init(repeating: .placeholder, count: 3) +// } +// } +// +// public var startIndex: Int { +// elements.startIndex +// } +// +// public var endIndex: Int { +// elements.endIndex +// } +// +// public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { +// if !(listState.value?.isEmpty ?? true) || forceReload { +// self.listState = .idle +// self.state = .loading +// } +// self.listParameters = parameters +// +// let firstPageRequest = R.firstPageRequest(withParameters: parameters) +// try await list(request: firstPageRequest) +// } +// +// public func nextPage() async throws { +// guard let nextPageRequest = nextPageRequest() else { return } +// try await list(request: nextPageRequest) +// } +// +// internal func nextPageRequest() -> R.ListRequest? { +// guard let currentList = listState.value, +// let lastResponse = rawResponse.list as? R.ListResponse +// else { +// return nil +// } +// +// return R.nextPageRequest( +// currentResource: currentList, +// parameters: self.listParameters, +// lastResponse: lastResponse +// ) +// } +// +// public func list(request: R.ListRequest) async throws { +// if !state.isAvailable { +// self.state = .loading +// } +// +// do { +// let response = try await remote.list( +// using: session(), +// request: request +// ) +// self.rawResponse.list = response +// +// let list = R.list(from: response, oldValue: listState.value ?? []) +// self.listState = .available(list) +// +// let resource = list.first +// if let resource { +// self.state = .available(resource) +// } else { +// self.state = .idle +// } +// } catch let error { +// self.state = .failure(error) +// self.listState = .failure(error) +// +// throw error +// } +// } +// +//} From d6a3d3f12f0a3e94baf3057b298ce00a8b049634 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:04:32 +0200 Subject: [PATCH 08/13] feat: Remove Alamofire vol.2 --- Sources/GoodNetworking/NetworkActor.swift | 13 + .../GoodNetworking/Protocols/Endpoint.swift | 29 +- .../Providers/DefaultValidationProvider.swift | 2 +- .../GoodNetworking/Session/GRSession.swift | 259 ++++++++++++++++-- .../GoodNetworking/Session/NetworkError.swift | 76 ++--- 5 files changed, 308 insertions(+), 71 deletions(-) diff --git a/Sources/GoodNetworking/NetworkActor.swift b/Sources/GoodNetworking/NetworkActor.swift index 8201f6a..9255694 100644 --- a/Sources/GoodNetworking/NetworkActor.swift +++ b/Sources/GoodNetworking/NetworkActor.swift @@ -22,6 +22,19 @@ import Foundation self.executor = NetworkActorSerialExecutor(queue: NetworkActor.queue) } + public static func assumeIsolated(_ operation: @NetworkActor () throws -> T) rethrows -> T { + typealias YesActor = @NetworkActor () throws -> T + typealias NoActor = () throws -> T + + self.shared.preconditionIsolated() + + // To do the unsafe cast, we have to pretend it's @escaping. + return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in + let rawFn = unsafeBitCast(fn, to: NoActor.self) + return try rawFn() + } + } + } final class NetworkActorSerialExecutor: SerialExecutor { diff --git a/Sources/GoodNetworking/Protocols/Endpoint.swift b/Sources/GoodNetworking/Protocols/Endpoint.swift index d1dc6a1..7b4b801 100644 --- a/Sources/GoodNetworking/Protocols/Endpoint.swift +++ b/Sources/GoodNetworking/Protocols/Endpoint.swift @@ -88,6 +88,26 @@ public enum EndpointParameters { } } + internal func data() -> Data? { + switch self { + case .model(let codableModel as CustomEncodable): + return try? JSONSerialization.data(withJSONObject: codableModel.jsonDictionary) + + case .model(let codableModel): + let encoder = JSONEncoder() + let data = try? encoder.encode(codableModel) + return data + + case .parameters(let parameters): + return try? JSONSerialization.data(withJSONObject: parameters) + } + } + + internal func queryItems() -> [URLQueryItem] { + guard let dictionary = self.dictionary else { return [] } + return dictionary.map { key, value in URLQueryItem(name: key, value: "\(value)") } + } + } // MARK: - Compatibility @@ -105,12 +125,13 @@ public enum JSONEncoding: ParameterEncoding { case `default` } -@available(*, deprecated) -extension String { +@available(*, deprecated, message: "Use URLConvertible instead.") +public extension String { - func asURL() throws -> URL { + @available(*, deprecated, message: "Use URLConvertible instead.") + public func asURL() throws -> URL { guard let url = URL(string: self) else { - throw NetworkError.invalidBaseURL + throw URLError(.badURL).asNetworkError() } return url } diff --git a/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift b/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift index 9c68d0f..b7df5bd 100644 --- a/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift @@ -37,7 +37,7 @@ public struct DefaultValidationProvider: ValidationProviding { /// - Throws: A `NetworkError.remote` if the status code indicates a failure (outside the 200-299 range). public func validate(statusCode: Int, data: Data) throws(Failure) { if statusCode < 200 || statusCode >= 300 { - throw NetworkError.remote(statusCode: statusCode, data: data) + throw NetworkError.remote(HTTPError(statusCode: statusCode, errorResponse: data)) } } diff --git a/Sources/GoodNetworking/Session/GRSession.swift b/Sources/GoodNetworking/Session/GRSession.swift index 18120f6..21e4713 100644 --- a/Sources/GoodNetworking/Session/GRSession.swift +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -7,21 +7,68 @@ import Foundation +// MARK: - Interception + +public protocol Interceptor: Sendable { + + func intercept(urlRequest: inout URLRequest) + +} + +public final class NoInterceptor: Interceptor { + + public init() {} + public func intercept(urlRequest: inout URLRequest) {} + +} + +public final class CompositeInterceptor: Interceptor { + + private let interceptors: [Interceptor] + + public init(interceptors: [Interceptor]) { + self.interceptors = interceptors + } + + public func intercept(urlRequest: inout URLRequest) { + for interceptor in interceptors { + interceptor.intercept(urlRequest: &urlRequest) + } + } + +} + // MARK: - Initialization -public final class NetworkSession: NSObject, Sendable { +@NetworkActor public final class NetworkSession: NSObject, Sendable { private let baseUrl: any URLConvertible - private let session: URLSession private let baseHeaders: HTTPHeaders + private let interceptor: any Interceptor + + private let configuration: URLSessionConfiguration + private let delegateQueue: OperationQueue + private lazy var session: URLSession = { + URLSession( + configuration: configuration, + delegate: NetworkSessionDelegate(for: self), + delegateQueue: delegateQueue + ) + }() + + /// Holds references to `DataTaskProxy` objects based on + /// DataTask `taskIdentifier`-s. + private var taskProxyMap: [Int: DataTaskProxy] = [:] - public init( + nonisolated public init( baseUrl: any URLConvertible, baseHeaders: HTTPHeaders = [], + interceptor: any Interceptor = NoInterceptor(), logger: any NetworkLogger = PrintNetworkLogger() ) { self.baseUrl = baseUrl self.baseHeaders = baseHeaders + self.interceptor = interceptor let operationQueue = OperationQueue() operationQueue.underlyingQueue = NetworkActor.queue @@ -29,18 +76,41 @@ public final class NetworkSession: NSObject, Sendable { let configuration = URLSessionConfiguration.ephemeral configuration.httpAdditionalHeaders = baseHeaders.map { $0.resolveHeader() }.reduce(into: [:], { $0[$1.name] = $1.value }) - self.session = URLSession( - configuration: configuration, - delegate: NetworkSessionDelegate(), - delegateQueue: operationQueue - ) + self.configuration = configuration + self.delegateQueue = operationQueue + + // create URLSession lazily, isolated on @NetworkActor, when required first time + + super.init() + } + +} + +internal extension NetworkSession { + + func proxyForTask(_ task: URLSessionTask) -> DataTaskProxy { + if let existingProxy = self.taskProxyMap[task.taskIdentifier] { + return existingProxy + } else { + let newProxy = DataTaskProxy(task: task) + self.taskProxyMap[task.taskIdentifier] = newProxy + return newProxy + } } } // MARK: - Network session delegate -final class NetworkSessionDelegate: NSObject, Sendable {} +final class NetworkSessionDelegate: NSObject { + + private unowned let networkSession: NetworkSession + + internal init(for networkSession: NetworkSession) { + self.networkSession = networkSession + } + +} extension NetworkSessionDelegate: URLSessionDelegate { @@ -55,33 +125,190 @@ extension NetworkSessionDelegate: URLSessionDelegate { } +extension NetworkSessionDelegate: URLSessionTaskDelegate { + + func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + NetworkActor.assumeIsolated { + print(Thread.current.name) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + NetworkActor.assumeIsolated { + networkSession + .proxyForTask(task) + .dataTaskDidComplete(withError: error) + } + } + +} + +extension NetworkSessionDelegate: URLSessionDataDelegate { + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse + ) async -> URLSession.ResponseDisposition { + return .allow + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + NetworkActor.assumeIsolated { + networkSession + .proxyForTask(dataTask) + .dataTaskDidReceive(data: data) + } + } + +} + // MARK: - Request extension NetworkSession { - public func request(endpoint: Endpoint) async -> Data { - return Data() + public func request(endpoint: Endpoint) async throws(NetworkError) -> T { + guard let url = try? await endpoint.url(on: endpoint.path) else { + throw URLError(.badURL).asNetworkError() + } + + // url + method + var request = URLRequest(url: url) + request.httpMethod = endpoint.method.rawValue + + // encoding + if endpoint.encoding is URLEncoding { + if #available(iOS 16, *) { + request.url?.append(queryItems: endpoint.parameters?.queryItems() ?? []) + } else { + var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + urlComponents?.queryItems?.append(contentsOf: endpoint.parameters?.queryItems() ?? []) + request.url = urlComponents?.url + } + } else if endpoint.encoding is JSONEncoding { + request.httpBody = endpoint.parameters?.data() + } else { +// fatalError("LEGACY ENDPOINT INVOKED WITH UNSUPPORTED ENCODING SCHEME") + throw URLError(.cannotEncodeRawData).asNetworkError() + } + + interceptor.intercept(urlRequest: &request) + + let dataTask = session.dataTask(with: request) + let dataTaskProxy = proxyForTask(dataTask) + let data = try await dataTaskProxy.data() + + do { + let model = try JSONDecoder().decode(T.self, from: data) + return model + } catch { + throw URLError(.cannotDecodeRawData).asNetworkError() + } + } + + public func get(_ path: URLConvertible) async throws(NetworkError) -> Data { + guard let url = await path.resolveUrl() else { + throw URLError(.badURL).asNetworkError() + } + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.get.rawValue + request.httpBody = nil + + interceptor.intercept(urlRequest: &request) + + let dataTask = session.dataTask(with: request) + let dataTaskProxy = proxyForTask(dataTask) + return try await dataTaskProxy.data() } - public func get(_ path: URLConvertible) async -> Data { - return Data() +} + +// MARK: - Custom URLErrors + +extension URLError.Code { + + public static var cannotEncodeRawData: URLError.Code { + URLError.Code(rawValue: 7777) } } +// MARK: - DataTaskProxy +@NetworkActor final class DataTaskProxy { + + private(set) var task: URLSessionTask + + private var receivedData: Data = Data() + private var receivedError: (URLError)? = nil + private var isFinished = false + private var continuation: CheckedContinuation? = nil + + func data() async throws(NetworkError) -> Data { + if !isFinished { await waitForCompletion() } + if let receivedError { throw receivedError.asNetworkError() } + return receivedData + } + + func result() async -> Result { + if !isFinished { await waitForCompletion() } + if let receivedError { + return .failure(receivedError.asNetworkError()) + } else { + return .success(receivedData) + } + } + + init(task: URLSessionTask) { + self.task = task + } + + func dataTaskDidReceive(data: Data) { + assert(isFinished == false, "ILLEGAL ATTEMPT TO APPEND DATA TO FINISHED PROXY INSTANCE") + receivedData.append(data) + } + + func dataTaskDidComplete(withError error: (any Error)?) { + assert(isFinished == false, "ILLEGAL ATTEMPT TO RESUME FINISHED CONTINUATION") + self.isFinished = true + + if let error = error as? URLError { + self.receivedError = error + } else { + fatalError("URLSessionTaskDelegate does not throw URLErrors") + } + + continuation?.resume() + continuation = nil + } + + func waitForCompletion() async { + assert(self.continuation == nil, "CALLING RESULT/DATA CONCURRENTLY WILL LEAK RESOURCES") + assert(isFinished == false, "FINISHED PROXY CANNOT RESUME CONTINUATION") + try await withCheckedContinuation { self.continuation = $0 } + } + +} // MARK: - Sample func x() async { - let session = NetworkSession( baseUrl: "https://api.sampleapis.com/", baseHeaders: [HTTPHeader("User-Agent: iOS app")] ) - await session.request(endpoint: CoffeeEndpoint.hot) - await session.get("/coffee/hot") + do { + try await session.request(endpoint: CoffeeEndpoint.hot) as String + try await session.get("/coffee/hot") + } catch let error { + assert(error is URLError) + } } diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index 765ed26..57cc81c 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -7,76 +7,52 @@ import Foundation +extension URLError { + + func asNetworkError() -> NetworkError { + return NetworkError.local(self) + } + +} + public enum NetworkError: LocalizedError, Hashable { - case endpoint(EndpointError) - case remote(statusCode: Int, data: Data) - case paging(PagingError) - case missingLocalData - case missingRemoteData - case sessionError - case invalidBaseURL - case cancelled + case local(URLError) + case remote(HTTPError) public var errorDescription: String? { switch self { - case .endpoint(let endpointError): - return endpointError.errorDescription - - case .remote(let statusCode, _): - return "HTTP \(statusCode) - \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" + case .local(let urlError): + return "" - case .paging(let pagingError): - return pagingError.errorDescription - - case .missingLocalData: - return "Missing data - Failed to map local resource to remote type" + case .remote(let httpError): + return httpError.localizedDescription + } + } - case .missingRemoteData: - return "Missing data - Failed to map remote resource to local type" +} - case .sessionError: - return "Internal session error" +public struct HTTPError: LocalizedError, Hashable { - case .invalidBaseURL: - return "Resolved server base URL is invalid" + let statusCode: Int? + let errorResponse: Data? - case .cancelled: - return "Operation cancelled" - } - } - - var statusCode: Int? { - if case let .remote(statusCode, _) = self { - return statusCode - } else { - return nil - } + public init(statusCode: Int? = nil, errorResponse: Data? = nil) { + self.statusCode = statusCode + self.errorResponse = errorResponse } func remoteError(as errorType: E.Type) -> E? { - if case let .remote(_, data) = self { + if let data = errorResponse { return try? JSONDecoder().decode(errorType, from: data) } else { return nil } } -} - -public enum EndpointError: LocalizedError { - - case noSuchEndpoint - case operationNotSupported - public var errorDescription: String? { - switch self { - case .noSuchEndpoint: - return "No such endpoint" - - case .operationNotSupported: - return "Operation not supported" - } + guard let statusCode else { return "HTTP error" } + return "HTTP \(statusCode) - \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" } } From b48aecd1033bf54db7f390c1b14b2c42b9fe287e Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:28:45 +0200 Subject: [PATCH 09/13] feat: Remove Alamofire vol.3 --- .../GoodNetworking/Session/GRSession.swift | 38 ++++++++++++++----- .../GoodNetworking/Session/NetworkError.swift | 23 +++++------ 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/Sources/GoodNetworking/Session/GRSession.swift b/Sources/GoodNetworking/Session/GRSession.swift index 21e4713..e1c3b11 100644 --- a/Sources/GoodNetworking/Session/GRSession.swift +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -171,7 +171,29 @@ extension NetworkSessionDelegate: URLSessionDataDelegate { extension NetworkSession { + public func request( + endpoint: Endpoint, + validationProvider: any ValidationProviding = DefaultValidationProvider() + ) async throws(F) -> T { + do { + return try await request(endpoint: endpoint) + } catch let error { + throw validationProvider.transformError(error) + } + } + public func request(endpoint: Endpoint) async throws(NetworkError) -> T { + let data = try await request(endpoint: endpoint) + + do { + let model = try JSONDecoder().decode(T.self, from: data) + return model + } catch { + throw URLError(.cannotDecodeRawData).asNetworkError() + } + } + + public func request(endpoint: Endpoint) async throws(NetworkError) -> Data { guard let url = try? await endpoint.url(on: endpoint.path) else { throw URLError(.badURL).asNetworkError() } @@ -192,7 +214,6 @@ extension NetworkSession { } else if endpoint.encoding is JSONEncoding { request.httpBody = endpoint.parameters?.data() } else { -// fatalError("LEGACY ENDPOINT INVOKED WITH UNSUPPORTED ENCODING SCHEME") throw URLError(.cannotEncodeRawData).asNetworkError() } @@ -200,16 +221,15 @@ extension NetworkSession { let dataTask = session.dataTask(with: request) let dataTaskProxy = proxyForTask(dataTask) - let data = try await dataTaskProxy.data() - - do { - let model = try JSONDecoder().decode(T.self, from: data) - return model - } catch { - throw URLError(.cannotDecodeRawData).asNetworkError() - } + return try await dataTaskProxy.data() } +} + +// MARK: - Shorthand requests + +extension NetworkSession { + public func get(_ path: URLConvertible) async throws(NetworkError) -> Data { guard let url = await path.resolveUrl() else { throw URLError(.badURL).asNetworkError() diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index 57cc81c..9abd03b 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -34,25 +34,20 @@ public enum NetworkError: LocalizedError, Hashable { public struct HTTPError: LocalizedError, Hashable { - let statusCode: Int? - let errorResponse: Data? + public let statusCode: Int + public let errorResponse: Data - public init(statusCode: Int? = nil, errorResponse: Data? = nil) { - self.statusCode = statusCode - self.errorResponse = errorResponse + public var errorDescription: String? { + return "HTTP \(statusCode) - \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" } - func remoteError(as errorType: E.Type) -> E? { - if let data = errorResponse { - return try? JSONDecoder().decode(errorType, from: data) - } else { - return nil - } + public init(statusCode: Int, errorResponse: Data) { + self.statusCode = statusCode + self.errorResponse = errorResponse } - public var errorDescription: String? { - guard let statusCode else { return "HTTP error" } - return "HTTP \(statusCode) - \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" + public func remoteError(as errorType: E.Type) -> E? { + return try? JSONDecoder().decode(errorType, from: errorResponse) } } From 1524674834cd1cb2bb439ad6cab2c9a29e5557d5 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Thu, 10 Jul 2025 00:21:38 +0200 Subject: [PATCH 10/13] feat: Remove Alamofire vol.4 --- .../GoodNetworking/Protocols/Endpoint.swift | 2 +- .../GoodNetworking/Session/GRSession.swift | 26 ++++++++++++++++++- .../GoodNetworking/Session/NetworkError.swift | 8 ++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Sources/GoodNetworking/Protocols/Endpoint.swift b/Sources/GoodNetworking/Protocols/Endpoint.swift index 7b4b801..453a97b 100644 --- a/Sources/GoodNetworking/Protocols/Endpoint.swift +++ b/Sources/GoodNetworking/Protocols/Endpoint.swift @@ -62,7 +62,7 @@ public enum EndpointParameters { /// Case for sending `Parameters`. case parameters(Parameters) - + /// Case for sending an instance of `Encodable`. case model(Encodable) diff --git a/Sources/GoodNetworking/Session/GRSession.swift b/Sources/GoodNetworking/Session/GRSession.swift index e1c3b11..a6f0e7b 100644 --- a/Sources/GoodNetworking/Session/GRSession.swift +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -171,6 +171,25 @@ extension NetworkSessionDelegate: URLSessionDataDelegate { extension NetworkSession { + public func requestResult( + endpoint: Endpoint, + validationProvider: any ValidationProviding = DefaultValidationProvider() + ) async -> Result { + do { + let response: T = try await request(endpoint: endpoint, validationProvider: validationProvider) + return .success(response) + } catch let error { + return .failure(error) + } + } + + public func request( + endpoint: Endpoint, + validationProvider: any ValidationProviding = DefaultValidationProvider() + ) async throws(F) { + _ = try await request(endpoint: endpoint, validationProvider: validationProvider) + } + public func request( endpoint: Endpoint, validationProvider: any ValidationProviding = DefaultValidationProvider() @@ -185,6 +204,11 @@ extension NetworkSession { public func request(endpoint: Endpoint) async throws(NetworkError) -> T { let data = try await request(endpoint: endpoint) + // exist-fast if decoding is not needed + if T.self is Data.Type { + return data as! T + } + do { let model = try JSONDecoder().decode(T.self, from: data) return model @@ -194,7 +218,7 @@ extension NetworkSession { } public func request(endpoint: Endpoint) async throws(NetworkError) -> Data { - guard let url = try? await endpoint.url(on: endpoint.path) else { + guard let url = await URL(string: endpoint.path, relativeTo: baseUrl.resolveUrl()) else { throw URLError(.badURL).asNetworkError() } diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index 9abd03b..903a948 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -30,6 +30,14 @@ public enum NetworkError: LocalizedError, Hashable { } } + public var httpStatusCode: Int? { + if case .remote(let httpError) = self { + return httpError.statusCode + } else { + return nil + } + } + } public struct HTTPError: LocalizedError, Hashable { From af941e3dbaa3f76ed703e88f448eb9732f1a71cf Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:09:23 +0200 Subject: [PATCH 11/13] feat: Interceptors, Retriers, Adapters, JSON, AsyncLock + Authenticators --- Sources/GoodNetworking/AsyncLock.swift | 67 +++++ .../GoodNetworking/Session/GRSession.swift | 223 +++++++++++++-- Sources/GoodNetworking/Wrapper/JSON.swift | 266 ++++++++++++++++++ 3 files changed, 537 insertions(+), 19 deletions(-) create mode 100644 Sources/GoodNetworking/AsyncLock.swift create mode 100644 Sources/GoodNetworking/Wrapper/JSON.swift diff --git a/Sources/GoodNetworking/AsyncLock.swift b/Sources/GoodNetworking/AsyncLock.swift new file mode 100644 index 0000000..348b024 --- /dev/null +++ b/Sources/GoodNetworking/AsyncLock.swift @@ -0,0 +1,67 @@ +// +// AsyncLock.swift +// GoodReactor +// +// Created by Filip Ε aΕ‘ala on 26/08/2024. +// + +// Implementation of AsyncLock using Swift Concurrency +// Inspired by https://github.com/mattmassicotte/Lock + +import Foundation + +public final class AsyncLock: @unchecked Sendable { + + private enum State { + typealias Continuation = CheckedContinuation + + case unlocked + case locked([Continuation]) + + mutating func addContinuation(_ continuation: Continuation) { + guard case var .locked(continuations) = self else { + fatalError("Continuations cannot be added when unlocked") + } + + continuations.append(continuation) + + self = .locked(continuations) + } + + mutating func resumeNextContinuation() { + guard case var .locked(continuations) = self else { + fatalError("No continuations to resume") + } + + if continuations.isEmpty { + self = .unlocked + return + } + + let continuation = continuations.removeFirst() + continuation.resume() + + self = .locked(continuations) + } + } + + private var state = State.unlocked + + public init() {} + + public func lock(isolation: isolated (any Actor)? = #isolation) async { + switch state { + case .unlocked: + self.state = .locked([]) + case .locked: + await withCheckedContinuation { continuation in + state.addContinuation(continuation) + } + } + } + + public func unlock() { + state.resumeNextContinuation() + } + +} diff --git a/Sources/GoodNetworking/Session/GRSession.swift b/Sources/GoodNetworking/Session/GRSession.swift index a6f0e7b..d19aa3d 100644 --- a/Sources/GoodNetworking/Session/GRSession.swift +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -7,18 +7,130 @@ import Foundation +// MARK: - Authenticator + +public enum RetryResult { + + case doNotRetry + case retryAfter(TimeInterval) + case retry + +} + +public protocol Authenticator: Sendable { + + associatedtype Credential + + func getCredential() async -> Credential? + func storeCredential(_ newCredential: Credential?) async + + func apply(credential: Credential, to request: inout URLRequest) async throws(NetworkError) + func refresh(credential: Credential, for session: NetworkSession) async throws(NetworkError) -> Credential + func didRequest(_ request: inout URLRequest, failDueToAuthenticationError: HTTPError) -> Bool + func isRequest(_ request: inout URLRequest, authenticatedWith credential: Credential) -> Bool + +} + +public final class AuthenticationInterceptor: Interceptor, @unchecked Sendable { + + private let authenticator: AuthenticatorType + private let lock: AsyncLock + + init(authenticator: AuthenticatorType) { + self.authenticator = authenticator + self.lock = AsyncLock() + } + + public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) { + await lock.lock() + if let credential = await authenticator.getCredential() { + try await authenticator.apply(credential: credential, to: &urlRequest) + } + lock.unlock() + } + + public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult { + // Request failed due to HTTP Error and not due to connection failure + guard case .remote(let hTTPError) = error else { + return .doNotRetry + } + + // Remote failure occured due to authentication error + guard authenticator.didRequest(&urlRequest, failDueToAuthenticationError: hTTPError) else { + return .doNotRetry + } + + // A credential is available + guard let credential = await authenticator.getCredential() else { + return .doNotRetry + } + + // Request is authenticated with the latest available credential + // Retry if request was sent with invalid credential (previously expired, etc.) + guard authenticator.isRequest(&urlRequest, authenticatedWith: credential) else { + return .retry + } + + await lock.lock() + + // Current credential must be expired at this point + // Disable further authentication with invalid credential + await authenticator.storeCredential(nil) + + // Refresh the expired credential + + let newCredential = try await authenticator.refresh(credential: credential, for: session) + + // Store refreshed credential + await authenticator.storeCredential(newCredential) + + lock.unlock() + + // Retry previous request by applying new authentication credential + return .retry + } + +} + +public final class NoAuthenticator: Authenticator { + + public typealias Credential = Void + + public init() {} + public func getCredential() async -> Credential? { nil } + public func storeCredential(_ newCredential: Credential?) async {} + public func apply(credential: Credential, to request: inout URLRequest) async throws(NetworkError) {} + public func refresh(credential: Credential, for session: NetworkSession) async throws(NetworkError) -> Credential {} + public func didRequest(_ request: inout URLRequest, failDueToAuthenticationError: HTTPError) -> Bool { false } + public func isRequest(_ request: inout URLRequest, authenticatedWith credential: Credential) -> Bool { false } + +} + // MARK: - Interception -public protocol Interceptor: Sendable { +public protocol Adapter: Sendable { + + func adapt(urlRequest: inout URLRequest) async throws(NetworkError) + +} + +public protocol Retrier: Sendable { - func intercept(urlRequest: inout URLRequest) + func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult } +public protocol Interceptor: Adapter, Retrier {} + public final class NoInterceptor: Interceptor { public init() {} - public func intercept(urlRequest: inout URLRequest) {} + + public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) {} + + public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult { + return .doNotRetry + } } @@ -30,10 +142,23 @@ public final class CompositeInterceptor: Interceptor { self.interceptors = interceptors } - public func intercept(urlRequest: inout URLRequest) { - for interceptor in interceptors { - interceptor.intercept(urlRequest: &urlRequest) + public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) { + for adapter in interceptors { + try await adapter.adapt(urlRequest: &urlRequest) + } + } + + public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult { + for retrier in interceptors { + let retryResult = try await retrier.retry(urlRequest: &urlRequest, for: session, dueTo: error) + switch retryResult { + case .doNotRetry: + continue + case .retry, .retryAfter: + return retryResult + } } + return .doNotRetry } } @@ -79,7 +204,7 @@ public final class CompositeInterceptor: Interceptor { self.configuration = configuration self.delegateQueue = operationQueue - // create URLSession lazily, isolated on @NetworkActor, when required first time + // create URLSession lazily, isolated on @NetworkActor, when requested first time super.init() } @@ -241,11 +366,43 @@ extension NetworkSession { throw URLError(.cannotEncodeRawData).asNetworkError() } - interceptor.intercept(urlRequest: &request) + return try await executeRequest(request: &request) + } + +} + +// MARK: - Private + +private extension NetworkSession { + + func executeRequest(request: inout URLRequest) async throws(NetworkError) -> Data { + try await interceptor.adapt(urlRequest: &request) let dataTask = session.dataTask(with: request) let dataTaskProxy = proxyForTask(dataTask) - return try await dataTaskProxy.data() + + do { + #warning("TODO: validation should happen here") + return try await dataTaskProxy.data() + } catch let networkError { + let retryResult = try await interceptor.retry(urlRequest: &request, for: self, dueTo: networkError) + + switch retryResult { + case .doNotRetry: + throw networkError + + case .retryAfter(let timeInterval): + do { + try await Task.sleep(nanoseconds: UInt64(timeInterval * 10e9)) + } catch { + throw URLError(.cancelled).asNetworkError() + } + fallthrough + + case .retry: + return try await self.executeRequest(request: &request) + } + } } } @@ -254,7 +411,32 @@ extension NetworkSession { extension NetworkSession { - public func get(_ path: URLConvertible) async throws(NetworkError) -> Data { + public func get(_ path: URLConvertible) async throws(NetworkError) -> T { + let data = try await getRaw(path) + + // exit-fast if decoding is not needed + if T.self is Data.Type { + return data as! T + } + + do { + let model = try JSONDecoder().decode(T.self, from: data) + return model + } catch { + throw URLError(.cannotDecodeRawData).asNetworkError() + } + } + + public func get(_ path: URLConvertible) async throws(NetworkError) -> JSON { + do { + let data = try await getRaw(path) + return try JSON(data: data) + } catch { + throw URLError(.cannotDecodeRawData).asNetworkError() + } + } + + public func getRaw(_ path: URLConvertible) async throws(NetworkError) -> Data { guard let url = await path.resolveUrl() else { throw URLError(.badURL).asNetworkError() } @@ -263,11 +445,7 @@ extension NetworkSession { request.httpMethod = HTTPMethod.get.rawValue request.httpBody = nil - interceptor.intercept(urlRequest: &request) - - let dataTask = session.dataTask(with: request) - let dataTaskProxy = proxyForTask(dataTask) - return try await dataTaskProxy.data() + return try await executeRequest(request: &request) } } @@ -324,7 +502,7 @@ extension URLError.Code { if let error = error as? URLError { self.receivedError = error } else { - fatalError("URLSessionTaskDelegate does not throw URLErrors") + fatalError("URLSessionTaskDelegate did not throw expected type URLError") } continuation?.resume() @@ -344,12 +522,19 @@ extension URLError.Code { func x() async { let session = NetworkSession( baseUrl: "https://api.sampleapis.com/", - baseHeaders: [HTTPHeader("User-Agent: iOS app")] + baseHeaders: [HTTPHeader("User-Agent: iOS app")], + interceptor: CompositeInterceptor(interceptors: [ + AuthenticationInterceptor(authenticator: NoAuthenticator()) + ]) ) do { - try await session.request(endpoint: CoffeeEndpoint.hot) as String - try await session.get("/coffee/hot") +// try await session.request(endpoint: CoffeeEndpoint.hot) as String + + let coffeeList = try await session.get("/coffee/hot") + + + } catch let error { assert(error is URLError) } diff --git a/Sources/GoodNetworking/Wrapper/JSON.swift b/Sources/GoodNetworking/Wrapper/JSON.swift new file mode 100644 index 0000000..3cf0acd --- /dev/null +++ b/Sources/GoodNetworking/Wrapper/JSON.swift @@ -0,0 +1,266 @@ +// +// JSON.swift +// GoodNetworking +// +// Created by Filip Ε aΕ‘ala on 15/07/2025. +// + +import Foundation + +// MARK: - JSON + +@dynamicMemberLookup public enum JSON: Sendable { + + case dictionary(Dictionary) + case array(Array) + case string(String) + case number(NSNumber) + case bool(Bool) + case null + + // MARK: - Dynamic Member Lookup + + public subscript(dynamicMember member: String) -> JSON { + if case .dictionary(let dict) = self { + return dict[member] ?? .null + } + return .null + } + + // MARK: - Subscript access + + public subscript(index: Int) -> JSON { + if case .array(let arr) = self { + return index < arr.count ? arr[index] : .null + } + return .null + } + + public subscript(key: String) -> JSON { + if case .dictionary(let dict) = self { + return dict[key] ?? .null + } + return .null + } + + // MARK: - Initializers + + public init(data: Data, options: JSONSerialization.ReadingOptions = .allowFragments) throws { + let object = try JSONSerialization.jsonObject(with: data, options: options) + self = JSON(object) + } + + public init(_ object: Any) { + if let data = object as? Data, let converted = try? JSON(data: data) { + self = converted + } else if let dictionary = object as? [String: Any] { + self = JSON.dictionary(dictionary.mapValues { JSON($0) }) + } else if let array = object as? [Any] { + self = JSON.array(array.map { JSON($0) }) + } else if let string = object as? String { + self = JSON.string(string) + } else if let bool = object as? Bool { + self = JSON.bool(bool) + } else if let number = object as? NSNumber { + self = JSON.number(number) + } else if let json = object as? JSON { + self = json + } else { + self = JSON.null + } + } + + // MARK: - Accessors + + public var dictionary: Dictionary? { + if case .dictionary(let value) = self { + return value + } + return nil + } + + public var array: Array? { + if case .array(let value) = self { + return value + } + return nil + } + + public var string: String? { + if case .string(let value) = self { + return value + } else if case .bool(let value) = self { + return value ? "true" : "false" + } else if case .number(let value) = self { + return value.stringValue + } else { + return nil + } + } + + public var number: NSNumber? { + if case .number(let value) = self { + return value + } else if case .bool(let value) = self { + return NSNumber(value: value) + } else if case .string(let value) = self, let doubleValue = Double(value) { + return NSNumber(value: doubleValue) + } else { + return nil + } + } + + public var double: Double? { + return number?.doubleValue + } + + public var int: Int? { + return number?.intValue + } + + public var bool: Bool? { + if case .bool(let value) = self { + return value + } else if case .number(let value) = self { + return value.boolValue + } else if case .string(let value) = self, + (["true", "t", "yes", "y", "1"].contains { value.caseInsensitiveCompare($0) == .orderedSame }) { + return true + } else if case .string(let value) = self, + (["false", "f", "no", "n", "0"].contains { value.caseInsensitiveCompare($0) == .orderedSame }) { + return false + } else { + return nil + } + } + + // MARK: - Public + + public var object: Any { + get { + switch self { + case .dictionary(let value): return value.mapValues { $0.object } + case .array(let value): return value.map { $0.object } + case .string(let value): return value + case .number(let value): return value + case .bool(let value): return value + case .null: return NSNull() + } + } + } + + public func data(options: JSONSerialization.WritingOptions = []) -> Data { + return (try? JSONSerialization.data(withJSONObject: self.object, options: options)) ?? Data() + } + +} + +// MARK: - Comparable + +extension JSON: Comparable { + + public static func == (lhs: JSON, rhs: JSON) -> Bool { + switch (lhs, rhs) { + case (.dictionary, .dictionary): return lhs.dictionary == rhs.dictionary + case (.array, .array): return lhs.array == rhs.array + case (.string, .string): return lhs.string == rhs.string + case (.number, .number): return lhs.number == rhs.number + case (.bool, .bool): return lhs.bool == rhs.bool + case (.null, .null): return true + default: return false + } + } + + public static func < (lhs: JSON, rhs: JSON) -> Bool { + switch (lhs, rhs) { + case (.string, .string): + if let lhsString = lhs.string, let rhsString = rhs.string { + return lhsString < rhsString + } + return false + + case (.number, .number): + if let lhsNumber = lhs.number, let rhsNumber = rhs.number { + return lhsNumber.doubleValue < rhsNumber.doubleValue + } + return false + + default: + return false + } + } + +} + +// MARK: - ExpressibleByLiteral + +extension JSON: Swift.ExpressibleByDictionaryLiteral { + + public init(dictionaryLiteral elements: (String, Any)...) { + let dictionary = elements.reduce(into: [String: Any](), { $0[$1.0] = $1.1}) + self.init(dictionary) + } + +} + +extension JSON: Swift.ExpressibleByArrayLiteral { + + public init(arrayLiteral elements: Any...) { + self.init(elements) + } + +} + +extension JSON: Swift.ExpressibleByStringLiteral { + + public init(stringLiteral value: StringLiteralType) { + self.init(value) + } + + public init(extendedGraphemeClusterLiteral value: StringLiteralType) { + self.init(value) + } + + public init(unicodeScalarLiteral value: StringLiteralType) { + self.init(value) + } + +} + +extension JSON: Swift.ExpressibleByFloatLiteral { + + public init(floatLiteral value: FloatLiteralType) { + self.init(value) + } + +} + +extension JSON: Swift.ExpressibleByIntegerLiteral { + + public init(integerLiteral value: IntegerLiteralType) { + self.init(value) + } + +} + +extension JSON: Swift.ExpressibleByBooleanLiteral { + + public init(booleanLiteral value: BooleanLiteralType) { + self.init(value) + } + +} + +// MARK: - Pretty Print + +extension JSON: Swift.CustomStringConvertible, Swift.CustomDebugStringConvertible { + + public var description: String { + return String(describing: self.object as AnyObject).replacingOccurrences(of: ";\n", with: "\n") + } + + public var debugDescription: String { + return description + } + +} From c7a67370693de95818ddcd81ee06a017399c2379 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:40:34 +0200 Subject: [PATCH 12/13] fix: Authenticator issues and crash fixes --- Sources/GoodNetworking/NetworkActor.swift | 2 +- .../Protocols/ResourceOperations.swift | 1142 ++++++++--------- .../GoodNetworking/Session/GRSession.swift | 108 +- .../GoodNetworking/Session/NetworkError.swift | 2 +- Sources/GoodNetworking/Wrapper/Resource.swift | 840 ++++++------ 5 files changed, 1058 insertions(+), 1036 deletions(-) diff --git a/Sources/GoodNetworking/NetworkActor.swift b/Sources/GoodNetworking/NetworkActor.swift index 9255694..5068f01 100644 --- a/Sources/GoodNetworking/NetworkActor.swift +++ b/Sources/GoodNetworking/NetworkActor.swift @@ -26,7 +26,7 @@ import Foundation typealias YesActor = @NetworkActor () throws -> T typealias NoActor = () throws -> T - self.shared.preconditionIsolated() + dispatchPrecondition(condition: .onQueue(queue)) // To do the unsafe cast, we have to pretend it's @escaping. return try withoutActuallyEscaping(operation) { (_ fn: @escaping YesActor) throws -> T in diff --git a/Sources/GoodNetworking/Protocols/ResourceOperations.swift b/Sources/GoodNetworking/Protocols/ResourceOperations.swift index f81a6b5..0df36dd 100644 --- a/Sources/GoodNetworking/Protocols/ResourceOperations.swift +++ b/Sources/GoodNetworking/Protocols/ResourceOperations.swift @@ -16,574 +16,574 @@ import Sextant /// Types conforming to `Creatable` define the necessary types and functions for creating a resource on a server. /// This includes specifying the types for creation requests and responses, and providing methods for making the /// network request and transforming the responses. -//public protocol Creatable: RemoteResource { -// -// /// The type of request used to create the resource. -// associatedtype CreateRequest: Sendable -// -// /// The type of response returned after the resource is created. -// associatedtype CreateResponse: Decodable & Sendable -// -// /// Creates a new resource on the remote server using the provided session and request data. -// /// -// /// This method performs an asynchronous network request to create a resource, using the specified session and request. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The creation request data. -// /// - Returns: The response object containing the created resource data. -// /// - Throws: A `NetworkError` if the request fails. -// static func create( -// using session: NetworkSession, -// request: CreateRequest -// ) async throws(NetworkError) -> CreateResponse -// -// /// Constructs an `Endpoint` for the creation request. -// /// -// /// This method is used to convert the creation request data into an `Endpoint` that represents the request details. -// /// -// /// - Parameter request: The creation request data. -// /// - Returns: An `Endpoint` that represents the request. -// /// - Throws: A `NetworkError` if the endpoint cannot be created. -// nonisolated static func endpoint(_ request: CreateRequest) throws(NetworkError) -> Endpoint -// -// /// Transforms an optional `Resource` into a `CreateRequest`. -// /// -// /// This method can be used to generate a `CreateRequest` from a given `Resource`, if applicable. -// /// -// /// - Parameter resource: The optional resource to be transformed into a request. -// /// - Returns: A `CreateRequest` derived from the resource, or `nil` if not applicable. -// /// - Throws: A `NetworkError` if the transformation fails. -// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? -// -// /// Transforms the creation response into a `Resource`. -// /// -// /// This method is used to convert the response data from the creation request into a usable `Resource`. -// /// -// /// - Parameter response: The response received from the creation request. -// /// - Returns: A `Resource` derived from the response. -// /// - Throws: A `NetworkError` if the transformation fails. -// nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource -// -//} -// -//public extension Creatable { -// -// /// Creates a new resource on the remote server using the provided session and request data. -// /// -// /// This default implementation performs the network request to create the resource by first obtaining the -// /// `Endpoint` from the `CreateRequest`, then sending the request using the provided `NetworkSession`. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The creation request data. -// /// - Returns: The response object containing the created resource data. -// /// - Throws: A `NetworkError` if the request fails. -// static func create( -// using session: NetworkSession, -// request: CreateRequest -// ) async throws(NetworkError) -> CreateResponse { -// let endpoint: Endpoint = try Self.endpoint(request) -// let response: CreateResponse = try await session.request(endpoint: endpoint) -// return response -// } -// -// /// Provides a default implementation that throws an error indicating the request cannot be derived from the resource. -// /// -// /// This implementation can be overridden by conforming types to provide specific behavior. -// /// -// /// - Parameter resource: The optional resource to be transformed into a request. -// /// - Returns: `nil` by default. -// /// - Throws: A `NetworkError.missingLocalData` if the transformation is not supported. -// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? { -// throw .missingLocalData -// } -// -//} -// -//public extension Creatable where CreateResponse == Resource { -// -// /// Provides a default implementation that directly returns the response as the `Resource`. -// /// -// /// This implementation can be used when the `CreateResponse` type is the same as the `Resource` type, -// /// allowing the response to be returned directly. -// /// -// /// - Parameter response: The response received from the creation request. -// /// - Returns: The response as a `Resource`. -// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). -// nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { -// response -// } -// -//} -// -//// MARK: - Readable -// -///// Represents a resource that can be read from a remote server. -///// -///// Types conforming to `Readable` define the necessary types and functions for reading a resource from a server. -///// This includes specifying the types for read requests and responses, and providing methods for making the -///// network request and transforming the responses. -//public protocol Readable: RemoteResource { -// -// /// The type of request used to read the resource. -// associatedtype ReadRequest: Sendable -// -// /// The type of response returned after reading the resource. -// associatedtype ReadResponse: Decodable & Sendable -// -// /// Reads the resource from the remote server using the provided session and request data. -// /// -// /// This method performs an asynchronous network request to read a resource, using the specified session and request. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The read request data. -// /// - Returns: The response object containing the resource data. -// /// - Throws: A `NetworkError` if the request fails. -// static func read( -// using session: NetworkSession, -// request: ReadRequest -// ) async throws(NetworkError) -> ReadResponse -// -// /// Constructs an `Endpoint` for the read request. -// /// -// /// This method is used to convert the read request data into an `Endpoint` that represents the request details. -// /// -// /// - Parameter request: The read request data. -// /// - Returns: An `Endpoint` that represents the request. -// /// - Throws: A `NetworkError` if the endpoint cannot be created. -// nonisolated static func endpoint(_ request: ReadRequest) throws(NetworkError) -> Endpoint -// -// /// Transforms an optional `Resource` into a `ReadRequest`. -// /// -// /// This method can be used to generate a `ReadRequest` from a given `Resource`, if applicable. -// /// -// /// - Parameter resource: The optional resource to be transformed into a request. -// /// - Returns: A `ReadRequest` derived from the resource, or `nil` if not applicable. -// /// - Throws: A `NetworkError` if the transformation fails. -// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? -// -// /// Transforms the read response into a `Resource`. -// /// -// /// This method is used to convert the response data from the read request into a usable `Resource`. -// /// -// /// - Parameter response: The response received from the read request. -// /// - Returns: A `Resource` derived from the response. -// /// - Throws: A `NetworkError` if the transformation fails. -// nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource -// -//} -// -//public extension Readable { -// -// /// Reads the resource from the remote server using the provided session and request data. -// /// -// /// This default implementation performs the network request to read the resource by first obtaining the -// /// `Endpoint` from the `ReadRequest`, then sending the request using the provided `NetworkSession`. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The read request data. -// /// - Returns: The response object containing the resource data. -// /// - Throws: A `NetworkError` if the request fails. -// static func read( -// using session: NetworkSession, -// request: ReadRequest -// ) async throws(NetworkError) -> ReadResponse { -// let endpoint: Endpoint = try Self.endpoint(request) -// let response: ReadResponse = try await session.request(endpoint: endpoint) -// return response -// } -// -//} -// -//public extension Readable where ReadRequest == Void { -// -// /// Provides a default implementation that returns an empty `Void` request. -// /// -// /// This implementation can be used when the `ReadRequest` type is `Void`, indicating that no request data is needed. -// /// -// /// - Parameter resource: The optional resource to be transformed into a request. -// /// - Returns: An empty `Void` request. -// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? { -// () -// } -// -//} -// -//public extension Readable where ReadResponse == Resource { -// -// /// Provides a default implementation that directly returns the response as the `Resource`. -// /// -// /// This implementation can be used when the `ReadResponse` type is the same as the `Resource` type, -// /// allowing the response to be returned directly. -// /// -// /// - Parameter response: The response received from the read request. -// /// - Returns: The response as a `Resource`. -// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). -// nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { -// response -// } -// -//} -// -//// MARK: - Query -// -///// Represents a resource that can be read as a query response from a remote server. -///// -///// `Query` extends the `Readable` protocol to add support for resources where the `ReadResponse` is of type `Data`. -///// It provides additional methods for querying and parsing the raw response data. -//public protocol Query: Readable where ReadResponse == Data { -// -// /// Provides the query string for the request. -// /// -// /// This method is used to specify the query parameters for the request. -// /// -// /// - Returns: A string representing the query. -// nonisolated static func query() -> String -// -//} -// -//public extension Query where Resource: Decodable { -// -// /// Provides a default implementation for parsing the raw response data into a `Resource` using the query. -// /// -// /// This method uses the specified query to extract and decode the data from the response. -// /// -// /// - Parameter response: The raw response data received from the server. -// /// - Returns: The decoded `Resource` object. -// /// - Throws: A `NetworkError` if the parsing or decoding fails. -// nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { -// Sextant.shared.query(response, values: Hitch(string: query())) ?? .placeholder -// } -// -//} -// -//public extension Query { -// -// /// Reads the raw data from the remote server using the provided session and request data. -// /// -// /// This implementation performs a network request to read the resource as raw data. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The read request data. -// /// - Returns: The raw response data. -// /// - Throws: A `NetworkError` if the request fails. -// static func read( -// using session: NetworkSession, -// request: ReadRequest -// ) async throws(NetworkError) -> ReadResponse { -// let endpoint: Endpoint = try Self.endpoint(request) -// let response: ReadResponse = try await session.requestRaw(endpoint: endpoint) -// return response -// } -// -//} -// -//// MARK: - Updatable -// -///// Represents a resource that can be updated on a remote server. -///// -///// Types conforming to `Updatable` define the necessary types and functions for updating a resource on a server. -///// This includes specifying the types for update requests and responses, and providing methods for making the -///// network request and transforming the responses. -//public protocol Updatable: Readable { -// -// /// The type of request used to update the resource. -// associatedtype UpdateRequest: Sendable -// -// /// The type of response returned after updating the resource. -// associatedtype UpdateResponse: Decodable & Sendable -// -// /// Updates an existing resource on the remote server using the provided session and request data. -// /// -// /// This method performs an asynchronous network request to update a resource, using the specified session and request. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The update request data. -// /// - Returns: The response object containing the updated resource data. -// /// - Throws: A `NetworkError` if the request fails. -// static func update( -// using session: NetworkSession, -// request: UpdateRequest -// ) async throws(NetworkError) -> UpdateResponse -// -// /// Constructs an `Endpoint` for the update request. -// /// -// /// This method is used to convert the update request data into an `Endpoint` that represents the request details. -// /// -// /// - Parameter request: The update request data. -// /// - Returns: An `Endpoint` that represents the request. -// /// - Throws: A `NetworkError` if the endpoint cannot be created. -// nonisolated static func endpoint(_ request: UpdateRequest) throws(NetworkError) -> Endpoint -// -// /// Transforms an optional `Resource` into an `UpdateRequest`. -// /// -// /// This method can be used to generate an `UpdateRequest` from a given `Resource`, if applicable. -// /// -// /// - Parameter resource: The optional resource to be transformed into a request. -// /// - Returns: An `UpdateRequest` derived from the resource, or `nil` if not applicable. -// /// - Throws: A `NetworkError` if the transformation fails. -// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> UpdateRequest? -// -// nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource -// -//} -// -//public extension Updatable { -// -// /// Updates an existing resource on the remote server using the provided session and request data. -// /// -// /// This default implementation performs the network request to update the resource by first obtaining the -// /// `Endpoint` from the `UpdateRequest`, then sending the request using the provided `NetworkSession`. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The update request data. -// /// - Returns: The response object containing the updated resource data. -// /// - Throws: A `NetworkError` if the request fails. -// static func update( -// using session: NetworkSession, -// request: UpdateRequest -// ) async throws(NetworkError) -> UpdateResponse { -// let endpoint: Endpoint = try Self.endpoint(request) -// let response: UpdateResponse = try await session.request(endpoint: endpoint) -// return response -// } -// -//} -// -//public extension Updatable where UpdateResponse == Resource { -// -// /// Provides a default implementation that directly returns the response as the `Resource`. -// /// -// /// This implementation can be used when the `UpdateResponse` type is the same as the `Resource` type, -// /// allowing the response to be returned directly. -// /// -// /// - Parameter response: The response received from the update request. -// /// - Returns: The response as a `Resource`. -// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). -// nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { -// response -// } -// -//} -// -//// MARK: - Deletable -// -///// Represents a resource that can be deleted from a remote server. -///// -///// Types conforming to `Deletable` define the necessary types and functions for deleting a resource on a server. -///// This includes specifying the types for delete requests and responses, and providing methods for making the -///// network request and transforming the responses. -//public protocol Deletable: Readable { -// -// /// The type of request used to delete the resource. -// associatedtype DeleteRequest: Sendable -// -// /// The type of response returned after deleting the resource. -// associatedtype DeleteResponse: Decodable & Sendable -// -// /// Deletes the resource on the remote server using the provided session and request data. -// /// -// /// This method performs an asynchronous network request to delete a resource, using the specified session and request. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The delete request data. -// /// - Returns: The response object indicating the result of the deletion. -// /// - Throws: A `NetworkError` if the request fails. -// @discardableResult -// static func delete( -// using session: NetworkSession, -// request: DeleteRequest -// ) async throws(NetworkError) -> DeleteResponse -// -// /// Constructs an `Endpoint` for the delete request. -// /// -// /// This method is used to convert the delete request data into an `Endpoint` that represents the request details. -// /// -// /// - Parameter request: The delete request data. -// /// - Returns: An `Endpoint` that represents the request. -// /// - Throws: A `NetworkError` if the endpoint cannot be created. -// nonisolated static func endpoint(_ request: DeleteRequest) throws(NetworkError) -> Endpoint -// -// /// Transforms an optional `Resource` into a `DeleteRequest`. -// /// -// /// This method can be used to generate a `DeleteRequest` from a given `Resource`, if applicable. -// /// -// /// - Parameter resource: The optional resource to be transformed into a request. -// /// - Returns: A `DeleteRequest` derived from the resource, or `nil` if not applicable. -// /// - Throws: A `NetworkError` if the transformation fails. -// nonisolated static func request(from resource: Resource?) throws(NetworkError) -> DeleteRequest? -// -// /// Transforms the delete response into a `Resource`. -// /// -// /// This method is used to convert the response data from the delete request into a usable `Resource`. -// /// -// /// - Parameter response: The response received from the delete request. -// /// - Returns: A `Resource` derived from the response. -// /// - Throws: A `NetworkError` if the transformation fails. -// nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? -// -//} -// -//public extension Deletable { -// -// /// Deletes the resource on the remote server using the provided session and request data. -// /// -// /// This default implementation performs the network request to delete the resource by first obtaining the -// /// `Endpoint` from the `DeleteRequest`, then sending the request using the provided `NetworkSession`. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The delete request data. -// /// - Returns: The response object indicating the result of the deletion. -// /// - Throws: A `NetworkError` if the request fails. -// @discardableResult -// static func delete( -// using session: NetworkSession, -// request: DeleteRequest -// ) async throws(NetworkError) -> DeleteResponse { -// let endpoint: Endpoint = try Self.endpoint(request) -// let response: DeleteResponse = try await session.request(endpoint: endpoint) -// return response -// } -// -//} -// -//public extension Deletable where DeleteResponse == Resource { -// -// /// Provides a default implementation that directly returns the response as the `Resource`. -// /// -// /// This implementation can be used when the `DeleteResponse` type is the same as the `Resource` type, -// /// allowing the response to be returned directly. -// /// -// /// - Parameter response: The response received from the delete request. -// /// - Returns: The response as a `Resource`. -// /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). -// nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? { -// response -// } -// -//} -//// MARK: - Listable -// -///// Represents a resource that can be listed (retrieved in bulk) from a remote server. -///// -///// Types conforming to `Listable` define the necessary types and functions for retrieving a list of resources from a server. -///// This includes specifying the types for list requests and responses, and providing methods for making the network request, -///// managing pagination, and transforming responses. -//public protocol Listable: RemoteResource { -// -// /// The type of request used to list the resources. -// associatedtype ListRequest: Sendable -// -// /// The type of response returned after listing the resources. -// associatedtype ListResponse: Decodable & Sendable -// -// /// Lists the resources from the remote server using the provided session and request data. -// /// -// /// This method performs an asynchronous network request to retrieve a list of resources, using the specified session and request. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The list request data. -// /// - Returns: The response object containing the list of resources. -// /// - Throws: A `NetworkError` if the request fails. -// static func list( -// using session: NetworkSession, -// request: ListRequest -// ) async throws(NetworkError) -> ListResponse -// -// /// Constructs an `Endpoint` for the list request. -// /// -// /// This method is used to convert the list request data into an `Endpoint` that represents the request details. -// /// -// /// - Parameter request: The list request data. -// /// - Returns: An `Endpoint` that represents the request. -// /// - Throws: A `NetworkError` if the endpoint cannot be created. -// nonisolated static func endpoint(_ request: ListRequest) throws(NetworkError) -> Endpoint -// -// /// Provides the first page request for listing resources. -// /// -// /// This method is used to define the initial request for retrieving the first page of resources. -// /// -// /// - Returns: The `ListRequest` representing the first page request. -// nonisolated static func firstPageRequest(withParameters: Any?) -> ListRequest -// -// nonisolated static func nextPageRequest( -// currentResource: [Resource], -// parameters: Any?, -// lastResponse: ListResponse -// ) -> ListRequest? -// -// /// Combines the new response with the existing list of resources. -// /// -// /// This method is used to merge the response from the list request with an existing list of resources. -// /// -// /// - Parameters: -// /// - response: The new response data. -// /// - oldValue: The existing list of resources. -// /// - Returns: A new array of `Resource` combining the old and new data. -// nonisolated static func list(from response: ListResponse, oldValue: [Resource]) -> [Resource] -// -//} -// -//public extension Listable { -// -// /// Provides a default implementation for fetching the next page request. -// /// -// /// By default, this method returns `nil`, indicating that pagination is not supported. -// /// -// /// - Parameters: -// /// - currentResource: The current list of resources. -// /// - lastResponse: The last response received. -// /// - Returns: `nil` by default, indicating no next page request. -// nonisolated static func nextPageRequest( -// currentResource: [Resource], -// parameters: Any?, -// lastResponse: ListResponse -// ) -> ListRequest? { -// return nil -// } -// -// /// Lists the resources from the remote server using the provided session and request data. -// /// -// /// This default implementation performs the network request to list the resources by first obtaining the -// /// `Endpoint` from the `ListRequest`, then sending the request using the provided `NetworkSession`. -// /// -// /// - Parameters: -// /// - session: The network session used to perform the request. -// /// - request: The list request data. -// /// - Returns: The response object containing the list of resources. -// /// - Throws: A `NetworkError` if the request fails. -// static func list( -// using session: NetworkSession, -// request: ListRequest -// ) async throws(NetworkError) -> ListResponse { -// let endpoint: Endpoint = try Self.endpoint(request) -// let response: ListResponse = try await session.request(endpoint: endpoint) -// return response -// } -// -//} -// -//// MARK: - All CRUD Operations -// -///// Represents a resource that supports Create, Read, Update, and Delete operations. -///// -///// Types conforming to `CRUDable` define the necessary functions to perform all basic CRUD operations (Create, Read, Update, Delete). -///// This protocol combines the individual capabilities of `Creatable`, `Readable`, `Updatable`, and `Deletable`. -//public protocol CRUDable: Creatable, Readable, Updatable, Deletable {} -// -//// MARK: - CRUD + Listable -// -///// Represents a resource that supports full CRUD operations as well as bulk listing. -///// -///// Types conforming to `CRUDLable` support all basic CRUD operations (Create, Read, Update, Delete) and also provide -///// capabilities for retrieving lists of resources using the `Listable` protocol. -//public protocol CRUDLable: CRUDable, Listable {} +public protocol Creatable: RemoteResource { + + /// The type of request used to create the resource. + associatedtype CreateRequest: Sendable + + /// The type of response returned after the resource is created. + associatedtype CreateResponse: Decodable & Sendable + + /// Creates a new resource on the remote server using the provided session and request data. + /// + /// This method performs an asynchronous network request to create a resource, using the specified session and request. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The creation request data. + /// - Returns: The response object containing the created resource data. + /// - Throws: A `NetworkError` if the request fails. + static func create( + using session: NetworkSession, + request: CreateRequest + ) async throws(NetworkError) -> CreateResponse + + /// Constructs an `Endpoint` for the creation request. + /// + /// This method is used to convert the creation request data into an `Endpoint` that represents the request details. + /// + /// - Parameter request: The creation request data. + /// - Returns: An `Endpoint` that represents the request. + /// - Throws: A `NetworkError` if the endpoint cannot be created. + nonisolated static func endpoint(_ request: CreateRequest) throws(NetworkError) -> Endpoint + + /// Transforms an optional `Resource` into a `CreateRequest`. + /// + /// This method can be used to generate a `CreateRequest` from a given `Resource`, if applicable. + /// + /// - Parameter resource: The optional resource to be transformed into a request. + /// - Returns: A `CreateRequest` derived from the resource, or `nil` if not applicable. + /// - Throws: A `NetworkError` if the transformation fails. + nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? + + /// Transforms the creation response into a `Resource`. + /// + /// This method is used to convert the response data from the creation request into a usable `Resource`. + /// + /// - Parameter response: The response received from the creation request. + /// - Returns: A `Resource` derived from the response. + /// - Throws: A `NetworkError` if the transformation fails. + nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource + +} + +public extension Creatable { + + /// Creates a new resource on the remote server using the provided session and request data. + /// + /// This default implementation performs the network request to create the resource by first obtaining the + /// `Endpoint` from the `CreateRequest`, then sending the request using the provided `NetworkSession`. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The creation request data. + /// - Returns: The response object containing the created resource data. + /// - Throws: A `NetworkError` if the request fails. + static func create( + using session: NetworkSession, + request: CreateRequest + ) async throws(NetworkError) -> CreateResponse { + let endpoint: Endpoint = try Self.endpoint(request) + let response: CreateResponse = try await session.request(endpoint: endpoint) + return response + } + + /// Provides a default implementation that throws an error indicating the request cannot be derived from the resource. + /// + /// This implementation can be overridden by conforming types to provide specific behavior. + /// + /// - Parameter resource: The optional resource to be transformed into a request. + /// - Returns: `nil` by default. + /// - Throws: A `NetworkError.missingLocalData` if the transformation is not supported. + nonisolated static func request(from resource: Resource?) throws(NetworkError) -> CreateRequest? { + throw URLError(.cannotEncodeRawData).asNetworkError() + } + +} + +public extension Creatable where CreateResponse == Resource { + + /// Provides a default implementation that directly returns the response as the `Resource`. + /// + /// This implementation can be used when the `CreateResponse` type is the same as the `Resource` type, + /// allowing the response to be returned directly. + /// + /// - Parameter response: The response received from the creation request. + /// - Returns: The response as a `Resource`. + /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). + nonisolated static func resource(from response: CreateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { + response + } + +} + +// MARK: - Readable + +/// Represents a resource that can be read from a remote server. +/// +/// Types conforming to `Readable` define the necessary types and functions for reading a resource from a server. +/// This includes specifying the types for read requests and responses, and providing methods for making the +/// network request and transforming the responses. +public protocol Readable: RemoteResource { + + /// The type of request used to read the resource. + associatedtype ReadRequest: Sendable + + /// The type of response returned after reading the resource. + associatedtype ReadResponse: Decodable & Sendable + + /// Reads the resource from the remote server using the provided session and request data. + /// + /// This method performs an asynchronous network request to read a resource, using the specified session and request. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The read request data. + /// - Returns: The response object containing the resource data. + /// - Throws: A `NetworkError` if the request fails. + static func read( + using session: NetworkSession, + request: ReadRequest + ) async throws(NetworkError) -> ReadResponse + + /// Constructs an `Endpoint` for the read request. + /// + /// This method is used to convert the read request data into an `Endpoint` that represents the request details. + /// + /// - Parameter request: The read request data. + /// - Returns: An `Endpoint` that represents the request. + /// - Throws: A `NetworkError` if the endpoint cannot be created. + nonisolated static func endpoint(_ request: ReadRequest) throws(NetworkError) -> Endpoint + + /// Transforms an optional `Resource` into a `ReadRequest`. + /// + /// This method can be used to generate a `ReadRequest` from a given `Resource`, if applicable. + /// + /// - Parameter resource: The optional resource to be transformed into a request. + /// - Returns: A `ReadRequest` derived from the resource, or `nil` if not applicable. + /// - Throws: A `NetworkError` if the transformation fails. + nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? + + /// Transforms the read response into a `Resource`. + /// + /// This method is used to convert the response data from the read request into a usable `Resource`. + /// + /// - Parameter response: The response received from the read request. + /// - Returns: A `Resource` derived from the response. + /// - Throws: A `NetworkError` if the transformation fails. + nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource + +} + +public extension Readable { + + /// Reads the resource from the remote server using the provided session and request data. + /// + /// This default implementation performs the network request to read the resource by first obtaining the + /// `Endpoint` from the `ReadRequest`, then sending the request using the provided `NetworkSession`. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The read request data. + /// - Returns: The response object containing the resource data. + /// - Throws: A `NetworkError` if the request fails. + static func read( + using session: NetworkSession, + request: ReadRequest + ) async throws(NetworkError) -> ReadResponse { + let endpoint: Endpoint = try Self.endpoint(request) + let response: ReadResponse = try await session.request(endpoint: endpoint) + return response + } + +} + +public extension Readable where ReadRequest == Void { + + /// Provides a default implementation that returns an empty `Void` request. + /// + /// This implementation can be used when the `ReadRequest` type is `Void`, indicating that no request data is needed. + /// + /// - Parameter resource: The optional resource to be transformed into a request. + /// - Returns: An empty `Void` request. + nonisolated static func request(from resource: Resource?) throws(NetworkError) -> ReadRequest? { + () + } + +} + +public extension Readable where ReadResponse == Resource { + + /// Provides a default implementation that directly returns the response as the `Resource`. + /// + /// This implementation can be used when the `ReadResponse` type is the same as the `Resource` type, + /// allowing the response to be returned directly. + /// + /// - Parameter response: The response received from the read request. + /// - Returns: The response as a `Resource`. + /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). + nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { + response + } + +} + +// MARK: - Query + +/// Represents a resource that can be read as a query response from a remote server. +/// +/// `Query` extends the `Readable` protocol to add support for resources where the `ReadResponse` is of type `Data`. +/// It provides additional methods for querying and parsing the raw response data. +public protocol Query: Readable where ReadResponse == Data { + + /// Provides the query string for the request. + /// + /// This method is used to specify the query parameters for the request. + /// + /// - Returns: A string representing the query. + nonisolated static func query() -> String + +} + +public extension Query where Resource: Decodable { + + /// Provides a default implementation for parsing the raw response data into a `Resource` using the query. + /// + /// This method uses the specified query to extract and decode the data from the response. + /// + /// - Parameter response: The raw response data received from the server. + /// - Returns: The decoded `Resource` object. + /// - Throws: A `NetworkError` if the parsing or decoding fails. + nonisolated static func resource(from response: ReadResponse, updating resource: Resource?) throws(NetworkError) -> Resource { + Sextant.shared.query(response, values: Hitch(string: query())) ?? .placeholder + } + +} + +public extension Query { + + /// Reads the raw data from the remote server using the provided session and request data. + /// + /// This implementation performs a network request to read the resource as raw data. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The read request data. + /// - Returns: The raw response data. + /// - Throws: A `NetworkError` if the request fails. + static func read( + using session: NetworkSession, + request: ReadRequest + ) async throws(NetworkError) -> ReadResponse { + let endpoint: Endpoint = try Self.endpoint(request) + let response: ReadResponse = try await session.request(endpoint: endpoint) + return response + } + +} + +// MARK: - Updatable + +/// Represents a resource that can be updated on a remote server. +/// +/// Types conforming to `Updatable` define the necessary types and functions for updating a resource on a server. +/// This includes specifying the types for update requests and responses, and providing methods for making the +/// network request and transforming the responses. +public protocol Updatable: Readable { + + /// The type of request used to update the resource. + associatedtype UpdateRequest: Sendable + + /// The type of response returned after updating the resource. + associatedtype UpdateResponse: Decodable & Sendable + + /// Updates an existing resource on the remote server using the provided session and request data. + /// + /// This method performs an asynchronous network request to update a resource, using the specified session and request. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The update request data. + /// - Returns: The response object containing the updated resource data. + /// - Throws: A `NetworkError` if the request fails. + static func update( + using session: NetworkSession, + request: UpdateRequest + ) async throws(NetworkError) -> UpdateResponse + + /// Constructs an `Endpoint` for the update request. + /// + /// This method is used to convert the update request data into an `Endpoint` that represents the request details. + /// + /// - Parameter request: The update request data. + /// - Returns: An `Endpoint` that represents the request. + /// - Throws: A `NetworkError` if the endpoint cannot be created. + nonisolated static func endpoint(_ request: UpdateRequest) throws(NetworkError) -> Endpoint + + /// Transforms an optional `Resource` into an `UpdateRequest`. + /// + /// This method can be used to generate an `UpdateRequest` from a given `Resource`, if applicable. + /// + /// - Parameter resource: The optional resource to be transformed into a request. + /// - Returns: An `UpdateRequest` derived from the resource, or `nil` if not applicable. + /// - Throws: A `NetworkError` if the transformation fails. + nonisolated static func request(from resource: Resource?) throws(NetworkError) -> UpdateRequest? + + nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource + +} + +public extension Updatable { + + /// Updates an existing resource on the remote server using the provided session and request data. + /// + /// This default implementation performs the network request to update the resource by first obtaining the + /// `Endpoint` from the `UpdateRequest`, then sending the request using the provided `NetworkSession`. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The update request data. + /// - Returns: The response object containing the updated resource data. + /// - Throws: A `NetworkError` if the request fails. + static func update( + using session: NetworkSession, + request: UpdateRequest + ) async throws(NetworkError) -> UpdateResponse { + let endpoint: Endpoint = try Self.endpoint(request) + let response: UpdateResponse = try await session.request(endpoint: endpoint) + return response + } + +} + +public extension Updatable where UpdateResponse == Resource { + + /// Provides a default implementation that directly returns the response as the `Resource`. + /// + /// This implementation can be used when the `UpdateResponse` type is the same as the `Resource` type, + /// allowing the response to be returned directly. + /// + /// - Parameter response: The response received from the update request. + /// - Returns: The response as a `Resource`. + /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). + nonisolated static func resource(from response: UpdateResponse, updating resource: Resource?) throws(NetworkError) -> Resource { + response + } + +} + +// MARK: - Deletable + +/// Represents a resource that can be deleted from a remote server. +/// +/// Types conforming to `Deletable` define the necessary types and functions for deleting a resource on a server. +/// This includes specifying the types for delete requests and responses, and providing methods for making the +/// network request and transforming the responses. +public protocol Deletable: Readable { + + /// The type of request used to delete the resource. + associatedtype DeleteRequest: Sendable + + /// The type of response returned after deleting the resource. + associatedtype DeleteResponse: Decodable & Sendable + + /// Deletes the resource on the remote server using the provided session and request data. + /// + /// This method performs an asynchronous network request to delete a resource, using the specified session and request. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The delete request data. + /// - Returns: The response object indicating the result of the deletion. + /// - Throws: A `NetworkError` if the request fails. + @discardableResult + static func delete( + using session: NetworkSession, + request: DeleteRequest + ) async throws(NetworkError) -> DeleteResponse + + /// Constructs an `Endpoint` for the delete request. + /// + /// This method is used to convert the delete request data into an `Endpoint` that represents the request details. + /// + /// - Parameter request: The delete request data. + /// - Returns: An `Endpoint` that represents the request. + /// - Throws: A `NetworkError` if the endpoint cannot be created. + nonisolated static func endpoint(_ request: DeleteRequest) throws(NetworkError) -> Endpoint + + /// Transforms an optional `Resource` into a `DeleteRequest`. + /// + /// This method can be used to generate a `DeleteRequest` from a given `Resource`, if applicable. + /// + /// - Parameter resource: The optional resource to be transformed into a request. + /// - Returns: A `DeleteRequest` derived from the resource, or `nil` if not applicable. + /// - Throws: A `NetworkError` if the transformation fails. + nonisolated static func request(from resource: Resource?) throws(NetworkError) -> DeleteRequest? + + /// Transforms the delete response into a `Resource`. + /// + /// This method is used to convert the response data from the delete request into a usable `Resource`. + /// + /// - Parameter response: The response received from the delete request. + /// - Returns: A `Resource` derived from the response. + /// - Throws: A `NetworkError` if the transformation fails. + nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? + +} + +public extension Deletable { + + /// Deletes the resource on the remote server using the provided session and request data. + /// + /// This default implementation performs the network request to delete the resource by first obtaining the + /// `Endpoint` from the `DeleteRequest`, then sending the request using the provided `NetworkSession`. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The delete request data. + /// - Returns: The response object indicating the result of the deletion. + /// - Throws: A `NetworkError` if the request fails. + @discardableResult + static func delete( + using session: NetworkSession, + request: DeleteRequest + ) async throws(NetworkError) -> DeleteResponse { + let endpoint: Endpoint = try Self.endpoint(request) + let response: DeleteResponse = try await session.request(endpoint: endpoint) + return response + } + +} + +public extension Deletable where DeleteResponse == Resource { + + /// Provides a default implementation that directly returns the response as the `Resource`. + /// + /// This implementation can be used when the `DeleteResponse` type is the same as the `Resource` type, + /// allowing the response to be returned directly. + /// + /// - Parameter response: The response received from the delete request. + /// - Returns: The response as a `Resource`. + /// - Throws: A `NetworkError` if any transformation fails (not applicable in this case). + nonisolated static func resource(from response: DeleteResponse, updating resource: Resource?) throws(NetworkError) -> Resource? { + response + } + +} +// MARK: - Listable + +/// Represents a resource that can be listed (retrieved in bulk) from a remote server. +/// +/// Types conforming to `Listable` define the necessary types and functions for retrieving a list of resources from a server. +/// This includes specifying the types for list requests and responses, and providing methods for making the network request, +/// managing pagination, and transforming responses. +public protocol Listable: RemoteResource { + + /// The type of request used to list the resources. + associatedtype ListRequest: Sendable + + /// The type of response returned after listing the resources. + associatedtype ListResponse: Decodable & Sendable + + /// Lists the resources from the remote server using the provided session and request data. + /// + /// This method performs an asynchronous network request to retrieve a list of resources, using the specified session and request. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The list request data. + /// - Returns: The response object containing the list of resources. + /// - Throws: A `NetworkError` if the request fails. + static func list( + using session: NetworkSession, + request: ListRequest + ) async throws(NetworkError) -> ListResponse + + /// Constructs an `Endpoint` for the list request. + /// + /// This method is used to convert the list request data into an `Endpoint` that represents the request details. + /// + /// - Parameter request: The list request data. + /// - Returns: An `Endpoint` that represents the request. + /// - Throws: A `NetworkError` if the endpoint cannot be created. + nonisolated static func endpoint(_ request: ListRequest) throws(NetworkError) -> Endpoint + + /// Provides the first page request for listing resources. + /// + /// This method is used to define the initial request for retrieving the first page of resources. + /// + /// - Returns: The `ListRequest` representing the first page request. + nonisolated static func firstPageRequest(withParameters: Any?) -> ListRequest + + nonisolated static func nextPageRequest( + currentResource: [Resource], + parameters: Any?, + lastResponse: ListResponse + ) -> ListRequest? + + /// Combines the new response with the existing list of resources. + /// + /// This method is used to merge the response from the list request with an existing list of resources. + /// + /// - Parameters: + /// - response: The new response data. + /// - oldValue: The existing list of resources. + /// - Returns: A new array of `Resource` combining the old and new data. + nonisolated static func list(from response: ListResponse, oldValue: [Resource]) -> [Resource] + +} + +public extension Listable { + + /// Provides a default implementation for fetching the next page request. + /// + /// By default, this method returns `nil`, indicating that pagination is not supported. + /// + /// - Parameters: + /// - currentResource: The current list of resources. + /// - lastResponse: The last response received. + /// - Returns: `nil` by default, indicating no next page request. + nonisolated static func nextPageRequest( + currentResource: [Resource], + parameters: Any?, + lastResponse: ListResponse + ) -> ListRequest? { + return nil + } + + /// Lists the resources from the remote server using the provided session and request data. + /// + /// This default implementation performs the network request to list the resources by first obtaining the + /// `Endpoint` from the `ListRequest`, then sending the request using the provided `NetworkSession`. + /// + /// - Parameters: + /// - session: The network session used to perform the request. + /// - request: The list request data. + /// - Returns: The response object containing the list of resources. + /// - Throws: A `NetworkError` if the request fails. + static func list( + using session: NetworkSession, + request: ListRequest + ) async throws(NetworkError) -> ListResponse { + let endpoint: Endpoint = try Self.endpoint(request) + let response: ListResponse = try await session.request(endpoint: endpoint) + return response + } + +} + +// MARK: - All CRUD Operations + +/// Represents a resource that supports Create, Read, Update, and Delete operations. +/// +/// Types conforming to `CRUDable` define the necessary functions to perform all basic CRUD operations (Create, Read, Update, Delete). +/// This protocol combines the individual capabilities of `Creatable`, `Readable`, `Updatable`, and `Deletable`. +public protocol CRUDable: Creatable, Readable, Updatable, Deletable {} + +// MARK: - CRUD + Listable + +/// Represents a resource that supports full CRUD operations as well as bulk listing. +/// +/// Types conforming to `CRUDLable` support all basic CRUD operations (Create, Read, Update, Delete) and also provide +/// capabilities for retrieving lists of resources using the `Listable` protocol. +public protocol CRUDLable: CRUDable, Listable {} diff --git a/Sources/GoodNetworking/Session/GRSession.swift b/Sources/GoodNetworking/Session/GRSession.swift index d19aa3d..e253b65 100644 --- a/Sources/GoodNetworking/Session/GRSession.swift +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -17,6 +17,12 @@ public enum RetryResult { } +public protocol RefreshableCredential { + + var requiresRefresh: Bool { get } + +} + public protocol Authenticator: Sendable { associatedtype Credential @@ -28,6 +34,7 @@ public protocol Authenticator: Sendable { func refresh(credential: Credential, for session: NetworkSession) async throws(NetworkError) -> Credential func didRequest(_ request: inout URLRequest, failDueToAuthenticationError: HTTPError) -> Bool func isRequest(_ request: inout URLRequest, authenticatedWith credential: Credential) -> Bool + func refresh(didFailDueToError error: HTTPError) async } @@ -36,7 +43,7 @@ public final class AuthenticationInterceptor: private let authenticator: AuthenticatorType private let lock: AsyncLock - init(authenticator: AuthenticatorType) { + public init(authenticator: AuthenticatorType) { self.authenticator = authenticator self.lock = AsyncLock() } @@ -71,23 +78,33 @@ public final class AuthenticationInterceptor: return .retry } - await lock.lock() + // Refresh and store new token + try await refresh(credential: credential, for: session) + // Retry previous request by applying new authentication credential + return .retry + } + + private func refresh(credential: AuthenticatorType.Credential, for session: NetworkSession) async throws(NetworkError) { // Current credential must be expired at this point // Disable further authentication with invalid credential + await lock.lock() await authenticator.storeCredential(nil) - // Refresh the expired credential + defer { lock.unlock() } - let newCredential = try await authenticator.refresh(credential: credential, for: session) - - // Store refreshed credential - await authenticator.storeCredential(newCredential) - - lock.unlock() - - // Retry previous request by applying new authentication credential - return .retry + // Refresh the expired credential and store new credential + // Let user handle remote errors (eg. HTTP 403) before throwing + // (eg. kick user from session, or automatically log out). + do { + let newCredential = try await authenticator.refresh(credential: credential, for: session) + await authenticator.storeCredential(newCredential) + } catch let error { + if case .remote(let httpError) = error { + await authenticator.refresh(didFailDueToError: httpError) + } + throw error + } } } @@ -103,6 +120,7 @@ public final class NoAuthenticator: Authenticator { public func refresh(credential: Credential, for session: NetworkSession) async throws(NetworkError) -> Credential {} public func didRequest(_ request: inout URLRequest, failDueToAuthenticationError: HTTPError) -> Bool { false } public func isRequest(_ request: inout URLRequest, authenticatedWith credential: Credential) -> Bool { false } + public func refresh(didFailDueToError error: HTTPError) async {} } @@ -196,6 +214,7 @@ public final class CompositeInterceptor: Interceptor { self.interceptor = interceptor let operationQueue = OperationQueue() + operationQueue.name = "NetworkActorSerialExecutorOperationQueue" operationQueue.underlyingQueue = NetworkActor.queue let configuration = URLSessionConfiguration.ephemeral @@ -252,12 +271,6 @@ extension NetworkSessionDelegate: URLSessionDelegate { extension NetworkSessionDelegate: URLSessionTaskDelegate { - func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { - NetworkActor.assumeIsolated { - print(Thread.current.name) - } - } - func urlSession( _ session: URLSession, task: URLSessionTask, @@ -358,7 +371,7 @@ extension NetworkSession { } else { var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) urlComponents?.queryItems?.append(contentsOf: endpoint.parameters?.queryItems() ?? []) - request.url = urlComponents?.url + request.url = urlComponents?.url } } else if endpoint.encoding is JSONEncoding { request.httpBody = endpoint.parameters?.data() @@ -376,32 +389,51 @@ extension NetworkSession { private extension NetworkSession { func executeRequest(request: inout URLRequest) async throws(NetworkError) -> Data { + // Headers + if request.httpBody == nil { + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + } else { // assume all apps are always encoding data as JSON + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + baseHeaders.forEach { header in + request.setValue(header.value, forHTTPHeaderField: header.name) + } + + // Interceptors try await interceptor.adapt(urlRequest: &request) + // Data task let dataTask = session.dataTask(with: request) let dataTaskProxy = proxyForTask(dataTask) + dataTask.resume() + // Request data + validation + retry (?) do { #warning("TODO: validation should happen here") return try await dataTaskProxy.data() } catch let networkError { - let retryResult = try await interceptor.retry(urlRequest: &request, for: self, dueTo: networkError) + return try await retryRequest(request: &request, error: networkError) + } + } - switch retryResult { - case .doNotRetry: - throw networkError - - case .retryAfter(let timeInterval): - do { - try await Task.sleep(nanoseconds: UInt64(timeInterval * 10e9)) - } catch { - throw URLError(.cancelled).asNetworkError() - } - fallthrough - - case .retry: - return try await self.executeRequest(request: &request) + func retryRequest(request: inout URLRequest, error networkError: NetworkError) async throws(NetworkError) -> Data { + let retryResult = try await interceptor.retry(urlRequest: &request, for: self, dueTo: networkError) + + switch retryResult { + case .doNotRetry: + throw networkError + + case .retryAfter(let timeInterval): + do { + try await Task.sleep(nanoseconds: UInt64(timeInterval * 10e9)) + } catch { + throw URLError(.cancelled).asNetworkError() } + fallthrough + + case .retry: + return try await self.executeRequest(request: &request) } } @@ -501,7 +533,7 @@ extension URLError.Code { if let error = error as? URLError { self.receivedError = error - } else { + } else if error != nil { fatalError("URLSessionTaskDelegate did not throw expected type URLError") } @@ -529,9 +561,11 @@ func x() async { ) do { -// try await session.request(endpoint: CoffeeEndpoint.hot) as String - let coffeeList = try await session.get("/coffee/hot") + + + let coffeeListA: String = try await session.request(endpoint: CoffeeEndpoint.hot) + let coffeeListB: Data = try await session.get("/coffee/hot") diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index 903a948..cec3a84 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -7,7 +7,7 @@ import Foundation -extension URLError { +public extension URLError { func asNetworkError() -> NetworkError { return NetworkError.local(self) diff --git a/Sources/GoodNetworking/Wrapper/Resource.swift b/Sources/GoodNetworking/Wrapper/Resource.swift index ff67b52..39462ad 100644 --- a/Sources/GoodNetworking/Wrapper/Resource.swift +++ b/Sources/GoodNetworking/Wrapper/Resource.swift @@ -5,429 +5,417 @@ // Created by Filip Ε aΕ‘ala on 08/12/2023. // -//import SwiftUI -// -//// MARK: - Resource -// -//public struct RawResponse: Sendable { -// -// var create: (Decodable & Sendable)? -// var read: (Decodable & Sendable)? -// var update: (Decodable & Sendable)? -// var delete: (Decodable & Sendable)? -// var list: (Decodable & Sendable)? -// -//} -// -//@available(iOS 17.0, *) -//@MainActor @Observable public final class Resource { -// -// private var session: FutureSession -// private var rawResponse: RawResponse = RawResponse() -// private var remote: R.Type -// private let logger: NetworkLogger? -// -// private(set) public var state: ResourceState -// private var listState: ResourceState<[R.Resource], NetworkError> -// private var listParameters: Any? -// -// public var value: R.Resource? { -// get { -// state.value -// } -// set { -// if let newValue { -// state = .pending(newValue) -// listState = .pending([newValue]) -// } else { -// state = .idle -// listState = .idle -// } -// } -// } -// -// public init( -// wrappedValue: R.Resource? = nil, -// session: NetworkSession, -// remote: R.Type, -// logger: NetworkLogger? = nil -// ) { -// self.session = FutureSession { session } -// self.remote = remote -// self.logger = logger -// -// if let wrappedValue { -// self.state = .available(wrappedValue) -// self.listState = .available([wrappedValue]) -// } else { -// self.state = .idle -// self.listState = .idle -// } -// } -// -// public init( -// session: FutureSession? = nil, -// remote: R.Type, -// logger: NetworkLogger? = nil -// ) { -// self.session = session ?? .placeholder -// self.remote = remote -// self.logger = logger -// self.state = .idle -// self.listState = .idle -// } -// -// @discardableResult -// public func session(_ networkSession: NetworkSession) -> Self { -// self.session = FutureSession { networkSession } -// return self -// } -// -// @discardableResult -// public func session(_ futureSession: FutureSession) -> Self { -// self.session = futureSession -// return self -// } -// -// @discardableResult -// public func session(_ sessionSupplier: @escaping FutureSession.FutureSessionSupplier) -> Self { -// self.session = FutureSession(sessionSupplier) -// return self -// } -// -// @discardableResult -// public func initialResource(_ newValue: R.Resource) -> Self { -// self.state = .available(newValue) -// self.listState = .available([newValue]) -// return self -// } -// -//} -// -//// MARK: - Operations -// -//@available(iOS 17.0, *) -//extension Resource { -// -// public func create() async throws { -// logger?.logNetworkEvent(message: "CREATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) -// } -// -// public func read(forceReload: Bool = false) async throws { -// logger?.logNetworkEvent(message: "READ operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) -// } -// -// public func updateRemote() async throws { -// logger?.logNetworkEvent(message: "UPDATE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) -// } -// -// public func delete() async throws { -// logger?.logNetworkEvent(message: "DELETE operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) -// } -// -// public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { -// logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) -// logger?.logNetworkEvent(message: "Check type of parameters passed to this resource.", level: .error, fileName: #file, lineNumber: #line) -// logger?.logNetworkEvent(message: "Current parameters type: \(type(of: parameters))", level: .error, fileName: #file, lineNumber: #line) -// } -// -// public func nextPage() async throws { -// logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, fileName: #file, lineNumber: #line) -// } -// -//} -// -//// MARK: - Create -// -//@available(iOS 17.0, *) -//extension Resource where R: Creatable { -// -// public func create() async throws { -// guard let request = try R.request(from: state.value) else { -// logger?.logNetworkEvent(message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", level: .error, fileName: #file, lineNumber: #line) -// return -// } -// try await create(request: request) -// } -// -// public func create(request: R.CreateRequest) async throws { -// let resource = state.value -// if let resource { -// self.state = .uploading(resource) -// self.listState = .uploading([resource]) -// } else { -// self.state = .loading -// self.listState = .loading -// } -// -// do { -// let response = try await remote.create( -// using: session(), -// request: request -// ) -// self.rawResponse.create = response -// -// let resource = try R.resource(from: response, updating: resource) -// try Task.checkCancellation() -// -// self.state = .available(resource) -// self.listState = .available([resource]) -// } catch let error as NetworkError { -// if let resource { -// self.state = .stale(resource, error) -// self.listState = .stale([resource], error) -// } else { -// self.state = .failure(error) -// self.listState = .failure(error) -// } -// -// throw error -// } catch { -// throw error -// } -// } -// -//} -// -//// MARK: - Read -// -//@available(iOS 17.0, *) -//extension Resource where R: Readable { -// -// // forceReload is default true, when resource is already set, calling read() is expected to always reload the data -// public func read(forceReload: Bool = true) async throws { -// let resource = state.value -// guard let request = try R.request(from: resource) else { -// self.state = .idle -// logger?.logNetworkEvent( -// message: "Reading nil resource always fails! Use read(request:) with a custom request or supply a resource to read from.", -// level: .error, -// fileName: #file, -// lineNumber: #line -// ) -// return -// } -// -// try await read(request: request, forceReload: forceReload) -// } -// -// public func read(request: R.ReadRequest, forceReload: Bool = false) async throws { -// guard !state.isAvailable || forceReload else { -// logger?.logNetworkEvent(message: "Skipping read - value already exists", level: .info, fileName: #file, lineNumber: #line) -// return -// } -// -// let resource = state.value -// self.state = .loading -// self.listState = .loading -// -// do { -// let response = try await remote.read( -// using: session(), -// request: request -// ) -// self.rawResponse.read = response -// -// let resource = try R.resource(from: response, updating: resource) -// try Task.checkCancellation() -// -// self.state = .available(resource) -// self.listState = .available([resource]) -// } catch let error as NetworkError { -// if let resource { -// self.state = .stale(resource, error) -// self.listState = .stale([resource], error) -// } else { -// self.state = .failure(error) -// self.listState = .failure(error) -// } -// -// throw error -// } catch { -// throw error -// } -// } -// -//} -// -//// MARK: - Update -// -//@available(iOS 17.0, *) -//extension Resource where R: Updatable { -// -// public func updateRemote() async throws { -// guard let request = try R.request(from: state.value) else { -// logger?.logNetworkEvent(message: "Updating resource to nil always fails! Use DELETE instead.", level: .error, fileName: #file, lineNumber: #line) -// return -// } -// try await updateRemote(request: request) -// } -// -// public func updateRemote(request: R.UpdateRequest) async throws { -// let resource = state.value -// if let resource { -// self.state = .uploading(resource) -// self.listState = .uploading([resource]) -// } else { -// self.state = .loading -// self.listState = .loading -// } -// -// do { -// let response = try await remote.update( -// using: session(), -// request: request -// ) -// self.rawResponse.update = response -// -// let resource = try R.resource(from: response, updating: resource) -// try Task.checkCancellation() -// -// self.state = .available(resource) -// self.listState = .available([resource]) -// } catch let error as NetworkError { -// if let resource { -// self.state = .stale(resource, error) -// self.listState = .stale([resource], error) -// } else { -// self.state = .failure(error) -// self.listState = .failure(error) -// } -// -// throw error -// } catch { -// throw error -// } -// } -// -//} -// -//// MARK: - Delete -// -//@available(iOS 17.0, *) -//extension Resource where R: Deletable { -// -// public func delete() async throws { -// guard let request = try R.request(from: state.value) else { -// logger?.logNetworkEvent(message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", level: .error, fileName: #file, lineNumber: #line) -// return -// } -// try await delete(request: request) -// } -// -// public func delete(request: R.DeleteRequest) async throws { -// self.state = .loading -// self.listState = .loading -// -// do { -// let response = try await remote.delete( -// using: session(), -// request: request -// ) -// self.rawResponse.delete = response -// -// let resource = try R.resource(from: response, updating: state.value) -// try Task.checkCancellation() -// -// if let resource { -// // case with partial/soft delete only -// self.state = .available(resource) -// self.listState = .available([resource]) -// } else { -// self.state = .idle -// #warning("TODO: vymazat z listu iba prave vymazovany element") -// self.listState = .idle -// } -// } catch let error as NetworkError { -// self.state = .failure(error) -// self.listState = .failure(error) -// -// throw error -// } catch { -// throw error -// } -// } -// -//} -// -//// MARK: - List -// -//@available(iOS 17.0, *) -//extension Resource where R: Listable { -// -// public var elements: [R.Resource] { -// if let list = listState.value { -// return list -// } else { -// return Array.init(repeating: .placeholder, count: 3) -// } -// } -// -// public var startIndex: Int { -// elements.startIndex -// } -// -// public var endIndex: Int { -// elements.endIndex -// } -// -// public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { -// if !(listState.value?.isEmpty ?? true) || forceReload { -// self.listState = .idle -// self.state = .loading -// } -// self.listParameters = parameters -// -// let firstPageRequest = R.firstPageRequest(withParameters: parameters) -// try await list(request: firstPageRequest) -// } -// -// public func nextPage() async throws { -// guard let nextPageRequest = nextPageRequest() else { return } -// try await list(request: nextPageRequest) -// } -// -// internal func nextPageRequest() -> R.ListRequest? { -// guard let currentList = listState.value, -// let lastResponse = rawResponse.list as? R.ListResponse -// else { -// return nil -// } -// -// return R.nextPageRequest( -// currentResource: currentList, -// parameters: self.listParameters, -// lastResponse: lastResponse -// ) -// } -// -// public func list(request: R.ListRequest) async throws { -// if !state.isAvailable { -// self.state = .loading -// } -// -// do { -// let response = try await remote.list( -// using: session(), -// request: request -// ) -// self.rawResponse.list = response -// -// let list = R.list(from: response, oldValue: listState.value ?? []) -// self.listState = .available(list) -// -// let resource = list.first -// if let resource { -// self.state = .available(resource) -// } else { -// self.state = .idle -// } -// } catch let error { -// self.state = .failure(error) -// self.listState = .failure(error) -// -// throw error -// } -// } -// -//} +import SwiftUI + +// MARK: - Resource + +public struct RawResponse: Sendable { + + var create: (Decodable & Sendable)? + var read: (Decodable & Sendable)? + var update: (Decodable & Sendable)? + var delete: (Decodable & Sendable)? + var list: (Decodable & Sendable)? + +} + +@available(iOS 17.0, *) +@MainActor @Observable public final class Resource { + + private var session: NetworkSession + private var rawResponse: RawResponse = RawResponse() + private var remote: R.Type + private let logger: NetworkLogger? + + private(set) public var state: ResourceState + private var listState: ResourceState<[R.Resource], NetworkError> + private var listParameters: Any? + + public var value: R.Resource? { + get { + state.value + } + set { + if let newValue { + state = .pending(newValue) + listState = .pending([newValue]) + } else { + state = .idle + listState = .idle + } + } + } + + public init( + wrappedValue: R.Resource? = nil, + session: NetworkSession, + remote: R.Type, + logger: NetworkLogger? = nil + ) { + self.session = session + self.remote = remote + self.logger = logger + + if let wrappedValue { + self.state = .available(wrappedValue) + self.listState = .available([wrappedValue]) + } else { + self.state = .idle + self.listState = .idle + } + } + + public init( + session: NetworkSession, + remote: R.Type, + logger: NetworkLogger? = nil + ) { + self.session = session + self.remote = remote + self.logger = logger + self.state = .idle + self.listState = .idle + } + + @discardableResult + public func session(_ networkSession: NetworkSession) -> Self { + self.session = networkSession + return self + } + + @discardableResult + public func initialResource(_ newValue: R.Resource) -> Self { + self.state = .available(newValue) + self.listState = .available([newValue]) + return self + } + +} + +// MARK: - Operations + +@available(iOS 17.0, *) +extension Resource { + + public func create() async throws { + logger?.logNetworkEvent(message: "CREATE operation not defined for resource \(String(describing: R.self))", level: .error, file: #file, line: #line) + } + + public func read(forceReload: Bool = false) async throws { + logger?.logNetworkEvent(message: "READ operation not defined for resource \(String(describing: R.self))", level: .error, file: #file, line: #line) + } + + public func updateRemote() async throws { + logger?.logNetworkEvent(message: "UPDATE operation not defined for resource \(String(describing: R.self))", level: .error, file: #file, line: #line) + } + + public func delete() async throws { + logger?.logNetworkEvent(message: "DELETE operation not defined for resource \(String(describing: R.self))", level: .error, file: #file, line: #line) + } + + public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { + logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, file: #file, line: #line) + logger?.logNetworkEvent(message: "Check type of parameters passed to this resource.", level: .error, file: #file, line: #line) + logger?.logNetworkEvent(message: "Current parameters type: \(type(of: parameters))", level: .error, file: #file, line: #line) + } + + public func nextPage() async throws { + logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, file: #file, line: #line) + } + +} + +// MARK: - Create + +@available(iOS 17.0, *) +extension Resource where R: Creatable { + + public func create() async throws { + guard let request = try R.request(from: state.value) else { + logger?.logNetworkEvent(message: "Creating nil resource always fails! Use create(request:) with a custom request or supply a resource to create.", level: .error, file: #file, line: #line) + return + } + try await create(request: request) + } + + public func create(request: R.CreateRequest) async throws { + let resource = state.value + if let resource { + self.state = .uploading(resource) + self.listState = .uploading([resource]) + } else { + self.state = .loading + self.listState = .loading + } + + do { + let response = try await remote.create( + using: session, + request: request + ) + self.rawResponse.create = response + + let resource = try R.resource(from: response, updating: resource) + try Task.checkCancellation() + + self.state = .available(resource) + self.listState = .available([resource]) + } catch let error as NetworkError { + if let resource { + self.state = .stale(resource, error) + self.listState = .stale([resource], error) + } else { + self.state = .failure(error) + self.listState = .failure(error) + } + + throw error + } catch { + throw error + } + } + +} + +// MARK: - Read + +@available(iOS 17.0, *) +extension Resource where R: Readable { + + // forceReload is default true, when resource is already set, calling read() is expected to always reload the data + public func read(forceReload: Bool = true) async throws { + let resource = state.value + guard let request = try R.request(from: resource) else { + self.state = .idle + logger?.logNetworkEvent( + message: "Reading nil resource always fails! Use read(request:) with a custom request or supply a resource to read from.", + level: .error, + file: #file, + line: #line + ) + return + } + + try await read(request: request, forceReload: forceReload) + } + + public func read(request: R.ReadRequest, forceReload: Bool = false) async throws { + guard !state.isAvailable || forceReload else { + logger?.logNetworkEvent(message: "Skipping read - value already exists", level: .info, file: #file, line: #line) + return + } + + let resource = state.value + self.state = .loading + self.listState = .loading + + do { + let response = try await remote.read( + using: session, + request: request + ) + self.rawResponse.read = response + + let resource = try R.resource(from: response, updating: resource) + try Task.checkCancellation() + + self.state = .available(resource) + self.listState = .available([resource]) + } catch let error as NetworkError { + if let resource { + self.state = .stale(resource, error) + self.listState = .stale([resource], error) + } else { + self.state = .failure(error) + self.listState = .failure(error) + } + + throw error + } catch { + throw error + } + } + +} + +// MARK: - Update + +@available(iOS 17.0, *) +extension Resource where R: Updatable { + + public func updateRemote() async throws { + guard let request = try R.request(from: state.value) else { + logger?.logNetworkEvent(message: "Updating resource to nil always fails! Use DELETE instead.", level: .error, file: #file, line: #line) + return + } + try await updateRemote(request: request) + } + + public func updateRemote(request: R.UpdateRequest) async throws { + let resource = state.value + if let resource { + self.state = .uploading(resource) + self.listState = .uploading([resource]) + } else { + self.state = .loading + self.listState = .loading + } + + do { + let response = try await remote.update( + using: session, + request: request + ) + self.rawResponse.update = response + + let resource = try R.resource(from: response, updating: resource) + try Task.checkCancellation() + + self.state = .available(resource) + self.listState = .available([resource]) + } catch let error as NetworkError { + if let resource { + self.state = .stale(resource, error) + self.listState = .stale([resource], error) + } else { + self.state = .failure(error) + self.listState = .failure(error) + } + + throw error + } catch { + throw error + } + } + +} + +// MARK: - Delete + +@available(iOS 17.0, *) +extension Resource where R: Deletable { + + public func delete() async throws { + guard let request = try R.request(from: state.value) else { + logger?.logNetworkEvent(message: "Deleting nil resource always fails. Use delete(request:) with a custom request or supply a resource to delete.", level: .error, file: #file, line: #line) + return + } + try await delete(request: request) + } + + public func delete(request: R.DeleteRequest) async throws { + self.state = .loading + self.listState = .loading + + do { + let response = try await remote.delete( + using: session, + request: request + ) + self.rawResponse.delete = response + + let resource = try R.resource(from: response, updating: state.value) + try Task.checkCancellation() + + if let resource { + // case with partial/soft delete only + self.state = .available(resource) + self.listState = .available([resource]) + } else { + self.state = .idle + #warning("TODO: vymazat z listu iba prave vymazovany element") + self.listState = .idle + } + } catch let error as NetworkError { + self.state = .failure(error) + self.listState = .failure(error) + + throw error + } catch { + throw error + } + } + +} + +// MARK: - List + +@available(iOS 17.0, *) +extension Resource where R: Listable { + + public var elements: [R.Resource] { + if let list = listState.value { + return list + } else { + return Array.init(repeating: .placeholder, count: 3) + } + } + + public var startIndex: Int { + elements.startIndex + } + + public var endIndex: Int { + elements.endIndex + } + + public func firstPage(parameters: Any? = nil, forceReload: Bool = false) async throws { + if !(listState.value?.isEmpty ?? true) || forceReload { + self.listState = .idle + self.state = .loading + } + self.listParameters = parameters + + let firstPageRequest = R.firstPageRequest(withParameters: parameters) + try await list(request: firstPageRequest) + } + + public func nextPage() async throws { + guard let nextPageRequest = nextPageRequest() else { return } + try await list(request: nextPageRequest) + } + + internal func nextPageRequest() -> R.ListRequest? { + guard let currentList = listState.value, + let lastResponse = rawResponse.list as? R.ListResponse + else { + return nil + } + + return R.nextPageRequest( + currentResource: currentList, + parameters: self.listParameters, + lastResponse: lastResponse + ) + } + + public func list(request: R.ListRequest) async throws { + if !state.isAvailable { + self.state = .loading + } + + do { + let response = try await remote.list( + using: session, + request: request + ) + self.rawResponse.list = response + + let list = R.list(from: response, oldValue: listState.value ?? []) + self.listState = .available(list) + + let resource = list.first + if let resource { + self.state = .available(resource) + } else { + self.state = .idle + } + } catch let error { + self.state = .failure(error) + self.listState = .failure(error) + + throw error + } + } + +} From 57d1d8818e3bd02cb34c8c2452eb4b48e7b44336 Mon Sep 17 00:00:00 2001 From: Filip Sasala <31418257+plajdo@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:18:29 +0200 Subject: [PATCH 13/13] fix: Request bugfixes --- .../Extensions/CodableExtensions.swift | 16 +- .../GoodNetworking/Protocols/Endpoint.swift | 7 - .../GoodNetworking/Session/GRSession.swift | 68 ++++-- .../Session/LoggingEventMonitor.swift | 223 ++++++------------ .../GoodNetworking/Session/NetworkError.swift | 16 +- 5 files changed, 142 insertions(+), 188 deletions(-) diff --git a/Sources/GoodNetworking/Extensions/CodableExtensions.swift b/Sources/GoodNetworking/Extensions/CodableExtensions.swift index bf2e821..249f5a1 100644 --- a/Sources/GoodNetworking/Extensions/CodableExtensions.swift +++ b/Sources/GoodNetworking/Extensions/CodableExtensions.swift @@ -9,6 +9,7 @@ import Foundation // MARK: - Encodable extensions +@available(iOS, obsoleted: 4.0, message: "Use custom encode(to:) implementation instead where required") public protocol WithCustomEncoder { static var encoder: JSONEncoder { get } @@ -17,6 +18,7 @@ public protocol WithCustomEncoder { } +@available(iOS, obsoleted: 4.0, message: "Use custom encode(to:) implementation instead where required") public extension Encodable where Self: WithCustomEncoder { /// The `keyEncodingStrategy` property returns the default key encoding strategy of the `JSONEncoder`. @@ -44,15 +46,17 @@ public extension Encodable where Self: WithCustomEncoder { /// - Throws: An error if encoding fails. /// - Returns: `Data` representation of the `Encodable` object. func encode() throws -> Data { - return try encoder.encode(self) + return Data() +// return try encoder.encode(self) } /// Returns the `Encodable` object as a JSON dictionary if encoding succeeds, otherwise it returns `nil`. var jsonDictionary: [String: (Any & Sendable)]? { - guard let data = try? encoder.encode(self) else { return nil } - - return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) - .flatMap { $0 as? [String: (Any & Sendable)] } + return nil +// guard let data = try? encoder.encode(self) else { return nil } +// +// return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) +// .flatMap { $0 as? [String: (Any & Sendable)] } } } @@ -60,6 +64,7 @@ public extension Encodable where Self: WithCustomEncoder { // MARK: - Decodable extensions +@available(iOS, obsoleted: 4.0, message: "Use custom init(from decoder:) implementation instead where required") public protocol WithCustomDecoder { static var decoder: JSONDecoder { get } @@ -68,6 +73,7 @@ public protocol WithCustomDecoder { } +@available(iOS, obsoleted: 4.0, message: "Use custom init(from decoder:) implementation instead where required") public extension Decodable where Self: WithCustomDecoder { /// Defines the `KeyDecodingStrategy` for all `Decodable WithCustomDecoder` objects using this extension. diff --git a/Sources/GoodNetworking/Protocols/Endpoint.swift b/Sources/GoodNetworking/Protocols/Endpoint.swift index 453a97b..b7dfa1d 100644 --- a/Sources/GoodNetworking/Protocols/Endpoint.swift +++ b/Sources/GoodNetworking/Protocols/Endpoint.swift @@ -58,7 +58,6 @@ public extension Endpoint { public enum EndpointParameters { public typealias Parameters = [String: Any] - public typealias CustomEncodable = (Encodable & Sendable & WithCustomEncoder) /// Case for sending `Parameters`. case parameters(Parameters) @@ -71,9 +70,6 @@ public enum EndpointParameters { case .parameters(let parameters): return parameters - case .model(let customEncodable as CustomEncodable): - return customEncodable.jsonDictionary - case .model(let anyEncodable): let encoder = JSONEncoder() @@ -90,9 +86,6 @@ public enum EndpointParameters { internal func data() -> Data? { switch self { - case .model(let codableModel as CustomEncodable): - return try? JSONSerialization.data(withJSONObject: codableModel.jsonDictionary) - case .model(let codableModel): let encoder = JSONEncoder() let data = try? encoder.encode(codableModel) diff --git a/Sources/GoodNetworking/Session/GRSession.swift b/Sources/GoodNetworking/Session/GRSession.swift index e253b65..107b9a2 100644 --- a/Sources/GoodNetworking/Session/GRSession.swift +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -188,6 +188,7 @@ public final class CompositeInterceptor: Interceptor { private let baseUrl: any URLConvertible private let baseHeaders: HTTPHeaders private let interceptor: any Interceptor + private let logger: any NetworkLogger private let configuration: URLSessionConfiguration private let delegateQueue: OperationQueue @@ -212,6 +213,7 @@ public final class CompositeInterceptor: Interceptor { self.baseUrl = baseUrl self.baseHeaders = baseHeaders self.interceptor = interceptor + self.logger = logger let operationQueue = OperationQueue() operationQueue.name = "NetworkActorSerialExecutorOperationQueue" @@ -236,7 +238,7 @@ internal extension NetworkSession { if let existingProxy = self.taskProxyMap[task.taskIdentifier] { return existingProxy } else { - let newProxy = DataTaskProxy(task: task) + let newProxy = DataTaskProxy(task: task, logger: logger) self.taskProxyMap[task.taskIdentifier] = newProxy return newProxy } @@ -343,20 +345,22 @@ extension NetworkSession { let data = try await request(endpoint: endpoint) // exist-fast if decoding is not needed - if T.self is Data.Type { + if T.self is Data { return data as! T } do { let model = try JSONDecoder().decode(T.self, from: data) return model + } catch let error as DecodingError { + throw error.asNetworkError() } catch { throw URLError(.cannotDecodeRawData).asNetworkError() } } public func request(endpoint: Endpoint) async throws(NetworkError) -> Data { - guard let url = await URL(string: endpoint.path, relativeTo: baseUrl.resolveUrl()) else { + guard let url = await baseUrl.resolveUrl()?.appendingPathComponent(endpoint.path) else { throw URLError(.badURL).asNetworkError() } @@ -390,10 +394,15 @@ private extension NetworkSession { func executeRequest(request: inout URLRequest) async throws(NetworkError) -> Data { // Headers - if request.httpBody == nil { - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - } else { // assume all apps are always encoding data as JSON + let httpMethodSupportsBody = request.method.hasRequestBody + let httpMethodHasBody = (request.httpBody != nil) + + if httpMethodSupportsBody && httpMethodHasBody { // assume all apps are always encoding data as JSON request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } else if httpMethodSupportsBody && !httpMethodHasBody { // supports body, but has parameters in query + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + } else { + // do not set Content-Type } baseHeaders.forEach { header in @@ -469,9 +478,11 @@ extension NetworkSession { } public func getRaw(_ path: URLConvertible) async throws(NetworkError) -> Data { - guard let url = await path.resolveUrl() else { + #warning("Check URL creation") + guard let path = await path.resolveUrl(), let baseUrl = await baseUrl.resolveUrl() else { throw URLError(.badURL).asNetworkError() } + let url = baseUrl.appendingPathComponent(path.path) var request = URLRequest(url: url) request.httpMethod = HTTPMethod.get.rawValue @@ -494,22 +505,24 @@ extension URLError.Code { // MARK: - DataTaskProxy -@NetworkActor final class DataTaskProxy { +@NetworkActor internal final class DataTaskProxy { private(set) var task: URLSessionTask + private let logger: any NetworkLogger + + internal var receivedData: Data = Data() + internal var receivedError: (URLError)? = nil - private var receivedData: Data = Data() - private var receivedError: (URLError)? = nil private var isFinished = false private var continuation: CheckedContinuation? = nil - func data() async throws(NetworkError) -> Data { + internal func data() async throws(NetworkError) -> Data { if !isFinished { await waitForCompletion() } if let receivedError { throw receivedError.asNetworkError() } return receivedData } - func result() async -> Result { + internal func result() async -> Result { if !isFinished { await waitForCompletion() } if let receivedError { return .failure(receivedError.asNetworkError()) @@ -518,30 +531,41 @@ extension URLError.Code { } } - init(task: URLSessionTask) { + internal init(task: URLSessionTask, logger: any NetworkLogger) { self.task = task + self.logger = logger } - func dataTaskDidReceive(data: Data) { + internal func dataTaskDidReceive(data: Data) { assert(isFinished == false, "ILLEGAL ATTEMPT TO APPEND DATA TO FINISHED PROXY INSTANCE") receivedData.append(data) } - func dataTaskDidComplete(withError error: (any Error)?) { + internal func dataTaskDidComplete(withError error: (any Error)?) { assert(isFinished == false, "ILLEGAL ATTEMPT TO RESUME FINISHED CONTINUATION") self.isFinished = true if let error = error as? URLError { self.receivedError = error } else if error != nil { - fatalError("URLSessionTaskDelegate did not throw expected type URLError") + assertionFailure("URLSessionTaskDelegate did not throw expected type URLError") + self.receivedError = URLError(.unknown) + } + + Task { @NetworkActor in + logger.logNetworkEvent( + message: prepareRequestInfo(), + level: receivedError == nil ? .debug : .warning, + file: #file, + line: #line + ) } continuation?.resume() continuation = nil } - func waitForCompletion() async { + internal func waitForCompletion() async { assert(self.continuation == nil, "CALLING RESULT/DATA CONCURRENTLY WILL LEAK RESOURCES") assert(isFinished == false, "FINISHED PROXY CANNOT RESUME CONTINUATION") try await withCheckedContinuation { self.continuation = $0 } @@ -549,6 +573,16 @@ extension URLError.Code { } +// MARK: - Extensions + +public extension URLRequest { + + var method: HTTPMethod { + HTTPMethod(rawValue: self.httpMethod ?? "GET") ?? .get + } + +} + // MARK: - Sample func x() async { diff --git a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift index b9e74b4..b4c48e1 100644 --- a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift +++ b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift @@ -8,160 +8,69 @@ import Combine import Foundation -//public struct LoggingEventMonitor: EventMonitor, Sendable { -// -// nonisolated(unsafe) public static var verbose: Bool = true -// nonisolated(unsafe) public static var prettyPrinted: Bool = true -// nonisolated(unsafe) public static var maxVerboseLogSizeBytes: Int = 100_000 -// -// /// The `DispatchQueue` onto which Alamofire's root `CompositeEventMonitor` will dispatch events. `.main` by default. -// public let queue = DispatchQueue(label: C.queueLabel, qos: .background) -// -// private enum C { -// -// static let queueLabel = "com.goodrequest.networklogger" -// -// } -// -// private let logger: NetworkLogger -// -// /// Creates a new logging monitor. -// /// -// /// - Parameter logger: The logger instance to use for output. If nil, no logging occurs. -// public init(logger: NetworkLogger, configuration: Configuration = .init()) { -// self.logger = logger -// } -// -// public func request(_ request: DataRequest, didParseResponse response: DataResponse) { -// let requestInfoMessage = parseRequestInfo(response: response) -// let metricsMessage = parse(metrics: response.metrics) -// let requestBodyMessage = parse(data: request.request?.httpBody, error: response.error as NSError?, prefix: "⬆️ Request body:") -// let errorMessage: String? = if let afError = response.error { -// "🚨 Error:\n\(afError)" -// } else { -// nil -// } -// -// let responseBodyMessage = if Self.useMimeTypeWhitelist, Self.responseTypeWhiteList.contains(where: { $0 == response.response?.mimeType }) { -// parse(data: response.data, error: response.error as NSError?, prefix: "⬇️ Response body:") -// } else { -// "❓❓❓ Response MIME type not whitelisted (\(response.response?.mimeType ?? "❓")). You can try adding it to whitelist using logMimeType(_ mimeType:)." -// } -// -// let logMessage = [ -// requestInfoMessage, -// metricsMessage, -// requestBodyMessage, -// errorMessage, -// responseBodyMessage -// ].compactMap { $0 }.joined(separator: "\n") -// -// switch response.result { -// case .success: -// logger.logNetworkEvent(message: logMessage, level: .debug, fileName: #file, lineNumber: #line) -// case .failure: -// logger.logNetworkEvent(message: logMessage, level: .error, fileName: #file, lineNumber: #line) -// } -// } -// -//} -// -//private extension LoggingEventMonitor { -// -// func parseRequestInfo(response: DataResponse) -> String? { -// guard let request = response.request, -// let url = request.url?.absoluteString.removingPercentEncoding, -// let method = request.httpMethod, -// let response = response.response -// else { -// return nil -// } -// guard Self.verbose else { -// return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)" -// } -// -// if let headers = request.allHTTPHeaderFields, -// !headers.isEmpty, -// let headersData = try? JSONSerialization.data(withJSONObject: headers, options: [.prettyPrinted]), -// let headersPrettyMessage = parse(data: headersData, error: nil, prefix: "🏷 Headers:") { -// -// return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage -// } else { -// let headers = if let allHTTPHeaderFields = request.allHTTPHeaderFields, !allHTTPHeaderFields.isEmpty { -// allHTTPHeaderFields.description -// } else { -// "empty headers" -// } -// return "πŸš€ \(method)|\(parseResponseStatus(response: response))|\(url)\n🏷 Headers: \(headers)" -// } -// } -// -// func parse(data: Data?, error: NSError?, prefix: String) -> String? { -// guard Self.verbose else { return nil } -// -// if let data = data, !data.isEmpty { -// guard data.count < Self.maxVerboseLogSizeBytes else { -// return [ -// prefix, -// "Data size is too big!", -// "Max size is: \(Self.maxVerboseLogSizeBytes) bytes.", -// "Data size is: \(data.count) bytes", -// "πŸ’‘Tip: Change LoggingEventMonitor.maxVerboseLogSizeBytes = \(data.count)" -// ].joined(separator: "\n") -// } -// if let string = String(data: data, encoding: .utf8) { -// if let jsonData = try? JSONSerialization.jsonObject(with: data, options: []), -// let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: Self.prettyPrinted ? [.prettyPrinted, .withoutEscapingSlashes] : [.withoutEscapingSlashes]), -// let prettyPrintedString = String(data: prettyPrintedData, encoding: .utf8) { -// return "\(prefix) \n\(prettyPrintedString)" -// } else { -// return "\(prefix)\(string)" -// } -// } -// } -// -// return nil -// } -// -// func parse(metrics: URLSessionTaskMetrics?) -> String? { -// guard let metrics, Self.verbose else { -// return nil -// } -// return "↗️ Start: \(metrics.taskInterval.start)" + "\n" + "βŒ›οΈ Duration: \(metrics.taskInterval.duration)s" -// } -// -// -// func parseResponseStatus(response: HTTPURLResponse) -> String { -// let statusCode = response.statusCode -// let logMessage = (200 ..< 300).contains(statusCode) ? "βœ… \(statusCode)" : "❌ \(statusCode)" -// return logMessage -// } -// -//} -// -//public extension LoggingEventMonitor { -// -// nonisolated(unsafe) private(set) static var responseTypeWhiteList: [String] = [ -// "application/json", -// "application/ld+json", -// "application/xml", -// "text/plain", -// "text/csv", -// "text/html", -// "text/javascript", -// "application/rtf" -// ] -// -// nonisolated(unsafe) static var useMimeTypeWhitelist: Bool = true -// -// nonisolated(unsafe) static func logMimeType(_ mimeType: String) { -// responseTypeWhiteList.append(mimeType) -// } -// -// nonisolated(unsafe) static func stopLoggingMimeType(_ mimeType: String) { -// responseTypeWhiteList.removeAll(where: { -// $0 == mimeType -// }) -// } -// -//} +// MARK: - Logging + +@NetworkActor internal extension DataTaskProxy { + + private static var maxLogSizeBytes: Int { 32_768 } // 32 kB + + internal func prepareRequestInfo() -> String { + """ + πŸš€ \(task.currentRequest?.method.rawValue ?? "-") \(task.currentRequest?.url?.absoluteString ?? "") + \(prepareResponseStatus(response: task.response, error: receivedError)) + + 🏷 Headers: + \(prepareHeaders(request: task.originalRequest)) + + πŸ“€ Request body: + \(prettyPrintMessage(data: task.originalRequest?.httpBody)) + + πŸ“¦ Received data: + \(prettyPrintMessage(data: receivedData)) + """ + } + + private func prepareResponseStatus(response: URLResponse?, error: (any Error)?) -> String { + guard let response = response as? HTTPURLResponse else { return "" } + let statusCode = response.statusCode + + var logMessage = (200 ..< 300).contains(statusCode) ? "βœ… \(statusCode): " : "❌ \(statusCode): " + logMessage.append(HTTPURLResponse.localizedString(forStatusCode: statusCode)) + + if error != nil { + logMessage.append("\n🚨 Error: \(error?.localizedDescription)") + } + + return logMessage + } + + private func prepareHeaders(request: URLRequest?) -> String { + guard let request, let headerFields = request.allHTTPHeaderFields else { return " " } + + return headerFields.map { key, value in + " - \(key): \(value ?? "")" + } + .joined(separator: "\n") + } + + private func prettyPrintMessage(data: Data?) -> String { + guard let data else { return "" } + + guard data.count < Self.maxLogSizeBytes else { + return "πŸ’‘ Data size is too big (\(data.count) bytes), console limit is \(Self.maxLogSizeBytes) bytes" + } + + if let string = String(data: data, encoding: .utf8) { + if let jsonData = try? JSONSerialization.jsonObject(with: data, options: []), + let prettyPrintedData = try? JSONSerialization.data(withJSONObject: jsonData, options: [.prettyPrinted, .withoutEscapingSlashes]), + let prettyPrintedString = String(data: prettyPrintedData, encoding: .utf8) { + return prettyPrintedString + } else { + return string + } + } + + return "πŸ” Couldn't decode data as UTF-8" + } + +} diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index cec3a84..e242b3d 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -15,18 +15,30 @@ public extension URLError { } -public enum NetworkError: LocalizedError, Hashable { +public extension DecodingError { + + func asNetworkError() -> NetworkError { + return NetworkError.decoding(self) + } + +} + +public enum NetworkError: LocalizedError { case local(URLError) case remote(HTTPError) + case decoding(DecodingError) public var errorDescription: String? { switch self { case .local(let urlError): - return "" + return urlError.localizedDescription case .remote(let httpError): return httpError.localizedDescription + + case .decoding(let decodingError): + return decodingError.localizedDescription } }