diff --git a/.github/ISSUE_TEMPLATE/general-issue.md b/.github/ISSUE_TEMPLATE/general-issue.md new file mode 100644 index 0000000..92ea455 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general-issue.md @@ -0,0 +1,34 @@ +--- +name: General issue +about: Report a reproducible bug or issue +title: '[Bug] [Feature Request] Short description of the issue' +labels: '' +assignees: '' +--- + +## 🐞 Expected Behavior + + +## ❌ Actual Behavior + + +## 🔄 Steps to Reproduce +1. +2. +3. + +## 📄 Logs / Error Messages + + +## 📸 Screenshots + + +## 💻 Code Snippets +```swift +// Include relevant code snippets that help reproduce the issue. +``` + +## 🛠 System Information +- **Package Version**: +- **Operating System**: +- **Device / Hardware**: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6e2eae8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +## What + + +## Why + + +## Changes + \ No newline at end of file diff --git a/.github/workflows/test-and-coverage.yml b/.github/workflows/test-and-coverage.yml new file mode 100644 index 0000000..ac469c4 --- /dev/null +++ b/.github/workflows/test-and-coverage.yml @@ -0,0 +1,14 @@ +name: Package Test + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + coverage: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: Snapp-Mobile/swift-coverage-action@v1.0.1 diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..f837726 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - documentation_targets: [Fetcher] + platform: ios diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..a7a81dc --- /dev/null +++ b/.swiftformat @@ -0,0 +1,57 @@ +{ + "version": 1, + "lineLength": 120, + "indentation": { + "spaces": 4 + }, + "tabWidth": 4, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "prioritizeKeepingFunctionOutputTogether": false, + "indentConditionalCompilationBlocks": true, + "lineBreakAroundMultilineExpressionChainComponents": false, + "indentSwitchCaseLabels": false, + "spacesAroundRangeFormationOperators": false, + "noAssignmentInExpressions": { + "allowedFunctions": ["XCTAssertNoThrow"] + }, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "rules": { + "AllPublicDeclarationsHaveDocumentation": true, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoVoidReturnOnFunctionSignature": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": false, + "UseLetInEveryBoundCaseVariable": false, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": false, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4f5e442 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Change Log +All notable changes to this project will be documented in this file. + + + +## [0.1.0] - 2026-01-12 + +Initial public release of Fetcher. + +### Added +- MIT License for the project +- Continuous Integration workflow +- Swift Package Index configuration (.spi.yml) +- Shield badges to README +- Description, installation and usage instructions to README +- `Fetcher` class with `async/await` support +- `APIURL` and `FetcherEnvironment` protocols +- `Token` and `APIError` models +- `URLSession` backports for older iOS versions +- Error handling for API responses and token management +- Concurrency utilities for multiple requests + +## [0.0.2] - 2023-02-10 +Improved Concurrency integration + +## [0.0.1] - 2022-07-01 +Initial implementation diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..382e798 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Snapp Mobile Germany GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift index f9ecacb..4be13e7 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,10 @@ let package = Package( targets: ["Fetcher"]), ], dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + .package( + url: "https://github.com/Snapp-Mobile/SwiftFormatLintPlugin.git", + exact: "1.0.4" + ) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -27,7 +29,11 @@ let package = Package( dependencies: [], resources: [ .process("Mocks") - ]), + ], + plugins: [ + .plugin(name: "Lint", package: "SwiftFormatLintPlugin") + ] + ), .testTarget( name: "FetcherTests", dependencies: ["Fetcher"]), diff --git a/README.md b/README.md index 9316229..99d89bd 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,144 @@ +
+ Fetcher Logo +
+ # Fetcher -A lightweight REST API client written in Swift +A lightweight, modern, and robust REST API client written in Swift. +[![Swift Package Index](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FSnapp-Mobile%2FFetcher%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Snapp-Mobile/Fetcher) +[![iOS 13.0+ | macOS 11.0+](https://img.shields.io/badge/iOS_13.0%2B_%7C_macOS_11.0%2B-007AFF?logo=apple&logoColor=white)](https://www.apple.com/ios/) +[![Latest Release](https://img.shields.io/github/v/release/Snapp-Mobile/Fetcher?color=8B5CF6&logo=github&logoColor=white)](https://github.com/Snapp-Mobile/Fetcher/releases) +[![Tests](https://github.com/Snapp-Mobile/Fetcher/actions/workflows/test-and-coverage.yml/badge.svg)](https://github.com/Snapp-Mobile/Fetcher/actions) +[![License: MIT](https://img.shields.io/badge/License-MIT-22C55E)](LICENSE) ## Overview -The framework exposes a tiny client and a few helper protocols to consume REST APIs. +`Fetcher` is a Swift library designed to simplify interactions with RESTful APIs. It provides a clean, protocol-oriented, and testable solution for making network requests, handling responses, and managing common API workflows like authentication and error handling. + +The core of the framework is the `Fetcher` struct, which acts as the main client for performing requests. It operates on objects conforming to the `APIURL` protocol, which provides a structured and type-safe way to define your API endpoints, avoiding scattered URL strings across your codebase. + +Key features include: +- **Asynchronous API**: Modern `async/await` syntax for clean and concurrent network calls. +- **Protocol-Driven Endpoints**: Define your API endpoints semantically using the `APIURL` protocol. +- **Robust Error Handling**: A comprehensive `APIError` enum to represent various network and server errors. +- **Token Management**: Built-in logic to handle token-based authentication, including automatic retries and token refresh. +- **Environment-Based Configuration**: Use the `FetcherEnvironment` protocol to inject dependencies like `URLSession` and custom logic, making your networking layer highly testable. +- **Concurrency Utilities**: Helpers for running multiple requests in parallel. + +## Installation + +Add `Fetcher` as a dependency to your `Package.swift` file: + +```swift +.package(url: "https://github.com/Snapp-Mobile/Fetcher.git", from: "0.1.0") +``` + +## Setup + +To use Fetcher, you need to provide an implementation of `FetcherEnvironment`. + +```swift +import Fetcher +import Foundation +import os.log + +// A basic implementation of FetcherEnvironment for demonstration. +// In a real application, you would manage tokens, network reachability, +// and logging more robustly. +class MyProductionEnvironment: FetcherEnvironment { + let urlSession: URLSession = .shared + var isNetworkingAvailable: Bool = true // You might use Network.framework here + + var apiErrorsLogger: FetcherLogger? = nil // Implement a custom logger if needed + + // Placeholder implementations for token management + func updateToken(to newToken: Fetcher.Token?, logger: FetcherLogger?) async throws -> Fetcher.Token? { + // ... update stored token ... + return newToken + } + + func refreshToken(_ token: Fetcher.Token?, using fetcher: Fetcher) async throws -> Fetcher.Token? { + // ... logic to refresh token, e.g., make an API call to your auth server ... + return token // Return a new, valid token + } + + func getToken(logger: FetcherLogger?) async throws -> Fetcher.Token? { + // ... retrieve current token from storage ... + return nil + } + + func logout() async throws { + // ... clear stored token and user session ... + } +} + +let myEnvironment = MyProductionEnvironment() +let fetcher = Fetcher(environment: myEnvironment) +``` + +## Usage + +Define your API endpoints by conforming to the `APIURL` protocol: + +```swift +import Fetcher +import Foundation + +// Example API endpoint definition +enum MyAPIEndpoint: APIURL { + case getGreeting + + var path: String { + switch self { + case .getGreeting: + return "/greeting" + } + } + + var requestMethod: String { + switch self { + case .getGreeting: + return "GET" + } + } + + var url: URL? { + return URL(string: "https://api.example.com")?.appendingPathComponent(path) + } + + func bodyParams(token: Fetcher.Token?) -> [String: Any]? { + return nil + } + + func request(token: Fetcher.Token?) -> URLRequest? { + guard let url = self.url else { return nil } + var request = URLRequest(url: url) + request.httpMethod = requestMethod + // Add authorization header if token is available + if let accessToken = token?.accessToken { + request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + return request + } +} + +// Define a Decodable struct for your API response +struct GreetingResponse: Decodable { + let message: String +} -The core of the framework is the `Fetcher` API client itself along with the `APIURL` protocol. -These two are connected in a way that the `Fetcher` only operates on implementations of `APIURL`. -The main reason for operating with `Fetcher/APIURL` is to avoid having hardcoded URL strings accross your entire -codebase. Instead, you can wrap them in enums that conform to the `APIURL` protocol. +// To fetch data from your API: +func fetchGreeting() async { + do { + let greeting: GreetingResponse = try await fetcher.fetch(MyAPIEndpoint.getGreeting) + print("Received greeting: \(greeting.message)") + } catch { + print("Failed to fetch greeting: \(error.localizedDescription)") + } +} -Of course `Fetcher` can also operate on plain `URLRequest` objects to fetch the response data, if needed. +// Call the function (e.g., from an async context) +// Task { +// await fetchGreeting() +// } diff --git a/Sources/Fetcher/Extensions/OSLog+Categories.swift b/Sources/Fetcher/Extensions/OSLog+Categories.swift index 85d989b..d51ace6 100644 --- a/Sources/Fetcher/Extensions/OSLog+Categories.swift +++ b/Sources/Fetcher/Extensions/OSLog+Categories.swift @@ -1,6 +1,6 @@ // // OSLog+Categories.swift -// +// // // Created by Ilian Konchev on 17.06.20. // Copyright © 2020 Ilian Konchev. All rights reserved. @@ -8,15 +8,15 @@ import Foundation import os.log -public extension OSLog { +extension OSLog { private static var subsystem = Bundle.main.bundleIdentifier! /// Logs the view cycles like viewDidLoad. - static let viewCycle = OSLog(subsystem: subsystem, category: "ViewCycle") + public static let viewCycle = OSLog(subsystem: subsystem, category: "ViewCycle") /// Logs the API data-related info - static let apiData = OSLog(subsystem: subsystem, category: "APIData") + public static let apiData = OSLog(subsystem: subsystem, category: "APIData") /// Logs generic app info - static let app = OSLog(subsystem: subsystem, category: "Application") + public static let app = OSLog(subsystem: subsystem, category: "Application") /// Logs out the info that goes to file loggers - static let fileLogger = OSLog(subsystem: subsystem, category: "FileLogger") + public static let fileLogger = OSLog(subsystem: subsystem, category: "FileLogger") } diff --git a/Sources/Fetcher/Extensions/URLSession+Backports.swift b/Sources/Fetcher/Extensions/URLSession+Backports.swift index 705e37b..99f9b07 100644 --- a/Sources/Fetcher/Extensions/URLSession+Backports.swift +++ b/Sources/Fetcher/Extensions/URLSession+Backports.swift @@ -1,15 +1,28 @@ // // URLSession+Backports.swift -// +// // // Created by Ilian Konchev on 18.11.21. // import Foundation +/// This file provides backported async/await functionality for `URLSession` for older iOS versions. +/// It allows using `async` and `await` with `URLSession`'s data and download tasks +/// on platforms where these APIs are not natively available (pre-iOS 15.0). +/// +/// Use the built-in API instead on iOS 15.0 and later. @available(iOS, deprecated: 15.0, message: "Use the built-in API instead") -public extension URLSession { - func data(for request: URLRequest) async throws -> (Data, URLResponse) { +extension URLSession { + /// Performs a URL session data task for the given request asynchronously. + /// + /// This is a backported version of `URLSession.data(for:)` using `withCheckedThrowingContinuation` + /// to provide an `async/await` interface for older iOS/macOS versions. + /// + /// - Parameter request: The `URLRequest` to perform. + /// - Returns: A tuple containing the `Data` and `URLResponse` from the server. + /// - Throws: An `Error` if the request fails or no data/response is received. + public func data(for request: URLRequest) async throws -> (Data, URLResponse) { try await withCheckedThrowingContinuation { continuation in let task = self.dataTask(with: request) { data, response, error in guard let data = data, let response = response else { @@ -24,7 +37,15 @@ public extension URLSession { } } - func download(for request: URLRequest) async throws -> (URL, URLResponse) { + /// Performs a URL session download task for the given request asynchronously. + /// + /// This is a backported version of `URLSession.download(for:)` using `withCheckedThrowingContinuation` + /// to provide an `async/await` interface for older iOS/macOS versions. + /// + /// - Parameter request: The `URLRequest` to perform. + /// - Returns: A tuple containing the `URL` to the downloaded file and the `URLResponse`. + /// - Throws: An `Error` if the request fails or no URL/response is received. + public func download(for request: URLRequest) async throws -> (URL, URLResponse) { try await withCheckedThrowingContinuation { completion in let task = self.downloadTask(with: request) { url, response, error in guard let url = url, let response = response else { diff --git a/Sources/Fetcher/Fetcher+Concurrency.swift b/Sources/Fetcher/Fetcher+Concurrency.swift index 06a0cce..da19339 100644 --- a/Sources/Fetcher/Fetcher+Concurrency.swift +++ b/Sources/Fetcher/Fetcher+Concurrency.swift @@ -1,6 +1,6 @@ // // Fetcher+Concurrency.swift -// +// // // Created by Ilian Konchev on 30.10.21. // @@ -9,11 +9,17 @@ import Foundation import os.log @available(iOS 13.0, macOS 11.0, *) -public extension Fetcher { - // Execute an `URLRequest` and return the response data - /// - Parameter request: The `URLRequest` to perform - /// - Returns: The response's `Data` or ``Fetcher/APIError`` - func perform(_ request: URLRequest) async throws -> Data { +extension Fetcher { + /// Executes an `URLRequest` and returns the response data. + /// + /// This method performs the given `URLRequest` asynchronously. It first checks for network availability. + /// If the request is successful and returns a 2xx status code, the response data is returned. + /// Otherwise, it attempts to parse an error from the response and throws an appropriate `APIError`. + /// + /// - Parameter request: The `URLRequest` to perform. + /// - Returns: The response's `Data`. + /// - Throws: An `APIError` if the request fails, if there is no network connection, or if the response code is not in the 200-299 range. + public func perform(_ request: URLRequest) async throws -> Data { guard environment.isNetworkingAvailable else { throw APIError.noConnectionAvailable } @@ -45,9 +51,19 @@ public extension Fetcher { } } - /// Execute a request to an ``Fetcher/APIURL`` - /// - Returns: A type hinted response or an ``Fetcher/APIError`` - func fetch(_ apiURL: APIURL, token: Token?, allowRetry: Bool = true) async throws -> T { + /// Executes a request for a decodable object from an ``APIURL``. + /// + /// This method creates and performs a request for the given ``APIURL``. If the request fails with an + /// `unauthorized` error and `allowRetry` is `true`, it will attempt to refresh the token and + /// retry the request once. + /// + /// - Parameters: + /// - apiURL: The ``APIURL`` endpoint to fetch from. + /// - token: An optional `Token` to use for authorization. + /// - allowRetry: A boolean indicating whether to retry the request after a token refresh. + /// - Returns: A decoded object of type `T`. + /// - Throws: An `APIError` if the request fails, including `userLoggedOut` if token refresh fails or is not allowed. + public func fetch(_ apiURL: APIURL, token: Token?, allowRetry: Bool = true) async throws -> T { guard let request = apiURL.request(token: token) else { throw APIError.unknownRequest } @@ -71,16 +87,30 @@ public extension Fetcher { } } - /// Execute a request to an ``Fetcher/APIURL`` by attempting to get and refresh the token, if needed - /// - Returns: A type hinted response or an ``Fetcher/APIError`` - func fetch(_ apiURL: APIURL) async throws -> T { + /// Executes a request for a decodable object, automatically handling token retrieval. + /// + /// This method retrieves a token from the environment and then calls the fetch method to execute the request. + /// It simplifies the process of making an authenticated request by managing the token internally. + /// + /// - Parameter apiURL: The ``APIURL`` endpoint to fetch from. + /// - Returns: A decoded object of type `T`. + /// - Throws: An `APIError` if token retrieval or the request fails. + public func fetch(_ apiURL: APIURL) async throws -> T { let token = try await environment.getToken(logger: environment.apiErrorsLogger) return try await fetch(apiURL, token: token) } - /// Execute a request to an ``Fetcher/APIURL`` by attemptng to refresh the token first, if needed - /// - Returns: A boolean representing a successful response or an ``Fetcher/APIError`` - func fetch(_ apiURL: APIURL, allowRetry: Bool = true) async throws -> Bool { + /// Executes a request to an ``APIURL`` and returns whether the request was successful. + /// + /// This method is useful for endpoints where the response body is not important, but the success of the call (indicated by a 2xx HTTP status) is. + /// It handles token retrieval and optional request retry on authorization failure. + /// + /// - Parameters: + /// - apiURL: The ``APIURL`` endpoint to call. + /// - allowRetry: A boolean indicating whether to retry the request after a token refresh. + /// - Returns: `true` if the request was successful, otherwise throws an error. + /// - Throws: An `APIError` if the request fails, including `userLoggedOut` if token refresh fails or is not allowed. + public func fetch(_ apiURL: APIURL, allowRetry: Bool = true) async throws -> Bool { let token = try await environment.getToken(logger: environment.apiErrorsLogger) guard let request = apiURL.request(token: token) else { throw APIError.unknownRequest @@ -103,14 +133,18 @@ public extension Fetcher { } } - /// Execute a request to a ``Fetcher/APIURL`` and check if the response headers match a specific HTTP code + /// Executes a request and checks if the response's HTTP status code matches an expected value. + /// + /// This method automatically handles token retrieval and can retry the request if an authorization failure occurs. + /// It is useful for verifying API call outcomes that are communicated solely through HTTP status codes, such as a `204 No Content`. /// - /// This method attempts to refresh the access token in the environment before calling the API endpoint /// - Parameters: - /// - apiURL: The APIURL to call - /// - code: The expected HTTP response code - /// - Returns: A boolean representing a matched HTTP response code or an ``Fetcher/APIError`` - func matchHTTPCode(_ apiURL: APIURL, code expectedCode: Int, allowRetry: Bool = true) async throws -> Bool { + /// - apiURL: The ``APIURL`` to call. + /// - expectedCode: The expected HTTP response code. + /// - allowRetry: A boolean indicating whether to retry the request after a token refresh. + /// - Returns: `true` if the HTTP response code matches `expectedCode`. + /// - Throws: An `APIError` if the network request fails, if the status code does not match, or if token refresh fails. + public func matchHTTPCode(_ apiURL: APIURL, code expectedCode: Int, allowRetry: Bool = true) async throws -> Bool { let token = try await environment.getToken(logger: environment.apiErrorsLogger) guard let request = apiURL.request(token: token) else { throw APIError.unknownRequest @@ -146,11 +180,17 @@ public extension Fetcher { } } - /// Execute requests to two separate APIURLs and waits for both of them to reply + /// Executes two API requests concurrently and returns a tuple of their results. /// - /// This method attempts to refresh the access token in the environment before calling the API endpoint - /// - Returns: A tuple of the results or an ``Fetcher/APIError`` - func fetchBoth(_ apiURL: APIURL, _ apiURL2: APIURL) async throws -> (T1, T2) { + /// This method automatically handles token retrieval and refresh. If the token is expired, it refreshes it + /// before initiating the concurrent requests. Both requests are performed in parallel. + /// + /// - Parameters: + /// - apiURL: The first ``APIURL`` to fetch from. + /// - apiURL2: The second ``APIURL`` to fetch from. + /// - Returns: A tuple `(T1, T2)` containing the decoded results of the two requests. + /// - Throws: An `APIError` if token handling or any of the requests fail. + public func fetchBoth(_ apiURL: APIURL, _ apiURL2: APIURL) async throws -> (T1, T2) { let token = try await environment.getToken(logger: environment.apiErrorsLogger) if token?.isAccessTokenExpired == true { let updatedToken = try await environment.refreshToken(token, using: self) @@ -167,12 +207,22 @@ public extension Fetcher { } } - /// Execute requests to three separate APIURLs and waits for all of them to reply + /// Executes three API requests concurrently and returns a tuple of their results. /// - /// This method attempts to refresh the access token in the environment before calling the API endpoint - /// - Returns: A tuple of the results or an ``Fetcher/APIError`` - // swiftlint:disable large_tuple - func fetchBoth(_ apiURL: APIURL, _ apiURL2: APIURL, _ apiURL3: APIURL) async throws -> (T1, T2, T3) { + /// This method automatically handles token retrieval and refresh. If the token is expired, it refreshes it + /// before initiating the concurrent requests. All three requests are performed in parallel. + /// + /// - Parameters: + /// - apiURL: The first ``APIURL`` to fetch from. + /// - apiURL2: The second ``APIURL`` to fetch from. + /// - apiURL3: The third ``APIURL`` to fetch from. + /// - Returns: A tuple `(T1, T2, T3)` containing the decoded results of the three requests. + /// - Throws: An `APIError` if token handling or any of the requests fail. + public func fetchBoth( + _ apiURL: APIURL, + _ apiURL2: APIURL, + _ apiURL3: APIURL + ) async throws -> (T1, T2, T3) { let token = try await environment.getToken(logger: environment.apiErrorsLogger) if token?.isAccessTokenExpired == true { let updatedToken = try await environment.refreshToken(token, using: self) diff --git a/Sources/Fetcher/Fetcher.docc/Fetcher.md b/Sources/Fetcher/Fetcher.docc/Fetcher.md index af885bd..4cd5e5b 100644 --- a/Sources/Fetcher/Fetcher.docc/Fetcher.md +++ b/Sources/Fetcher/Fetcher.docc/Fetcher.md @@ -1,20 +1,38 @@ # ``Fetcher`` -A lightweight REST API client written in Swift +@Metadata { + @PageImage(purpose: icon, source:"Fetcher-logo") +} + +A lightweight, modern, and robust REST API client written in Swift. ## Overview -The framework exposes a tiny client and a few helper protocols to consume REST APIs. +`Fetcher` is a Swift package designed to simplify interactions with RESTful APIs. It provides a clean, protocol-oriented, and testable solution for making network requests, handling responses, and managing common API workflows like authentication and error handling. + +The core of the framework is the ``Fetcher/Fetcher`` struct, which acts as the main client for performing requests. It operates on objects conforming to the ``Fetcher/APIURL`` protocol, which provides a structured and type-safe way to define your API endpoints, avoiding scattered URL strings across your codebase. -The core of the framework is the ``Fetcher/Fetcher`` API client itself along with the ``Fetcher/APIURL`` protocol. -These two are connected in a way that the ``Fetcher/Fetcher`` only operates on implementations of ``Fetcher/APIURL``. -The main reason for operating with ``Fetcher/APIURL`` is to avoid having hardcoded URL strings accross your entire -codebase. Instead, you can wrap them in enums that conform to the ``Fetcher/APIURL`` protocol. +Key features include: +- **Asynchronous API**: Modern `async/await` syntax for clean and concurrent network calls (``Fetcher/Fetcher/perform(_:)``). +- **Protocol-Driven Endpoints**: Define your API endpoints semantically using the ``Fetcher/APIURL`` protocol. +- **Robust Error Handling**: A comprehensive ``Fetcher/APIError`` enum to represent various network and server errors. +- **Token Management**: Built-in logic to handle token-based authentication, including automatic retries and token refresh (``Fetcher/Fetcher/fetch(_:token:allowRetry:)``). +- **Environment-Based Configuration**: Use the ``Fetcher/FetcherEnvironment`` protocol to inject dependencies like `URLSession` and custom logic, making your networking layer highly testable. +- **Concurrency Utilities**: Helpers for running multiple requests in parallel (e.g., ``Fetcher/Fetcher/fetchBoth(_:_:)``). -Of course ``Fetcher/Fetcher`` can also operate on plain `URLRequest` objects to fetch the response data, if needed. +Whether you're fetching data for a simple request or building a complex multi-endpoint workflow, `Fetcher` provides the tools to do so cleanly and efficiently. ## Topics -### Group +### API Client +- ``Fetcher/Fetcher`` + +### Protocols +- ``Fetcher/APIURL`` +- ``Fetcher/FetcherEnvironment`` + +### Models +- ``Fetcher/Token`` -- ``Symbol`` +### Error Handling +- ``Fetcher/APIError`` diff --git a/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo.png b/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo.png new file mode 100644 index 0000000..3242d30 Binary files /dev/null and b/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo.png differ diff --git a/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo@2x.png b/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo@2x.png new file mode 100644 index 0000000..a9be64f Binary files /dev/null and b/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo@2x.png differ diff --git a/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo@3x.png b/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo@3x.png new file mode 100644 index 0000000..f2b3464 Binary files /dev/null and b/Sources/Fetcher/Fetcher.docc/Resources/Fetcher-logo@3x.png differ diff --git a/Sources/Fetcher/Fetcher.swift b/Sources/Fetcher/Fetcher.swift index d7b3b38..14cd80f 100644 --- a/Sources/Fetcher/Fetcher.swift +++ b/Sources/Fetcher/Fetcher.swift @@ -1,6 +1,6 @@ // // Fetcher.swift -// +// // // Created by Ilian Konchev on 13.02.20. // Copyright © 2020 Ilian Konchev. All rights reserved. @@ -10,16 +10,36 @@ import Combine import Foundation import os -/// API Client +/// A robust API client responsible for performing network requests, handling responses, +/// and managing authentication tokens. +/// +/// `Fetcher` simplifies interaction with RESTful APIs by providing methods for executing +/// `URLRequest` instances, decoding responses into Swift types, and intelligently +/// managing common API concerns such as network availability, HTTP errors, +/// token expiration, and retries. It leverages a `FetcherEnvironment` to +/// abstract away dependencies like `URLSession` and token management logic, +/// making it highly testable and configurable. public struct Fetcher: Sendable { + /// The environment in which the `Fetcher` operates. + /// + /// This property holds an instance conforming to `FetcherEnvironment`, which provides + /// essential dependencies such as the `URLSession` for network requests, + /// a mechanism for logging API errors, and functions for token management + /// (getting, refreshing, and logging out). This allows the `Fetcher` to be + /// configured and tested independently of its environment. public let environment: FetcherEnvironment /// Create an instance of the client - /// - Parameter environment: the ``Fetcher/Environment`` the client will operate in + /// - Parameter environment: the ``Fetcher/FetcherEnvironment`` the client will operate in public init(environment: FetcherEnvironment) { self.environment = environment } + /// Parses a `ServerErrorResponse` from data and throws the corresponding `APIError`. + /// - Parameters: + /// - data: The `Data` received from the server. + /// - httpResponse: The `HTTPURLResponse` received. + /// - Throws: An `APIError` extracted from the response data, or a generic `httpError` if data is nil. func parseError(from data: Data?, response httpResponse: HTTPURLResponse) throws { guard let data = data else { throw APIError.httpError(code: httpResponse.statusCode) @@ -33,6 +53,9 @@ public struct Fetcher: Sendable { } } + /// Parses a `TokenErrorResponse` from data and returns the corresponding `APIError`. + /// - Parameter data: The `Data` received from the server, which might contain a token error. + /// - Returns: The `APIError` extracted from the response data, or `.badRequest` if data is nil. func tokenError(from data: Data?) -> APIError { guard let data = data else { return APIError.badRequest @@ -46,21 +69,31 @@ public struct Fetcher: Sendable { } } + /// Converts a generic `Error` into an `APIError`. + /// + /// This function provides detailed parsing for `DecodingError` to generate a descriptive + /// `.parserError`. It preserves existing `APIError` types and wraps other errors in a generic + /// `.apiError`. + /// - Parameter error: The `Error` to be handled. + /// - Returns: A specific `APIError`. func handleError(_ error: Error) -> APIError { if let error = error as? DecodingError { var errorToReport = error.localizedDescription switch error { case .dataCorrupted(let context): - let details = context.underlyingError?.localizedDescription ?? - context.codingPath.map(\.stringValue).joined(separator: ".") + let details = + context.underlyingError?.localizedDescription + ?? context.codingPath.map(\.stringValue).joined(separator: ".") errorToReport = "\(context.debugDescription) - (\(details))" case let .keyNotFound(key, context): - let details = context.underlyingError?.localizedDescription ?? - context.codingPath.map(\.stringValue).joined(separator: ".") + let details = + context.underlyingError?.localizedDescription + ?? context.codingPath.map(\.stringValue).joined(separator: ".") errorToReport = "\(context.debugDescription) - (key: \(key), \(details))" case let .typeMismatch(type, context), let .valueNotFound(type, context): - let details = context.underlyingError?.localizedDescription ?? - context.codingPath.map(\.stringValue).joined(separator: ".") + let details = + context.underlyingError?.localizedDescription + ?? context.codingPath.map(\.stringValue).joined(separator: ".") errorToReport = "\(context.debugDescription) - (type: \(type), \(details))" @unknown default: return APIError.unknown diff --git a/Sources/Fetcher/Models/APIError.swift b/Sources/Fetcher/Models/APIError.swift index 292267b..15018a1 100644 --- a/Sources/Fetcher/Models/APIError.swift +++ b/Sources/Fetcher/Models/APIError.swift @@ -1,6 +1,6 @@ // // APIError.swift -// +// // // Created by Ilian Konchev on 13.02.20. // Copyright © 2020 Ilian Konchev. All rights reserved. @@ -8,22 +8,38 @@ import Foundation +/// Represents various errors that can occur during API interactions. public enum APIError: Error, LocalizedError, Sendable { + /// An API-specific error with a given reason. case apiError(reason: String) + /// The request was malformed or invalid. case badRequest + /// The input provided was empty. case emptyInput + /// An HTTP error with a specific status code. case httpError(code: Int) + /// No network connection is available. case noConnectionAvailable + /// An error occurred during data parsing. case parserError(reason: String) + /// Protected data is not accessible. case protectedDataUnavailable + /// A server-side error with a given reason. case serverError(reason: String) + /// An error related to token handling, with a reason and an error code. case tokenError(reason: String, code: String) + /// The request was unauthorized. case unauthorized + /// An unknown error occurred. case unknown + /// The received data was in an unknown format. case unknownData + /// The request type was unknown. case unknownRequest + /// The user has been logged out. case userLoggedOut + /// A localized description of the error. public var errorDescription: String? { switch self { case let .apiError(reason): diff --git a/Sources/Fetcher/Models/Mock/APIURLMock.swift b/Sources/Fetcher/Models/Mock/APIURLMock.swift index 8a7a80b..133bf52 100644 --- a/Sources/Fetcher/Models/Mock/APIURLMock.swift +++ b/Sources/Fetcher/Models/Mock/APIURLMock.swift @@ -1,6 +1,6 @@ // // APIURLMock.swift -// +// // // Created by Ilian Konchev on 7.12.22. // diff --git a/Sources/Fetcher/Models/Mock/URLProtocolMock.swift b/Sources/Fetcher/Models/Mock/URLProtocolMock.swift index eb094d8..efa4d11 100644 --- a/Sources/Fetcher/Models/Mock/URLProtocolMock.swift +++ b/Sources/Fetcher/Models/Mock/URLProtocolMock.swift @@ -1,6 +1,6 @@ // // URLProtocolMock.swift -// +// // // Created by Ilian Konchev on 6.12.22. // @@ -9,11 +9,13 @@ import Foundation final class URLProtocolMock: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { - let token = Token(accessToken: "abc123", - expiresIn: Int32(Date().addingTimeInterval(3600).timeIntervalSince1970), - refreshToken: "123abc", - refreshExpiresIn: Int32(Date().addingTimeInterval(86_400).timeIntervalSince1970), - tokenType: "bearer") + let token = Token( + accessToken: "abc123", + expiresIn: Int32(Date().addingTimeInterval(3600).timeIntervalSince1970), + refreshToken: "123abc", + refreshExpiresIn: Int32(Date().addingTimeInterval(86_400).timeIntervalSince1970), + tokenType: "bearer" + ) for urlMock in T.allCases { let mapRequest = urlMock.apiURL.request(token: token) if mapRequest?.url == request.url && mapRequest?.httpMethod == request.httpMethod { @@ -29,11 +31,13 @@ final class URLProtocolMock: URLProtocol { } func mockResponse(for request: URLRequest) -> (HTTPURLResponse, Data?, Error?) { - let token = Token(accessToken: "abc123", - expiresIn: Int32(Date().addingTimeInterval(3600).timeIntervalSince1970), - refreshToken: "123abc", - refreshExpiresIn: Int32(Date().addingTimeInterval(86_400).timeIntervalSince1970), - tokenType: "bearer") + let token = Token( + accessToken: "abc123", + expiresIn: Int32(Date().addingTimeInterval(3600).timeIntervalSince1970), + refreshToken: "123abc", + refreshExpiresIn: Int32(Date().addingTimeInterval(86_400).timeIntervalSince1970), + tokenType: "bearer" + ) var mockFileName: String? var wantedResponseCode: Int = 200 @@ -46,18 +50,22 @@ final class URLProtocolMock: URLProtocol { } } guard let mockFileName = mockFileName, - let url = Bundle.module.url(forResource: mockFileName, withExtension: "json"), - let data = try? Data(contentsOf: url) + let url = Bundle.module.url(forResource: mockFileName, withExtension: "json"), + let data = try? Data(contentsOf: url) else { let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil) return (response!, nil, APIError.unknown) } - let response = HTTPURLResponse(url: request.url!, statusCode: wantedResponseCode, httpVersion: nil, headerFields: nil) + let response = HTTPURLResponse( + url: request.url!, + statusCode: wantedResponseCode, + httpVersion: nil, + headerFields: nil + ) return (response!, data, nil) } - override func startLoading() { let (response, data, error) = mockResponse(for: request) if let data = data { diff --git a/Sources/Fetcher/Models/ServerErrorResponse.swift b/Sources/Fetcher/Models/ServerErrorResponse.swift index 71ce116..c4acd4a 100644 --- a/Sources/Fetcher/Models/ServerErrorResponse.swift +++ b/Sources/Fetcher/Models/ServerErrorResponse.swift @@ -1,6 +1,6 @@ // // ServerErrorResponse.swift -// +// // // Created by Ilian Konchev on 20.10.21. // @@ -11,7 +11,7 @@ import Foundation struct ServerErrorResponse: Codable, Sendable { let message: String - /// the Zeplin API error as a ``Fetcher/APIError`` + /// The API error as a ``Fetcher/APIError`` var apiError: APIError { .serverError(reason: message) } diff --git a/Sources/Fetcher/Models/Token.swift b/Sources/Fetcher/Models/Token.swift index 624cd0d..77336fa 100644 --- a/Sources/Fetcher/Models/Token.swift +++ b/Sources/Fetcher/Models/Token.swift @@ -1,13 +1,13 @@ // // Token.swift -// +// // // Created by Ilian Konchev on 31.10.21. // import Foundation -/// A model that represents an access token for the Zeplin API +/// A model that represents an access token for an API public struct Token: Codable, Sendable { /// Access token that allows you to make requests to the API on behalf of a user public let accessToken: String @@ -20,18 +20,20 @@ public struct Token: Codable, Sendable { /// Type of the token returned public let tokenType: String - /// Create a `ZeplinToken` instance + /// Create a `Token` instance /// - Parameters: /// - accessToken: Access token that allows you to make requests to the API on behalf of a user /// - expiresIn: Access token's lifetime in seconds /// - refreshToken: Refresh token that allows you to obtain access tokens /// - refreshExpiresIn: Refresh token's lifetime in seconds /// - tokenType: Type of the token returned - public init(accessToken: String, - expiresIn: Int32, - refreshToken: String, - refreshExpiresIn: Int32, - tokenType: String) { + public init( + accessToken: String, + expiresIn: Int32, + refreshToken: String, + refreshExpiresIn: Int32, + tokenType: String + ) { self.accessToken = accessToken self.expiresIn = expiresIn self.refreshToken = refreshToken diff --git a/Sources/Fetcher/Models/TokenErrorResponse.swift b/Sources/Fetcher/Models/TokenErrorResponse.swift index dfcc781..4ffecf3 100644 --- a/Sources/Fetcher/Models/TokenErrorResponse.swift +++ b/Sources/Fetcher/Models/TokenErrorResponse.swift @@ -1,6 +1,6 @@ // // TokenErrorResponse.swift -// +// // // Created by Ilian Konchev on 20.10.21. // @@ -13,7 +13,7 @@ struct TokenErrorResponse: Codable, Sendable { let detail: String let message: String - /// the Zeplin API error as a ``Fetcher/APIError`` + /// The API error as a ``Fetcher/APIError`` var apiError: APIError { .tokenError(reason: message + "\n" + detail, code: code) } diff --git a/Sources/Fetcher/Protocols/APIURL.swift b/Sources/Fetcher/Protocols/APIURL.swift index f308cd7..07b45b1 100644 --- a/Sources/Fetcher/Protocols/APIURL.swift +++ b/Sources/Fetcher/Protocols/APIURL.swift @@ -1,6 +1,6 @@ // // APIURL.swift -// ZeplinKit +// Fetcher // // Created by Ilian Konchev on 24.03.20. // Copyright © 2020 Ilian Konchev. All rights reserved. diff --git a/Sources/Fetcher/Protocols/FetcherEnvironment.swift b/Sources/Fetcher/Protocols/FetcherEnvironment.swift index f9cccb3..605aff3 100644 --- a/Sources/Fetcher/Protocols/FetcherEnvironment.swift +++ b/Sources/Fetcher/Protocols/FetcherEnvironment.swift @@ -1,6 +1,6 @@ // // FetcherEnvironment.swift -// +// // // Created by Ilian Konchev on 30.10.21. // diff --git a/Sources/Fetcher/Protocols/FetcherLogger.swift b/Sources/Fetcher/Protocols/FetcherLogger.swift index ff152cd..e32921c 100644 --- a/Sources/Fetcher/Protocols/FetcherLogger.swift +++ b/Sources/Fetcher/Protocols/FetcherLogger.swift @@ -1,6 +1,6 @@ // // FetcherLogger.swift -// +// // // Created by Ilian Konchev on 30.10.21. // diff --git a/Tests/FetcherTests/ErrorHandlingTests.swift b/Tests/FetcherTests/ErrorHandlingTests.swift index 26ef8e0..1622cbe 100644 --- a/Tests/FetcherTests/ErrorHandlingTests.swift +++ b/Tests/FetcherTests/ErrorHandlingTests.swift @@ -16,6 +16,7 @@ struct NumericResponseMock: Codable { let data: Int } +@MainActor final class ErrorHandlingTests: XCTestCase { let fetcher = Fetcher.mock @@ -89,7 +90,7 @@ final class ErrorHandlingTests: XCTestCase { let numericResponse: NumericResponseMock = try await fetcher.fetch(FetcherAPIURL.getData) XCTFail("Response should no be present: \(numericResponse)") } catch let error { - XCTAssertEqual(error.localizedDescription, "Parser error: Expected to decode Int but found a string/data instead. - (type: Int, data)") + XCTAssertEqual(error.localizedDescription, "Parser error: Expected to decode Int but found a string instead. - (type: Int, data)") } }