Skip to content

Commit 065d3cd

Browse files
authored
Merge pull request #51 from GoodRequest/feature/composite-adapter-retrier
Composite adapter+retrier
2 parents 12b9d6a + 10fa1d8 commit 065d3cd

File tree

6 files changed

+179
-6
lines changed

6 files changed

+179
-6
lines changed

Sources/GoodNetworking/Interception/AuthenticationInterceptor.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ public final class AuthenticationInterceptor<AuthenticatorType: Authenticator>:
2323

2424
public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) {
2525
await lock.lock()
26+
defer { lock.unlock() }
27+
2628
if let credential = await authenticator.getCredential() {
2729
if let refreshableCredential = credential as? RefreshableCredential, refreshableCredential.requiresRefresh {
2830
try await refresh(credential: credential)
2931
}
3032
try await authenticator.apply(credential: credential, to: &urlRequest)
3133
}
32-
lock.unlock()
3334
}
3435

3536
public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult {

Sources/GoodNetworking/Interception/Interceptor.swift

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ import Foundation
99

1010
// MARK: - Interceptor
1111

12+
/// Interceptor merges request adaptation and retry behavior.
1213
public protocol Interceptor: Adapter, Retrier {}
1314

1415
// MARK: - Default interceptor
1516

17+
/// Default interceptor does not adapt (modify) requests in any way.
18+
/// Default retrying behaviour is applied as per RFC9110 specification.
19+
///
20+
/// - warning: Retrying is currently not implemented and all requests
21+
/// are resolved as `.doNotRetry`.
1622
public final class DefaultInterceptor: Interceptor {
1723

1824
public init() {}
@@ -28,22 +34,42 @@ public final class DefaultInterceptor: Interceptor {
2834

2935
// MARK: - Composite interceptor
3036

37+
/// Merges multiple interceptors, adapters and retriers into single interceptor instance.
38+
///
39+
/// Adapters have priority over general interceptors and are executed first when adapting
40+
/// requests. All adapters execute in order they are passed in at initialization.
41+
///
42+
/// Retriers have priority over general interceptors and are executed first when retrying
43+
/// requests. The first retrier to allow retrying the request is used, rest are not executed.
44+
///
45+
/// This behaviour effectively accomplishes that request authentication is executed
46+
/// last, and requests are retried if a specific retrier allows it.
3147
public final class CompositeInterceptor: Interceptor {
3248

3349
private let interceptors: [Interceptor]
34-
35-
public init(interceptors: [Interceptor]) {
50+
private let adapters: [Adapter]
51+
private let retriers: [Retrier]
52+
53+
public init(
54+
interceptors: [Interceptor] = [],
55+
adapters: [Adapter] = [],
56+
retriers: [Retrier] = []
57+
) {
3658
self.interceptors = interceptors
59+
self.adapters = adapters
60+
self.retriers = retriers
3761
}
3862

3963
public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) {
40-
for adapter in interceptors {
64+
let allAdapters: [Adapter] = adapters + interceptors
65+
for adapter in allAdapters {
4166
try await adapter.adapt(urlRequest: &urlRequest)
4267
}
4368
}
4469

4570
public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult {
46-
for retrier in interceptors {
71+
let allRetriers: [Retrier] = retriers + interceptors
72+
for retrier in allRetriers {
4773
let retryResult = try await retrier.retry(urlRequest: &urlRequest, for: session, dueTo: error)
4874
switch retryResult {
4975
case .doNotRetry:

Sources/GoodNetworking/Interception/Retrier.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,18 @@ public protocol Retrier: Sendable {
1717

1818
// MARK: - Retry result
1919

20+
/// Result of a retry operation.
21+
///
22+
/// See ``Retrier``.
2023
public enum RetryResult: Sendable {
2124

25+
/// Request will not be retried
2226
case doNotRetry
27+
28+
/// Request will be retried only after the specified time interval has passed
2329
case retryAfter(TimeInterval)
30+
31+
/// Request will be retried immediately
2432
case retry
2533

2634
}

Sources/GoodNetworking/Models/Endpoint.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ public extension Endpoint {
5050
let path = await path.resolveUrl()
5151

5252
guard let baseUrl, let path else { return nil }
53-
return baseUrl.appendingPathComponent(path.absoluteString)
53+
54+
// merge URLs as strings to avoid URL escaping
55+
return URL(baseUrl.absoluteString + path.absoluteString)
5456
}
5557

5658
}

Sources/GoodNetworking/Models/EndpointBuilder.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ public extension EndpointBuilder {
6868
return self
6969
}
7070

71+
func query(_ items: URLQueryItem...) -> Self {
72+
assertBothQueryAndBodyUsage()
73+
self.parameters = .query(items)
74+
return self
75+
}
76+
7177
func query(_ items: [URLQueryItem]) -> Self {
7278
assertBothQueryAndBodyUsage()
7379
self.parameters = .query(items)

Tests/GoodNetworkingTests/NetworkSessionTests.swift

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,134 @@
66
//
77

88
@testable import GoodNetworking
9+
import Testing
10+
import Foundation
11+
import Sextant
12+
import Hitch
913

14+
let session = NetworkSession(baseUrl: "https://dummyjson.com")
15+
16+
// MARK: - Decoding
17+
18+
@Test func decodeDynamicPosts() async throws {
19+
let responseData = try await session.get("/products?limit=1000") as Data
20+
21+
print(responseData.count)
22+
23+
measure {
24+
let jsonResponse = JSON(responseData)
25+
print(jsonResponse.products[100].title.string as Any)
26+
print(jsonResponse.products[100]["description"].string as Any)
27+
print(jsonResponse.products[100].price.double as Any)
28+
print(jsonResponse.products[100].reviews.array?.first?.comment.string as Any)
29+
print(jsonResponse.products[100].images.array?.first?.string as Any)
30+
}
31+
32+
measure {
33+
let structResponse = try? JSONDecoder().decode(ProductsResponse.self, from: responseData)
34+
print(structResponse?.products[100].title as Any)
35+
print(structResponse?.products[100].description as Any)
36+
print(structResponse?.products[100].price as Any)
37+
print(structResponse?.products[100].reviews?.first?.comment as Any)
38+
print(structResponse?.products[100].images?.first as Any)
39+
}
40+
41+
// measure {
42+
// let results = Sextant.shared.query(responseData, values: Hitch(string: "$.products..[?(@.price>10)]..['title', 'description', 'price']")) as [String]?
43+
// print(results as Any)
44+
// }
45+
46+
}
47+
48+
func measure(_ block: () -> ()) {
49+
var duration: UInt64 = 0
50+
for _ in 0..<50 {
51+
let startTime: UInt64 = mach_absolute_time()
52+
block()
53+
let finishTime: UInt64 = mach_absolute_time()
54+
let timeDelta = (finishTime - startTime) / 1000
55+
duration += timeDelta
56+
}
57+
58+
let averageDuration = duration / 50
59+
print(averageDuration, "us")
60+
}
61+
62+
struct ProductsResponse: Decodable {
63+
64+
struct Product: Decodable {
65+
let id: Int
66+
let title: String?
67+
let description: String?
68+
let category: String?
69+
let price: Double?
70+
let discountPercentage: Double?
71+
let rating: Double?
72+
let stock: Int?
73+
let tags: [String]?
74+
let brand: String?
75+
let sku: String?
76+
let weight: Double?
77+
let dimensions: Dimensions?
78+
let warrantyInformation: String?
79+
let shippingInformation: String?
80+
let availabilityStatus: String?
81+
let reviews: [Review]?
82+
let returnPolicy: String?
83+
let minimumOrderQuantity: Int?
84+
let meta: Meta?
85+
let images: [String]?
86+
let thumbnail: String?
87+
}
88+
89+
struct Dimensions: Decodable {
90+
let width: Double?
91+
let height: Double?
92+
let depth: Double?
93+
}
94+
95+
struct Review: Decodable {
96+
let rating: Int?
97+
let comment: String?
98+
let date: String?
99+
let reviewerName: String?
100+
let reviewerEmail: String?
101+
}
102+
103+
struct Meta: Decodable {
104+
let createdAt: String?
105+
let updatedAt: String?
106+
let barcode: String?
107+
let qrCode: String?
108+
}
109+
110+
let products: [Product]
111+
112+
}
113+
114+
// MARK: - Encoding
115+
116+
@Test func encodeDynamicJSON() async throws {
117+
let newUser = NewUserRequest(
118+
name: "Alice",
119+
email: "alice@example.com",
120+
age: 30
121+
)
122+
123+
let newUserJson = [
124+
"name": "Alice",
125+
"email": "alice@example.com",
126+
"age": 30
127+
] as JSON
128+
129+
_ = try await session.post("/users", newUser) as JSON
130+
_ = try await session.post("/users", newUserJson) as JSON
131+
}
132+
133+
struct NewUserRequest: Encodable {
134+
135+
let name: String
136+
let email: String
137+
let age: Int
138+
139+
}

0 commit comments

Comments
 (0)