Skip to content

Commit 245d27d

Browse files
committed
Documentation & code review fixes
1 parent 2505dad commit 245d27d

12 files changed

Lines changed: 363 additions & 34 deletions

Sources/GoodNetworking/Interception/AuthenticationInterceptor.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77

88
import Foundation
99

10+
/// Interceptor responsible for authentication of network requests.
11+
///
12+
/// Authentication interceptor requires an instance of ``Authenticator``, which handles
13+
/// the logic of refreshing the credential, checking its validity or caching.
1014
public final class AuthenticationInterceptor<AuthenticatorType: Authenticator>: Interceptor, @unchecked Sendable {
1115

1216
private let authenticator: AuthenticatorType

Sources/GoodNetworking/Interception/Authenticator.swift

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,108 @@ import Foundation
99

1010
// MARK: - Authenticator
1111

12+
/// Authenticators provide the concrete implementation of authentication,
13+
/// credential management and refreshing of expired credentials for
14+
/// `AuthenticationInterceptor`.
1215
public protocol Authenticator: Sendable {
1316

17+
/// Persistent credential used to authorize requests.
18+
///
19+
/// This is often a combination of access and refresh tokens,
20+
/// an API key, an authentication string, passphrase, etc..
21+
///
22+
/// Credentials conforming to ``RefreshableCredential`` can indicate
23+
/// their validity and thus request a credential refresh early, resulting in better
24+
/// networking performance.
1425
associatedtype Credential
15-
26+
27+
/// Return the latest available credential from storage (eg. cache).
28+
/// - Returns: ``Credential`` if available or `nil`
1629
func getCredential() async -> Credential?
30+
31+
/// Stores the new credential to storage (eg. cache).
32+
///
33+
/// If the new credential is `nil`, this function MUST remove the
34+
/// cached credential from storage. Subsequent calls to ``getCredential()``
35+
/// must then return `nil`.
36+
///
37+
/// - Parameter newCredential: New credential to store or `nil` if
38+
/// credential should be deleted.
1739
func storeCredential(_ newCredential: Credential?) async
18-
40+
41+
/// Applies the credential to a URL request.
42+
///
43+
/// When using HTTP with Bearer authorization, this function is responsible for
44+
/// adding the `Authorization` header to the request.
45+
///
46+
/// - Parameters:
47+
/// - credential: Credential to be added to the request
48+
/// - request: Request to modify
1949
func apply(credential: Credential, to request: inout URLRequest) async throws(NetworkError)
50+
51+
/// Refreshes the expired or invalid credential.
52+
///
53+
/// This function is responsible for refreshing the credential - the way this is done
54+
/// is up to the implementation. Most often it will involve making a network call
55+
/// to a backend authorization service.
56+
///
57+
/// - note: Parameter `credential` may contain a valid credential, if the expiration
58+
/// date is known and the credential is able to request a refresh. See ``RefreshableCredential``.
59+
///
60+
/// - important: This session will block other requests while refresh is pending.
61+
/// As a result, all potential refresh requests must be handled by another network session.
62+
///
63+
/// - Parameter credential: Credential that needs to be refreshed.
64+
/// - Returns: Refreshed and valid credential
65+
/// - Throws: This function can throw a ``NetworkError``, if refreshing the credential fails, or
66+
/// the credential cannot be refreshed.
2067
func refresh(credential: Credential) async throws(NetworkError) -> Credential
21-
func didRequest(_ request: inout URLRequest, failDueToAuthenticationError: HTTPError) -> Bool
68+
69+
/// Checks whether invalid response from the backend is an authentication error.
70+
///
71+
/// The simplest implementation would be a check if the status code is `401 Unauthorized`.
72+
/// This implementation, however, has a flaw. For example - in case the system gates access
73+
/// to resources which require a certain level of authorization, it is possible that a resource is unavailable,
74+
/// but the client can authenticate with a higher level of permissions to access it.
75+
///
76+
/// - important: It is **highly recommended** to inspect the backend response and decide
77+
/// whether the credential is invalid/expired, or the resource is not accessible to the current user
78+
/// and the credential itself is correct.
79+
///
80+
/// - Parameters:
81+
/// - request: Failed URL request
82+
/// - failDueToAuthenticationError: Remote error containing the backend response and status code
83+
/// - Returns: `true` if it can be safely determined, that the failure occured due to invalid credential
84+
func didRequest(_ request: inout URLRequest, failDueToAuthenticationError error: HTTPError) -> Bool
85+
86+
/// Verifies if a request is authenticated with a given credential.
87+
///
88+
/// The simplest implementation in a system using HTTP with Bearer authorization
89+
/// would be checking if access token from ``Credential`` matches the token
90+
/// in `Authorization` header.
91+
///
92+
/// - Parameters:
93+
/// - request: URL request to verify
94+
/// - credential: Credential which is expected to authenticate the request
95+
/// - Returns: `true` if request is authenticated using the `credential`, or `false` otherwise
96+
/// (if the request is unauthenticated or the credentials do not match).
2297
func isRequest(_ request: inout URLRequest, authenticatedWith credential: Credential) -> Bool
98+
99+
/// Notifies the user that credential could not be refreshed and failed due to an error.
100+
///
101+
/// This function is often responsible for changing the app state to show an alert,
102+
/// open the verification/login dialog, or cleaning up now invalid resources
103+
/// (eg. authorized image cache).
104+
///
105+
/// - Parameter error: Remote error containing the backend response and status code
106+
/// when trying to refresh invalid credential, which could not be refreshed.
23107
func refresh(didFailDueToError error: HTTPError) async
24108

25109
}
26110

27111
// MARK: - No authenticator
28112

113+
/// Empty implementation of authenticator for unauthorized network sessions.
29114
public final class NoAuthenticator: Authenticator {
30115

31116
public typealias Credential = Void
@@ -43,8 +128,20 @@ public final class NoAuthenticator: Authenticator {
43128

44129
// MARK: - Refreshable credential
45130

131+
/// Denotes that a credential is refreshable and is capable of telling whether a refresh
132+
/// is required before making an invalid network call.
133+
///
134+
/// Conforming a `Credential` to this protocol is not required, however, it will
135+
/// improve the networking performance by refreshing the token early, before a failed
136+
/// API call.
46137
public protocol RefreshableCredential {
47-
138+
139+
/// This property should return `true` if the credential knows that a refresh
140+
/// is required.
141+
///
142+
/// In the simplest case it can contain logic comparing expiration date
143+
/// with the current date minus a time delta (eg. 5 minutes before expiration).
144+
///
48145
var requiresRefresh: Bool { get }
49146

50147
}

Sources/GoodNetworking/Interception/Interceptor.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ import Foundation
1111

1212
public protocol Interceptor: Adapter, Retrier {}
1313

14-
// MARK: - No interceptor
14+
// MARK: - Default interceptor
1515

16-
public final class NoInterceptor: Interceptor {
16+
public final class DefaultInterceptor: Interceptor {
1717

1818
public init() {}
1919

2020
public func adapt(urlRequest: inout URLRequest) async throws(NetworkError) {}
2121

2222
public func retry(urlRequest: inout URLRequest, for session: NetworkSession, dueTo error: NetworkError) async throws(NetworkError) -> RetryResult {
23+
#warning("TODO: better default retry logic (handle error type, HTTP method, ...")
2324
return .doNotRetry
2425
}
2526

Sources/GoodNetworking/Interception/Retrier.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public protocol Retrier: Sendable {
1717

1818
// MARK: - Retry result
1919

20-
public enum RetryResult {
20+
public enum RetryResult: Sendable {
2121

2222
case doNotRetry
2323
case retryAfter(TimeInterval)

Sources/GoodNetworking/Models/Endpoint.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Foundation
99

1010
// MARK: - Endpoint
1111

12-
/// `GREndpoint` protocol defines a set of requirements for an endpoint.
12+
/// `Endpoint` protocol defines a set of requirements for an endpoint.
1313
public protocol Endpoint {
1414

1515
/// The path to be appended to the base URL.

Sources/GoodNetworking/Models/EndpointFactory.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public final class EndpointFactory: Endpoint {
2323
AutomaticEncoding.default
2424
}
2525

26-
init(path: URLConvertible) {
26+
init(at path: URLConvertible) {
2727
self.path = path
2828
}
2929

@@ -91,5 +91,5 @@ private extension EndpointFactory {
9191
}
9292

9393
public func at(_ path: URLConvertible) -> EndpointFactory {
94-
EndpointFactory(path: path)
94+
EndpointFactory(at: path)
9595
}

Sources/GoodNetworking/Models/HTTPMethod.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import Foundation
99

1010
// MARK: - Method
1111

12+
/// Enumeration of all HTTP methods as stated in
13+
/// [RFC9110 specification](https://httpwg.org/specs/rfc9110.html#rfc.section.9.3)
1214
@frozen public enum HTTPMethod: String {
1315

1416
case get = "GET"

Sources/GoodNetworking/Models/Header.swift

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import Foundation
99

1010
// MARK: - HTTPHeader
1111

12+
/// HTTP headers are colon separated name-value pairs used for specifying request
13+
/// details, authentication and more.
1214
public struct HTTPHeader: Equatable, Hashable, HeaderConvertible {
1315

1416
public let name: String
@@ -45,7 +47,11 @@ public struct HTTPHeader: Equatable, Hashable, HeaderConvertible {
4547
self.name = String(split[0])
4648
self.value = String(split[1])
4749
}
48-
50+
51+
/// Initialize HTTPHeader as a name-value pair.
52+
/// - Parameters:
53+
/// - name: Name of the header (part before colon)
54+
/// - value: Value of the header (part after colon)
4955
public init(name: String, value: String) {
5056
self.name = name
5157
self.value = value
@@ -75,29 +81,68 @@ extension HTTPHeader: CustomStringConvertible {
7581

7682
// MARK: - HTTPHeaders
7783

78-
public struct HTTPHeaders: Equatable, Hashable, Sendable {
79-
80-
public var headers: [HTTPHeader]
81-
84+
/// A collection of multiple headers. Can contain any entities convertible to
85+
/// `HTTPHeader` (`HeaderConvertible`). Final header names
86+
/// and values are resolved at the time the request is sent.
87+
public struct HTTPHeaders: Sendable {
88+
89+
/// List of contained headers
90+
public var headers: [any HeaderConvertible]
91+
92+
/// Create collection of `HTTPHeader`-s from key-value dictionary mapped as
93+
/// name-value header pairs.
94+
/// - Parameter headers: Dictionary, where keys are header names
8295
public init(_ headers: [String: String]) {
8396
self.headers = headers.map(HTTPHeader.init).reduce(into: [], { $0.append($1) })
8497
}
8598

99+
/// Get the value of a header with name `name`
86100
public subscript(_ name: String) -> String? {
87101
value(for: name)
88102
}
89-
103+
104+
/// Resolves all headers to their final values and returns the value for header
105+
/// with a given name.
106+
///
107+
/// - important: This operation resolves all header values and can be expensive
108+
///
109+
/// - Parameter name: Name of the header
110+
/// - Returns: Value of the specified header
90111
public func value(for name: String) -> String? {
91-
guard let index = headers.firstIndex(where: { $0.name == name }) else { return nil }
92-
return headers[index].value
112+
let resolved = resolve()
113+
guard let index = resolved.firstIndex(where: { $0.name == name }) else { return nil }
114+
return resolved[index].value
93115
}
94116

95-
public mutating func add(header: HTTPHeader) {
117+
/// Appends a new entity to the header collection. Does not resolve
118+
/// the header name or value.
119+
/// - Parameter header: New header
120+
public mutating func add(header: any HeaderConvertible) {
96121
headers.append(header)
97122
}
123+
124+
/// Resolve all headers to their final values.
125+
/// - Returns: Array of resolved headers as `HTTPHeader`-s
126+
public func resolve() -> [HTTPHeader] {
127+
headers.map { $0.resolveHeader() }
128+
}
98129

99130
}
100131

132+
extension HTTPHeaders: Equatable, Hashable {
133+
134+
nonisolated public static func == (lhs: Self, rhs: Self) -> Bool {
135+
lhs.resolve() == rhs.resolve()
136+
}
137+
138+
nonisolated public func hash(into hasher: inout Hasher) {
139+
for header in self.resolve() {
140+
hasher.combine(header)
141+
}
142+
}
143+
144+
}
145+
101146
extension HTTPHeaders: ExpressibleByDictionaryLiteral {
102147

103148
public init(dictionaryLiteral elements: (String, String)...) {
@@ -109,14 +154,14 @@ extension HTTPHeaders: ExpressibleByDictionaryLiteral {
109154
extension HTTPHeaders: ExpressibleByArrayLiteral {
110155

111156
public init(arrayLiteral elements: HeaderConvertible...) {
112-
self.headers = elements.map { $0.resolveHeader() }
157+
self.headers = elements
113158
}
114159

115160
}
116161

117162
extension HTTPHeaders: Sequence {
118163

119-
public func makeIterator() -> IndexingIterator<[HTTPHeader]> {
164+
public func makeIterator() -> IndexingIterator<[any HeaderConvertible]> {
120165
headers.makeIterator()
121166
}
122167

@@ -132,7 +177,7 @@ extension HTTPHeaders: Collection {
132177
headers.endIndex
133178
}
134179

135-
public subscript(position: Int) -> HTTPHeader {
180+
public subscript(position: Int) -> any HeaderConvertible {
136181
headers[position]
137182
}
138183

@@ -145,15 +190,19 @@ extension HTTPHeaders: Collection {
145190
extension HTTPHeaders: CustomStringConvertible {
146191

147192
public var description: String {
148-
headers.map(\.description).joined(separator: "\n")
193+
self.resolve().map(\.description).joined(separator: "\n")
149194
}
150195

151196
}
152197

153198
// MARK: - HeaderConvertible
154199

200+
/// Allows conforming entities to be converted to HTTPHeader
201+
/// for subsequent use in HTTP requests.
155202
public protocol HeaderConvertible: Sendable {
156-
203+
204+
/// Resolves the final name and value of the header
205+
/// - Returns: Valid HTTP header name-value pair
157206
func resolveHeader() -> HTTPHeader
158207

159208
}

0 commit comments

Comments
 (0)