A lightweight, Swift-6-ready networking library designed for modern iOS apps using async/await, clean architecture, and testable abstractions.
🌍 Language · Canonical docs in English · Español · Português (Brasil) · 日本語 · 简体中文 · 한국어 · Русский
- ✅ Native
async/awaitAPI - ✅ Protocol-based, fully mockable networking layer
- ✅ Typed request / response decoding
- ✅ Swift 6 + Swift Concurrency friendly
- ✅ Designed for MVVM / Clean Architecture
- ✅ Zero third-party dependencies
- ✅ Built-in canned response transports for testing
- ✅ Retry with exponential backoff and jitter
- ✅ Transparent token refresh and re-authentication
- ✅ Conditional GET via ETag / If-None-Match / 304
💬 Join the discussion. Feedback and questions welcome
A runnable SwiftUI demo app is included in this repository using a local package reference.
- Clone the repository:
git clone https://github.com/gentle-giraffe-apps/GentleNetworking.git
- Open the demo project:
Demo/GentleNetworkingDemo/GentleNetworkingDemo.xcodeproj - Select an iOS 17+ simulator.
- Build & Run (⌘R).
The project is preconfigured with a local Swift Package reference to GentleNetworking and should run without any additional setup.
- Open your project in Xcode
- Go to File → Add Packages...
- Enter the repository URL:
https://github.com/gentle-giraffe-apps/GentleNetworking.git - Choose a version rule (or
mainwhile developing) - Add the GentleNetworking product to your app target
Add the dependency to your Package.swift:
dependencies: [
.package(url: "https://github.com/gentle-giraffe-apps/GentleNetworking.git", from: "1.0.0")
]Then add "GentleNetworking" to the target that needs it:
.target(
name: "YourApp",
dependencies: ["GentleNetworking"]
)This project enforces quality gates via CI and static analysis:
- CI: All commits to
mainmust pass GitHub Actions checks - Static analysis: DeepSource runs on every commit to
main.
The badge indicates the current number of outstanding static analysis issues. - Test coverage: Codecov reports line coverage for the
mainbranch
These checks are intended to keep the design system safe to evolve over time.
GentleNetworking is centered around a single, protocol-driven HTTPNetworkService that coordinates requests using injected endpoint, environment, and authentication abstractions.
flowchart TB
HTTP["HTTPNetworkService<br/><br/>- request(...)"]
Endpoint["EndpointProtocol<br/><br/><br/>"]
Env["APIEnvironmentProtocol<br/><br/><br/>"]
Auth["AuthServiceProtocol<br/><br/><br/>"]
HTTP --> Endpoint
HTTP --> Env
HTTP -->|injected| Auth
flowchart TB
APIEndpoint["APIEndpoint enum<br/><br/>case endpoint1<br/>…<br/>endpointN"]
EndpointProtocol["EndpointProtocol<br/><br/>- path<br/>- method<br/>- query<br/>- body<br/>- requiresAuth"]
APIEndpoint -->|conforms to| EndpointProtocol
import GentleNetworking
let apiEnvironment = DefaultAPIEnvironment(
baseURL: URL(string: "https://api.company.com")
)
nonisolated enum APIEndpoint: EndpointProtocol {
case signIn(username: String, password: String)
case model(id: Int)
case models
var path: String {
switch self {
case .signIn: "/api/signIn"
case .model(let id): "/api/model/\(id)"
case .models: "/api/models"
}
}
var method: HTTPMethod {
switch self {
case .signIn: .post
case .model, .models: .get
}
}
var query: [URLQueryItem]? {
switch self {
case .signIn, .model, .models: nil
}
}
var body: [String: EndpointAnyEncodable]? {
switch self {
case .signIn(let username, let password): [
"username": EndpointAnyEncodable(username),
"password": EndpointAnyEncodable(password)
]
case .model, .models: nil
}
}
var requiresAuth: Bool {
switch self {
case .model, .models: true
case .signIn(username: _, password: _): false
}
}
}let networkService = HTTPNetworkService()SystemKeyChainAuthService is the built-in implementation of AuthServiceProtocol. It stores a Bearer token in the system keychain and automatically attaches it to requests for endpoints where requiresAuth is true.
let keyChainAuthService = SystemKeyChainAuthService()
struct AuthTokenModel: Decodable, Sendable {
let token: String
}
let authTokenModel: AuthTokenModel = try await networkService.request(
to: .signIn(username: "user", password: "pass"),
via: apiEnvironment
)
try await keyChainAuthService.saveAccessToken(
authTokenModel.token
)Use request to decode a single object from the response:
struct Model: Decodable, Sendable {
let id: Int
let property: String
}
let model: Model = try await networkService.request(
to: .model(id: 123),
via: apiEnvironment
)Use requestModels to decode an array of objects from the response:
let models: [Model] = try await networkService.requestModels(
to: .models,
via: apiEnvironment
)GentleNetworking provides a transport-layer abstraction for easy mocking in tests.
Returns a fixed response for any request:
let transport = CannedResponseTransport(
string: #"{"id": 1, "title": "Test"}"#,
statusCode: 200
)
let networkService = HTTPNetworkService(transport: transport)Match requests by method and path pattern for more realistic test scenarios:
let transport = CannedRoutesTransport(routes: [
CannedRoute(
pattern: RequestPattern(method: .get, path: "/api/models"),
response: CannedResponse(string: #"[{"id": 1}]"#)
),
CannedRoute(
pattern: RequestPattern(method: .post, pathRegex: "^/api/model/\\d+$"),
response: CannedResponse(string: #"{"success": true}"#)
)
])
let networkService = HTTPNetworkService(transport: transport)GentleNetworking relies on Apple's App Transport Security (ATS) for transport-layer protection — TLS 1.2+, certificate validation, forward secrecy — all enforced by the OS and enabled by default.
For apps with elevated security requirements, use the built-in PinningTransport with public key or certificate pinning:
import CryptoKit
// Public key pinning (recommended — survives cert renewals)
let service = HTTPNetworkService(
transport: PinningTransport(
pinnedDomains: [
"api.example.com": PublicKeyPinningEvaluator(
pinnedKeyHashes: [primaryKeyHash, backupKeyHash]
)
]
)
)
// Certificate pinning (simpler, breaks on cert renewal)
let service = HTTPNetworkService(
transport: PinningTransport(
pinnedDomains: [
"api.example.com": CertificatePinningEvaluator(
pinnedCertificates: [certDERData]
)
]
)
)Unpinned domains fall through to standard ATS validation. Implement ServerTrustEvaluator for custom trust logic.
See Docs/SECURITY.md for the full guide including best practices, custom evaluators, and alternative approaches.
GentleNetworking provides composable transport wrappers for retry logic and automatic token refresh. Because they are transports, they stack with each other and with PinningTransport.
Retries failed requests with exponential backoff and configurable jitter. By default retries on 429, 500, 503, and network errors — never on 401 or other client errors.
let service = HTTPNetworkService(
transport: RetryTransport(
inner: URLSessionTransport(session: .shared),
policy: RetryPolicy(
maxRetries: 3,
baseDelay: 0.5,
maxDelay: 30.0,
jitter: .full // .full | .equal | .decorrelated
)
)
)Intercepts HTTP 401 responses, refreshes the token via a caller-supplied closure, re-authorizes the original request, and retries it once. Concurrent 401s are serialized so only one refresh occurs.
let service = HTTPNetworkService(
transport: ReauthTransport(
inner: RetryTransport(), // retry transient errors first
authService: keyChainAuthService,
refreshToken: {
// 1. Call your refresh endpoint to obtain a new access credential
// 2. Save the new credential via authService so future requests use it
}
)
)Avoids re-downloading expensive, unchanged resources. On the first GET, the server's ETag header and response body are cached. Subsequent GETs send If-None-Match; if the server replies 304 Not Modified, the cached body is returned without transferring the payload again.
let service = HTTPNetworkService(
transport: ETagTransport(
inner: URLSessionTransport(session: .shared)
)
)Inject a custom ETagStoreProtocol for disk-backed or database-backed persistence:
let service = HTTPNetworkService(
transport: ETagTransport(
inner: URLSessionTransport(session: .shared),
store: MyDiskETagStore()
)
)Place ReauthTransport on the outside, RetryTransport in the middle, and ETagTransport on the inside:
ReauthTransport ← catches 401 after retries are exhausted
└─ RetryTransport ← retries 429/500/503 with backoff + jitter
└─ ETagTransport ← conditional GET via ETag / 304
└─ URLSessionTransport (or PinningTransport)
RetryTransport already skips 401 (defaultShouldRetry returns false), so it passes auth failures straight through to ReauthTransport without wasting retries. ETagTransport sits inside retry so that retried requests still benefit from the cache.
GentleNetworking is built around:
- ✅ Predictability over magic
- ✅ Protocol-driven design
- ✅ Explicit dependency injection
- ✅ Modern Swift concurrency
- ✅ Testability by default
- ✅ Small surface area with strong guarantees
It is intentionally minimal and avoids over-abstracting or hiding networking behavior.
Portions of drafting and editorial refinement in this repository were accelerated using large language models (including ChatGPT, Claude, and Gemini) under direct human design, validation, and final approval. All technical decisions, code, and architectural conclusions are authored and verified by the repository maintainer.
MIT License Free for personal and commercial use.
Built by Jonathan Ritchey Gentle Giraffe Apps Senior iOS Engineer --- Swift | SwiftUI | Concurrency