Last active
April 1, 2026 10:40
-
-
Save thomsmed/76aac9adf5257312156a0691859a259a to your computer and use it in GitHub Desktop.
Showcasing the idea of an extendable and modular Endpoint type. Heavily inspired by [TinyNetworking](https://github.com/objcio/tiny-networking).
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
| // | |
| // Endpoint.playground | |
| // | |
| // Showcasing the idea of an Endpoint type (heavily inspired by [TinyNetworking](https://github.com/objcio/tiny-networking)). | |
| // Endpoint is an extendable and modular type (struct) holding all information (and logic) needed in order to prepare a HTTP request, launch a HTTP request, and process the returned HTTP response. | |
| // | |
| import Foundation | |
| import PlaygroundSupport | |
| PlaygroundPage.current.needsIndefiniteExecution = true | |
| struct UnexpectedResponse: Error { | |
| let response: HTTPURLResponse | |
| let data: Data | |
| } | |
| struct RequestPayload { | |
| let mimeType: String? | |
| let body: Data? | |
| } | |
| struct ResponseParser<Value> { | |
| let mimeType: String? | |
| let parse: (HTTPURLResponse, Data) throws -> Value | |
| } | |
| extension RequestPayload { | |
| /// Empty HTTP Request Payload. | |
| static func empty() -> RequestPayload { | |
| RequestPayload(mimeType: nil, body: nil) | |
| } | |
| /// JSON HTTP Request Payload. | |
| static func json<T: Encodable>(encoding value: T) throws -> RequestPayload { | |
| let encoder = JSONEncoder() | |
| let data = try encoder.encode(value) | |
| return RequestPayload(mimeType: "application/json", body: data) | |
| } | |
| // ...other shared request payloads goes here... | |
| } | |
| extension ResponseParser { | |
| /// HTTP Response Parser that ignores any HTTP Response body, and just returns `Void`. | |
| static func void( | |
| expecting expectedStatusCodes: [Int] = [200] | |
| ) -> ResponseParser<Void> { | |
| ResponseParser<Void>(mimeType: nil) { response, data in | |
| guard expectedStatusCodes.contains(response.statusCode) else { | |
| throw UnexpectedResponse(response: response, data: data) | |
| } | |
| } | |
| } | |
| /// HTTP Response Parser that tries to parse any HTTP Response body as JSON. | |
| static func json<T: Decodable>( | |
| expecting expectedStatusCodes: [Int] = [200, 201] | |
| ) -> ResponseParser<T> { | |
| ResponseParser<T>(mimeType: "application/json") { response, data in | |
| guard expectedStatusCodes.contains(response.statusCode) else { | |
| throw UnexpectedResponse(response: response, data: data) | |
| } | |
| let decoder = JSONDecoder() | |
| return try decoder.decode(T.self, from: data) | |
| } | |
| } | |
| // ...other shared response parsers goes here... | |
| } | |
| /// A type that holds everything needed in order to prepare a HTTP request, send it, and process the received HTTP response. | |
| struct Endpoint<Resource> { | |
| public let url: URL | |
| public let method: String | |
| public let payload: RequestPayload | |
| public let parser: ResponseParser<Resource> | |
| // ...additional data goes here (details about authentication++)... | |
| } | |
| extension Endpoint { | |
| /// Utility method for mapping raw response types to more relevant domain types. | |
| func map<MappedResource>( | |
| _ transform: @escaping (Resource) -> MappedResource | |
| ) -> Endpoint<MappedResource> { | |
| Endpoint<MappedResource>( | |
| url: self.url, | |
| method: self.method, | |
| payload: self.payload, | |
| parser: ResponseParser( | |
| mimeType: self.parser.mimeType | |
| ) { response, data in | |
| transform(try self.parser.parse(response, data)) | |
| } | |
| ) | |
| } | |
| } | |
| /// Only one abstraction layer around networking - basically just a simple function. | |
| protocol HTTPClient { | |
| func call<Resource>(_ endpoint: Endpoint<Resource>) async throws -> Resource | |
| } | |
| /// For the simplest and most straight forward implementation of `HTTPClient` - just extend Foundation Framework's `URLSession`! | |
| extension URLSession: HTTPClient { | |
| func call<Resource>(_ endpoint: Endpoint<Resource>) async throws -> Resource { | |
| var request = URLRequest(url: endpoint.url) | |
| request.httpMethod = endpoint.method | |
| if let contentType = endpoint.payload.mimeType { | |
| request.setValue(contentType, forHTTPHeaderField: "Content-Type") | |
| } | |
| if let accept = endpoint.parser.mimeType { | |
| request.setValue(accept, forHTTPHeaderField: "Accept") | |
| } | |
| request.httpBody = endpoint.payload.body | |
| let (data, response) = try await data(for: request) as! (Data, HTTPURLResponse) | |
| return try endpoint.parser.parse(response, data) | |
| } | |
| } | |
| // Usage - defining and calling Endpoints | |
| // Free and open REST API for testing: https://jsonplaceholder.typicode.com/ | |
| struct Item: Codable { | |
| let id: Int? | |
| var title: String | |
| } | |
| /// Attach factory methods for Endpoints to types of relevance. | |
| extension Item { | |
| static func all() -> Endpoint<[Item]> { | |
| Endpoint( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos")!, | |
| method: "GET", | |
| payload: .empty(), | |
| parser: .json() | |
| ) | |
| } | |
| static func with(id: Int) -> Endpoint<Item> { | |
| struct Todo: Decodable { | |
| let id: Int | |
| let userId: Int | |
| let title: String | |
| let completed: Bool | |
| } | |
| return Endpoint<Todo>( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos/\(id)")!, | |
| method: "GET", | |
| payload: .empty(), | |
| parser: .json() | |
| ).map { todo in | |
| Item(id: todo.id, title: todo.title) | |
| } | |
| } | |
| func save() throws -> Endpoint<Item> { | |
| if let id = self.id { | |
| Endpoint( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos/\(id)")!, | |
| method: "PUT", | |
| payload: try .json(encoding: self), | |
| parser: .json() | |
| ) | |
| } else { | |
| Endpoint( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos")!, | |
| method: "POST", | |
| payload: try .json(encoding: self), | |
| parser: .json() | |
| ) | |
| } | |
| } | |
| // ...other endpoint factory methods goes here... | |
| } | |
| /// Alternatively, attach factory methods for Endpoints to dedicated "namespaces". | |
| enum ItemEndpoints { | |
| static func all() -> Endpoint<[Item]> { | |
| Endpoint( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos")!, | |
| method: "GET", | |
| payload: .empty(), | |
| parser: .json() | |
| ) | |
| } | |
| static func with(id: Int) -> Endpoint<Item> { | |
| struct Todo: Decodable { | |
| let id: Int | |
| let userId: Int | |
| let title: String | |
| let completed: Bool | |
| } | |
| return Endpoint<Todo>( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos/\(id)")!, | |
| method: "GET", | |
| payload: .empty(), | |
| parser: .json() | |
| ).map { todo in | |
| Item(id: todo.id, title: todo.title) | |
| } | |
| } | |
| static func save(_ item: Item) throws -> Endpoint<Item> { | |
| if let id = item.id { | |
| Endpoint( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos/\(id)")!, | |
| method: "PUT", | |
| payload: try .json(encoding: item), | |
| parser: .json() | |
| ) | |
| } else { | |
| Endpoint( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos")!, | |
| method: "POST", | |
| payload: try .json(encoding: item), | |
| parser: .json() | |
| ) | |
| } | |
| } | |
| } | |
| Task { | |
| do { | |
| let httpClient: HTTPClient = URLSession.shared | |
| var allItemsEndpoint = Endpoint<[Item]>( | |
| url: URL(string: "https://jsonplaceholder.typicode.com/todos")!, | |
| method: "GET", | |
| payload: .empty(), | |
| parser: .json() | |
| ) // Construct Endpoints inline | |
| allItemsEndpoint = Item.all() // Construct Endpoints using factory methods on the type of interest | |
| allItemsEndpoint = ItemEndpoints.all() // Construct Endpoints using factory methods under dedicated "namespace" | |
| let items = try await httpClient.call(allItemsEndpoint) | |
| print("Items:", items) | |
| // ... | |
| var newItem = Item(id: nil, title: "New Item") | |
| newItem = try await httpClient.call(newItem.save()) | |
| print("New item:", newItem) | |
| // ... | |
| let previousItemId = newItem.id! - 1 | |
| let previousItem = try await httpClient.call(Item.with(id: previousItemId)) | |
| print("Previous item:", previousItem) | |
| // ... | |
| var changedItem = previousItem | |
| changedItem.title = "Updated Item" | |
| let updatedItem = try await httpClient.call(changedItem.save()) | |
| print("Updated item:", updatedItem) | |
| } catch let unexpectedResponse as UnexpectedResponse { | |
| print("Unexpected response:", unexpectedResponse) | |
| print("Unexpected response (body):", String(data: unexpectedResponse.data, encoding: .utf8) ?? "") | |
| } catch { | |
| print("Error:", error) | |
| } | |
| PlaygroundPage.current.finishExecution() | |
| } | |
| // End |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment