diff --git a/Package.swift b/Package.swift index 763f06a..bd09fa9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,8 +6,10 @@ import PackageDescription let package = Package( name: "MauriNet", platforms: [ - .iOS(.v13), - .macOS(SupportedPlatform.MacOSVersion.v10_15) + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/README.md b/README.md index a88a82c..baf782b 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,157 @@ # [MauriNet](https://github.com/GeekingwithMauri/MauriNet) -Thin layer for standard network handling +Thin layer for standard network handling, with **async/await as a first class citizen**. -- Wrapper around Foundation's `URLSession` and all of its settings -- WIP: Combine wrapper for chained requests +- ✅ Vanilla async/await wrappers for CRUD network operations (`create` / `read` / `update` / `delete`) +- ✅ async/await download and upload APIs +- ✅ Type-safe `Decodable` requests out of the box +- ✅ Cooperative cancellation: cancelling the surrounding `Task` cancels the network request +- ✅ Swift 6 language mode with audited `Sendable` conformances +- ✅ 100% `URLSession` under the hood. Zero third-party dependencies +- ✅ Fully unit tested + +## Requirements + +- Swift 6.0+ (Xcode 16+) +- iOS 17+ / macOS 14+ / tvOS 17+ / watchOS 10+ + +## Installation + +### Swift Package Manager + +In Xcode: _File ▸ Add Package Dependencies..._ and paste + +``` +https://github.com/GeekingwithMauri/MauriNet +``` + +Or declare it directly in your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/GeekingwithMauri/MauriNet", branch: "main") +], +targets: [ + .target(name: "YourTarget", dependencies: ["MauriNet"]) +] +``` + +## Usage + +### CRUD operations with async/await + +`AsyncRequestManager` is the modern entry point of the library. Its CRUD conveniences take any `URLRequest` and stamp the appropriate HTTP verb on it for you: + +```swift +import MauriNet + +let manager = AsyncRequestManager() +let postsRequest = URLRequest(url: URL(validURL: "https://example.com/api/posts")) + +// Create (POST) +let creationReply = try await manager.create(postsRequest, payload: encodedNewPost) + +// Read (GET) +let retrievedData = try await manager.read(postsRequest) + +// Update (PUT by default, PATCH on demand) +let updateReply = try await manager.update(postsRequest, payload: encodedAmendedPost) +let patchReply = try await manager.update(postsRequest, payload: encodedDelta, method: .patch) + +// Delete (DELETE) +try await manager.delete(postsRequest) +``` + +Any failure throws a strongly typed `NetworkError`, mapped from the reply's HTTP status code: + +```swift +do { + let retrievedData = try await manager.read(postsRequest) +} catch let error as NetworkError { + switch error { + case .notFound: + // 404: handle the missing resource + case .unauthorized: + // 401: refresh credentials + default: + print(error.localizedDescription) + } +} +``` + +### Decodable requests + +Skip the manual decoding dance. Declare your model as the return type and let the manager handle the rest: + +```swift +struct Post: Decodable { + let id: Int + let title: String +} + +let posts: [Post] = try await manager.request(postsRequest) +``` + +A custom-configured `JSONDecoder` can be passed as a second argument when needed: + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let posts: [Post] = try await manager.request(postsRequest, decoder: decoder) +``` + +### Downloads + +```swift +let resourceRequest = URLRequest(url: URL(validURL: "https://example.com/resource.zip")) +let localFile = try await manager.download(resourceRequest) +// localFile points to the app's temporary directory: move it to a +// permanent location (or consume it) at your earliest convenience +``` + +The destination can also be made explicit by providing an existing directory: + +```swift +let downloadsFolder = URL.documentsDirectory.appending(path: "Downloads") +let localFile = try await manager.download(resourceRequest, into: downloadsFolder) +``` + +### Uploads + +```swift +var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) +uploadRequest.httpMethod = HTTPMethod.post.value + +// the data to be uploaded +let avatarData = try Data(contentsOf: avatarFileURL) + +let serverReply = try await manager.upload(uploadRequest, from: avatarData) +``` + +### Testability + +`AsyncRequestManager` is backed by the `URLSessionable` protocol (which `URLSession` already conforms to). Inject your own conforming double to unit test your networking layer without hitting the actual network: + +```swift +struct StubbedSession: URLSessionable { + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { + // return canned data + response + } + + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { + // return canned file location + response + } + + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { + // return canned server reply + response + } +} + +let testableManager = AsyncRequestManager(session: StubbedSession()) +``` # Documentation 🔎📜 -All the documentation was generated with [Jazzy](https://github.com/realm/jazzy) as explained in [this article](https://geekingwithmauri.com/swift/documentation-in-practice.html) and can be found [here](https://geekingwithmauri.github.io/MauriNet/) +All public signatures are fully documented. Reference documentation was generated with [Jazzy](https://github.com/realm/jazzy) as explained in [this article](https://geekingwithmauri.com/swift/documentation-in-practice.html) and can be found [here](https://geekingwithmauri.github.io/MauriNet/) diff --git a/Sources/MauriNet/Combine/Agent.swift b/Sources/MauriNet/Combine/Agent.swift deleted file mode 100644 index e019cc9..0000000 --- a/Sources/MauriNet/Combine/Agent.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Agent.swift -// -// -// Created by Mauricio Chirino on 29/11/20. -// - -import Combine -import Foundation - -/// Response object resulting from network request -struct Response { - /// Generic value type from request's response - let value: T - - /// Useful for logging purposes and HTTP low level debugging - let response: URLResponse -} - -/// Promise-based agent for network requests executions. -/// [Inspiration post](https://www.vadimbulavin.com/modern-networking-in-swift-5-with-urlsession-combine-framework-and-codable/) -struct Agent { - private let urlSession: URLSession - - /// Initializes an agent for a `URLSession` - /// - Parameter urlSession: session where agent will live. Defaults to `.shared` - init(urlSession: URLSession = .shared) { - self.urlSession = urlSession - } - - /// Runs the agent for given request - /// - /// Example of usage: - /// - /// func executeAgentFor(urlString: String) -> AnyPublisher { - /// let agent = Agent() - /// let request = URLRequest(url: urlString) - /// - /// return agent.run(request) - /// .map(\.value) - /// .eraseToAnyPublisher() - /// } - /// - /// - /// - Parameters: - /// - request: request to be performed by the agent - /// - decoder: type of decoding for request. Defaults to `JSONDecoder` instance - /// - Returns: chainable response `AnyPublisher` object - @available(iOS 13.0, *) - func run(_ request: URLRequest, - _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher, Error> { - urlSession - .dataTaskPublisher(for: request) - .tryMap { result -> Response in - let value = try decoder.decode(T.self, from: result.data) - - return Response(value: value, response: result.response) - } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } -} - -// Add a more general agent that only returns data and that doesn't assume Main thread handling by default. -// Check here https://heckj.github.io/swiftui-notes/ diff --git a/Sources/MauriNet/Concurrency/AsyncRequestManager.swift b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift new file mode 100644 index 0000000..b105b87 --- /dev/null +++ b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift @@ -0,0 +1,226 @@ +// +// AsyncRequestManager.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Wrapper object that handles network sessions through Swift concurrency (async/await) +public struct AsyncRequestManager: AsyncRequestable { + private let session: any URLSessionable + + /// Initializes the network session + /// - Parameter session: network session through which connection will be stablished with remote server. Defaults to Foundation `URLSession.shared` + public init(session: any URLSessionable = URLSession.shared) { + self.session = session + } + + /// Triggers a generic request, suspending until the remote server replies + /// + /// Cancelling the surrounding `Task` cancels the underlying network request as well, + /// surfacing as a thrown `NetworkError.cancelled`. + /// + /// Example of usage: + /// + /// let endpointRequest = URLRequest(url: URL(validURL: "https://github.com")) + /// let sessionManager = AsyncRequestManager() + /// let retrievedData = try await sessionManager.request(endpointRequest) + /// + /// - Parameter request: URL request specifications + /// - Returns: raw bytes retrieved from the remote server + /// - Throws: `NetworkError` describing what went wrong with the request + public func request(_ request: URLRequest) async throws -> Data { + do { + let (retrievedData, response) = try await session.retrieveData(for: request) + try ResponseValidator.validate(response) + + return retrievedData + } catch { + throw ResponseValidator.mapTransportFailure(error) + } + } + + /// Triggers a generic request, decoding the remote server's reply into the inferred `Decodable` type + /// + /// Example of usage: + /// + /// struct Event: Decodable { ... } + /// + /// let endpointRequest = URLRequest(url: URL(validURL: "https://api.github.com/events")) + /// let sessionManager = AsyncRequestManager() + /// let events: [Event] = try await sessionManager.request(endpointRequest) + /// + /// - Parameters: + /// - request: URL request specifications + /// - decoder: decoding strategy for the reply. Defaults to a plain `JSONDecoder` instance + /// - Returns: decoded object from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request or `DecodingError` should the reply not match the expected type + public func request(_ request: URLRequest, + decoder: JSONDecoder = JSONDecoder()) async throws -> Model { + let retrievedData = try await self.request(request) + + return try decoder.decode(Model.self, from: retrievedData) + } + + /// Downloads the resource at the given request, suspending until it's fully retrieved + /// + /// The downloaded file is moved out of `URLSession`'s ephemeral storage into the given directory + /// (under a unique name preserving the original extension), so it remains available after this + /// method returns. When targeting the temporary directory, move the file to a permanent location + /// (or consume it) at your earliest convenience since the system may eventually purge it. + /// + /// Cancelling the surrounding `Task` cancels the underlying download as well, + /// surfacing as a thrown `NetworkError.cancelled`. + /// + /// Example of usage: + /// + /// let resourceRequest = URLRequest(url: URL(validURL: "https://example.com/resource.zip")) + /// let downloadsFolder = URL.documentsDirectory.appending(path: "Downloads") + /// let sessionManager = AsyncRequestManager() + /// let localFile = try await sessionManager.download(resourceRequest, into: downloadsFolder) + /// + /// - Parameters: + /// - request: URL request specifications + /// - directory: local directory where the downloaded resource will be stored. It must already exist + /// - Returns: local file `URL` where the downloaded resource was stored + /// - Throws: `NetworkError` describing what went wrong with the request + public func download(_ request: URLRequest, into directory: URL) async throws -> URL { + do { + let (temporaryLocation, response) = try await session.downloadFile(for: request) + try ResponseValidator.validate(response) + + // The temporary file only lives until the session reclaims it; relocate it so callers can use it + return try persistedCopy(of: temporaryLocation, into: directory) + } catch { + throw ResponseValidator.mapTransportFailure(error) + } + } + + /// Uploads the given payload as the request's body, suspending until the remote server replies + /// + /// Cancelling the surrounding `Task` cancels the underlying upload as well, + /// surfacing as a thrown `NetworkError.cancelled`. + /// + /// Example of usage: + /// + /// var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) + /// uploadRequest.httpMethod = HTTPMethod.post.value + /// + /// // the data to be uploaded + /// let avatarData: Data = try Data(contentsOf: avatarFileURL) + /// + /// let sessionManager = AsyncRequestManager() + /// let serverReply = try await sessionManager.upload(uploadRequest, from: avatarData) + /// + /// - Parameters: + /// - request: URL request specifications. Make sure its `httpMethod` is set accordingly (i.e. `POST` or `PUT`) + /// - payload: raw bytes to be uploaded to the remote server + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + public func upload(_ request: URLRequest, from payload: Data) async throws -> Data { + do { + let (serverReply, response) = try await session.upload(payload, for: request) + try ResponseValidator.validate(response) + + return serverReply + } catch { + throw ResponseValidator.mapTransportFailure(error) + } + } + + private func persistedCopy(of temporaryLocation: URL, into directory: URL) throws -> URL { + let enduringLocation = directory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(temporaryLocation.pathExtension) + + try FileManager.default.moveItem(at: temporaryLocation, to: enduringLocation) + + return enduringLocation + } +} + +// MARK: - CRUD conveniences + +public extension AsyncRequestManager { + /// **C**RUD convenience: triggers the given request as a `POST`, attaching the payload -if any- as its body + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let newResource = URLRequest(url: URL(validURL: "https://example.com/posts")) + /// let serverReply = try await sessionManager.create(newResource, payload: encodedPost) + /// + /// - Parameters: + /// - request: URL request specifications. Its `httpMethod` will be overridden with `POST` + /// - payload: raw bytes to attach as the request's body. Defaults to `nil` (empty body) + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + func create(_ request: URLRequest, payload: Data? = nil) async throws -> Data { + try await self.request(assembled(from: request, method: .post, payload: payload)) + } + + /// C**R**UD convenience: triggers the given request as a `GET` + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let existingResource = URLRequest(url: URL(validURL: "https://example.com/posts/1")) + /// let retrievedData = try await sessionManager.read(existingResource) + /// + /// - Parameter request: URL request specifications. Its `httpMethod` will be overridden with `GET` + /// - Returns: raw bytes retrieved from the remote server + /// - Throws: `NetworkError` describing what went wrong with the request + func read(_ request: URLRequest) async throws -> Data { + try await self.request(assembled(from: request, method: .get, payload: nil)) + } + + /// CR**U**D convenience: triggers the given request as either a `PUT` or a `PATCH`, attaching the payload -if any- as its body + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let existingResource = URLRequest(url: URL(validURL: "https://example.com/posts/1")) + /// let serverReply = try await sessionManager.update(existingResource, payload: encodedPost) + /// + /// - Parameters: + /// - request: URL request specifications. Its `httpMethod` will be overridden with the given method + /// - payload: raw bytes to attach as the request's body. Defaults to `nil` (empty body) + /// - method: HTTP method for the update. Defaults to `.put`; pass `.patch` for partial updates + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + func update(_ request: URLRequest, payload: Data? = nil, method: HTTPMethod = .put) async throws -> Data { + try await self.request(assembled(from: request, method: method, payload: payload)) + } + + /// CRU**D** convenience: triggers the given request as a `DELETE` + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let existingResource = URLRequest(url: URL(validURL: "https://example.com/posts/1")) + /// try await sessionManager.delete(existingResource) + /// + /// - Parameter request: URL request specifications. Its `httpMethod` will be overridden with `DELETE` + /// - Returns: raw bytes from the remote server's reply, should it provide any + /// - Throws: `NetworkError` describing what went wrong with the request + @discardableResult + func delete(_ request: URLRequest) async throws -> Data { + try await self.request(assembled(from: request, method: .delete, payload: nil)) + } + + private func assembled(from request: URLRequest, method: HTTPMethod, payload: Data?) -> URLRequest { + var crudRequest = request + crudRequest.httpMethod = method.value + + if let payload = payload { + crudRequest.httpBody = payload + } + + return crudRequest + } +} diff --git a/Sources/MauriNet/Concurrency/AsyncRequestable.swift b/Sources/MauriNet/Concurrency/AsyncRequestable.swift new file mode 100644 index 0000000..143022d --- /dev/null +++ b/Sources/MauriNet/Concurrency/AsyncRequestable.swift @@ -0,0 +1,49 @@ +// +// AsyncRequestable.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Network manager type for Swift concurrency (async/await) +public protocol AsyncRequestable: Sendable { + /// Triggers a generic request, suspending until the remote server replies + /// - Parameter request: URL request specifications + /// - Returns: raw bytes retrieved from the remote server + /// - Throws: `NetworkError` describing what went wrong with the request + func request(_ request: URLRequest) async throws -> Data + + /// Downloads the resource at the given request, suspending until it's fully retrieved + /// - Parameters: + /// - request: URL request specifications + /// - directory: local directory where the downloaded resource will be stored + /// - Returns: local file `URL` where the downloaded resource was stored + /// - Throws: `NetworkError` describing what went wrong with the request + func download(_ request: URLRequest, into directory: URL) async throws -> URL + + /// Uploads the given payload as the request's body, suspending until the remote server replies + /// - Parameters: + /// - request: URL request specifications. Make sure its `httpMethod` is set accordingly (i.e. `POST` or `PUT`) + /// - payload: raw bytes to be uploaded to the remote server + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + func upload(_ request: URLRequest, from payload: Data) async throws -> Data +} + +public extension AsyncRequestable { + /// Downloads the resource at the given request into the app's temporary directory + /// + /// Convenience over `download(_:into:)`. Move the file to a permanent location (or consume it) + /// at your earliest convenience since the system may eventually purge temporary files. + /// - Parameter request: URL request specifications + /// - Returns: local file `URL` where the downloaded resource was stored + /// - Throws: `NetworkError` describing what went wrong with the request + func download(_ request: URLRequest) async throws -> URL { + try await download(request, into: FileManager.default.temporaryDirectory) + } +} diff --git a/Sources/MauriNet/Global/Constants.swift b/Sources/MauriNet/Global/Constants.swift deleted file mode 100644 index 70b0584..0000000 --- a/Sources/MauriNet/Global/Constants.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Constans.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import Foundation - -/// Custom type for resulting object from `APIEndpoint` built request -public typealias URLBuilderResult = Result - -/// Custom error type for `APIEndpoint` built request -public enum URLBuilderError: Error { - /// The endpoint was not assembled properly - case poorAssembling -} - -/// Types of HTTP scheme -public enum APIScheme: String { - /// secure scheme - case safe = "https" - - /// insecure scheme - case unsafe = "http" - - /// scheme value appending _://_ - /// - /// Example of usage: - /// - /// APIScheme.safe.value - /// // returns -> https:// - var value: String { - return rawValue + "://" - } -} diff --git a/Sources/MauriNet/Global/Extensions.swift b/Sources/MauriNet/Global/Extensions.swift index 420514e..df36b75 100644 --- a/Sources/MauriNet/Global/Extensions.swift +++ b/Sources/MauriNet/Global/Extensions.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// Extensions.swift +// // // Created by Mauricio Chirino on 05/12/20. // @@ -15,26 +15,3 @@ public extension URL { self.init(string: validURL)! } } - -public extension URLComponents { - /// Assumes the provided string contains a valid URL. Recommended usage with static assembled URLs; - /// for dynamic generated ones use failable init to avoid runtime crashes. - /// - Parameter validURL: A valid URL according to [RFC 1738](https://tools.ietf.org/html/rfc1738) - init(validURL: String) { - self.init(string: validURL)! - } -} - -/// Support for native `Collection` object formed by `URLQueryItem` items -public extension Collection where Element == URLQueryItem { - /// Returns -if available- the value for a parameter query within a URL - /// Example of usage: - /// - /// let tweetURLAvatar = "https://wwww.twitter.com/avatar.png?width=500&&height=500" - /// let components = URLComponent(validURL: tweetURLAvatar) - /// components.queryItems?["width"] // 500 - /// - subscript(_ name: String) -> String? { - first(where: { $0.name == name })?.value - } -} diff --git a/Sources/MauriNet/Global/HTTPEncoding.swift b/Sources/MauriNet/Global/HTTPEncoding.swift deleted file mode 100644 index 38fe0e8..0000000 --- a/Sources/MauriNet/Global/HTTPEncoding.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// HTTPEncoding.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import Foundation - -/// Types of HTTP supported encoding -enum HTTPEncoding { - /// url type (i.e. used in `GET` requests) - case url - - /// JSON type - case json - - /// body type - case body -} diff --git a/Sources/MauriNet/Global/HTTPMethods.swift b/Sources/MauriNet/Global/HTTPMethods.swift index a17e87d..a595dd3 100644 --- a/Sources/MauriNet/Global/HTTPMethods.swift +++ b/Sources/MauriNet/Global/HTTPMethods.swift @@ -8,7 +8,7 @@ import Foundation /// HTTP methods supported -public enum HTTPMethod { +public enum HTTPMethod: Sendable { /// Representation of [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) method case get @@ -18,6 +18,9 @@ public enum HTTPMethod { /// Representation of [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) method case put + /// Representation of [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) method + case patch + /// Representation of [DELETE](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE) method case delete diff --git a/Sources/MauriNet/Global/NetworkError.swift b/Sources/MauriNet/Global/NetworkError.swift index f93f8f3..b85d09c 100644 --- a/Sources/MauriNet/Global/NetworkError.swift +++ b/Sources/MauriNet/Global/NetworkError.swift @@ -8,7 +8,7 @@ import Foundation /// Type of network error codes supported -public enum NetworkError: Int, Error, CaseIterable { +public enum NetworkError: Int, Error, CaseIterable, Sendable { /// The request was cancelled case cancelled = -999 diff --git a/Sources/MauriNet/URLSession/APIEndpoint.swift b/Sources/MauriNet/URLSession/APIEndpoint.swift deleted file mode 100644 index 8f987c4..0000000 --- a/Sources/MauriNet/URLSession/APIEndpoint.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// APIEndpoint.swift -// -// -// Created by Mauricio Chirino on 29/11/20. -// - -import Foundation - -/// Helper object that centralize all required parameters for an endpoint -public struct APIEndpoint { - /// root URL. I.e. _github.com_, _developer.apple.com_ and so forth. Ending slash (`/`) is not required; in case a path is provided it'll be automatically added for those cases. - public let host: String - - /// HTTP method for this endpoint - public let httpMethod: HTTPMethod - - /// endpoint http scheme that defines what kind of connection is - public let scheme: APIScheme - - /// timeout before the request expires - public let timeout: TimeInterval - - /// Creates an `APIEndpoint` with the set of given values - /// - Parameters: - /// - host: root URL. I.e. _github.com_, _developer.apple.com_ and so forth. Ending slash (`/`) is not required; in case a path is provided it'll be automatically added for those cases. - /// - httpMethod: type of HTTP method this endpoint will handle. Defaults to `.get` type - /// - scheme: whether or not this is a safe connection. Defaults to `.safe` type (https) - /// - timeout: timeout before the request expires. Defaults to `10` seconds - public init(host: String, - httpMethod: HTTPMethod = .get, - scheme: APIScheme = .safe, - timeout: TimeInterval = 10) { - self.host = host - self.httpMethod = httpMethod - self.scheme = scheme - self.timeout = timeout - } - - /// Builds a complete URL from the `host` value, given a path - /// - /// Example of usage: - /// - /// let githubEndpoint = APIEndpoint(host: "github.com") - /// githubEndpoint.buildRequest(for: "events") - /// // returns -> https://github.com/events - /// - /// - Parameter path: path (with or without query parameters) to append to the existing host - /// - Parameter queryDictionary: param query in the form of a dictionary to be assembled into the resulting URLRequest. Defaults to an empty one - /// - Returns: The assembled URL. - /// - public func buildRequest(for path: String, with queryDictionary: [String: String] = [:]) -> URLBuilderResult { - let assembledURL = scheme.value + host + "/\(path)" - var components = URLComponents(validURL: assembledURL) - - if !queryDictionary.isEmpty { - components.queryItems = buildQuery(basedOn: queryDictionary) - } - - if let builtURL = components.url { - var request = URLRequest(url: builtURL, timeoutInterval: timeout) - request.httpMethod = httpMethod.value - - return .success(request) - } else { - return .failure(.poorAssembling) - } - } - - private func buildQuery(basedOn dictionary: [String: String]) -> [URLQueryItem] { - return dictionary.reduce([URLQueryItem]()) { assembled, item in - assembled + [URLQueryItem(name: item.key, value: item.value)] - } - } -} diff --git a/Sources/MauriNet/URLSession/EndpointBuilder.swift b/Sources/MauriNet/URLSession/EndpointBuilder.swift deleted file mode 100644 index 7c6e26c..0000000 --- a/Sources/MauriNet/URLSession/EndpointBuilder.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// EndpointBuilder.swift -// -// -// Created by Mauricio Chirino on 11/4/21. -// - -import Foundation - -/// Convenience endpoint builder handler -public struct EndpointBuilder { - private let endpointSetup: APIEndpoint - - /// Instantiate the builder for the provided setup - /// - Parameter endpointSetup: root endpoint setup from where all builder setup will bootstrap - public init(endpointSetup: APIEndpoint) { - self.endpointSetup = endpointSetup - } - - /// Assembles an URL for the current host - /// - /// Example of usage: - /// - /// let githubEndpoint = APIEndpoint(host: "github.com") - /// let endpointBuilder = EndpointBuilder(endpointSetup: githubEndpoint) - /// endpointBuilder.assembleRequest(path: "events") { result in - /// switch(result) in - /// case success(let assembledRequest): - /// // do whatever you wish with URLRequest resulting type - /// case failure(let errorType): - /// // defaults to an URLRequest for google.com - /// } - /// - /// - Parameters: - /// - path: path for the desired endpoint - /// - queryParameters: URL's query parameters, should they exist. Defaults to an empty dictionary (no parameters) - /// - Returns: valid URL (defaults to [google.com](google.com) in case of error in assembly) - public func assembleRequest(path: String, queryParameters: [String: String] = [:]) -> URLRequest { - switch endpointSetup.buildRequest(for: path, with: queryParameters) { - case .success(let assembledRequest): - return assembledRequest - case .failure: - // TODO: this might be handled in a better way. - return URLRequest(url: URL.init(validURL: "google.com")) - } - } -} diff --git a/Sources/MauriNet/URLSession/RequestManager.swift b/Sources/MauriNet/URLSession/RequestManager.swift deleted file mode 100644 index ca18e55..0000000 --- a/Sources/MauriNet/URLSession/RequestManager.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// RequestManager.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import Foundation - -/// Wrapper object that handles network session -public struct RequestManager: RequestableManager { - private let httpResponseCodeSuccessRange = (200..<300) - private let session: URLSessionable - - /// Initializes the network session - /// - Parameter session: network session through which connection will be stablished with remote server. Defaults to Foundation `URLSession.shared` - public init(session: URLSessionable = URLSession.shared) { - self.session = session - } - - /// Triggers a generic request in an asynchronous matter - /// - /// Example of usage: - /// - /// let endpointRequest = URLRequest(url: URL(validURL: "github.com")) - /// let sessionManager = RequestManager() - /// sessionManager.request(endpointRequest) { result in - /// switch(result) in - /// case success(let response): - /// // do whatever you wish with NetworkResult resulting type - /// case failure(let errorType): - /// // handle error appropriately - /// } - /// - /// - Parameters: - /// - request: URL request specifications - /// - completion: Resulting completion closure from request - public func request(_ request: URLRequest, completion: @escaping NetworkResult) { - session.dataTaskWithURL(request) { data, response, error in - guard let requestResponse = response as? HTTPURLResponse else { - debugPrint("Unable to parse response due to \(String(describing: error?.localizedDescription))") - completion(.failure(.malformedRequest)) - return - } - - guard let retrievedData = data else { - debugPrint("Unable to parse data due to \(String(describing: error?.localizedDescription))") - completion(.failure(.forbiddenResource)) - - return - } - - if httpResponseCodeSuccessRange.contains(requestResponse.statusCode) { - completion(.success(retrievedData)) - } else { - completion(.failure(NetworkError.buildError(from: requestResponse.statusCode))) - } - }.resume() - } -} diff --git a/Sources/MauriNet/URLSession/RequestableManager.swift b/Sources/MauriNet/URLSession/RequestableManager.swift deleted file mode 100644 index 82912e3..0000000 --- a/Sources/MauriNet/URLSession/RequestableManager.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// RequestableManager.swift -// -// -// Created by Mauricio Chirino on 07/12/20. -// - -import Foundation - -/// Custom `Result` object for type of networking result -public typealias NetworkResult = (Result) -> Void - -/// Wrapper for encapsulating Foundation native response values from Data task -public typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void - -extension URLSession: URLSessionable { - public func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { - dataTask(with: request.url!, completionHandler: completion) - } -} - -/// Network session type -public protocol URLSessionable { - func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask -} - -/// Network manager type -public protocol RequestableManager { - /// Triggers a generic request in an asynchronous matter - /// - Parameters: - /// - request: URL request specifications - /// - completion: Resulting completion closure from request - func request(_ request: URLRequest, completion: @escaping NetworkResult) -} diff --git a/Sources/MauriNet/URLSession/ResponseValidator.swift b/Sources/MauriNet/URLSession/ResponseValidator.swift new file mode 100644 index 0000000..8aaa650 --- /dev/null +++ b/Sources/MauriNet/URLSession/ResponseValidator.swift @@ -0,0 +1,52 @@ +// +// ResponseValidator.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Internal helper that centralizes HTTP response validation for every flavor of request +/// (completion handler based, Combine and async/await), guaranteeing consistent error mapping +enum ResponseValidator { + static let httpResponseCodeSuccessRange = (200..<300) + + /// Validates the HTTP metadata attached to a network reply + /// - Parameter response: response metadata coming from the network session + /// - Throws: `NetworkError.malformedRequest` for non-HTTP replies or the mapped + /// `NetworkError` when the reply's status code falls outside the success range + static func validate(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse else { + debugPrint("Unable to parse non-HTTP response: \(response)") + throw NetworkError.malformedRequest + } + + guard httpResponseCodeSuccessRange.contains(httpResponse.statusCode) else { + throw NetworkError.buildError(from: httpResponse.statusCode) + } + } + + /// Maps transport-layer failures into the library's `NetworkError` domain + /// - Parameter error: error thrown by the underlying network session + /// - Returns: matching `NetworkError`. Already-mapped errors pass through untouched; + /// cancellations and timeouts keep their specific case and anything else falls back to `.unknown` + static func mapTransportFailure(_ error: Error) -> NetworkError { + switch error { + case let alreadyMapped as NetworkError: + return alreadyMapped + case is CancellationError: + return .cancelled + case let urlError as URLError where urlError.code == .cancelled: + return .cancelled + case let urlError as URLError where urlError.code == .timedOut: + return .timeout + default: + debugPrint("Unhandled transport failure: \(error.localizedDescription)") + return .unknown + } + } +} diff --git a/Sources/MauriNet/URLSession/URLSessionable.swift b/Sources/MauriNet/URLSession/URLSessionable.swift new file mode 100644 index 0000000..f28bca6 --- /dev/null +++ b/Sources/MauriNet/URLSession/URLSessionable.swift @@ -0,0 +1,45 @@ +// +// URLSessionable.swift +// +// +// Created by Mauricio Chirino on 07/12/20. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension URLSession: URLSessionable { + public func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { + try await data(for: request) + } + + public func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { + try await download(for: request) + } + + public func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { + try await upload(for: request, from: payload) + } +} + +/// Network session type +public protocol URLSessionable: Sendable { + /// Retrieves the contents for the given request, honoring its whole specification (HTTP method, headers, body and timeout) + /// - Parameter request: URL request specifications + /// - Returns: raw bytes retrieved from the remote server alongside its response metadata + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) + + /// Downloads the resource at the given request into a temporary file + /// - Parameter request: URL request specifications + /// - Returns: temporary file location where the resource was stored alongside its response metadata + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) + + /// Uploads the given payload as the request's body + /// - Parameters: + /// - payload: raw bytes to be uploaded to the remote server + /// - request: URL request specifications + /// - Returns: raw bytes from the remote server's reply alongside its response metadata + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index e2db000..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import MauriNetTests - -var tests = [XCTestCaseEntry]() -tests += MauriNetTests.allTests() -XCTMain(tests) diff --git a/Tests/MauriNetTests/Setup/XCTestManifests.swift b/Tests/MauriNetTests/Setup/XCTestManifests.swift deleted file mode 100644 index f83f786..0000000 --- a/Tests/MauriNetTests/Setup/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(MauriNetTests.allTests), - ] -} -#endif diff --git a/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift b/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift index 32cc979..46ee869 100644 --- a/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift +++ b/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift @@ -1,11 +1,12 @@ // // DummyDecodable.swift -// +// // // Created by Mauricio Chirino on 29/11/20. // import Foundation -struct DummyDecodable: Decodable { +struct DummyDecodable: Decodable, Equatable { + let message: String } diff --git a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift index 708351a..c453633 100644 --- a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift +++ b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift @@ -1,33 +1,79 @@ // -// URLFailingSessionMock.swift -// +// SessionMock.swift +// // // Created by Mauricio Chirino on 29/11/20. // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif @testable import MauriNet -/// URL session mocking implementation during testing. -/// [Source](https://masilotti.com/testing-nsurlsession-input/) -struct URLFailingSessionMock: URLSessionable { - func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { - return URLSessionDataTaskMock { - completion(nil, nil, nil) - } +/// Fully configurable session mock: stubs replies per task flavor and records +/// the last request/payload that went through it for later assertion +final class ConfigurableSessionMock: URLSessionable, @unchecked Sendable { + /// Data returned on data/upload replies. Defaults to a non empty dummy + var stubbedData: Data = Data("dummyData".utf8) + + /// HTTP status code stamped on the stubbed response. Defaults to `200` + var stubbedStatusCode: Int = 200 + + /// When `false`, replies carry a plain (non-HTTP) response, as in transport failures. Defaults to `true` + var repliesWithHTTPResponse = true + + /// When set, every session method throws this error instead of replying. Defaults to `nil` + var stubbedTransportError: Error? + + /// Temporary file location returned on download replies. Defaults to the temporary directory itself + var stubbedDownloadLocation: URL = FileManager.default.temporaryDirectory + + /// Last request that went through the session + private(set) var lastRequest: URLRequest? + + /// Last payload that went through the session's upload + private(set) var lastUploadedPayload: Data? + + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { + lastRequest = request + try throwIfStubbed() + + return (stubbedData, assembleResponse(for: request)) } -} -struct URLSuccessSessionMock: URLSessionable { - func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { - return URLSessionDataTaskMock { - let dummyData = "dummyData".data(using: .utf8)! - let response = HTTPURLResponse(url: request.url!, - mimeType: nil, - expectedContentLength: 10, - textEncodingName: nil) + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { + lastRequest = request + try throwIfStubbed() + + return (stubbedDownloadLocation, assembleResponse(for: request)) + } + + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { + lastRequest = request + lastUploadedPayload = payload + try throwIfStubbed() + + return (stubbedData, assembleResponse(for: request)) + } - completion(dummyData, response, nil) + private func throwIfStubbed() throws { + if let stubbedTransportError = stubbedTransportError { + throw stubbedTransportError } } + + private func assembleResponse(for request: URLRequest) -> URLResponse { + guard repliesWithHTTPResponse else { + return URLResponse(url: request.url!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil) + } + + return HTTPURLResponse(url: request.url!, + statusCode: stubbedStatusCode, + httpVersion: nil, + headerFields: nil)! + } } diff --git a/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift b/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift deleted file mode 100644 index 8103a00..0000000 --- a/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// URLSessionDataTaskMock.swift -// -// -// Created by Mauricio Chirino on 29/11/20. -// - -import Foundation - -final class URLSessionDataTaskMock: URLSessionDataTask { - private let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - /// Fulfills the provided closure immediately - override func resume() { - closure() - } -} diff --git a/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift new file mode 100644 index 0000000..d93cf52 --- /dev/null +++ b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift @@ -0,0 +1,309 @@ +// +// AsyncRequestManagerTestCases.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import MauriNet + +final class AsyncRequestManagerTestCases: XCTestCase { + var asyncRequestManager: AsyncRequestManager! + var configurableSession: ConfigurableSessionMock! + var dummyURLRequest: URLRequest! + + override func setUp() { + super.setUp() + configurableSession = ConfigurableSessionMock() + asyncRequestManager = AsyncRequestManager(session: configurableSession) + dummyURLRequest = URLRequest(url: URL(validURL: "https://loremIpsum.com")) + } + + override func tearDown() { + super.tearDown() + asyncRequestManager = nil + configurableSession = nil + dummyURLRequest = nil + } + + // MARK: Vanilla request + + func testAsyncRequestSuccessfulConsumption() async throws { + // Given + let expectedData = try XCTUnwrap("expectedData".data(using: .utf8)) + configurableSession.stubbedData = expectedData + + // When + let retrievedData = try await asyncRequestManager.request(dummyURLRequest) + + // Then + XCTAssertEqual(retrievedData, expectedData) + } + + func testAsyncRequestFailureWithoutHTTPResponse() async { + // Given + configurableSession.repliesWithHTTPResponse = false + + // When / Then + await assertThrowsNetworkError(.malformedRequest) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsHTTPErrorCodes() async { + // Given + configurableSession.stubbedStatusCode = 404 + + // When / Then + await assertThrowsNetworkError(.notFound) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + // MARK: Transport failure mapping + + func testAsyncRequestMapsCancellation() async { + // Given + configurableSession.stubbedTransportError = URLError(.cancelled) + + // When / Then + await assertThrowsNetworkError(.cancelled) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsSwiftConcurrencyCancellation() async { + // Given + configurableSession.stubbedTransportError = CancellationError() + + // When / Then + await assertThrowsNetworkError(.cancelled) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsTimeouts() async { + // Given + configurableSession.stubbedTransportError = URLError(.timedOut) + + // When / Then + await assertThrowsNetworkError(.timeout) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsUnhandledTransportFailures() async { + // Given + configurableSession.stubbedTransportError = URLError(.notConnectedToInternet) + + // When / Then + await assertThrowsNetworkError(.unknown) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + // MARK: Decodable request + + func testAsyncDecodableRequestSuccessfulConsumption() async throws { + // Given + configurableSession.stubbedData = try XCTUnwrap(#"{ "message": "hello world" }"#.data(using: .utf8)) + + // When + let decodedReply: DummyDecodable = try await asyncRequestManager.request(dummyURLRequest) + + // Then + XCTAssertEqual(decodedReply, DummyDecodable(message: "hello world")) + } + + func testAsyncDecodableRequestFailureOnCorruptedReply() async throws { + // Given + configurableSession.stubbedData = try XCTUnwrap("notAValidJSON".data(using: .utf8)) + + // When + do { + let _: DummyDecodable = try await asyncRequestManager.request(dummyURLRequest) + XCTFail("A corrupted reply shouldn't decode successfully") + } catch { + // Then + XCTAssertTrue(error is DecodingError) + } + } + + // MARK: CRUD conveniences + + func testCreatePerformsPOSTAttachingPayload() async throws { + // Given + let payload = try XCTUnwrap("newResource".data(using: .utf8)) + + // When + _ = try await asyncRequestManager.create(dummyURLRequest, payload: payload) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "POST") + XCTAssertEqual(configurableSession.lastRequest?.httpBody, payload) + } + + func testReadPerformsGET() async throws { + // When + _ = try await asyncRequestManager.read(dummyURLRequest) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "GET") + XCTAssertNil(configurableSession.lastRequest?.httpBody) + } + + func testUpdatePerformsPUTByDefault() async throws { + // Given + let payload = try XCTUnwrap("amendedResource".data(using: .utf8)) + + // When + _ = try await asyncRequestManager.update(dummyURLRequest, payload: payload) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "PUT") + XCTAssertEqual(configurableSession.lastRequest?.httpBody, payload) + } + + func testUpdateSupportsPATCH() async throws { + // When + _ = try await asyncRequestManager.update(dummyURLRequest, method: .patch) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "PATCH") + } + + func testDeletePerformsDELETE() async throws { + // When + try await asyncRequestManager.delete(dummyURLRequest) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "DELETE") + } + + func testCRUDConveniencesPropagateFailures() async { + // Given + configurableSession.stubbedStatusCode = 500 + + // When / Then + await assertThrowsNetworkError(.serverDown) { + _ = try await self.asyncRequestManager.read(self.dummyURLRequest) + } + } + + // MARK: Upload + + func testUploadSuccessfulConsumption() async throws { + // Given + let payload = try XCTUnwrap("avatarBytes".data(using: .utf8)) + let serverReply = try XCTUnwrap("uploadAccepted".data(using: .utf8)) + configurableSession.stubbedData = serverReply + + // When + let retrievedReply = try await asyncRequestManager.upload(dummyURLRequest, from: payload) + + // Then + XCTAssertEqual(retrievedReply, serverReply) + XCTAssertEqual(configurableSession.lastUploadedPayload, payload) + } + + func testUploadFailureMapsHTTPErrorCodes() async { + // Given + configurableSession.stubbedStatusCode = 413 + + // When / Then + await assertThrowsNetworkError(.payloadTooLarge) { + _ = try await self.asyncRequestManager.upload(self.dummyURLRequest, from: Data()) + } + } + + // MARK: Download + + func testDownloadSuccessfulConsumptionPersistsFile() async throws { + // Given + let expectedContent = "downloadedContent" + let temporaryDownload = try givenTemporaryFile(content: expectedContent) + configurableSession.stubbedDownloadLocation = temporaryDownload + + // When + let persistedFile = try await asyncRequestManager.download(dummyURLRequest) + purgeAfterTest(persistedFile) + + // Then + XCTAssertNotEqual(persistedFile, temporaryDownload) + XCTAssertEqual(try String(contentsOf: persistedFile, encoding: .utf8), expectedContent) + } + + func testDownloadPersistsIntoProvidedDirectory() async throws { + // Given + let temporaryDownload = try givenTemporaryFile(content: "downloadedContent") + configurableSession.stubbedDownloadLocation = temporaryDownload + let customDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("MauriNetCustomDownloads-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: customDirectory, withIntermediateDirectories: true) + purgeAfterTest(customDirectory) + + // When + let persistedFile = try await asyncRequestManager.download(dummyURLRequest, into: customDirectory) + + // Then + XCTAssertEqual(persistedFile.deletingLastPathComponent().standardizedFileURL.path, + customDirectory.standardizedFileURL.path) + } + + func testDownloadFailureWithoutHTTPResponse() async { + // Given + configurableSession.repliesWithHTTPResponse = false + + // When / Then + await assertThrowsNetworkError(.malformedRequest) { + _ = try await self.asyncRequestManager.download(self.dummyURLRequest) + } + } + + func testDownloadFailureMapsHTTPErrorCodes() async { + // Given + configurableSession.stubbedStatusCode = 401 + + // When / Then + await assertThrowsNetworkError(.unauthorized) { + _ = try await self.asyncRequestManager.download(self.dummyURLRequest) + } + } +} + +private extension AsyncRequestManagerTestCases { + func givenTemporaryFile(content: String) throws -> URL { + let temporaryFile = FileManager.default.temporaryDirectory + .appendingPathComponent("MauriNetTestDownload-\(UUID().uuidString)") + .appendingPathExtension("txt") + try content.write(to: temporaryFile, atomically: true, encoding: .utf8) + purgeAfterTest(temporaryFile) + + return temporaryFile + } + + /// Guarantees the given file is wiped once the current test finishes, regardless of its outcome + func purgeAfterTest(_ file: URL) { + addTeardownBlock { + try? FileManager.default.removeItem(at: file) + } + } + + func assertThrowsNetworkError(_ expectedError: NetworkError, + during operation: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line) async { + do { + try await operation() + XCTFail("Operation should've thrown \(expectedError)", file: file, line: line) + } catch let thrownError as NetworkError { + XCTAssertEqual(thrownError, expectedError, file: file, line: line) + } catch { + XCTFail("Unexpected error type thrown: \(error)", file: file, line: line) + } + } +} diff --git a/Tests/MauriNetTests/Tests/HTTPMethodTestCases.swift b/Tests/MauriNetTests/Tests/HTTPMethodTestCases.swift new file mode 100644 index 0000000..8b68bc5 --- /dev/null +++ b/Tests/MauriNetTests/Tests/HTTPMethodTestCases.swift @@ -0,0 +1,19 @@ +// +// HTTPMethodTestCases.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import XCTest +@testable import MauriNet + +final class HTTPMethodTestCases: XCTestCase { + func testUppercasedRepresentations() { + XCTAssertEqual(HTTPMethod.get.value, "GET") + XCTAssertEqual(HTTPMethod.post.value, "POST") + XCTAssertEqual(HTTPMethod.put.value, "PUT") + XCTAssertEqual(HTTPMethod.patch.value, "PATCH") + XCTAssertEqual(HTTPMethod.delete.value, "DELETE") + } +} diff --git a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift b/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift deleted file mode 100644 index 042c8c9..0000000 --- a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// MauriNetSessionTestCases.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import XCTest -@testable import MauriNet - -final class MauriNetSessionTestCases: XCTestCase { - var mockRequestManager: RequestManager! - var dummyURLRequest: URLRequest! - var mockSession: URLSessionable! - - override func tearDown() { - super.tearDown() - mockRequestManager = nil - dummyURLRequest = nil - mockSession = nil - } - - func testRequestManagerErrorConsumptionHandling() { - // Given - let requestExpectation = expectation(description: "requestExpectation") - givenCorruptedManager() - - // When - mockRequestManager.request(dummyURLRequest) { result in - switch result { - case .success: - XCTFail("A failing response shouldn't produce a successful status code") - requestExpectation.fulfill() - case .failure(let error): - XCTAssertEqual(error, NetworkError.malformedRequest) - requestExpectation.fulfill() - } - } - - // Then - wait(for: [requestExpectation], timeout: 0.1) - } - - func testRequestManagerSuccessConsumptionHandling() { - // Given - let requestExpectation = expectation(description: "requestExpectation") - givenProperSetManager() - - // When - mockRequestManager.request(dummyURLRequest) { result in - switch result { - case .success(let data): - XCTAssertNotEqual(data.count, 0) - requestExpectation.fulfill() - case .failure(let error): - XCTFail("Failed due to: \(error.localizedDescription)") - requestExpectation.fulfill() - } - } - - // Then - wait(for: [requestExpectation], timeout: 0.1) - } -} - -private extension MauriNetSessionTestCases { - func givenCorruptedManager() { - mockSession = URLFailingSessionMock() - mockRequestManager = RequestManager(session: mockSession) - dummyURLRequest = URLRequest(url: URL(validURL: "loremIpsum")) - } - - func givenProperSetManager() { - mockSession = URLSuccessSessionMock() - mockRequestManager = RequestManager(session: mockSession) - dummyURLRequest = URLRequest(url: URL(validURL: "loremIpsum")) - } -} diff --git a/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift b/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift deleted file mode 100644 index c1a829e..0000000 --- a/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift +++ /dev/null @@ -1,132 +0,0 @@ - -import XCTest -@testable import MauriNet - -final class MauriNetURLBuilderTestCases: XCTestCase { - var root: String! - var path: String! - var endpoint: APIEndpoint! - var endpointBuilder: EndpointBuilder! - var resultingURL: URLRequest! - var query: [String: String]! - - override func setUp() { - super.setUp() - givenDummyURL() - } - - override func tearDown() { - super.tearDown() - root = nil - path = nil - endpoint = nil - endpointBuilder = nil - resultingURL = nil - query = nil - } - - func testURLAssemble() { - whenDefaultSetupIsReady() - thenVerifyAValidURLIsAssembled() - } - - func testURLProperConfiguration() { - whenCustomConfigurationIsSet() - thenVerifyCustomConfigurationIsHandledProperly() - } - - func testBuilderForURLWithQueryParams() throws { - whenDefaultSetupIsReady() - try thenVerifyQueryParamsAreProperlyBuilt() - } - - func testConvenienceBuilderAPI() { - whenCustomConfigurationIsSet() - whenURLIsAssembledUsingConvenienceBuilder() - thenVerifyProperBuiltFor(assembledURL: resultingURL, against: endpoint) - } - - func testConvenienceBuilderAPIWithParameters() throws { - whenDefaultSetupIsReady() - whenURLIWithParamssAssembledUsingConvenienceBuilder() - try thenVerifyProperQueryBuiltFor(assembledURL: resultingURL, query: query) - } - - func testDefaultCaseForConvenienceAPIBuilder() { - } -} - -private extension MauriNetURLBuilderTestCases { - func givenDummyURL() { - root = "github.com" - path = "testing" - query = ["param": "1", "param2": "lalala"] - } - - func whenDefaultSetupIsReady() { - endpoint = APIEndpoint(host: root) - } - - func whenCustomConfigurationIsSet() { - endpoint = APIEndpoint(host: root, httpMethod: .post, scheme: .unsafe, timeout: 30) - } - - func whenURLIsAssembledUsingConvenienceBuilder() { - endpointBuilder = EndpointBuilder(endpointSetup: endpoint) - resultingURL = endpointBuilder.assembleRequest(path: path) - } - - func whenURLIWithParamssAssembledUsingConvenienceBuilder() { - endpointBuilder = EndpointBuilder(endpointSetup: endpoint) - resultingURL = endpointBuilder.assembleRequest(path: path, queryParameters: query) - } - - func thenVerifyAValidURLIsAssembled() { - switch endpoint.buildRequest(for: path) { - case .success(let assembledURL): - XCTAssertEqual(assembledURL.url?.absoluteString, "https://github.com/testing") - case .failure(let error): - XCTFail("Failed due to \(error.localizedDescription)") - } - } - - func thenVerifyCustomConfigurationIsHandledProperly() { - switch endpoint.buildRequest(for: path) { - case .success(let assembledURL): - thenVerifyProperBuiltFor(assembledURL: assembledURL, against: endpoint) - XCTAssertEqual(assembledURL.url?.absoluteString, "http://github.com/testing") - case .failure(let error): - XCTFail("Failed due to \(error.localizedDescription)") - } - } - - func thenVerifyQueryParamsAreProperlyBuilt() throws { - let path = "testing" - - switch endpoint.buildRequest(for: path, with: query) { - case .success(let assembledURL): - try thenVerifyProperQueryBuiltFor(assembledURL: assembledURL, query: query) - case .failure(let error): - XCTFail("Failed due to \(error.localizedDescription)") - } - } - - func thenVerifyProperBuiltFor(assembledURL: URLRequest, against config: APIEndpoint) { - XCTAssertEqual(assembledURL.url?.host, config.host) - XCTAssertEqual(assembledURL.httpMethod, config.httpMethod.value) - XCTAssertEqual(assembledURL.timeoutInterval, config.timeout) - } - - func thenVerifyProperQueryBuiltFor(assembledURL: URLRequest, query: [String: String]) throws { - let resultingURL = try XCTUnwrap(assembledURL.url?.absoluteString) - let urlWithComponents = URLComponents(validURL: resultingURL) - - XCTAssertNotNil(assembledURL.url?.query) - XCTAssertEqual(urlWithComponents.queryItems?.count, query.count) - try urlWithComponents.queryItems?.forEach { item in - let value = try XCTUnwrap(item.value) - XCTAssertEqual(urlWithComponents.queryItems?[item.name], query[item.name]) - XCTAssertEqual(urlWithComponents.queryItems?[value], query[value]) - } - } -}