Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,50 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `AuthToken` model with `accessToken`, `expiration`, `isExpired`, and `expiresSoon(threshold:)`
- `TokenProvider` protocol (`validToken()`, `forceRefresh()`)
- `RefreshingTokenProvider` actor with proactive refresh (expired / expiring soon), concurrent refresh deduplication, and forced refresh on demand
- `TokenStore.clear()` to remove the stored token

### Changed

- `AuthMiddleware` now takes a `TokenProvider` instead of `TokenStore` + refresh closure
- `AuthMiddleware` calls `forceRefresh()` on `401` and retries the request once
- `TokenStore` stores `AuthToken?` instead of `String`; `get()` returns an optional `AuthToken`
- Refresh closures used by `RefreshingTokenProvider` return `AuthToken` instead of `String`

### Migration from 0.1.0

**Before:**

```swift
let tokenStore = TokenStore(token: "initial-token")

AuthMiddleware(
tokenStore: tokenStore,
refresh: { try await fetchNewToken() } // String
)
```

**After:**

```swift
let store = TokenStore(token: AuthToken(
accessToken: "initial-token",
expiration: expiresAt
))

let provider = RefreshingTokenProvider(store: store) {
try await fetchNewToken() // AuthToken
}

AuthMiddleware(tokenProvider: provider)
```

## [0.1.0] - 2026-05-20

### Added
Expand All @@ -18,7 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `EmptyResponse` for 204 / no-body endpoints
- Unified `NetworkError` (`transport`, `decoding`, `endpoint`) mapped by `APIClient`
- Middleware chain with `next` transport closure pattern
- `AuthMiddleware` with Bearer token injection and refresh on 401
- `AuthMiddleware` with Bearer token injection and refresh on 401 (superseded in Unreleased by `TokenProvider`-based auth)
- `RetryMiddleware` and `RetryPolicy` (HTTP-status based retries with `Duration` delay)
- `LoggingMiddleware` for request URL, method, headers, status, and duration
- `MockMiddleware` and `MockRegistry` for test doubles without real network calls
Expand All @@ -32,4 +76,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- macOS 13+
- Swift 5.9+

[Unreleased]: https://github.com/maumar2000/TypedNetwork/compare/0.1.0...HEAD
[0.1.0]: https://github.com/maumar2000/TypedNetwork/releases/tag/0.1.0
86 changes: 74 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ import TypedNetwork
- ✅ `EmptyResponse` for 204 / no-body endpoints
- ✅ Middleware chain with `next` transport closure
- ✅ `HTTPBody` with JSON and raw data support
- ✅ `AuthMiddleware` with token refresh on 401
- ✅ `AuthMiddleware` with `TokenProvider` (proactive refresh + retry on 401)
- ✅ `AuthToken`, `TokenStore`, and `RefreshingTokenProvider` for token lifecycle
- ✅ `RetryMiddleware` with configurable `RetryPolicy` (HTTP-status based)
- ✅ `LoggingMiddleware` for request/response diagnostics
- ✅ `MockMiddleware` and `MockRegistry` for test doubles
Expand Down Expand Up @@ -146,7 +147,7 @@ let client = APIClient(
mockRegistry: nil, // optional, for tests
middlewares: [
LoggingMiddleware(),
AuthMiddleware(tokenStore: tokenStore, refresh: refreshToken),
AuthMiddleware(tokenProvider: tokenProvider),
RetryMiddleware(policy: .default)
],
requestModifiers: [UserAgentModifier()], // optional
Expand Down Expand Up @@ -312,7 +313,7 @@ let client = APIClient(
baseURL: baseURL,
middlewares: [
LoggingMiddleware(),
AuthMiddleware(tokenStore: tokenStore, refresh: refreshToken),
AuthMiddleware(tokenProvider: tokenProvider),
RetryMiddleware(policy: RetryPolicy(
maxRetries: 2,
shouldRetry: { (500...599).contains($0.statusCode) }
Expand All @@ -325,24 +326,66 @@ Retry decisions are based on `HTTPURLResponse`, not thrown errors.

---

## 🔐 8. AuthMiddleware
## 🔐 8. Authentication

Adds a Bearer token and retries once after refreshing on `401`.
Auth is split into a **token provider** (when to refresh) and **`AuthMiddleware`** (how to attach the token and react to `401`).

### AuthToken & TokenStore

`AuthToken` carries the access token and its expiration. `TokenStore` is an actor that holds the current token in memory.

```swift
let tokenStore = TokenStore(token: "initial-token")
let token = AuthToken(
accessToken: "eyJ…",
expiration: Date().addingTimeInterval(3600)
)

let store = TokenStore(token: token)

await store.set(newToken)
await store.clear()
```

`AuthToken` exposes `isExpired` and `expiresSoon(threshold:)` (default threshold: 60 seconds) for proactive refresh.

### TokenProvider & RefreshingTokenProvider

`TokenProvider` is the abstraction `AuthMiddleware` uses:

| Method | Role |
|--------|------|
| `validToken()` | Returns a token valid for the next request; refreshes when missing, expired, or expiring soon |
| `forceRefresh()` | Always fetches a new token (used after `401`) |

`RefreshingTokenProvider` implements both methods: it reads from `TokenStore`, calls your refresh closure, deduplicates concurrent `validToken()` refreshes, and bypasses coalescing on `forceRefresh()` so a server `401` always triggers a new fetch.

```swift
let provider = RefreshingTokenProvider(store: store) {
let response = try await authService.refresh()
return AuthToken(
accessToken: response.accessToken,
expiration: response.expiresAt
)
}
```

Implement `TokenProvider` yourself when you need a custom strategy (e.g. static token for tests).

### AuthMiddleware

Injects `Authorization: Bearer …` on every request. On `401`, calls `forceRefresh()` and retries the request **once** with the new token.

```swift
let client = APIClient(
baseURL: baseURL,
middlewares: [
AuthMiddleware(
tokenStore: tokenStore,
refresh: { try await fetchNewToken() }
)
AuthMiddleware(tokenProvider: provider)
]
)
```

If the retry also returns `401`, that response is returned as-is (no further auth retries).

---

## 🔁 9. RetryMiddleware
Expand Down Expand Up @@ -482,6 +525,9 @@ let (data, response) = try await middleware.intercept(request) { _ in
| `HTTPBody` | Encodes body and provides content type |
| `Middleware` | Intercepts/modifies requests and responses |
| `RetryPolicy` | Configures retry count, delay, and retry predicate |
| `AuthToken` / `TokenStore` | Token model and in-memory storage |
| `TokenProvider` / `RefreshingTokenProvider` | Valid token + refresh lifecycle |
| `AuthMiddleware` | Bearer header injection and 401 retry |
| `NetworkSession` | Executes HTTP transport |
| `ResponseDecoder` | Decodes successful responses |
| `NetworkError` | Unified error surface from `APIClient` |
Expand Down Expand Up @@ -510,13 +556,17 @@ struct GetProfile: Endpoint {
}
}

let tokenStore = TokenStore(token: accessToken)
let store = TokenStore(token: initialAuthToken)

let tokenProvider = RefreshingTokenProvider(store: store) {
try await refreshAccessToken() // must return AuthToken
}

let client = APIClient(
baseURL: URL(string: "https://api.myapp.com")!,
middlewares: [
LoggingMiddleware(),
AuthMiddleware(tokenStore: tokenStore, refresh: refreshAccessToken),
AuthMiddleware(tokenProvider: tokenProvider),
RetryMiddleware(policy: RetryPolicy(
maxRetries: 2,
delay: .seconds(1),
Expand All @@ -539,6 +589,18 @@ do {

## 📌 What's Next

- [x] Typed errors per endpoint
- [x] Unified `NetworkError`
- [x] Mock registry and `MockMiddleware`
- [x] Pluggable response decoding
- [x] `EmptyResponse` support
- [x] Transport abstraction (`NetworkSession`)
- [x] `RequestModifier`
- [x] Per-endpoint timeout
- [x] `AuthMiddleware`
- [x] `AuthToken`, `TokenProvider`, and `RefreshingTokenProvider`
- [x] `RetryMiddleware`
- [x] `LoggingMiddleware`
- [ ] CacheMiddleware

---
Expand Down
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/APIClient.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

actor APIClient {

Expand Down
33 changes: 22 additions & 11 deletions Sources/TypedNetwork/AuthMiddleware.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,48 @@
//

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct AuthMiddleware: Middleware {

private let tokenStore: TokenStore
private let refresh: @Sendable () async throws -> String
private let tokenProvider: any TokenProvider

public init(
tokenStore: TokenStore,
refresh: @escaping @Sendable () async throws -> String
tokenProvider: any TokenProvider
) {
self.tokenStore = tokenStore
self.refresh = refresh
self.tokenProvider = tokenProvider
}

public func intercept(
_ request: URLRequest,
next: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
) async throws -> (Data, HTTPURLResponse) {

let token = try await tokenProvider.validToken()

var authorized = request
let token = await tokenStore.get()
authorized.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

authorized.setValue(
"Bearer \(token)",
forHTTPHeaderField: "Authorization"
)

let (data, response) = try await next(authorized)

// Edge case:
// invalid token or revoken suddenly
if response.statusCode == 401 {
let newToken = try await refresh()
await tokenStore.set(newToken)

let refreshed = try await tokenProvider.forceRefresh()

var retry = request
retry.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")

retry.setValue(
"Bearer \(refreshed)",
forHTTPHeaderField: "Authorization"
)

return try await next(retry)
}
Expand Down
35 changes: 35 additions & 0 deletions Sources/TypedNetwork/AuthToken.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// AuthToken.swift
// TypedNetwork
//
// Created by Mauricio Martinez on 21/5/26.
//

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct AuthToken: Sendable {

public let accessToken: String
public let expiration: Date

public init(
accessToken: String,
expiration: Date
) {
self.accessToken = accessToken
self.expiration = expiration
}

public var isExpired: Bool {
expiration <= Date()
}

public func expiresSoon(
threshold: TimeInterval = 60
) -> Bool {
expiration.timeIntervalSinceNow <= threshold
}
}
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/EmptyResponse.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct EmptyResponse: Decodable, Sendable, Equatable {}
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/Endpoint.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public protocol Endpoint: Sendable {
associatedtype Response: Decodable & Sendable
Expand Down
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/ErrorMapping.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public enum NetworkError: Error {
case transport(URLError)
Expand Down
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/HTTPBody.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
//

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct HTTPBody: Sendable {
private let encoder: @Sendable () throws -> Data
Expand Down
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/LoggingMiddleware.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct LoggingMiddleware: Middleware {

Expand Down
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/Middleware.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public protocol Middleware: Sendable {
func intercept(
Expand Down
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/MockMiddleware.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public struct MockMiddleware: Middleware {

Expand Down
3 changes: 3 additions & 0 deletions Sources/TypedNetwork/Mocking.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

public actor MockRegistry {
private var storage: [String: Any] = [:]
Expand Down
Loading
Loading