|
| 1 | +import Foundation |
| 2 | +#if canImport(FoundationNetworking) |
| 3 | +import FoundationNetworking |
| 4 | +#endif |
| 5 | + |
| 6 | +/// A client to consume a REST API. Uses JSON to encode/decode body data. |
| 7 | +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) |
| 8 | +public final class RESTClient: Sendable { |
| 9 | + public enum RequestError: Error, LocalizedError, Sendable { |
| 10 | + public typealias Context = String |
| 11 | + |
| 12 | + case failedToEncodeBody(Error, Context?) |
| 13 | + case failedToLoadData(Error, Context?) |
| 14 | + case failedToDecodeSuccessBody(Error, Context?) |
| 15 | + case failedToDecodeClientErrorBody(Error, Context?) |
| 16 | + case clientError(String, Context?) |
| 17 | + case unexpectedResponseType(URLResponse, Context?) |
| 18 | + case unexpectedStatusCode(Int, Context?) |
| 19 | + |
| 20 | + public var errorDescription: String? { |
| 21 | + switch self { |
| 22 | + case .failedToEncodeBody(let error, _): |
| 23 | + "\(self.errorContext) Failed to encode body: \(error.localizedDescription)" |
| 24 | + case .failedToLoadData(let error, _): |
| 25 | + "\(self.errorContext) Failed to load data: \(error.localizedDescription)" |
| 26 | + case .failedToDecodeSuccessBody(let error, _): |
| 27 | + "\(self.errorContext) Failed to decode success body: \(error.localizedDescription)" |
| 28 | + case .failedToDecodeClientErrorBody(let error, _): |
| 29 | + "\(self.errorContext) Failed to decode client error body: \(error.localizedDescription)" |
| 30 | + case .clientError(let string, _): |
| 31 | + "\(self.errorContext) \(string)" |
| 32 | + case .unexpectedResponseType(let urlResponse, _): |
| 33 | + "\(self.errorContext) Unexpected response type (non-HTTP): \(String(describing: type(of: urlResponse)))" |
| 34 | + case .unexpectedStatusCode(let int, _): |
| 35 | + "\(self.errorContext) Unexpected status code: \(int)" |
| 36 | + } |
| 37 | + } |
| 38 | + |
| 39 | + private var errorContext: String { |
| 40 | + switch self { |
| 41 | + case .failedToEncodeBody(_, let context), .failedToLoadData(_, let context), .failedToDecodeSuccessBody(_, let context), |
| 42 | + .failedToDecodeClientErrorBody(_, let context), .clientError(_, let context): |
| 43 | + if let context { |
| 44 | + return "[\(context): Client Error]" |
| 45 | + } else { |
| 46 | + return "[Client Error]" |
| 47 | + } |
| 48 | + |
| 49 | + case .unexpectedResponseType(_, let context), .unexpectedStatusCode(_, let context): |
| 50 | + if let context { |
| 51 | + return "[\(context): Server Error]" |
| 52 | + } else { |
| 53 | + return "[Server Error]" |
| 54 | + } |
| 55 | + } |
| 56 | + } |
| 57 | + } |
| 58 | + |
| 59 | + public enum HTTPMethod: String, Sendable { |
| 60 | + case get = "GET" |
| 61 | + case post = "POST" |
| 62 | + case put = "PUT" |
| 63 | + case patch = "PATCH" |
| 64 | + case delete = "DELETE" |
| 65 | + } |
| 66 | + |
| 67 | + public enum Body: Sendable { |
| 68 | + case binary(Data) |
| 69 | + case json(Encodable & Sendable) |
| 70 | + case string(String) |
| 71 | + case form([URLQueryItem]) |
| 72 | + |
| 73 | + var contentType: String { |
| 74 | + switch self { |
| 75 | + case .binary: return "application/octet-stream" |
| 76 | + case .json: return "application/json" |
| 77 | + case .string: return "text/plain" |
| 78 | + case .form: return "application/x-www-form-urlencoded" |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + func httpData() throws -> Data { |
| 83 | + switch self { |
| 84 | + case .binary(let data): |
| 85 | + return data |
| 86 | + |
| 87 | + case .json(let json): |
| 88 | + return try JSONEncoder().encode(json) |
| 89 | + |
| 90 | + case .string(let string): |
| 91 | + return Data(string.utf8) |
| 92 | + |
| 93 | + case .form(let queryItems): |
| 94 | + var urlComponents = URLComponents(string: "https://example.com")! |
| 95 | + urlComponents.queryItems = queryItems |
| 96 | + return Data(urlComponents.percentEncodedQuery!.utf8) |
| 97 | + } |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + let baseURL: URL |
| 102 | + let baseHeaders: [String: String] |
| 103 | + let baseQueryItems: [URLQueryItem] |
| 104 | + let jsonEncoder: JSONEncoder |
| 105 | + let jsonDecoder: JSONDecoder |
| 106 | + let baseErrorContext: String? |
| 107 | + let errorBodyToMessage: @Sendable (Data) throws -> String |
| 108 | + |
| 109 | + // no need to pass 'application/json' to `baseHeaders`, it'll automatically be added of a body is sent |
| 110 | + public init( |
| 111 | + baseURL: URL, |
| 112 | + baseHeaders: [String: String] = [:], |
| 113 | + baseQueryItems: [URLQueryItem] = [], |
| 114 | + jsonEncoder: JSONEncoder = .init(), |
| 115 | + jsonDecoder: JSONDecoder = .init(), |
| 116 | + baseErrorContext: String? = nil, |
| 117 | + errorBodyToMessage: @Sendable @escaping (Data) throws -> String |
| 118 | + ) { |
| 119 | + self.baseURL = baseURL |
| 120 | + self.baseHeaders = baseHeaders |
| 121 | + self.baseQueryItems = baseQueryItems |
| 122 | + self.jsonEncoder = jsonEncoder |
| 123 | + self.jsonDecoder = jsonDecoder |
| 124 | + self.baseErrorContext = baseErrorContext |
| 125 | + self.errorBodyToMessage = errorBodyToMessage |
| 126 | + } |
| 127 | + |
| 128 | + public func requestObject<ResponseBodyType: Decodable>( |
| 129 | + method: HTTPMethod, |
| 130 | + path: String, |
| 131 | + body: Body? = nil, |
| 132 | + extraHeaders: [String: String] = [:], |
| 133 | + extraQueryItems: [URLQueryItem] = [], |
| 134 | + errorContext: String? = nil |
| 135 | + ) async throws(RequestError) -> ResponseBodyType { |
| 136 | + let responseData = try await self.requestData(method: method, path: path, body: body, extraHeaders: extraHeaders, extraQueryItems: extraQueryItems) |
| 137 | + |
| 138 | + do { |
| 139 | + return try self.jsonDecoder.decode(ResponseBodyType.self, from: responseData) |
| 140 | + } catch { |
| 141 | + throw .failedToDecodeSuccessBody(error, self.errorContext(requestContext: errorContext)) |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + @discardableResult |
| 146 | + public func requestData( |
| 147 | + method: HTTPMethod, |
| 148 | + path: String, |
| 149 | + body: Body? = nil, |
| 150 | + extraHeaders: [String: String] = [:], |
| 151 | + extraQueryItems: [URLQueryItem] = [], |
| 152 | + errorContext: String? = nil |
| 153 | + ) async throws(RequestError) -> Data { |
| 154 | + let url = self.baseURL |
| 155 | + .appending(path: path) |
| 156 | + .appending(queryItems: self.baseQueryItems + extraQueryItems) |
| 157 | + |
| 158 | + var request = URLRequest(url: url) |
| 159 | + request.httpMethod = method.rawValue |
| 160 | + |
| 161 | + for (field, value) in self.baseHeaders.merging(extraHeaders, uniquingKeysWith: { $1 }) { |
| 162 | + request.setValue(value, forHTTPHeaderField: field) |
| 163 | + } |
| 164 | + |
| 165 | + if let body { |
| 166 | + do { |
| 167 | + request.httpBody = try body.httpData() |
| 168 | + } catch { |
| 169 | + throw RequestError.failedToEncodeBody(error, self.errorContext(requestContext: errorContext)) |
| 170 | + } |
| 171 | + |
| 172 | + request.setValue(body.contentType, forHTTPHeaderField: "Content-Type") |
| 173 | + } |
| 174 | + |
| 175 | + request.setValue("application/json", forHTTPHeaderField: "Accept") |
| 176 | + |
| 177 | + let (data, response) = try await self.performRequest(request, errorContext: errorContext) |
| 178 | + return try await self.handle(data: data, response: response, for: request, errorContext: errorContext) |
| 179 | + } |
| 180 | + |
| 181 | + private func errorContext(requestContext: String?) -> String? { |
| 182 | + let context = [self.baseErrorContext, requestContext].compactMap { $0 }.joined(separator: "->") |
| 183 | + guard !context.isEmpty else { return nil } |
| 184 | + return context |
| 185 | + } |
| 186 | + |
| 187 | + private func performRequest(_ request: URLRequest, errorContext: String?) async throws(RequestError) -> (Data, URLResponse) { |
| 188 | + self.logRequestIfDebug(request) |
| 189 | + |
| 190 | + let data: Data |
| 191 | + let response: URLResponse |
| 192 | + do { |
| 193 | + (data, response) = try await URLSession.shared.data(for: request) |
| 194 | + } catch { |
| 195 | + throw RequestError.failedToLoadData(error, self.errorContext(requestContext: errorContext)) |
| 196 | + } |
| 197 | + |
| 198 | + self.logResponseIfDebug(response, data: data) |
| 199 | + return (data, response) |
| 200 | + } |
| 201 | + |
| 202 | + private func handle(data: Data, response: URLResponse, for request: URLRequest, errorContext: String?, attempt: Int = 1) async throws(RequestError) -> Data { |
| 203 | + guard let httpResponse = response as? HTTPURLResponse else { |
| 204 | + throw .unexpectedResponseType(response, self.errorContext(requestContext: errorContext)) |
| 205 | + } |
| 206 | + |
| 207 | + switch httpResponse.statusCode { |
| 208 | + case 200..<300: |
| 209 | + return data |
| 210 | + |
| 211 | + case 429: |
| 212 | + guard attempt < 5 else { fallthrough } |
| 213 | + |
| 214 | + #if DEBUG |
| 215 | + print("Received Status Code 429 'Too Many Requests'. Retrying in \(attempt) second(s)...") |
| 216 | + #endif |
| 217 | + |
| 218 | + try? await Task.sleep(for: .seconds(attempt)) |
| 219 | + |
| 220 | + let (newData, newResponse) = try await self.performRequest(request, errorContext: errorContext) |
| 221 | + return try await self.handle(data: newData, response: newResponse, for: request, errorContext: errorContext, attempt: attempt + 1) |
| 222 | + |
| 223 | + case 400..<500: |
| 224 | + guard !data.isEmpty else { |
| 225 | + throw .clientError("Unexpected status code \(httpResponse.statusCode) without a response body.", self.errorContext(requestContext: errorContext)) |
| 226 | + } |
| 227 | + |
| 228 | + let clientErrorMessage: String |
| 229 | + do { |
| 230 | + clientErrorMessage = try self.errorBodyToMessage(data) |
| 231 | + } catch { |
| 232 | + throw .failedToDecodeClientErrorBody(error, self.errorContext(requestContext: errorContext)) |
| 233 | + } |
| 234 | + throw .clientError(clientErrorMessage, self.errorContext(requestContext: errorContext)) |
| 235 | + |
| 236 | + default: |
| 237 | + throw .unexpectedStatusCode(httpResponse.statusCode, self.errorContext(requestContext: errorContext)) |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + private func logRequestIfDebug(_ request: URLRequest) { |
| 242 | + #if DEBUG |
| 243 | + var requestBodyString: String? |
| 244 | + if let bodyData = request.httpBody { |
| 245 | + requestBodyString = String(data: bodyData, encoding: .utf8) |
| 246 | + } |
| 247 | + |
| 248 | + print("[\(self)] Sending \(request.httpMethod!) request to '\(request.url!)': \(request)\n\nHeaders:\n\(request.allHTTPHeaderFields ?? [:])\n\nBody:\n\(requestBodyString ?? "No body")") |
| 249 | + #endif |
| 250 | + } |
| 251 | + |
| 252 | + private func logResponseIfDebug(_ response: URLResponse, data: Data?) { |
| 253 | + #if DEBUG |
| 254 | + var responseBodyString: String? |
| 255 | + if let data = data { |
| 256 | + responseBodyString = String(data: data, encoding: .utf8) |
| 257 | + } |
| 258 | + |
| 259 | + print("[\(self)] Received response & body from '\(response.url!)': \(response)\n\nResponse headers:\n\((response as? HTTPURLResponse)?.allHeaderFields ?? [:])\n\nResponse body:\n\(responseBodyString ?? "No body")") |
| 260 | + #endif |
| 261 | + } |
| 262 | +} |
0 commit comments