Last active
September 2, 2023 19:26
-
-
Save thomsmed/60fb99ef2c658d918d9550c260a035f3 to your computer and use it in GitHub Desktop.
A general purpose HTTPClient with support for interceptors.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // DefaultHTTPClient+HTTPClient.swift | |
| // | |
| import Foundation | |
| /// A context for interceptors to get a hold on the ``URLSession``, ``JSONEncoder`` and ``JSONDecoder`` the associated ``HTTPClient`` uses. | |
| /// | |
| /// These properties can be used to decode/encode or do intermediate network request before/after outgoing network request. | |
| public struct HTTPClientContext { | |
| let urlSession: URLSession | |
| let encoder: JSONEncoder | |
| let decoder: JSONDecoder | |
| } | |
| /// Protocol for implementing interceptors used by ``HTTPClient``. | |
| /// | |
| /// The first interceptor passed to ``HTTPClient`` is the first to prepare outgoing request, | |
| /// and the last to process incoming responses. | |
| public protocol HTTPClientInterceptor { | |
| /// Prepare the outgoing ``URLRequest``. | |
| /// The first interceptor passed to ``HTTPClient`` will be the first to have a chance at manipulating the outgoing requests. | |
| func prepare(_ request: inout URLRequest, with context: HTTPClientContext) async throws | |
| /// Handle any error that might occur while the outgoing ``URLRequest`` is in flight. | |
| /// The first interceptor passed to ``HTTPClient`` will be the first to have a chance at reacting to the error. | |
| func handle(_ request: URLRequest, error: Error) async | |
| /// Process the incoming ``HTTPURLResponse``. | |
| /// The first interceptor passed to ``HTTPClient`` will be the last to have a chance at manipulating the incoming response data. | |
| /// And vice versa, the last interceptor will be the first to have a chance processing the incoming response data. | |
| func process(_ response: HTTPURLResponse, data: inout Data, with context: HTTPClientContext) async throws | |
| } | |
| /// Make methods non-required with default empty implementations. | |
| extension HTTPClientInterceptor { | |
| public func prepare(_ request: inout URLRequest, with context: HTTPClientContext) async throws {} | |
| public func handle(_ request: URLRequest, error: Error) async {} | |
| public func process(_ response: HTTPURLResponse, data: inout Data, with context: HTTPClientContext) async throws {} | |
| } | |
| /// Enumeration representing possible HTTP MIME (Multipurpose Internet Mail Extensions) Types. | |
| public enum HTTPMimeType: String { | |
| case textHtml = "text/html" | |
| case applicationJson = "application/json" | |
| case applicationJoseJson = "application/jose+json" | |
| } | |
| /// A general purpose ``HTTPClient`` for doing network requests, and with support for interceptors. | |
| public protocol HTTPClient { | |
| func get<ResponseBody: Decodable>( | |
| url: URL, | |
| responseType: HTTPMimeType, | |
| interceptors: [HTTPClientInterceptor] | |
| ) async throws -> ResponseBody | |
| func post<RequestBody: Encodable>( | |
| url: URL, | |
| requestBody: RequestBody, | |
| requestType: HTTPMimeType, | |
| interceptors: [HTTPClientInterceptor] | |
| ) async throws | |
| func post<RequestBody: Encodable, ResponseBody: Decodable>( | |
| url: URL, | |
| requestBody: RequestBody, | |
| requestType: HTTPMimeType, | |
| responseType: HTTPMimeType, | |
| interceptors: [HTTPClientInterceptor] | |
| ) async throws -> ResponseBody | |
| } | |
| /// Convenience methods with empty array of interceptors. | |
| extension HTTPClient { | |
| public func get<ResponseBody: Decodable>( | |
| url: URL, | |
| responseType: HTTPMimeType | |
| ) async throws -> ResponseBody { | |
| try await get( | |
| url: url, | |
| responseType: responseType, | |
| interceptors: [] | |
| ) | |
| } | |
| public func post<RequestBody: Encodable>( | |
| url: URL, | |
| requestBody: RequestBody, | |
| requestType: HTTPMimeType | |
| ) async throws { | |
| try await post( | |
| url: url, | |
| requestBody: requestBody, | |
| requestType: requestType, | |
| interceptors: [] | |
| ) | |
| } | |
| public func post<RequestBody: Encodable, ResponseBody: Decodable>( | |
| url: URL, | |
| requestBody: RequestBody, | |
| requestType: HTTPMimeType, | |
| responseType: HTTPMimeType | |
| ) async throws -> ResponseBody { | |
| try await post( | |
| url: url, | |
| requestBody: requestBody, | |
| requestType: requestType, | |
| responseType: responseType, | |
| interceptors: [] | |
| ) | |
| } | |
| } | |
| /// Enumeration representing possible HTTP Request Methods. | |
| public enum HTTPMethod: String { | |
| case get = "GET" | |
| case post = "POST" | |
| case put = "PUT" | |
| case delete = "DELETE" | |
| } | |
| /// Enumeration describing errors that might occur in ``HTTPClient``. | |
| public enum HTTPClientError: Error { | |
| case failedToEncodeRequest | |
| case failedToDecodeResponse | |
| case clientError(Int) | |
| case serverError(Int) | |
| case unexpectedStatusCode(Int) | |
| } | |
| /// A default implementation of ``HTTPClient``. | |
| public class DefaultHTTPClient { | |
| private static let defaultTimeout: TimeInterval = 15 | |
| let urlSession: URLSession | |
| let encoder: JSONEncoder | |
| let decoder: JSONDecoder | |
| let interceptors: [HTTPClientInterceptor] | |
| /// Initialize a new instance of ``HTTPClient``. | |
| /// | |
| /// - Parameters: | |
| /// - urlSession: URLSession used for network requests. | |
| /// - encoder: JSONEncoder used to encode request bodies. | |
| /// - decoder: JSONDecoder used to decode response bodies. | |
| /// - interceptors: An array of ``HTTPClientInterceptor``. The order matter, as the first ``HTTPClientInterceptor`` is the first to prepare outgoing requests, and the last to process incoming responses. | |
| public init( | |
| urlSession: URLSession = URLSession.shared, | |
| encoder: JSONEncoder = JSONEncoder(), | |
| decoder: JSONDecoder = JSONDecoder(), | |
| interceptors: [HTTPClientInterceptor] = [] | |
| ) { | |
| self.urlSession = urlSession | |
| self.encoder = encoder | |
| self.decoder = decoder | |
| self.interceptors = interceptors | |
| } | |
| private func send<RequestBody: Encodable>( | |
| url: URL, | |
| httpMethod: HTTPMethod, | |
| requestBody: RequestBody, | |
| contentType: HTTPMimeType, | |
| timeout timeoutInterval: TimeInterval, | |
| perRequestInterceptors: [HTTPClientInterceptor] | |
| ) async throws { | |
| var request = URLRequest(url: url) | |
| request.httpMethod = httpMethod.rawValue | |
| request.setValue(contentType.rawValue, forHTTPHeaderField: "Content-Type") | |
| request.timeoutInterval = timeoutInterval | |
| request.httpBody = try encoder.encode(requestBody) | |
| for interceptor in interceptors { | |
| try await interceptor.prepare( | |
| &request, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| // Per-request interceptors prepare the request last. | |
| for interceptor in perRequestInterceptors { | |
| try await interceptor.prepare( | |
| &request, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| var (data, response): (Data, URLResponse) | |
| do { | |
| (data, response) = try await urlSession.data(for: request) | |
| } catch { | |
| for interceptor in interceptors { | |
| await interceptor.handle(request, error: error) | |
| } | |
| for interceptor in perRequestInterceptors { | |
| await interceptor.handle(request, error: error) | |
| } | |
| // Re-throw the error | |
| throw error | |
| } | |
| guard let httpResponse = response as? HTTPURLResponse else { | |
| throw HTTPClientError.failedToEncodeRequest | |
| } | |
| // Let the last per-request interceptor process the response first. | |
| for interceptor in perRequestInterceptors.reversed() { | |
| try await interceptor.process( | |
| httpResponse, | |
| data: &data, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| // Let the last interceptor process the response first. | |
| for interceptor in interceptors.reversed() { | |
| try await interceptor.process( | |
| httpResponse, | |
| data: &data, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| switch httpResponse.statusCode { | |
| case 200..<300: | |
| return | |
| case 300..<400: | |
| // Handle 3xx in a different way? | |
| return | |
| case 400..<500: | |
| throw HTTPClientError.clientError(httpResponse.statusCode) | |
| case 500..<600: | |
| throw HTTPClientError.serverError(httpResponse.statusCode) | |
| default: | |
| throw HTTPClientError.unexpectedStatusCode(httpResponse.statusCode) | |
| } | |
| } | |
| private func send<ResponseBody: Decodable>( | |
| url: URL, | |
| httpMethod: HTTPMethod, | |
| accept: HTTPMimeType, | |
| timeout timeoutInterval: TimeInterval, | |
| perRequestInterceptors: [HTTPClientInterceptor] | |
| ) async throws -> ResponseBody { | |
| var request = URLRequest(url: url) | |
| request.httpMethod = httpMethod.rawValue | |
| request.setValue(accept.rawValue, forHTTPHeaderField: "Accept") | |
| request.timeoutInterval = timeoutInterval | |
| for interceptor in interceptors { | |
| try await interceptor.prepare( | |
| &request, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| // Per-request interceptors prepare the request last. | |
| for interceptor in perRequestInterceptors { | |
| try await interceptor.prepare( | |
| &request, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| var (data, response): (Data, URLResponse) | |
| do { | |
| (data, response) = try await urlSession.data(for: request) | |
| } catch { | |
| for interceptor in interceptors { | |
| await interceptor.handle(request, error: error) | |
| } | |
| for interceptor in perRequestInterceptors { | |
| await interceptor.handle(request, error: error) | |
| } | |
| // Re-throw the error | |
| throw error | |
| } | |
| guard let httpResponse = response as? HTTPURLResponse else { | |
| throw HTTPClientError.failedToEncodeRequest | |
| } | |
| // Let the last per-request interceptor process the response first. | |
| for interceptor in perRequestInterceptors.reversed() { | |
| try await interceptor.process( | |
| httpResponse, | |
| data: &data, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| // Let the last interceptor process the response first. | |
| for interceptor in interceptors.reversed() { | |
| try await interceptor.process( | |
| httpResponse, | |
| data: &data, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| switch httpResponse.statusCode { | |
| case 200..<300: | |
| return try decoder.decode(ResponseBody.self, from: data) | |
| case 300..<400: | |
| // Handle 3xx in a different way? | |
| return try decoder.decode(ResponseBody.self, from: data) | |
| case 400..<500: | |
| throw HTTPClientError.clientError(httpResponse.statusCode) | |
| case 500..<600: | |
| throw HTTPClientError.serverError(httpResponse.statusCode) | |
| default: | |
| throw HTTPClientError.unexpectedStatusCode(httpResponse.statusCode) | |
| } | |
| } | |
| private func send<RequestBody: Encodable, ResponseBody: Decodable>( | |
| url: URL, | |
| httpMethod: HTTPMethod, | |
| requestBody: RequestBody, | |
| contentType: HTTPMimeType, | |
| accept: HTTPMimeType, | |
| timeout timeoutInterval: TimeInterval, | |
| perRequestInterceptors: [HTTPClientInterceptor] | |
| ) async throws -> ResponseBody { | |
| var request = URLRequest(url: url) | |
| request.httpMethod = httpMethod.rawValue | |
| request.setValue(contentType.rawValue, forHTTPHeaderField: "Content-Type") | |
| request.setValue(accept.rawValue, forHTTPHeaderField: "Accept") | |
| request.timeoutInterval = timeoutInterval | |
| request.httpBody = try encoder.encode(requestBody) | |
| for interceptor in interceptors { | |
| try await interceptor.prepare( | |
| &request, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| // Per-request interceptors prepare the request last. | |
| for interceptor in perRequestInterceptors { | |
| try await interceptor.prepare( | |
| &request, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| var (data, response): (Data, URLResponse) | |
| do { | |
| (data, response) = try await urlSession.data(for: request) | |
| } catch { | |
| for interceptor in interceptors { | |
| await interceptor.handle(request, error: error) | |
| } | |
| for interceptor in perRequestInterceptors { | |
| await interceptor.handle(request, error: error) | |
| } | |
| // Re-throw the error | |
| throw error | |
| } | |
| guard let httpResponse = response as? HTTPURLResponse else { | |
| throw HTTPClientError.failedToEncodeRequest | |
| } | |
| // Let the last per-request interceptor process the response first. | |
| for interceptor in perRequestInterceptors.reversed() { | |
| try await interceptor.process( | |
| httpResponse, | |
| data: &data, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| // Let the last interceptor process the response first. | |
| for interceptor in interceptors.reversed() { | |
| try await interceptor.process( | |
| httpResponse, | |
| data: &data, | |
| with: .init( | |
| urlSession: urlSession, | |
| encoder: encoder, | |
| decoder: decoder | |
| ) | |
| ) | |
| } | |
| switch httpResponse.statusCode { | |
| case 200..<300: | |
| return try decoder.decode(ResponseBody.self, from: data) | |
| case 300..<400: | |
| // Handle 3xx in a different way? | |
| return try decoder.decode(ResponseBody.self, from: data) | |
| case 400..<500: | |
| throw HTTPClientError.clientError(httpResponse.statusCode) | |
| case 500..<600: | |
| throw HTTPClientError.serverError(httpResponse.statusCode) | |
| default: | |
| throw HTTPClientError.unexpectedStatusCode(httpResponse.statusCode) | |
| } | |
| } | |
| } | |
| extension DefaultHTTPClient: HTTPClient { | |
| public func get<ResponseBody: Decodable>( | |
| url: URL, | |
| responseType: HTTPMimeType, | |
| interceptors: [HTTPClientInterceptor] | |
| ) async throws -> ResponseBody { | |
| try await send( | |
| url: url, | |
| httpMethod: .get, | |
| accept: responseType, | |
| timeout: Self.defaultTimeout, | |
| perRequestInterceptors: interceptors | |
| ) | |
| } | |
| public func post<RequestBody: Encodable>( | |
| url: URL, | |
| requestBody: RequestBody, | |
| requestType: HTTPMimeType, | |
| interceptors: [HTTPClientInterceptor] | |
| ) async throws { | |
| try await send( | |
| url: url, | |
| httpMethod: .post, | |
| requestBody: requestBody, | |
| contentType: requestType, | |
| timeout: Self.defaultTimeout, | |
| perRequestInterceptors: interceptors | |
| ) | |
| } | |
| public func post<RequestBody: Encodable, ResponseBody: Decodable>( | |
| url: URL, | |
| requestBody: RequestBody, | |
| requestType: HTTPMimeType, | |
| responseType: HTTPMimeType, | |
| interceptors: [HTTPClientInterceptor] | |
| ) async throws -> ResponseBody { | |
| try await send( | |
| url: url, | |
| httpMethod: .post, | |
| requestBody: requestBody, | |
| contentType: requestType, | |
| accept: responseType, | |
| timeout: Self.defaultTimeout, | |
| perRequestInterceptors: interceptors | |
| ) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment