Skip to content

Commit 0b72bf5

Browse files
committed
✨ Add HTTPNetworking nibble.
1 parent a8dcd27 commit 0b72bf5

24 files changed

+1968
-0
lines changed

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ let package = Package(
1313
products: [
1414
.library(name: "Cache", targets: ["Cache"]),
1515
.library(name: "Extensions", targets: ["Extensions"]),
16+
.library(name: "HTTPNetworking", targets: ["HTTPNetworking"]),
1617
.library(name: "Identified", targets: ["Identified"]),
1718
],
1819
targets: [
@@ -22,6 +23,9 @@ let package = Package(
2223
.target(name: "Extensions"),
2324
.testTarget(name: "ExtensionsTests", dependencies: ["Extensions"]),
2425

26+
.target(name: "HTTPNetworking"),
27+
.testTarget(name: "HTTPNetworkingTests", dependencies: ["HTTPNetworking"]),
28+
2529
.target(name: "Identified"),
2630
.testTarget(name: "IdentifiedTests", dependencies: ["Identified"]),
2731
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Foundation
2+
3+
extension Result where Success == Void {
4+
public static var success: Result<Success, Failure> {
5+
return .success(())
6+
}
7+
}
8+
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import Foundation
2+
3+
/// `HTTPClient` creates and manages requests over the network.
4+
///
5+
/// The client also provides common functionality for all ``HTTPRequest`` objects, including encoding and decoding strategies,
6+
/// request adaptation, response validation and retry stratagies.
7+
///
8+
/// Generally an ``HTTPClient`` is used to manage the interaction with a single API service. Most APIs
9+
/// have their own nuance and complexities, and encapsulating all of that in one place can help structure your code in a
10+
/// more scalable and testable way.
11+
///
12+
/// If your API requires a custom encoder or decoder, you can provide your own custom ones to the client. These encoder and decoders
13+
/// will then be used for all requests made using the client.
14+
///
15+
/// If your API requires some sort of repetitive task applied to each request, rather than manipulating it at each call, you can make use of an
16+
/// ``HTTPRequestAdaptor``. The adaptors that you provide to the client will all be run on a request before it is sent to your API.
17+
/// This provides you with an opportunity to insert various headers or perform asyncrounous tasks before sending the outbound request.
18+
///
19+
/// Different APIs require different forms of validation that help you determine if a request was successful or not.
20+
/// The default ``HTTPClient`` makes no assumptions about the format and/or success of an API's response. Instead, you can make use of an
21+
/// ``HTTPResponseValidator``. The validators that you provide the client will all be run on the response received by your API.
22+
/// This provides you with an opportunity to determine wether or not the response was a success and failure, and consolidate potentially repeated logic in one place.
23+
///
24+
/// Sometimes you may want to implement logic for handling retries of failed requests. An ``HTTPClient`` can make use of an
25+
/// ``HTTPRequestRetriers``. The retriers that you provide the client will be run when a request fails.
26+
/// This provides you with an opportunity to observe the error and determine wether or not the request should be sent again. You can implement complex retry
27+
/// logic across your entire client without having to repeat yourself.
28+
///
29+
/// In addition to providing adaptors, validators and retriers to your entire client, you can provide additional configuration to each
30+
/// request using a chaining style syntax. This allows you to add additional configuration to endpoints that you may want to provide further
31+
/// customization to beyond the standard configuration at the client level.
32+
///
33+
/// ```swift
34+
/// // Create a client.
35+
/// let client = HTTPClient()
36+
///
37+
/// // Create a request.
38+
/// let request = client.request(for: .get, to: url, expecting: [Dog].self)
39+
/// .adapt(with: adaptor)
40+
/// .validate(with: validator)
41+
/// .retry(with: retrier)
42+
///
43+
/// // Send a request.
44+
/// let response = try await request.run()
45+
/// ```
46+
///
47+
/// > Note: All adaptors, validators and retriers at the client level will be run first, after which the
48+
/// request configurations at the request level will be run.
49+
///
50+
/// > Important: All adaptors will always run for every request.
51+
///
52+
/// > Important: Validators will be run one by one on a request's response. If any validators determines that the response is invalid,
53+
/// the request will fail and throw the error returned by that validator. No other subsequent validators will be run for that run of a request.
54+
///
55+
/// > Important: Retriers will be run one by one in the event of a failred request. If any retrier concedes that the request should not be retried,
56+
/// the request will ask the next retrier if the request should be retried. If any retrier determines that a request should be retired, the request will
57+
/// immedietly be retired, without asking any subsequent retriers.
58+
public struct HTTPClient {
59+
60+
// MARK: Properties
61+
62+
/// The encoder that each request uses to encode request bodies.
63+
public let encoder: JSONEncoder
64+
65+
/// The decoder that each request uses to decode response bodies.
66+
public let decoder: JSONDecoder
67+
68+
/// The dispatcher that sends out each request.
69+
public let dispatcher: HTTPDispatcher
70+
71+
/// A collection of adaptors that adapt each request.
72+
public let adaptors: [any HTTPRequestAdaptor]
73+
74+
/// A collection of retriers that handle retrying failed requests.
75+
public let retriers: [any HTTPRequestRetrier]
76+
77+
/// A collection of validators that validate each response.
78+
public let validators: [any HTTPResponseValidator]
79+
80+
// MARK: Initializers
81+
82+
/// Creates an ``HTTPClient`` with the provided configuration.
83+
///
84+
/// - Parameters:
85+
/// - encoder: The encoder that each request should use to encode request bodies.
86+
/// - decoder: The decoder that each request should use to decode response bodies.
87+
/// - dispatcher: The dispatcher that will actually send out each request.
88+
/// - adaptors: A collection of adaptors that adapt each request.
89+
/// - retriers: A collection of retriers that handle retrying failed requests.
90+
/// - validators: A collection of validators that validate each response.
91+
public init(
92+
encoder: JSONEncoder = JSONEncoder(),
93+
decoder: JSONDecoder = JSONDecoder(),
94+
dispatcher: HTTPDispatcher = .live(),
95+
adaptors: [any HTTPRequestAdaptor] = [],
96+
retriers: [any HTTPRequestRetrier] = [],
97+
validators: [any HTTPResponseValidator] = []
98+
) {
99+
self.encoder = encoder
100+
self.decoder = decoder
101+
self.dispatcher = dispatcher
102+
self.adaptors = adaptors
103+
self.retriers = retriers
104+
self.validators = validators
105+
}
106+
107+
// MARK: Public
108+
109+
/// Creates a request with a body.
110+
///
111+
/// - Parameters:
112+
/// - method: The ``HTTPMethod`` of the request.
113+
/// - url: The url the request is being sent to.
114+
/// - body: The object to encode into the body of the request.
115+
/// - responseType: The expected type to be decoded from the response body.
116+
/// - Returns: An `HTTPRequest` with the provided configuration.
117+
public func request<T: Encodable, U: Decodable>(
118+
for method: HTTPMethod,
119+
to url: URL,
120+
with body: T,
121+
expecting responseType: U.Type
122+
) throws -> HTTPRequest<U> {
123+
request(
124+
method: method,
125+
url: url,
126+
body: try encoder.encode(body),
127+
responseType: responseType
128+
)
129+
}
130+
131+
/// Creates a request without a body.
132+
///
133+
/// - Parameters:
134+
/// - method: The ``HTTPMethod`` of the request.
135+
/// - url: The url the request is being sent to.
136+
/// - responseType: The expected type to be decoded from the response body.
137+
/// - Returns: An `HTTPRequest` with the provided configuration.
138+
public func request<T: Decodable>(
139+
for method: HTTPMethod,
140+
to url: URL,
141+
expecting responseType: T.Type
142+
) -> HTTPRequest<T> {
143+
return request(
144+
method: method,
145+
url: url,
146+
body: nil,
147+
responseType: responseType
148+
)
149+
}
150+
151+
// MARK: Private
152+
153+
/// Creates a request with a body.
154+
///
155+
/// - Parameters:
156+
/// - method: The ``HTTPMethod`` of the request.
157+
/// - url: The url the request is being sent to.
158+
/// - body: The data to attach to the request's body.
159+
/// - responseType: The expected type to be decoded from the response body.
160+
/// - Returns: An `HTTPRequest` with the provided configuration.
161+
private func request<T: Decodable>(
162+
method: HTTPMethod,
163+
url: URL,
164+
body: Data?,
165+
responseType: T.Type
166+
) -> HTTPRequest<T> {
167+
var request = URLRequest(url: url)
168+
169+
if body != nil {
170+
assert(method != .get,"GET requests should not contain a body.")
171+
}
172+
173+
request.httpMethod = method.rawValue
174+
request.httpBody = body
175+
return HTTPRequest(
176+
request: request,
177+
decoder: decoder,
178+
dispatcher: dispatcher,
179+
adaptors: adaptors,
180+
retriers: retriers,
181+
validators: validators
182+
)
183+
}
184+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import Foundation
2+
3+
// MARK: - HTTPDispatcher
4+
5+
/// A dispatcher that can send requests over the network using a `URLSession`.
6+
public struct HTTPDispatcher {
7+
8+
// MARK: Properties
9+
10+
/// The session that performs the requests.
11+
let session: URLSession
12+
13+
// MARK: Initializers
14+
15+
/// Creates a dispatcher with the provided session.
16+
init(session: URLSession) {
17+
self.session = session
18+
}
19+
20+
/// Fetches data using the provided request.
21+
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
22+
let (data, response) = try await session.data(for: request)
23+
guard let response = response as? HTTPURLResponse else {
24+
throw URLError(.cannotParseResponse)
25+
}
26+
27+
return (data, response)
28+
}
29+
}
30+
31+
// MARK: - HTTPDispatcher + Live
32+
33+
extension HTTPDispatcher {
34+
/// A live implementation of an ``HTTPDispatcher`` that will utilize the provided session to perform requests.
35+
///
36+
/// - Parameter session: The session that powers the dispatcher.
37+
/// - Returns: An ``HTTPDispatcher``
38+
public static func live(session: URLSession = .shared) -> HTTPDispatcher {
39+
HTTPDispatcher(session: session)
40+
}
41+
}
42+
43+
// MARK: - HTTPDispatcher + Mock
44+
45+
extension HTTPDispatcher {
46+
/// A mock implementation of an ``HTTPDispatcher`` that will return the provided
47+
/// response to the corresponding request.
48+
///
49+
/// - Parameter responses: A dictionary of responses keyed by the requests for which they should respond to.
50+
/// - Returns: An ``HTTPDispatcher``
51+
public static func mock(
52+
responses: [URL: MockResponse]
53+
) -> HTTPDispatcher {
54+
MockURLProtocol.mocks.merge(responses, uniquingKeysWith: { $1 })
55+
let configuration = URLSessionConfiguration.ephemeral
56+
configuration.protocolClasses = [MockURLProtocol.self]
57+
let session = URLSession(configuration: configuration)
58+
return HTTPDispatcher(session: session)
59+
}
60+
}
61+
62+
// MARK: - HTTPDispatcher + MockResponse
63+
64+
extension HTTPDispatcher {
65+
/// A mock response can be used to mock interaction with an API.
66+
public struct MockResponse {
67+
68+
// MARK: Properties
69+
70+
/// The amount of delay that should occur before returning the response.
71+
let delay: Duration
72+
73+
/// The result of the mock request.
74+
let result: () -> Result<(Data, HTTPURLResponse), URLError>
75+
76+
/// A closure that should be run in order to introspect the request that was received by the responder.
77+
let onRecieveRequest: ((URLRequest) -> Void)?
78+
79+
// MARK: Initializers
80+
81+
/// Creates a ``MockResponse`` for the provided configuration.
82+
public init(
83+
delay: Duration = .zero,
84+
result: @escaping () -> Result<(Data, HTTPURLResponse), URLError>,
85+
onRecieveRequest: ((URLRequest) -> Void)? = nil
86+
) {
87+
self.delay = delay
88+
self.result = result
89+
self.onRecieveRequest = onRecieveRequest
90+
}
91+
92+
/// Creates a ``MockResponse`` for the provided configuration.
93+
public init(
94+
delay: Duration = .zero,
95+
result: Result<(Data, HTTPURLResponse), URLError>,
96+
onRecieveRequest: ((URLRequest) -> Void)? = nil
97+
) {
98+
self.delay = delay
99+
self.result = { result }
100+
self.onRecieveRequest = onRecieveRequest
101+
}
102+
103+
// MARK: Helpers
104+
105+
/// Creates a successful ``MockResponse`` for the provided configuration.
106+
public static func success(
107+
data: Data,
108+
response: HTTPURLResponse,
109+
delay: Duration = .zero,
110+
onRecieveRequest: ((URLRequest) -> Void)? = nil
111+
) -> MockResponse {
112+
.init(delay: delay, result: .success((data, response)), onRecieveRequest: onRecieveRequest)
113+
}
114+
115+
/// Creates a failed ``MockResponse`` for the provided configuration.
116+
public static func failure(
117+
_ error: URLError,
118+
delay: Duration = .zero,
119+
onRecieveRequest: ((URLRequest) -> Void)? = nil
120+
) -> MockResponse {
121+
.init(delay: delay, result: .failure(error), onRecieveRequest: onRecieveRequest)
122+
}
123+
}
124+
}
125+
126+
// MARK: HTTPDispatcher + MockURLProtocol
127+
128+
extension HTTPDispatcher {
129+
/// An object that allows mocking an ``HTTPDispatcher``.
130+
private class MockURLProtocol: URLProtocol {
131+
132+
// MARK: Properties
133+
134+
static var mocks: [URL: MockResponse] = [:]
135+
136+
// MARK: URLProtocol
137+
138+
override class func canInit(with request: URLRequest) -> Bool { true }
139+
140+
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
141+
142+
override func startLoading() {
143+
guard let url = request.url, let response = Self.mocks[url] else {
144+
preconditionFailure("Request dispatched without providing a matching mock.")
145+
}
146+
147+
response.onRecieveRequest?(request)
148+
149+
Task {
150+
try? await Task.sleep(for: response.delay)
151+
152+
switch response.result() {
153+
case .success(let (data, response)):
154+
client?.urlProtocol(self, didLoad: data)
155+
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
156+
case .failure(let error):
157+
client?.urlProtocol(self, didFailWithError: error)
158+
}
159+
160+
161+
client?.urlProtocolDidFinishLoading(self)
162+
}
163+
}
164+
165+
override func stopLoading() {}
166+
}
167+
}

0 commit comments

Comments
 (0)