brainstate

3lvis/Networking · source

Snapshot 0 · 2026-11-04T12:47:23Z · Swift · MIT

Content fingerprint94447ad0b7b6d908a9c950be75c2ccd81d7c1e1a228f2421fe3f497f6c9ee3f8

GitHub description at capture

Swift HTTP Networking with stubbing and caching support

README at capture

![Networking](https://raw.githubusercontent.com/3lvis/Networking/1702d0e4575947ad12583b8f94a5ba1953804efc/.github/cover-v3.png)

**Networking** was born out of the necessity of having a networking library that has a straightforward API that supports faking requests and caching images out of the box.

- Friendly API
- Singleton free
- Dependency-free
- Minimal implementation
- Fully unit tested
- Simple request cancellation
- Fake requests easily (mocking/stubbing)
- Flexible caching
- Image downloading

## Table of Contents

* [Choosing a configuration](#choosing-a-configuration)
* [Changing request headers](#changing-request-headers)
* [Authenticating](#authenticating)
    * [HTTP basic](#http-basic)
    * [Bearer token](#bearer-token)
    * [Custom authentication header](#custom-authentication-header)
* [Making a request](#making-a-request)
  * [The basics](#the-basics)
  * [The Result type](#the-result-type)
  * [Handling errors](#handling-errors)
  * [Typed request bodies](#typed-request-bodies)
* [Choosing how the body is encoded](#choosing-how-the-body-is-encoded)
    * [JSON](#json)
    * [URL-encoding](#url-encoding)
    * [Multipart](#multipart)
    * [Raw data](#raw-data)
    * [Query items](#query-items)
* [Cancelling a request](#cancelling-a-request)
* [Faking a request](#faking-a-request)
* [Downloading and caching an image](#downloading-and-caching-an-image)
* [Observing requests](#observing-requests)
* [Updating the Network Activity Indicator](#updating-the-network-activity-indicator)
* [Installing](#installing)
* [Author](#author)
* [License](#license)
* [Attribution](#attribution)

## Choosing a configuration

Initializing an instance of **Networking** means you have to select a [URLSessionConfiguration](https://developer.apple.com/documentation/foundation/urlsessionconfiguration). The available types are `.default`, `.ephemeral` and `.background`, if you don't provide any or don't have special needs then `default` will be used.

 - `.default`: The default session configuration uses a persistent disk-based cache (except when the result is downloaded to a file) and stores credentials in the user’s keychain.

- `.ephemeral`: Keeps everything in RAM — caches, credential stores and session data all live there, and the one thing it writes to disk is a URL you explicitly ask it to save to a file. Privacy is the point: data that stays out of the file system stays out of reach later, which is what makes this the configuration behind private browsing modes.

- `.background`: This configuration type is suitable for transferring data files while the app runs in the background. A session configured with this object hands control of the transfers over to the system, which handles the transfers in a separate process. In iOS, this configuration makes it possible for transfers to continue even when the app itself is suspended or terminated.

```swift
// Default
let networking = Networking(baseURL: "http://example.com")

// Ephemeral
let networking = Networking(baseURL: "http://example.com", configuration: .ephemeral)
```

`Networking` is an `actor`, so it's safe to share one instance across tasks. Its members are accessed with `await` (e.g. `await networking.setAuthorizationHeader(...)`), and it can't be subclassed.

## Changing request headers

You can set the `headerFields` in any networking object.

This sets what `URLSession` sends on each request: a header it already carries is overwritten, and a new one is added.

```swift
await networking.setHeaderFields(["User-Agent": "your new user agent"])
```

## Authenticating

### HTTP basic

To authenticate using [basic authentication](http://www.w3.org/Protocols/HTTP/1.0/spec.html#BasicAA) with a username **"aladdin"** and password **"opensesame"** you only need to do this:

```swift
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(username: "aladdin", password: "opensesame")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/basic-auth/aladdin/opensesame")
// Successfully authenticated!
```

### Bearer token

To authenticate using a [bearer token](https://tools.ietf.org/html/rfc6750) **"AAAFFAAAA3DAAAAAA"** you only need to do this:

```swift
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(token: "AAAFFAAAA3DAAAAAA")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
// Successfully authenticated!
```

### Custom authentication header

To authenticate using a custom authentication header, for example **"Token token=AAAFFAAAA3DAAAAAA"** you would need to set the following header field: `Authorization: Token token=AAAFFAAAA3DAAAAAA`. Luckily, **Networking** provides a simple way to do this:

```swift
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(headerValue: "Token token=AAAFFAAAA3DAAAAAA")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
// Successfully authenticated!
```

Providing the following authentication header `Anonymous-Token: AAAFFAAAA3DAAAAAA` is also possible:

```swift
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(headerKey: "Anonymous-Token", headerValue: "AAAFFAAAA3DAAAAAA")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
// Successfully authenticated!
```

### Refreshing an expired credential

When a token expires, the server answers `401`/`403`. Rather than failing that request, register an `AuthRefreshInterceptor`: on an unauthorized response it runs your `refresh` closure, then **replays** the request once with the new credential. Return `nil` from `refresh` to give up and let the original failure through.

```swift
let networking = Networking(baseURL: "http://example.com")
await networking.setInterceptors([
    AuthRefreshInterceptor {
        let token = try await myAuth.refreshAccessToken()   // your refresh call
        await networking.setAuthorizationHeader(token: token) // keep future requests authed
        return "Bearer \(token)"                            // …and replay this one with it
    }
])
```

Concurrent requests that all hit `401` at once share a **single** refresh — they wait on the one in flight, so a single token refresh serves them all. For pure *notification* of a `401`, where a replay is beside the point, [`events()`](#observing-requests) already carries it as a `.completed(_, .failure)` with `error.statusCode == 401`, which is enough on its own.

`AuthRefreshInterceptor` is one implementation of the general `HTTPInterceptor` seam — an async `intercept(_:next:)` hook that wraps every verb request. Calling `next` runs the rest of the chain (the innermost being the real network call); calling it again replays.

### Retrying transient failures

`RetryInterceptor` retries a request when it fails transiently — a dropped connection or timeout, or an HTTP status in `retryableStatusCodes` (`408`/`429`/`500`/`502`/`503`/`504` by default, the same set behind [`NetworkingError.isRetryable`](#handling-errors)). It backs off exponentially with full jitter between attempts (capped at `maxDelay`) and honors a server's `Retry-After` header (seconds or HTTP-date) when present.

```swift
await networking.setInterceptors([
    RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(500), maxDelay: .seconds(30))
])
```

**Only idempotent methods are retried by default** (`GET`/`HEAD`/`PUT`/`DELETE`/`OPTIONS`/`TRACE`). Retrying a `POST`/`PATCH` after a timeout or 5xx could duplicate a side effect — a second charge, a duplicate mutation — because the server may have already processed the first attempt. If a non-idempotent endpoint is safe to retry (e.g. it takes an idempotency key), opt it in explicitly:

```swift
RetryInterceptor(retryableMethods: ["GET", "HEAD", "PUT", "DELETE", "POST"])
```

Interceptors run outermost-first, so order matters: put `RetryInterceptor` **before** `AuthRefreshInterceptor` to retry around a refreshed credential, or after to refresh around each retry.

```swift
await networking.setInterceptors([
    RetryInterceptor(),                                   // outer: retries the whole thing
    AuthRefreshInterceptor { "Bearer \(try await refresh())" },  // inner: refreshes on 401
])
```

Interceptors apply to **downloads** (`downloadImage`/`downloadData`) as well as the verbs.

### Validating the response

A `2xx` can still be the *wrong* response — the server might return the wrong content-type or a malformed envelope. `ResponseValidatorInterceptor` runs your check on each successful response and turns a failure into a typed `NetworkingError.validation(reason:_:)` (carrying the response metadata). Everything outside 2xx passes through untouched, so a real `500` still surfaces as `.http`.

```swift
await networking.setInterceptors([
    ResponseValidatorInterceptor { exchange in
        let contentType = exchange.response.value(forHTTPHeaderField: "Content-Type") ?? ""
        return contentType.hasPrefix("application/json")
            ? .valid
            : .invalid(reason: "expected JSON, got \(contentType)")
    }
])
```

Register it outermost (before `RetryInterceptor`) so it validates the final, post-retry response. The cache sits beneath the interceptors, so **cache hits are validated too** — a response cached before the validator existed can't slip through.

## Making a request

### The basics

Making a request is as simple as just calling `get`, `post`, `put`, or `delete`.

**GET example**:

```swift
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
switch result {
case .success(let response):
    let body = response.body // [String: AnyCodable]
    let statusCode = response.statusCode
case .failure(let error):
    // Handle error
}
```

**POST example**:

```swift
struct Credentials: Encodable { let username: String; let password: String }

let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/post", body: Credentials(username: "jameson", password: "secret"))
// On success, response.body holds the echoed JSON below.
 /*
 {
     "json" : {
         "username" : "jameson",
         "password" : "secret"
     },
     "url" : "http://example.com/post",
     "data" : "{"password" : "secret","username" : "jameson"}",
     "headers" : {
         "Accept" : "application/json",
         "Content-Type" : "application/json",
         "Host" : "example.com",
         "Content-Length" : "44",
         "Accept-Language" : "en-us"
     }
 }
 */
```

You can get the response headers and status code inside the success.

```swift
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
switch result {
case .success(let response):
    let headers = response.headers // [String: AnyCodable]
    let statusCode = response.statusCode // Int
case .failure(let error):
    // Handle error
}
```

### The Result type

`get` returns Swift's [Result](https://developer.apple.com/documentation/swift/result) with two cases: `.success(let response)` and `.failure(let error)`. The success carries the decoded value, the failure a `NetworkingError`.

`get` is generic over any `Decodable`, so you decode straight into your own model and the JSON digging stays here:

```swift
struct Recipe: Decodable { let title: String }

let networking = Networking(baseURL: "http://fakerecipes.com")
let result: Result<[Recipe], NetworkingError> = await networking.get("/recipes")
switch result {
case .success(let recipes):
    // recipes is [Recipe] — fully typed, no optionals
    print(recipes.map(\.title))
case .failure(let error):
    // error is a NetworkingError — see "Handling errors" below
    print(error.localizedDescription)
}
```

Use `JSONResponse` as the type when you want the raw `statusCode`, `headers`, and `body` instead of a model.

### Handling errors

`NetworkingError` is categorized by *where* the request failed, so you can branch on the cause instead of parsing a message. Each case preserves the underlying error or response:

```swift
switch result {
case .success(let value):
    break
case .failure(let error):
    switch error {
    case .invalidRequest(let reason):
        // Couldn't build/encode the request — a caller-side bug. reason says which.
        break
    case .transport(let urlError):
        // Never reached the server: offline, DNS, TLS, timeout. The underlying URLError is intact.
        break
    case .http(let httpError):
        // A non-2xx response. httpError.statusCode, .isClientError/.isServerError, and .metadata
        // (status, headers, and the full response body — decode it into your own error type).
        break
    case .decoding(let decodingError, let metadata):
        // 2xx but the body didn't match your type. The DecodingError and response metadata are preserved.
        break
    case .validation(let reason, let metadata):
        // 2xx but a ResponseValidatorInterceptor rejected it (wrong content-type, bad envelope, …).
        break
    case .invalidResponse:
        break
    case .cancelled:
        break
    }
}
```

Two conveniences cut across the cases:

```swift
error.statusCode       // Int? — present for .http, .decoding, and .validation
error.responseMetadata // ResponseMetadata? — status, headers, and the full body
error.isRetryable      // Bool — conservative: transient transport failures + HTTP 408/429/5xx
```

`isRetryable` is deliberately conservative: only transport timeouts/connection failures and a small set of status codes (408, 429, 500, 502, 503, 504). It reports a 4xx (other than 408/429), a decoding failure and an invalid request as final.

**The core treats the error body as opaque.** It hands you the complete body in `ResponseMetadata` so you can decode your API's own error envelope into a typed value — `decode(_:)` is a small convenience over `body`:

```swift
struct APIError: Decodable { let errors: [String: [String]] }   // e.g. a Rails/ActiveModel envelope

if case .failure(.http(let httpError)) = result,
   let apiError = try? httpError.metadata.decode(APIError.self) {
    // apiError.errors["start_time"] == ["can't be blank"]
}
// metadata.body is the full bytes; metadata.bodySnippet is a truncated excerpt for logs.
```

### Typed request bodies

Just as the response side is generic over any `Decodable`, the request side is generic over any `Encodable`. `post`, `put`, and `patch` take a `body:` that's JSON-encoded for you and sent with `Content-Type: application/json` — so the body you send is compile-checked, a typed value rather than a `[String: Any]` dictionary:

```swift
struct Credentials: Encodable {
    let username: String
    let password: String
}

let networking = Networking(baseURL: "http://example.com")
let body = Credentials(username: "jameson", password: "secret")

// Decode the response straight into your model…
let result: Result<Account, NetworkingError> = await networking.post("/login", body: body)

// …or ignore it with a Void result when you only care about success/failure.
let ack: Result<Void, NetworkingError> = await networking.put("/account", body: body)
```

`Date`s in the body are encoded as ISO-8601, matching how responses are decoded.

## Choosing how the body is encoded

Each encoding is a distinct, typed method, and the method you call picks the `Content-Type` — one decision, rather than a `parameters:`/`parameterType:` pair to keep in step.

### JSON

The common case: pass any `Encodable` as `body:`. It's serialized with `JSONEncoder` and sent as `application/json`.

```swift
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/post", body: ["name": "jameson"])
// Successful post using `application/json` as `Content-Type`
```

### URL-encoding

Pass `form:` to send `application/x-www-form-urlencoded`; **Networking** percent-encodes it for you ([`Percent-encoding` / `URL-encoding`](https://en.wikipedia.org/wiki/Percent-encoding#The_application.2Fx-www-form-urlencoded_type)). `form:` takes any flat `Encodable` — a `[String: String]` or your own model — and stringifies scalars for you, so a `Bool` becomes `"true"`:

```swift
let networking = Networking(baseURL: "http://example.com")

// A dictionary…
let result: Result<JSONResponse, NetworkingError> = await networking.post("/post", form: ["name": "jameson"])

// …or your own model.
struct SignIn: Encodable { let name: String; let remember: Bool }
let result2: Result<JSONResponse, NetworkingError> = await networking.post("/post", form: SignIn(name: "jameson", remember: true))
// Successful post using `application/x-www-form-urlencoded` as `Content-Type`
```

### Multipart

**Networking** provides a simple model to use `multipart/form-data`. A multipart request consists of appending one or several [FormDataPart](https://github.com/3lvis/Networking/blob/master/Sources/Networking/FormDataPart.swift) items to a request. The simplest multipart request would look like this.

```swift
let networking = Networking(baseURL: "https://example.com")
let imageData = imageToUpload.pngData()!
let part = FormDataPart(data: imageData, parameterName: "file", filename: "selfie.png")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/image/upload", parts: [part])
// Successful upload using `multipart/form-data` as `Content-Type`
```

To send several parts, or string form fields alongside the files, pass `fields:`:

```swift
let networking = Networking(baseURL: "https://example.com")
let part1 = FormDataPart(data: imageData1, parameterName: "file1", filename: "selfie1.png")
let part2 = FormDataPart(data: imageData2, parameterName: "file2", filename: "selfie2.png")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/image/upload", parts: [part1, part2], fields: ["username": "3lvis"])
// Do something
```

**FormDataPart Content-Type**:

Each part's `Content-Type` comes from its `FormDataPartType`, which carries the MIME string. The default is `.octetStream` (`application/octet-stream`); `.png` and `.jpeg` are provided as conveniences. For anything else, construct one directly — `FormDataPart(type: FormDataPartType("application/pdf"), data: …, parameterName: …)`.

### Raw data

To send bytes verbatim under a `Content-Type` you choose, pass `data:contentType:`.

```swift
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/upload", data: imageData, contentType: "application/octet-stream")
// Successful upload using `application/octet-stream` as `Content-Type`
```

### Query items

`get` and `delete` carry their parameters in the URL query string. Pass typed `[URLQueryItem]` when you need ordering or repeated keys, or any flat `Encodable` model:

```swift
let networking = Networking(baseURL: "http://example.com")

// Explicit query items…
let result: Result<JSONResponse, NetworkingError> = await networking.get("/search", query: [URLQueryItem(name: "q", value: "swift")])

// …or a model.
struct Search: Encodable { let q: String; let page: Int }
let result2: Result<JSONResponse, NetworkingError> = await networking.get("/search", query: Search(q: "swift", page: 2))
// GET /search?q=swift&page=2
```

## Cancelling a request

Hold the `Task` running the request and call `cancel()` on it. A cancelled request fails with `NetworkingError.cancelled`.

```swift
let networking = Networking(baseURL: "http://example.com")
let task = Task {
    let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
    // On cancellation this is .failure(.cancelled)
}

// In another place
task.cancel()
```

## Faking a request

Faking a request means that after calling this method on a specific path, any call to this resource, will return what you registered as a response. This technique is also known as mocking or stubbing.

**Faking with successfull response**:

```swift
struct Story: Codable { let id: Int; let title: String }

let networking = Networking(baseURL: "https://example.com")
await networking.fakeGET("/stories", response: [Story(id: 47333, title: "Site Design: Aquest")])
let result: Result<[Story], NetworkingError> = await networking.get("/stories")
// .success carrying the stories
```

**Faking with contents of a file**:

A file outside the main bundle takes the bundle parameter; everything else uses `Bundle.main`.

```swift
let networking = Networking(baseURL: baseURL)
await networking.fakeGET("/entries", fileName: "entries.json")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/entries")
// Response with the contents of entries.json
```

**Faking with status code**:

A fake request defaults to 200 (SUCCESS). Give it a status code outside 2xx and **Networking** returns `.failure(.http(HTTPError))` carrying that status code, with the fake's body in `metadata` for you to `decode` — see [Handling errors](#handling-errors).

Omit `response:` for a status-code-only fake:

```swift
let networking = Networking(baseURL: "https://example.com")
await networking.fakeGET("/stories", statusCode: 500)
let result: Result<JSONResponse, NetworkingError> = await networking.get("/stories")
// .failure with status code 500
```

## Downloading and caching an image

**Downloading**:

```swift
let networking = Networking(baseURL: "http://example.com")
let result: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
switch result {
case .success(let image):
    // Do something with the downloaded image
case .failure(let error):
    // Handle error
}
```

Ask for `ImageResponse` instead of `Image` (or `DataResponse`/`Data` from `downloadData`) when you also need the response's `statusCode` and `headers`:

```swift
let result: Result<ImageResponse, NetworkingError> = await networking.downloadImage("/image/png")
// response.image, response.statusCode, response.headers
```

**Cancelling**:

```swift
let networking = Networking(baseURL: baseURL)
let task = Task {
    let result: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
    // On cancellation this is .failure(.cancelled)
}

// In another place
try await networking.cancelImageDownload("/image/png")
```

**Caching**:

**Networking** uses a multi-cache architecture when downloading images, the first time the `downloadImage` method is called for a specific path, it will store the results on disk (Caches directory) and in memory (`NSCache`), so in the next call it will return the cached results without hitting the network.

```swift
let networking = Networking(baseURL: "http://example.com")
let _: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
// Image from network

let _: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
// Image from cache
```

**Clearing and expiring the cache.** Call `await networking.clearCache()` to empty it — **both** the in-memory layer and the on-disk files. (`reset()` does the same, plus wiping credentials.) `clearCache()` takes the whole cache at once: it clears everything, and stale entries expire on their own (below). For a download, `downloadImage`/`downloadData` with `cachingLevel: .none` force a fresh fetch and drop that cached copy; for the verbs, `.none` bypasses the cache and is the default, so an entry you cached deliberately stays where it is.

On-disk entries expire on their own, so a long-lived cache stays bounded. An entry whose **on-disk last use** is older than `cacheTTL` (default **7 days**; set it via `init(…, cacheTTL:)` or `setCacheTTL(_:)`) is swept. The persisted clock is the cache file's modification date, refreshed by **disk reads and writes** — caching an entry, or reading one back after it's left the in-memory layer, re-warms it; only genuinely idle entries are removed. The in-memory layer is the warm tier (served fast, left to `NSCache`'s own memory-pressure eviction) and leaves the disk file's date alone on a hit, so an entry kept warm *only* in memory for longer than `cacheTTL` may be re-fetched once it's evicted — in normal use it would have hit disk again first and stayed warm.

> **This is a key→blob store rather than HTTP caching.** `cachingLevel` caches the downloaded bytes *unconditionally* by path (or `cacheName:`), ignoring `Cache-Control`/`ETag`/`Expires` — which is what you usually want for images and other assets whose hosts often omit cache headers entirely. HTTP cache semantics — freshness, conditional `304` revalidation, eviction — are deliberately out of scope. For those, configure a `URLCache` on the session and let `URLSession` handle it per the response headers — it composes with the verb requests:
>
> ```swift
> let configuration = URLSessionConfiguration.default
> configuration.urlCache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 50_000_000)
> configuration.requestCachePolicy = .useProtocolCachePolicy
> let networking = Networking(baseURL: "https://example.com", configuration: configuration)
> ```

**Faking**:

```swift
let networking = Networking(baseURL: baseURL)
let pigImage = UIImage(named: "pig.png")!
await networking.fakeImageDownload("/image/png", image: pigImage)
let result: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
// Here you'll get the provided pig.png image
```

## Observing requests

**Networking** doesn't print to the console. Two separate things give you visibility: a **failure log** that's on by default (next section), and an **event stream** you can hook for the full lifecycle.

`events()` returns an `AsyncStream<NetworkingEvent>` — one `.started` then one `.completed` for **every** request (verbs *and* downloads). Iterate it with `for await`, accumulating into plain local state:

```swift
let stream = await networking.events()
Task {
    for await event in stream {
        switch event {
        case let .started(context):
            print("→ [\(context.id)] \(context.method) \(context.url?.absoluteString ?? "")")
        case let .completed(context, outcome, duration, metrics):
            switch outcome {
            case let .success(statusCode, byteCount):
                print("← [\(context.id)] \(statusCode) (\(byteCount) bytes) in \(duration)")
            case let .failure(error):
                print("✗ [\(context.id)] \(error.localizedDescription) — retryable: \(error.isRetryable)")
            }
            if let metrics { print("   DNS \(metrics.domainLookup ?? 0)s · TLS \(metrics.secureConnection ?? 0)s") }
        }
    }
}
```

- `RequestContext` carries a unique `id` (shared by the request's `.started`/`.completed`, and stamped into the failure logs), plus `method`, `url`, and the real request `headers` (events() is your own data — see *Privacy* below for why these aren't redacted).
- `.completed` carries the `Outcome` (`.success(statusCode:byteCount:)` / `.failure(NetworkingError)`), the measured `duration`, and — for real network requests — `TransactionMetrics` distilled from `URLSessionTaskMetrics` (DNS / connect / TLS / request / response timings, byte counts, redirect count, cache hit).
- Each `events()` call returns its own stream, so multiple consumers can listen independently.

**Which headers count as sensitive.** `Authorization`, the active auth-header key, `Cookie`, and `Set-Cookie` are the default redaction set — replaced with `<redacted>` in the built-in **logs** when redaction is on (release; see *Privacy* below). `events()` is unaffected. Change the set:

```swift
await networking.setRedactedHeaderFields(["Authorization", "X-Api-Key"])
```

### Built-in logging

Straight out of the box the library logs **failures** (HTTP 4xx/5xx, decoding, transport, invalid-request) to Apple's unified logging (`os.Logger`, subsystem `com.elvisnunez.networking`), tagged with the request id. This is the modern replacement for console `print`: it appears automatically in the Xcode console and Console.app, is filterable, and honors privacy annotations.

`setLogLevel` chooses **which requests** are logged — logged requests always get full detail (line, request + response headers, request + response bodies, truncated):

```swift
await networking.setLogLevel(.none)      // nothing
await networking.setLogLevel(.failures)  // default — every failure, full detail
await networking.setLogLevel(.all)       // every request too, success or failure — the opt-in firehose
```

`.failures` is the default: failures are rare, so logging them in full is cheap and it's the case you debug — including the **request body**, the quickest way to catch a wrong-shaped payload. `.all` adds successful requests (with their response body, for "succeeded but returned the wrong thing"). The level gates *only* the built-in logging — `events()` always delivers full structured events regardless. (Downloads — `downloadImage`/`downloadData` — log the line + request headers; their response headers/body are omitted since the payload is binary.)

**Privacy — one rule: debug shows, release redacts.** Requests carry sensitive data (logins, payments, profiles, `Authorization`/`Cookie` headers). `redactsLogs` governs the built-in **logs**: in **debug** builds it shows everything (you're debugging — "is my auth header set?" must be answerable); in **release** it replaces both the body lines *and* the `setRedactedHeaderFields` header values (`Authorization`/`Cookie`/`Set-Cookie` by default) with `<redacted>`. Override either way with `setRedactsLogs(_:)`. One boundary worth knowing: `events()` always carries the **real** headers — it's your own request data, and redaction is a logging concern rather than an observation one. For more control, `setLogLevel(.none)` or filter `events()` yourself.

**Reading logs from a CLI / test / headless run.** `os.Logger` isn't visible in `swift test` / `swift run` stdout. Point the library at a file and it mirrors the same diagnostics there as plain text:

```swift
await networking.setLogFileURL(URL(fileURLWithPath: "/tmp/networking.log"))
```

Or set it from the environment with **`NETWORKING_LOG_FILE`**, leaving the code untouched — handy for CI or an automated agent:

```shell
NETWORKING_LOG_FILE=/tmp/networking.log swift test
cat /tmp/networking.log
# 2026-06-14T20:09:58Z → GET /get [F97CE6EB-…]
# 2026-06-14T20:09:58Z ← 200 (452 bytes) in 0.022s [F97CE6EB-…]
# 2026-06-14T20:09:58Z ✗ GET …/status/404 [C3569F63-…] failed: The server returned status 404 (not found).
```

`NETWORKING_LOG_FILE` accepts an absolute path **or a bare filename** — a bare name resolves under the app's Caches directory (sandbox-safe; the same default location CocoaLumberjack uses). That makes it work inside a **running app on the simulator**, where you inject it at launch and read it back from the app's container:

```shell
# simctl forwards SIMCTL_CHILD_-prefixed vars into the app (prefix stripped)
xcrun simctl launch SIMCTL_CHILD_NETWORKING_LOG_FILE=networking.log <device> <bundle-id>
dir=$(xcrun simctl get_app_container <device> <bundle-id> data)
cat "$dir/Library/Caches/networking.log"
```

(A physical device has Console.app / `os.Logger` in place of `simctl`.)

## Installing

**Networking** is distributed through Swift Package Manager. It requires **Swift 6.2+** and **iOS 18 / macOS 15 / tvOS 18 / watchOS 11**.

In your `Package.swift`:

```swift
.package(url: "https://github.com/3lvis/Networking.git", from: "8.0.0")
```

…and add `"Networking"` to your target's dependencies. In Xcode, use **File ▸ Add Package Dependencies…** and enter `https://github.com/3lvis/Networking.git`.

> **Upgrading from 7.x?** 8.0.0 is a major, breaking release (async/`await` + `Result`, Swift 6, typed request bodies, a categorized error model, an event stream, and request interceptors). See the [release notes](https://github.com/3lvis/Networking/releases) for the full migration.

## Running the tests

The integration tests exercise the real HTTP stack against a local [go-httpbin](https://github.com/mccutchen/go-httpbin) — the same backend CI uses. With [Docker](https://www.docker.com) installed:

```shell
make test   # starts go-httpbin, runs the suite, tears it down
```

To iterate, start it once and run the suite directly:

```shell
make httpbin      # leaves go-httpbin running on :8080
swift test
make httpbin-stop
```

Tests default to `http://127.0.0.1:8080`; set `HTTPBIN_BASE_URL` to point elsewhere. The offline (faked) suites run on their own.

## Author

This library was made with love by [@3lvis](https://twitter.com/3lvis).


## License

**Networking** is available under the MIT license. See the [LICENSE file](https://github.com/3lvis/Networking/blob/master/LICENSE) for more info.


## Attribution

The logo typeface comes thanks to [Sanid Jusić](https://dribbble.com/shots/1049674-Free-Colorfull-Triangle-Typeface).