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/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/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/Package.resolved b/Package.resolved index c1546ad..13f164c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,24 +1,6 @@ { - "originHash" : "886644daa6f3bcf0e06b25d6f55a2d8e5f9fcd6c28b069e7970f478683a82860", + "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", @@ -28,15 +10,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/Package.swift b/Package.swift index 12368af..fe014bc 100644 --- a/Package.swift +++ b/Package.swift @@ -21,10 +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/GoodRequest/GoodLogger.git", .upToNextMajor(from: "1.2.4")) + .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. @@ -32,10 +29,7 @@ let package = Package( .target( name: "GoodNetworking", 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/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/Executor/DeduplicatingRequestExecutor.swift b/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift deleted file mode 100644 index 62498ce..0000000 --- a/Sources/GoodNetworking/Executor/DeduplicatingRequestExecutor.swift +++ /dev/null @@ -1,151 +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, Identifiable { - - /// A unique identifier used to track and deduplicate requests - private let taskId: String? - - #warning("Timeout should be configurable based on taskId") - /// 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] = [:] - - /// 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? = nil, 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 - ) async -> DataResponse { - 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 - } 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) - } - } - } - - logger.log(message: "🚀 taskId: \(taskId): Task created", level: .info) - - let executorTask: ExecutorTask = ExecutorTask( - taskId: taskId, - task: requestTask as ExecutorTask.TaskType, - cacheTimeout: cacheTimeout - ) - - DeduplicatingRequestExecutor.runningRequestTasks[taskId] = executorTask - - let dataResponse = await requestTask.value - switch dataResponse.result { - case .success: - 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 - - case .failure: - logger.log(message: "🚀 taskId: \(taskId): Task finished with error", level: .error) - DeduplicatingRequestExecutor.runningRequestTasks[taskId] = nil - return dataResponse - } - } - - } - -} diff --git a/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift b/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift deleted file mode 100644 index d4e0329..0000000 --- a/Sources/GoodNetworking/Executor/DefaultRequestExecutor.swift +++ /dev/null @@ -1,65 +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 - ) async -> DataResponse { - 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 - ).response { response in - continuation.resume(returning: response) - } - } - } - -} diff --git a/Sources/GoodNetworking/Executor/ExecutorTask.swift b/Sources/GoodNetworking/Executor/ExecutorTask.swift deleted file mode 100644 index 964855a..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 f693b57..0000000 --- a/Sources/GoodNetworking/Executor/RequestExecuting.swift +++ /dev/null @@ -1,54 +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 - ) async -> DataResponse - -} 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/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/Extensions/Goodify.swift b/Sources/GoodNetworking/Extensions/Goodify.swift new file mode 100644 index 0000000..f9df897 --- /dev/null +++ b/Sources/GoodNetworking/Extensions/Goodify.swift @@ -0,0 +1,270 @@ +// +// Goodify.swift +// GoodNetworking +// +// Created by Dominik Pethö on 4/30/19. +// + +//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) +// } +// +//} +// \ 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 93084d4..0000000 --- a/Sources/GoodNetworking/GRImageDownloader.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// GRImageDownloader.swift -// GoodNetworking -// -// Created by Andrej Jasso on 24/05/2022. -// - -import Foundation -import AlamofireImage -import Alamofire - -/// 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() - ) - } - -} diff --git a/Sources/GoodNetworking/Logger/NetworkLogger.swift b/Sources/GoodNetworking/Logger/NetworkLogger.swift new file mode 100644 index 0000000..c30103a --- /dev/null +++ b/Sources/GoodNetworking/Logger/NetworkLogger.swift @@ -0,0 +1,29 @@ +// +// 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. + nonisolated func logNetworkEvent( + message: Any, + level: LogLevel, + 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/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/NetworkActor.swift b/Sources/GoodNetworking/NetworkActor.swift new file mode 100644 index 0000000..5068f01 --- /dev/null +++ b/Sources/GoodNetworking/NetworkActor.swift @@ -0,0 +1,59 @@ +// +// 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) + } + + public static func assumeIsolated(_ operation: @NetworkActor () throws -> T) rethrows -> T { + typealias YesActor = @NetworkActor () throws -> T + typealias NoActor = () throws -> T + + 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 + let rawFn = unsafeBitCast(fn, to: NoActor.self) + return try rawFn() + } + } + +} + +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 012c3e9..b7dfa1d 100644 --- a/Sources/GoodNetworking/Protocols/Endpoint.swift +++ b/Sources/GoodNetworking/Protocols/Endpoint.swift @@ -5,26 +5,25 @@ // Created by Filip Šašala on 10/12/2023. // -import Alamofire 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 } /// 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,13 +54,14 @@ 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 CustomEncodable = (Encodable & Sendable & WithCustomEncoder) + public typealias Parameters = [String: Any] /// Case for sending `Parameters`. case parameters(Parameters) - + /// Case for sending an instance of `Encodable`. case model(Encodable) @@ -70,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() @@ -87,4 +84,49 @@ public enum EndpointParameters { } } + internal func data() -> Data? { + switch self { + 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 + +@available(*, deprecated) +public protocol ParameterEncoding {} + +@available(*, deprecated) +public enum URLEncoding: ParameterEncoding { + case `default` +} + +@available(*, deprecated) +public enum JSONEncoding: ParameterEncoding { + case `default` +} + +@available(*, deprecated, message: "Use URLConvertible instead.") +public extension String { + + @available(*, deprecated, message: "Use URLConvertible instead.") + public func asURL() throws -> URL { + guard let url = URL(string: self) else { + throw URLError(.badURL).asNetworkError() + } + 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 6a8b770..0df36dd 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. /// @@ -96,7 +96,7 @@ public extension Creatable { /// - 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 + throw URLError(.cannotEncodeRawData).asNetworkError() } } @@ -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. /// @@ -276,7 +276,7 @@ public extension Query { request: ReadRequest ) async throws(NetworkError) -> ReadResponse { let endpoint: Endpoint = try Self.endpoint(request) - let response: ReadResponse = try await session.requestRaw(endpoint: endpoint) + let response: ReadResponse = try await session.request(endpoint: endpoint) return response } @@ -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/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/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/DeduplicatingResultProvider.swift b/Sources/GoodNetworking/Providers/DeduplicatingResultProvider.swift deleted file mode 100644 index 7ee711d..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/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 4a63ee0..ca78b42 100644 --- a/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultSessionProvider.swift @@ -5,115 +5,114 @@ // Created by Andrej Jasso on 14/10/2024. // -@preconcurrency import Alamofire -import GoodLogger - -/// 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 `GoodLogger` 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 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? - - /// 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) { - self.configuration = configuration - self.currentSession = Alamofire.Session( - configuration: configuration.urlSessionConfiguration, - interceptor: configuration.interceptor, - serverTrustManager: configuration.serverTrustManager, - eventMonitors: configuration.eventMonitors - ) - - self.logger = logger - } - - /// 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) { - self.currentSession = session - self.configuration = NetworkSessionConfiguration( - urlSessionConfiguration: session.sessionConfiguration, - interceptor: session.interceptor, - serverTrustManager: session.serverTrustManager, - eventMonitors: [session.eventMonitor] - ) - - self.logger = logger - } - - /// 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?.log( - message: "✅ Default session is always valid", - level: .debug - ) - 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?.log( - message: "❌ Default session cannot be invalidated", - level: .debug - ) - } - - /// 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?.log( - message: "❌ Default Session Provider cannot be create a new Session, it's setup in the initializer", - level: .debug - ) - - 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?.log( - message: "❌ Default session provider always resolves current session which is setup in the initializer", - level: .debug - ) - 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/Providers/DefaultValidationProvider.swift b/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift index 4a454f5..b7df5bd 100644 --- a/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift +++ b/Sources/GoodNetworking/Providers/DefaultValidationProvider.swift @@ -35,9 +35,9 @@ 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) + throw NetworkError.remote(HTTPError(statusCode: statusCode, errorResponse: data)) } } 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..107b9a2 --- /dev/null +++ b/Sources/GoodNetworking/Session/GRSession.swift @@ -0,0 +1,624 @@ +// +// GRSession.swift +// GoodNetworking +// +// Created by Filip Šašala on 02/07/2025. +// + +import Foundation + +// MARK: - Authenticator + +public enum RetryResult { + + case doNotRetry + case retryAfter(TimeInterval) + case retry + +} + +public protocol RefreshableCredential { + + var requiresRefresh: Bool { get } + +} + +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 + func refresh(didFailDueToError error: HTTPError) async + +} + +public final class AuthenticationInterceptor: Interceptor, @unchecked Sendable { + + private let authenticator: AuthenticatorType + private let lock: AsyncLock + + public 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 + } + + // 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) + + defer { lock.unlock() } + + // 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 + } + } + +} + +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 } + public func refresh(didFailDueToError error: HTTPError) async {} + +} + +// MARK: - Interception + +public protocol Adapter: Sendable { + + func adapt(urlRequest: inout URLRequest) async throws(NetworkError) + +} + +public protocol Retrier: Sendable { + + 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 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 + } + +} + +public final class CompositeInterceptor: Interceptor { + + private let interceptors: [Interceptor] + + public init(interceptors: [Interceptor]) { + self.interceptors = interceptors + } + + 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 + } + +} + +// MARK: - Initialization + +@NetworkActor public final class NetworkSession: NSObject, Sendable { + + 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 + 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] = [:] + + 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 + self.logger = logger + + let operationQueue = OperationQueue() + operationQueue.name = "NetworkActorSerialExecutorOperationQueue" + operationQueue.underlyingQueue = NetworkActor.queue + + let configuration = URLSessionConfiguration.ephemeral + configuration.httpAdditionalHeaders = baseHeaders.map { $0.resolveHeader() }.reduce(into: [:], { $0[$1.name] = $1.value }) + + self.configuration = configuration + self.delegateQueue = operationQueue + + // create URLSession lazily, isolated on @NetworkActor, when requested 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, logger: logger) + self.taskProxyMap[task.taskIdentifier] = newProxy + return newProxy + } + } + +} + +// MARK: - Network session delegate + +final class NetworkSessionDelegate: NSObject { + + private unowned let networkSession: NetworkSession + + internal init(for networkSession: NetworkSession) { + self.networkSession = networkSession + } + +} + +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) + } + +} + +extension NetworkSessionDelegate: URLSessionTaskDelegate { + + 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 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() + ) 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) + + // exist-fast if decoding is not needed + 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 baseUrl.resolveUrl()?.appendingPathComponent(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 { + throw URLError(.cannotEncodeRawData).asNetworkError() + } + + return try await executeRequest(request: &request) + } + +} + +// MARK: - Private + +private extension NetworkSession { + + func executeRequest(request: inout URLRequest) async throws(NetworkError) -> Data { + // Headers + 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 + 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 { + return try await retryRequest(request: &request, error: networkError) + } + } + + 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) + } + } + +} + +// MARK: - Shorthand requests + +extension NetworkSession { + + 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 { + #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 + request.httpBody = nil + + return try await executeRequest(request: &request) + } + +} + +// MARK: - Custom URLErrors + +extension URLError.Code { + + public static var cannotEncodeRawData: URLError.Code { + URLError.Code(rawValue: 7777) + } + +} + +// MARK: - 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 isFinished = false + private var continuation: CheckedContinuation? = nil + + internal func data() async throws(NetworkError) -> Data { + if !isFinished { await waitForCompletion() } + if let receivedError { throw receivedError.asNetworkError() } + return receivedData + } + + internal func result() async -> Result { + if !isFinished { await waitForCompletion() } + if let receivedError { + return .failure(receivedError.asNetworkError()) + } else { + return .success(receivedData) + } + } + + internal init(task: URLSessionTask, logger: any NetworkLogger) { + self.task = task + self.logger = logger + } + + internal func dataTaskDidReceive(data: Data) { + assert(isFinished == false, "ILLEGAL ATTEMPT TO APPEND DATA TO FINISHED PROXY INSTANCE") + receivedData.append(data) + } + + 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 { + 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 + } + + 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 } + } + +} + +// MARK: - Extensions + +public extension URLRequest { + + var method: HTTPMethod { + HTTPMethod(rawValue: self.httpMethod ?? "GET") ?? .get + } + +} + +// MARK: - Sample + +func x() async { + let session = NetworkSession( + baseUrl: "https://api.sampleapis.com/", + baseHeaders: [HTTPHeader("User-Agent: iOS app")], + interceptor: CompositeInterceptor(interceptors: [ + AuthenticationInterceptor(authenticator: NoAuthenticator()) + ]) + ) + + do { + + + + let coffeeListA: String = try await session.request(endpoint: CoffeeEndpoint.hot) + let coffeeListB: Data = try await session.get("/coffee/hot") + + + + } catch let error { + assert(error is URLError) + } + +} + +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 64e89a6..b4c48e1 100644 --- a/Sources/GoodNetworking/Session/LoggingEventMonitor.swift +++ b/Sources/GoodNetworking/Session/LoggingEventMonitor.swift @@ -5,287 +5,72 @@ // 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) -/// ``` -import Alamofire import Combine -import GoodLogger import Foundation -public struct LoggingEventMonitor: EventMonitor, Sendable { +// MARK: - Logging - public let configuration: Configuration +@NetworkActor internal extension DataTaskProxy { - /// Configuration options for the logging monitor. - public struct Configuration: Sendable { + private static var maxLogSizeBytes: Int { 32_768 } // 32 kB - public init( - verbose: Bool = true, - prettyPrinted: Bool = true, - maxVerboseLogSizeBytes: Int = 100_000, - slowRequestThreshold: TimeInterval = 1.0, - prefixes: Prefixes = Prefixes(), - mimeTypeWhilelistConfiguration: MimeTypeWhitelistConfiguration? = MimeTypeWhitelistConfiguration() - ) { - self.verbose = verbose - self.prettyPrinted = prettyPrinted - self.maxVerboseLogSizeBytes = maxVerboseLogSizeBytes - self.slowRequestThreshold = slowRequestThreshold - self.prefixes = prefixes - self.mimeTypeWhilelistConfiguration = mimeTypeWhilelistConfiguration - } + internal func prepareRequestInfo() -> String { + """ + 🚀 \(task.currentRequest?.method.rawValue ?? "-") \(task.currentRequest?.url?.absoluteString ?? "") + \(prepareResponseStatus(response: task.response, error: receivedError)) - /// 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 + 🏷 Headers: + \(prepareHeaders(request: task.originalRequest)) - /// Maximum size in bytes for verbose logging of request/response bodies. Defaults to 100KB. - var maxVerboseLogSizeBytes: Int = 100_000 + 📤 Request body: + \(prettyPrintMessage(data: task.originalRequest?.httpBody)) - /// 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() - - 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. - - - /// 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 - } - - var request = "🚀" - var response = "⬇️" - var error = "🚨" - var headers = "🏷" - var metrics = "⌛️" - var success = "✅" - var failure = "❌" - } - } - - /// The queue on which logging events are dispatched. - public let queue = DispatchQueue(label: C.queueLabel, qos: .background) - - private enum C { - static let queueLabel = "com.goodrequest.networklogger" + 📦 Received data: + \(prettyPrintMessage(data: receivedData)) + """ } - 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)?, configuration: Configuration = .init()) { - self.logger = logger - self.configuration = configuration - } + private func prepareResponseStatus(response: URLResponse?, error: (any Error)?) -> String { + guard let response = response as? HTTPURLResponse else { return "" } + let statusCode = response.statusCode - public func request(_ request: DataRequest, didParseResponse response: DataResponse) { - let requestSize = request.request?.httpBody?.count ?? 0 - let responseSize = response.data?.count ?? 0 + var logMessage = (200 ..< 300).contains(statusCode) ? "✅ \(statusCode): " : "❌ \(statusCode): " + logMessage.append(HTTPURLResponse.localizedString(forStatusCode: statusCode)) - let requestInfoMessage = parseRequestInfo(response: response) - let metricsMessage = parse(metrics: response.metrics) - let requestBodyMessage = parse( - data: request.request?.httpBody, - error: response.error as NSError?, - prefix: "\(configuration.prefixes.request) Request body (\(formatBytes(requestSize))):" - ) - let errorMessage: String? = if let afError = response.error { - "\(configuration.prefixes.error) Error:\n\(afError)" - } else { - nil + if error != nil { + logMessage.append("\n🚨 Error: \(error?.localizedDescription)") } - let responseBodyMessage = if - let mimeTypeWhilelistConfiguration = configuration.mimeTypeWhilelistConfiguration, - mimeTypeWhilelistConfiguration.responseTypeWhiteList - .contains(where: { $0 == response.response?.mimeType }) - { - parse( - data: response.data, - error: response.error as NSError?, - prefix: "\(configuration.prefixes.response) Response body (\(formatBytes(responseSize))):" - ) - } else { - "❓❓❓ Response MIME type not whitelisted (\(response.response?.mimeType ?? "❓"))" - } + return logMessage + } - let logMessage = [ - requestInfoMessage, - metricsMessage, - requestBodyMessage, - errorMessage, - responseBodyMessage - ].compactMap { $0 }.joined(separator: "\n") + private func prepareHeaders(request: URLRequest?) -> String { + guard let request, let headerFields = request.allHTTPHeaderFields else { return " " } - switch response.result { - case .success: - logger?.log(message: logMessage, level: .debug) - case .failure: - logger?.log(message: logMessage, level: .fault) + return headerFields.map { key, value in + " - \(key): \(value ?? "")" } + .joined(separator: "\n") } -} - -private extension LoggingEventMonitor { + private func prettyPrintMessage(data: Data?) -> String { + guard let data else { return "" } - 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 data.count < Self.maxLogSizeBytes else { + return "💡 Data size is too big (\(data.count) bytes), console limit is \(Self.maxLogSizeBytes) bytes" } - guard configuration.verbose else { - return "\(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:") { - return "\(configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n" + headersPrettyMessage - } else { - let headers = if let allHTTPHeaderFields = request.allHTTPHeaderFields, !allHTTPHeaderFields.isEmpty { - allHTTPHeaderFields.description + 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 { - "empty headers" - } - return "\(configuration.prefixes.request) \(method)|\(parseResponseStatus(response: response))|\(url)\n\(configuration.prefixes.headers) Headers: \(headers)" - } - } - - func parse(data: Data?, error: NSError?, prefix: String) -> String? { - guard configuration.verbose else { return nil } - - if let data = data, !data.isEmpty { - guard data.count < configuration.maxVerboseLogSizeBytes else { - return [ - prefix, - "Data size is too big!", - "Max size is: \(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 prettyPrintedString = String(data: prettyPrintedData, encoding: .utf8) { - return "\(prefix) \n\(prettyPrintedString)" - } else { - return "\(prefix)\(string)" - } + return string } } - return nil - } - - func parse(metrics: URLSessionTaskMetrics?) -> String? { - guard let metrics, configuration.verbose else { - return nil - } - - let duration = metrics.taskInterval.duration - let warning = duration > configuration.slowRequestThreshold ? " ⚠️ Slow Request!" : "" - - return [ - "↗️ Start: \(metrics.taskInterval.start)", - "\(configuration.prefixes.metrics) Duration: \(String(format: "%.3f", duration))s\(warning)" - ].joined(separator: "\n") - } - - - func parseResponseStatus(response: HTTPURLResponse) -> String { - let statusCode = response.statusCode - let logMessage = (200 ..< 300).contains(statusCode) - ? "\(configuration.prefixes.success) \(statusCode)" - : "\(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)) + return "🔍 Couldn't decode data as UTF-8" } } diff --git a/Sources/GoodNetworking/Session/NetworkError.swift b/Sources/GoodNetworking/Session/NetworkError.swift index 4e18351..e242b3d 100644 --- a/Sources/GoodNetworking/Session/NetworkError.swift +++ b/Sources/GoodNetworking/Session/NetworkError.swift @@ -7,56 +7,44 @@ import Foundation -public enum NetworkError: LocalizedError, Hashable { +public extension URLError { - case endpoint(EndpointError) - case remote(statusCode: Int, data: Data?) - case paging(PagingError) - case missingLocalData - case missingRemoteData - case sessionError - case invalidBaseURL - case cancelled + func asNetworkError() -> NetworkError { + return NetworkError.local(self) + } - public var errorDescription: String? { - switch self { - case .endpoint(let endpointError): - return endpointError.errorDescription +} - case .remote(let statusCode, _): - return "HTTP \(statusCode) - \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" +public extension DecodingError { - case .paging(let pagingError): - return pagingError.errorDescription + func asNetworkError() -> NetworkError { + return NetworkError.decoding(self) + } - case .missingLocalData: - return "Missing data - Failed to map local resource to remote type" +} - case .missingRemoteData: - return "Missing data - Failed to map remote resource to local type" +public enum NetworkError: LocalizedError { - case .sessionError: - return "Internal session error" + case local(URLError) + case remote(HTTPError) + case decoding(DecodingError) - case .invalidBaseURL: - return "Resolved server base URL is invalid" + public var errorDescription: String? { + switch self { + case .local(let urlError): + return urlError.localizedDescription - case .cancelled: - return "Operation cancelled" - } - } + case .remote(let httpError): + return httpError.localizedDescription - var statusCode: Int? { - if case let .remote(statusCode, _) = self { - return statusCode - } else { - return nil + case .decoding(let decodingError): + return decodingError.localizedDescription } } - func remoteError(as errorType: E.Type) -> E? { - if case let .remote(_, data) = self { - return try? JSONDecoder().decode(errorType, from: data ?? Data()) + public var httpStatusCode: Int? { + if case .remote(let httpError) = self { + return httpError.statusCode } else { return nil } @@ -64,19 +52,22 @@ public enum NetworkError: LocalizedError, Hashable { } -public enum EndpointError: LocalizedError { +public struct HTTPError: LocalizedError, Hashable { - case noSuchEndpoint - case operationNotSupported + public let statusCode: Int + public let errorResponse: Data public var errorDescription: String? { - switch self { - case .noSuchEndpoint: - return "No such endpoint" + return "HTTP \(statusCode) - \(HTTPURLResponse.localizedString(forStatusCode: statusCode))" + } - case .operationNotSupported: - return "Operation not supported" - } + public init(statusCode: Int, errorResponse: Data) { + self.statusCode = statusCode + self.errorResponse = errorResponse + } + + public func remoteError(as errorType: E.Type) -> E? { + return try? JSONDecoder().decode(errorType, from: errorResponse) } } diff --git a/Sources/GoodNetworking/Session/NetworkSession.swift b/Sources/GoodNetworking/Session/NetworkSession.swift index 0e98112..83fe6f8 100644 --- a/Sources/GoodNetworking/Session/NetworkSession.swift +++ b/Sources/GoodNetworking/Session/NetworkSession.swift @@ -1,559 +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 - -/// A type responsible for executing network requests in a client application. -/// -/// `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) -/// ``` -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 - 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 - 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. - /// - /// - 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. - public init( - baseUrlProvider: BaseUrlProviding? = nil, - sessionProvider: NetworkSessionProviding = DefaultSessionProvider(configuration: .default) - ) { - self.baseUrlProvider = baseUrlProvider - 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. - /// - /// - 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`. - public init( - baseUrl: BaseUrlProviding? = nil, - configuration: NetworkSessionConfiguration = .default - ) { - self.baseUrlProvider = baseUrl - 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. - /// - /// - Parameters: - /// - baseUrlProvider: A provider for resolving base URLs. Pass `nil` to disable base URL resolution. - /// - session: An existing Alamofire session to use. - public init( - baseUrlProvider: BaseUrlProviding? = nil, - session: Alamofire.Session - ) { - self.baseUrlProvider = baseUrlProvider - self.sessionProvider = DefaultSessionProvider(session: session) - } - -} - -// MARK: - Request - -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: - /// - 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, - 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) - - // 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( - 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) - - // Decode - return try decodeResponse(response) - } - } - } - - /// 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. - /// - /// - 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 - 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 - ) - - guard let statusCode = response.response?.statusCode else { - throw response.error ?? NetworkError.sessionError - } - - try validationProvider.validate(statusCode: statusCode, data: response.data) - - return response.data ?? Data() - } - } - } - - /// 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. - /// - /// - 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 - @_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 that saves the response to a file and provides progress updates. - /// - /// 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 - /// - Throws: A NetworkError if the download setup fails - 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() - } - - } catch { - continuation.finish(throwing: validationProvider.transformError(.sessionError)) - } - } - } - } - -} - -// MARK: - Upload - -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. - /// - /// - 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 - 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 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. - /// - /// - 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 - 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 { - - /// 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 - /// - /// - Parameter sessionProvider: The provider managing the session - /// - Returns: A valid Alamofire Session instance - func resolveSession(sessionProvider: NetworkSessionProviding) async -> Alamofire.Session { - if await !sessionProvider.isSessionValid { - await sessionProvider.makeSession() - } else { - await sessionProvider.resolveSession() - } - } - - /// Resolves the base URL for a request. - /// - /// 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 - 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 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 - /// - /// - 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: 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) - } - } - - /// 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 - /// - /// - 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) { - 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) - } - } - - 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()) - } - -} +//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 00eeda2..51ed6c4 100644 --- a/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift +++ b/Sources/GoodNetworking/Session/NetworkSessionConfiguration.swift @@ -5,66 +5,63 @@ // Created by Andrej Jasso on 15/11/2022. // -@preconcurrency import Alamofire +//@preconcurrency import Alamofire import Foundation -import GoodLogger /// 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 var `default`: NetworkSessionConfiguration { - var eventMonitors: [EventMonitor] = [] - - if #available(iOS 14, *) { - eventMonitors.append(LoggingEventMonitor(logger: OSLogLogger(logMetaData: false))) - } else { - eventMonitors.append(LoggingEventMonitor(logger: PrintLogger(logMetaData: false))) - } - - 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/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 + } + +} 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 c1bd4ca..39462ad 100644 --- a/Sources/GoodNetworking/Wrapper/Resource.swift +++ b/Sources/GoodNetworking/Wrapper/Resource.swift @@ -5,9 +5,7 @@ // Created by Filip Šašala on 08/12/2023. // -import Alamofire import SwiftUI -import GoodLogger // MARK: - Resource @@ -24,9 +22,10 @@ public struct RawResponse: Sendable { @available(iOS 17.0, *) @MainActor @Observable public final class Resource { - private var session: FutureSession + 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> @@ -50,10 +49,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.session = session self.remote = remote + self.logger = logger if let wrappedValue { self.state = .available(wrappedValue) @@ -65,30 +66,20 @@ public struct RawResponse: Sendable { } public init( - session: FutureSession? = nil, - remote: R.Type + session: NetworkSession, + remote: R.Type, + logger: NetworkLogger? = nil ) { - self.session = session ?? .placeholder + self.session = session 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) + self.session = networkSession return self } @@ -106,68 +97,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( - message: "CREATE operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + 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 - .log( - message: "READ operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + 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 - .log( - message: "UPDATE operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + 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 - .log( - message: "DELETE operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + 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 - .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?.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 - .log( - message: "LIST operation not defined for resource \(String(describing: R.self))", - level: .error, - privacy: .auto - ) + logger?.logNetworkEvent(message: "LIST operation not defined for resource \(String(describing: R.self))", level: .error, file: #file, line: #line) } } @@ -179,11 +132,8 @@ 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 - ) + 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) } @@ -200,7 +150,7 @@ extension Resource where R: Creatable { do { let response = try await remote.create( - using: session(), + using: session, request: request ) self.rawResponse.create = response @@ -237,11 +187,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( - message: "Requesting nil resource always fails! Use read(request:forceReload:) with a custom request or supply a resource to read.", - level: .error - ) + 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) @@ -249,7 +201,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(message: "Skipping read - value already exists", level: .info, privacy: .auto) + logger?.logNetworkEvent(message: "Skipping read - value already exists", level: .info, file: #file, line: #line) + return } let resource = state.value @@ -258,7 +211,7 @@ extension Resource where R: Readable { do { let response = try await remote.read( - using: session(), + using: session, request: request ) self.rawResponse.read = response @@ -292,11 +245,8 @@ 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 - ) + logger?.logNetworkEvent(message: "Updating resource to nil always fails! Use DELETE instead.", level: .error, file: #file, line: #line) + return } try await updateRemote(request: request) } @@ -313,7 +263,7 @@ extension Resource where R: Updatable { do { let response = try await remote.update( - using: session(), + using: session, request: request ) self.rawResponse.update = response @@ -347,11 +297,8 @@ 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 - ) + 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) } @@ -362,7 +309,7 @@ extension Resource where R: Deletable { do { let response = try await remote.delete( - using: session(), + using: session, request: request ) self.rawResponse.delete = response @@ -449,7 +396,7 @@ extension Resource where R: Listable { do { let response = try await remote.list( - using: session(), + using: session, request: request ) self.rawResponse.list = response diff --git a/Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift b/Tests/GoodNetworkingTests/ArrayEncodingTests.swift similarity index 85% rename from Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift rename to Tests/GoodNetworkingTests/ArrayEncodingTests.swift index e5806b9..cf3036f 100644 --- a/Tests/GoodNetworkingTests/Tests/ArrayEncodingTests.swift +++ b/Tests/GoodNetworkingTests/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/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/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/Endpoints/SwapiEndpoint.swift b/Tests/GoodNetworkingTests/Endpoints/SwapiEndpoint.swift deleted file mode 100644 index 4297ddd..0000000 --- a/Tests/GoodNetworkingTests/Endpoints/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/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 ff23b27..0000000 --- a/Tests/GoodNetworkingTests/Models/MockResponse.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MockResponse.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -import GoodNetworking - -struct MockResponse: Codable, EmptyResponseCreatable { - - let code: Int - let description: String - - static var emptyInstance: MockResponse { - return MockResponse(code: 204, description: "No Content") - } - -} diff --git a/Tests/GoodNetworkingTests/Models/SwapiPerson.swift b/Tests/GoodNetworkingTests/Models/SwapiPerson.swift deleted file mode 100644 index 836b018..0000000 --- a/Tests/GoodNetworkingTests/Models/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/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/Services/TestingLogger.swift b/Tests/GoodNetworkingTests/Services/TestingLogger.swift deleted file mode 100644 index 3cf4580..0000000 --- a/Tests/GoodNetworkingTests/Services/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)) - } - -} 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 deleted file mode 100644 index 33aa8b6..0000000 --- a/Tests/GoodNetworkingTests/Tests/Executor/DeduplicatingExecutorTests.swift +++ /dev/null @@ -1,163 +0,0 @@ -// -// DeduplicatingExecutorTests.swift -// GoodNetworking -// -// Created by Assistant on 16/10/2024. -// - -import XCTest -@testable import GoodNetworking -import Alamofire -import GoodLogger - -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" - 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 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() - let executor = DeduplicatingRequestExecutor(taskId: "Luke", logger: logger) - 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( - endpoint: lukeEndpoint, - requestExecutor: executor - ) - - async let vaderResult: SwapiPerson = networkSession.request( - endpoint: vaderEndpoint, - requestExecutor: executor2 - ) - - // 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" - 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 - ) - - 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: "Invalid", logger: logger) - 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 { - print(logger.messages) - XCTAssertTrue(logger.messages.contains(where: { $0.contains("Task finished with error") } )) - } - } -} diff --git a/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift b/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift deleted file mode 100644 index 5c8dd17..0000000 --- a/Tests/GoodNetworkingTests/Tests/Executor/DefaultRequestExecutorTests.swift +++ /dev/null @@ -1,237 +0,0 @@ -// -// DefaultRequestExecutorTests.swift -// GoodNetworking -// -// Created by Andrej Jasso on 07/02/2025. -// - -import XCTest -@testable import GoodNetworking -import Alamofire -import GoodLogger - -final class DefaultRequestExecutorTestsGenerated: XCTestCase { - - // MARK: - Properties - - private var executor: DefaultRequestExecutor! - private var session: Session! - private var networkSession: NetworkSession! - - // 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: - Simple tests - - func testConcurrentRequests() async throws { - // Setup - 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 executor = DefaultRequestExecutor() - let baseURL = "https://swapi.dev/api" - - // 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) - } - } - - // 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 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 - ) - } - - // MARK: - Redirection Responses (3xx) - - func testPermanentRedirectToHTMLExpectingJSON301() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(301), requestExecutor: executor) - XCTFail("Expected missingRemoteData") - } catch { - print("Success") - } - } - - func testNotModifiedToHTMLExpectingJSON304() async throws { - do { - struct EmptyResponse: Decodable {} - let _: EmptyResponse = try await networkSession.request(endpoint: StatusAPI.status(304), requestExecutor: executor) - XCTFail("Expected missingRemoteData") - } catch { - print(error.errorDescription) - XCTAssertEqual(error.statusCode, 304) - } - } - - func testTemporaryRedirectToHTMLExpectingJSON307() async throws { - do { - let _: MockResponse = try await networkSession.request(endpoint: StatusAPI.status(307), requestExecutor: executor) - XCTFail("Expected missingRemoteData") - } catch { - print("Success") - } - } - - // 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) - } - } - -} 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 -}