Skip to content

Commit 842d3e2

Browse files
authored
Implement Chainable Request Interceptors (#15)
2 parents 4900003 + ed6601d commit 842d3e2

17 files changed

Lines changed: 298 additions & 302 deletions

README.md

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -215,41 +215,75 @@ let configuration = NetworkConfiguration(
215215

216216
### Request Interceptors
217217

218-
Modify requests before they're sent using synchronous or asynchronous interceptors.
218+
Modify requests before they are sent by creating a chain of objects that conform to the `NetworkRequestInterceptor` protocol. This is useful for cross-cutting concerns like adding authentication tokens, logging, or caching headers.
219219

220-
#### Synchronous Interceptor
220+
#### 1. Create an Interceptor
221221

222-
A synchronous interceptor is useful for quick modifications, like adding a static header.
222+
First, define a struct or class that conforms to `NetworkRequestInterceptor` and implement the `intercept` method.
223223

224224
```swift
225-
let configuration = NetworkConfiguration(
226-
baseURL: URL(string: "https://api.example.com")!,
227-
interceptor: { request in
225+
// An interceptor for adding a static API key to every request.
226+
struct APIKeyInterceptor: NetworkRequestInterceptor {
227+
let apiKey: String
228+
229+
func intercept(_ request: URLRequest) async throws -> URLRequest {
228230
var mutableRequest = request
229-
mutableRequest.setValue("Bearer <STATIC_TOKEN>", forHTTPHeaderField: "Authorization")
231+
mutableRequest.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
230232
return mutableRequest
231233
}
232-
)
234+
}
235+
236+
// An interceptor that asynchronously refreshes an auth token.
237+
struct AuthTokenInterceptor: NetworkRequestInterceptor {
238+
let tokenProvider: TokenProviding
239+
240+
func intercept(_ request: URLRequest) async throws -> URLRequest {
241+
// Asynchronously get a fresh token.
242+
let token = await tokenProvider.getFreshToken()
243+
244+
var mutableRequest = request
245+
mutableRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
246+
return mutableRequest
247+
}
248+
}
233249
```
234250

235-
#### Asynchronous Interceptor
251+
#### 2. Configure the Client
236252

237-
An asynchronous interceptor is ideal for operations that require waiting, such as refreshing an authentication token.
253+
Add instances of your interceptors to the `NetworkConfiguration`. They will be executed in the order they appear in the array.
238254

239255
```swift
240256
let configuration = NetworkConfiguration(
241257
baseURL: URL(string: "https://api.example.com")!,
242-
asyncInterceptor: { request in
258+
interceptors: [
259+
APIKeyInterceptor(apiKey: "my-secret-key"),
260+
AuthTokenInterceptor(tokenProvider: myTokenProvider)
261+
]
262+
)
263+
264+
let client = NetworkClient(configuration: configuration)
265+
```
266+
267+
#### 3. Per-Request Override (Optional)
268+
269+
You can also provide a specific set of interceptors for an individual request. This will override the interceptors set in the global configuration.
270+
271+
```swift
272+
struct OneTimeHeaderInterceptor: NetworkRequestInterceptor {
273+
func intercept(_ request: URLRequest) async throws -> URLRequest {
243274
var mutableRequest = request
244-
let token = await tokenProvider.refreshToken()
245-
mutableRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
275+
mutableRequest.setValue("true", forHTTPHeaderField: "X-Special-Request")
246276
return mutableRequest
247277
}
278+
}
279+
280+
let request = NetworkRequest<VoidRequest, User>(
281+
path: "/users/123",
282+
method: .get,
283+
interceptors: [OneTimeHeaderInterceptor()] // This interceptor runs instead of the global ones.
248284
)
249285
```
250286

251-
The synchronous interceptor runs first, followed by the asynchronous one.
252-
253287
### Custom Encoders/Decoders
254288

255289
Override global configuration per request:

Sources/MicroClient/Logger/NetworkLogLevel.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ public enum NetworkLogLevel: Int, Sendable {
2020

2121
extension NetworkLogLevel: Comparable {
2222

23-
public static func < (lhs: NetworkLogLevel, rhs: NetworkLogLevel) -> Bool {
23+
public static func < (
24+
lhs: NetworkLogLevel,
25+
rhs: NetworkLogLevel
26+
) -> Bool {
2427
lhs.rawValue < rhs.rawValue
2528
}
2629
}

Sources/MicroClient/Logger/NetworkLogger.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@ public protocol NetworkLogger: Sendable {
77
/// - Parameters:
88
/// - level: The log level.
99
/// - message: The message to log.
10-
func log(level: NetworkLogLevel, message: String)
10+
func log(
11+
level: NetworkLogLevel,
12+
message: String
13+
)
1114
}

Sources/MicroClient/NetworkClient.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public actor NetworkClient: NetworkClientProtocol {
5454

5555
// MARK: - Private
5656

57+
// swiftlint:disable function_body_length
5758
private func performRequest<RequestModel, ResponseModel>(
5859
_ networkRequest: NetworkRequest<RequestModel, ResponseModel>,
5960
attempt: Int
@@ -76,12 +77,15 @@ public actor NetworkClient: NetworkClientProtocol {
7677
throw NetworkClientError.malformedURL
7778
}
7879

79-
if let interceptor = configuration.interceptor {
80-
urlRequest = interceptor(urlRequest)
81-
}
80+
let interceptors = networkRequest.interceptors ?? configuration.interceptors
8281

83-
if let asyncInterceptor = configuration.asyncInterceptor {
84-
urlRequest = await asyncInterceptor(urlRequest)
82+
do {
83+
for interceptor in interceptors {
84+
urlRequest = try await interceptor.intercept(urlRequest)
85+
}
86+
} catch {
87+
log(.error, "Interceptor error: \(error.localizedDescription)")
88+
throw NetworkClientError.interceptorError(error)
8589
}
8690

8791
log(.info, "Request: \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString ?? "")")
@@ -132,8 +136,12 @@ public actor NetworkClient: NetworkClientProtocol {
132136
throw NetworkClientError.unknown(error)
133137
}
134138
}
139+
// swiftlint:enable function_body_length
135140

136-
private func log(_ level: NetworkLogLevel, _ message: String) {
141+
private func log(
142+
_ level: NetworkLogLevel,
143+
_ message: String
144+
) {
137145
guard let logger = configuration.logger,
138146
level >= configuration.logLevel else { return }
139147

Sources/MicroClient/NetworkClientError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ public enum NetworkClientError: Error {
2323
/// The associated `Error` value contains the original decoding error.
2424
case encodingError(Error)
2525

26+
/// An error occurred during the execution of a request interceptor.
27+
case interceptorError(Error)
28+
2629
/// An unexpected or unknown error occurred.
2730
case unknown(Error?)
2831
}

Sources/MicroClient/NetworkConfiguration.swift

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import Foundation
22

3-
/// Type alias for synchronous request interceptors
4-
public typealias NetworkRequestsInterceptor = @Sendable (URLRequest) -> URLRequest
5-
6-
/// Type alias for asynchronous request interceptors
7-
public typealias NetworkAsyncRequestInterceptor = @Sendable (URLRequest) async -> URLRequest
8-
93
/// The network client configuration.
104
public struct NetworkConfiguration: Sendable {
115

@@ -35,15 +29,9 @@ public struct NetworkConfiguration: Sendable {
3529
/// The log level for the logger. The default value is `.info`.
3630
public let logLevel: NetworkLogLevel
3731

38-
/// The interceptor called right before performing the
39-
/// network request. Can be used to modify the `URLRequest`
40-
/// if necessary.
41-
public let interceptor: NetworkRequestsInterceptor?
42-
43-
/// The async interceptor called after the synchronous interceptor
44-
/// and right before performing the network request. Can be used to
45-
/// modify the `URLRequest` with async operations if necessary.
46-
public let asyncInterceptor: NetworkAsyncRequestInterceptor?
32+
/// A chain of interceptors that can inspect and modify requests before they are sent.
33+
/// Interceptors are applied in the order they appear in this array.
34+
public let interceptors: [NetworkRequestInterceptor]
4735

4836
/// Initializes the network client configuration.
4937
/// - Parameters:
@@ -54,8 +42,7 @@ public struct NetworkConfiguration: Sendable {
5442
/// - retryStrategy: The retry strategy for network requests.
5543
/// - logger: The logger for network requests and responses.
5644
/// - logLevel: The log level for the logger.
57-
/// - interceptor: The synchronous interceptor function (optional).
58-
/// - asyncInterceptor: The asynchronous interceptor function (optional).
45+
/// - interceptors: A chain of interceptors to apply to requests. Defaults to an empty array.
5946
public init(
6047
session: URLSessionProtocol,
6148
defaultDecoder: JSONDecoder,
@@ -64,8 +51,7 @@ public struct NetworkConfiguration: Sendable {
6451
retryStrategy: RetryStrategy = .none,
6552
logger: NetworkLogger? = nil,
6653
logLevel: NetworkLogLevel = .info,
67-
interceptor: NetworkRequestsInterceptor? = nil,
68-
asyncInterceptor: NetworkAsyncRequestInterceptor? = nil
54+
interceptors: [NetworkRequestInterceptor] = []
6955
) {
7056
self.session = session
7157
self.defaultDecoder = defaultDecoder
@@ -74,7 +60,6 @@ public struct NetworkConfiguration: Sendable {
7460
self.retryStrategy = retryStrategy
7561
self.logger = logger
7662
self.logLevel = logLevel
77-
self.interceptor = interceptor
78-
self.asyncInterceptor = asyncInterceptor
63+
self.interceptors = interceptors
7964
}
8065
}

Sources/MicroClient/NetworkRequest.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ public struct NetworkRequest<
5252
/// `NetworkConfiguration.retryStrategy`.
5353
public let retryStrategy: RetryStrategy?
5454

55+
/// A chain of interceptors to apply to this specific request. If provided,
56+
/// this overrides the default interceptors from the `NetworkConfiguration`.
57+
public let interceptors: [NetworkRequestInterceptor]?
58+
5559
// MARK: - Life cycle
5660

5761
/// Initializes the request model.
@@ -66,6 +70,7 @@ public struct NetworkRequest<
6670
/// - encoder: The encoder used to encode the `RequestModel`.
6771
/// - retryStrategy: The retry strategy for the request.
6872
/// - additionalHeaders: A dictionary containing additional header fields.
73+
/// - interceptors: A chain of interceptors to apply to this specific request.
6974
public init(
7075
path: String? = nil,
7176
method: HTTPMethod,
@@ -76,7 +81,8 @@ public struct NetworkRequest<
7681
decoder: JSONDecoder? = nil,
7782
encoder: JSONEncoder? = nil,
7883
retryStrategy: RetryStrategy? = nil,
79-
additionalHeaders: [String: String]? = nil
84+
additionalHeaders: [String: String]? = nil,
85+
interceptors: [NetworkRequestInterceptor]? = nil
8086
) {
8187
self.path = path
8288
self.method = method
@@ -88,6 +94,7 @@ public struct NetworkRequest<
8894
self.encoder = encoder
8995
self.retryStrategy = retryStrategy
9096
self.additionalHeaders = additionalHeaders
97+
self.interceptors = interceptors
9198
}
9299
}
93100

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
3+
/// A protocol for intercepting and modifying network requests before they are sent.
4+
public protocol NetworkRequestInterceptor: Sendable {
5+
6+
/// Intercepts and potentially modifies a URLRequest.
7+
///
8+
/// This method is called for each interceptor in the chain. It can be used to add headers,
9+
/// modify the request body, or even perform asynchronous tasks like refreshing an authentication token.
10+
///
11+
/// - Parameter request: The `URLRequest` to be processed.
12+
/// - Returns: A potentially modified `URLRequest`.
13+
/// - Throws: An error if the interception process fails. Throwing an error will cancel the entire request.
14+
func intercept(_ request: URLRequest) async throws -> URLRequest
15+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
3+
@testable import MicroClient
4+
5+
struct HeaderInterceptor: NetworkRequestInterceptor {
6+
7+
// MARK: - Properties
8+
9+
let headerName: String
10+
let headerValue: String
11+
let storage: MockInterceptorStorage
12+
13+
// MARK: - Public
14+
15+
func intercept(_ request: URLRequest) async throws -> URLRequest {
16+
await storage.recordCall(id: headerName)
17+
18+
var mutableRequest = request
19+
mutableRequest.setValue(
20+
headerValue,
21+
forHTTPHeaderField: headerName
22+
)
23+
return mutableRequest
24+
}
25+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Foundation
2+
3+
@testable import MicroClient
4+
5+
struct InterceptorMock: NetworkRequestInterceptor {
6+
7+
// MARK: - Properties
8+
9+
let id = UUID()
10+
11+
// MARK: - Public
12+
13+
func intercept(_ request: URLRequest) async throws -> URLRequest {
14+
request
15+
}
16+
}

0 commit comments

Comments
 (0)