Skip to content

gentle-giraffe-apps/GentleNetworking

Repository files navigation

GentleNetworking

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) · 日本語 · 简体中文 · 한국어 · Русский

Build Coverage Swift SPM Compatible Platform Commit activity Last commit DeepSource Static Analysis DeepSource


✨ Features

  • ✅ Native async/await API
  • ✅ 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


Demo App

A runnable SwiftUI demo app is included in this repository using a local package reference.

How to Run

  1. Clone the repository:
    git clone https://github.com/gentle-giraffe-apps/GentleNetworking.git
  2. Open the demo project:
    Demo/GentleNetworkingDemo/GentleNetworkingDemo.xcodeproj
    
  3. Select an iOS 17+ simulator.
  4. Build & Run (⌘R).

The project is preconfigured with a local Swift Package reference to GentleNetworking and should run without any additional setup.


📦 Installation (Swift Package Manager)

Via Xcode

  1. Open your project in Xcode
  2. Go to File → Add Packages...
  3. Enter the repository URL: https://github.com/gentle-giraffe-apps/GentleNetworking.git
  4. Choose a version rule (or main while developing)
  5. Add the GentleNetworking product to your app target

Via Package.swift

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"]
)

Quality & Tooling

This project enforces quality gates via CI and static analysis:

  • CI: All commits to main must 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 main branch

Codecov Snapshot
Codecov coverage chart

These checks are intended to keep the design system safe to evolve over time.


Architecture

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
Loading

Endpoint

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
Loading

🚀 Basic Usage

1. Define an API and Endpoints

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
        }
    }
}

2. Create a Network Service

let networkService = HTTPNetworkService()

3. Authenticate if Needed

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
)

4. Request a Model

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
)

5. Request an Array of Models

Use requestModels to decode an array of objects from the response:

let models: [Model] = try await networkService.requestModels(
    to: .models,
    via: apiEnvironment
)

🧪 Testing

GentleNetworking provides a transport-layer abstraction for easy mocking in tests.

CannedResponseTransport

Returns a fixed response for any request:

let transport = CannedResponseTransport(
    string: #"{"id": 1, "title": "Test"}"#,
    statusCode: 200
)

let networkService = HTTPNetworkService(transport: transport)

CannedRoutesTransport

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)

🔒 Security

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.

SSL Certificate Pinning

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.


🔄 Retry & Re-Authentication

GentleNetworking provides composable transport wrappers for retry logic and automatic token refresh. Because they are transports, they stack with each other and with PinningTransport.

RetryTransport

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
        )
    )
)

ReauthTransport

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
        }
    )
)

ETagTransport

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()
    )
)

Stacking Order

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.


🧭 Design Philosophy

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.


🤖 Tooling Note

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.


🔐 License

MIT License Free for personal and commercial use.


👤 Author

Built by Jonathan Ritchey Gentle Giraffe Apps Senior iOS Engineer --- Swift | SwiftUI | Concurrency

Visitors

About

A lightweight, modern Swift network layer built on Swift 6 concurrency and best practices.

Resources

License

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages