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
-A lightweight REST API client written in Swift
+A lightweight, modern, and robust REST API client written in Swift.
+[](https://swiftpackageindex.com/Snapp-Mobile/Fetcher)
+[](https://www.apple.com/ios/)
+[](https://github.com/Snapp-Mobile/Fetcher/releases)
+[](https://github.com/Snapp-Mobile/Fetcher/actions)
+[](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)")
}
}