Skip to content

Instantly share code, notes, and snippets.

@thomsmed
Last active September 2, 2023 19:26
Show Gist options
  • Select an option

  • Save thomsmed/60fb99ef2c658d918d9550c260a035f3 to your computer and use it in GitHub Desktop.

Select an option

Save thomsmed/60fb99ef2c658d918d9550c260a035f3 to your computer and use it in GitHub Desktop.
A general purpose HTTPClient with support for interceptors.
//
// 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