https://github.com/diamirio/endpoints
Type-Safe Swift Networking
https://github.com/diamirio/endpoints
generics ios macos networking parsing swift swift-5 tvos watchos
Last synced: 6 months ago
JSON representation
Type-Safe Swift Networking
- Host: GitHub
- URL: https://github.com/diamirio/endpoints
- Owner: diamirio
- License: mit
- Created: 2017-02-22T20:32:16.000Z (over 9 years ago)
- Default Branch: develop
- Last Pushed: 2025-02-05T08:19:16.000Z (over 1 year ago)
- Last Synced: 2025-03-13T05:30:29.264Z (over 1 year ago)
- Topics: generics, ios, macos, networking, parsing, swift, swift-5, tvos, watchos
- Language: Swift
- Homepage:
- Size: 1.07 MB
- Stars: 47
- Watchers: 6
- Forks: 7
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README

# Endpoints
[](https://github.com/apple/swift-package-manager)
Endpoints makes it easy to write a type-safe network abstraction layer for any Web-API.
It requires Swift 6.2+, makes heavy use of generics and protocols (with protocol extensions). It also encourages a clean separation of concerns and the use of value types (i.e. structs). Built for modern Swift concurrency with async/await and actor support.
**Key Features:**
- **Type-safe API**: Strongly typed requests and responses
- **Swift 6.2+**: Full support for Swift's strict concurrency model
- **Actor-based Session**: Thread-safe networking with `Session` as an actor
- **Sendable conformance**: All core protocols require `Sendable` conformance for safe concurrent access
- **Async/await**: Native async/await support throughout the API
- **Flexible parsing**: Multiple built-in response parsers with support for custom parsers
- **JSON Codable**: First-class support for `Codable` types
## Requirements
* Swift 6.2+
* iOS 13+
* tvOS 12+
* macOS 10.15+
* watchOS 6+
* visionOS 1+
## Installation
**Swift Package Manager:**
```swift
.package(url: "https://github.com/diamirio/Endpoints.git", .upToNextMajor(from: "4.0.0"))
```
## Usage
### Basics
Here's how to load a random image from Giphy.
```swift
// A client is responsible for encoding and parsing all calls for a given Web-API.
let client = DefaultClient(url: URL(string: "https://api.giphy.com/v1/")!)
// A call encapsulates the request that is sent to the server and the type that is expected in the response.
let call = AnyCall(Request(.get, "gifs/random", query: ["tag": "cat", "api_key": "dc6zaTOxFJmzC"]))
// A session is an actor that wraps `URLSession` and allows you to start the request for the call and get the parsed response object (or an error).
// Session is an actor, ensuring thread-safe access to URLSession.
let session = Session(with: client)
// Start call - returns the parsed body and HTTPURLResponse
let (body, httpResponse) = try await session.dataTask(for: call)
```
### Response Parsing
A call is supposed to know exactly what response to expect from its request. It delegates the parsing of the response to a `ResponseParser`.
Some built-in types already adopt the `ResponseParser` protocol (using protocol extensions), so you can for example turn any response into a JSON array or dictionary:
```swift
// Replace `DataResponseParser` with any `ResponseParser` implementation
let call = AnyCall>(Request(.get, "gifs/random", query: ["tag": "cat", "api_key": "dc6zaTOxFJmzC"]))
...
// body is now a JSON dictionary 🎉
let (body, httpResponse) = try await session.dataTask(for: call)
````
```swift
let call = AnyCall>(Request(.get, "gifs/random", query: ["tag": "cat", "api_key": "dc6zaTOxFJmzC"]))
...
// body is now a `GiphyGif` dictionary 🎉
let (body, httpResponse) = try await session.dataTask(for: call)
```
#### Provided `ResponseParser`s
Look up the documentation in the code for further explanations of the types.
* `DataResponseParser`
* `DictionaryParser`
* `JSONParser`
* `NoContentParser`
* `StringConvertibleParser`
* `StringParser`
#### JSON Codable Integration
`Endpoints` has built-in JSON Codable support.
##### Decoding
The `ResponseParser` responsible for handling decodable types is the `JSONParser`.
The default `JSONParser` comes pre-configured with:
- `dateDecodingStrategy = .iso8601`
- `keyDecodingStrategy = .convertFromSnakeCase`
```swift
// Decode a type using the default decoder (with iso8601 dates and snake_case conversion)
struct GiphyCall: Call {
typealias Parser = JSONParser
var request: URLRequestEncodable {
Request(.get, "gifs/random", query: ["tag": "cat"])
}
}
// If you need different decoder settings, create a custom parser
// Note: T must be Sendable for Swift 6.2+ concurrency safety
struct CustomJSONParser: ResponseParser {
typealias OutputType = T
let jsonDecoder: JSONDecoder
init() {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
decoder.keyDecodingStrategy = .useDefaultKeys
self.jsonDecoder = decoder
}
func parse(data: Data, encoding: String.Encoding) throws -> T {
try jsonDecoder.decode(T.self, from: data)
}
}
struct GiphyCall: Call {
typealias Parser = CustomJSONParser
var request: URLRequestEncodable {
Request(.get, "gifs/random", query: ["tag": "cat"])
}
}
```
##### Encoding
Every encodable is able to provide a `JSONEncoder()` to encode itself via the `toJSON()` method.
### Dedicated Calls
`AnyCall` is the default implementation of the `Call` protocol, which you can use as-is. But if you want to make your networking layer really type-safe you'll want to create a dedicated `Call` type for each operation of your Web-API.
**Note:** All `Call` types must conform to `Sendable` for Swift 6.2+ concurrency safety. Use value types (structs) with sendable properties:
```swift
struct GetRandomImage: Call {
typealias Parser = DictionaryParser
var tag: String
var request: URLRequestEncodable {
return Request(.get, "gifs/random", query: [ "tag": tag, "api_key": "dc6zaTOxFJmzC" ])
}
}
// `GetRandomImage` is much safer and easier to use than `AnyCall`
let call = GetRandomImage(tag: "cat")
```
### Dedicated Clients
A client is responsible for handling things that are common for all operations of a given Web-API. Typically this includes appending API tokens or authentication tokens to a request or validating responses and handling errors.
`DefaultClient` is the default implementation of the `Client` protocol and can be used as-is or as a starting point for your own dedicated client.
You'll usually need to create your own dedicated client that implements the `Client` protocol and delegates the encoding of requests and parsing of responses to a `DefaultClient` instance, as done here.
**Note:** All `Client` types must conform to `Sendable`. Use structs with sendable properties to ensure thread-safety:
```swift
struct GiphyClient: Client {
private let client: Client
let apiKey = "dc6zaTOxFJmzC"
init() {
let url = URL(string: "https://api.giphy.com/v1/")!
self.client = DefaultClient(url: url)
}
func encode(call: some Call) async throws -> URLRequest {
var request = try await client.encode(call: call)
// Append the API key to every request's URL
if let url = request.url,
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) {
var queryItems = components.queryItems ?? []
queryItems.append(URLQueryItem(name: "api_key", value: apiKey))
components.queryItems = queryItems
request.url = components.url
}
return request
}
func parse(response: HTTPURLResponse?, data: Data?, for call: C) async throws -> C.Parser.OutputType
where C: Call {
do {
// Use `DefaultClient` to parse the response
// If this fails, try to read error details from response body
return try await client.parse(response: response, data: data, for: call)
} catch {
// See if the backend sent detailed error information
guard
let response,
let data,
let errorDict = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
let meta = errorDict?["meta"] as? [String: Any],
let errorCode = meta["error_code"] as? String
else {
// no error info from backend -> rethrow default error
throw error
}
// Propagate error that contains errorCode as reason from backend
throw StatusCodeError.unacceptable(code: response.statusCode, reason: errorCode)
}
}
func validate(response: HTTPURLResponse?, data: Data?) async throws {
// Delegate to the default client's validation
try await client.validate(response: response, data: data)
}
}
```
### Dedicated Response Types
You usually want your networking layer to provide a dedicated response type for every supported call. In our example this could look like this:
**Note:** Response types must conform to `Sendable` for Swift 6.2+ concurrency safety:
```swift
struct RandomImage: Decodable, Sendable {
struct Data: Decodable, Sendable {
let url: URL
private enum CodingKeys: String, CodingKey {
case url = "image_url"
}
}
let data: Data
}
struct GetRandomImage: Call {
typealias Parser = JSONParser
var tag: String
var request: URLRequestEncodable {
Request(.get, "gifs/random", query: ["tag": tag])
}
}
```
### Type-Safety
With all the parts in place, users of your networking layer can now perform type-safe requests and get a type-safe response with a few lines of code:
```swift
let client = GiphyClient()
let call = GetRandomImage(tag: "cat")
let session = Session(with: client)
let (body, response) = try await session.dataTask(for: call)
print("image url: \(body.data.url)")
```
## Example
Example implementation can be found [here](https://github.com/diamirio/Endpoints-Example).
## Migration Guides
If you're upgrading from a previous version, please refer to the migration guides:
- [Migrating from 3.x to 4.x](Migration/V4_0_0.md) - Swift 6.2+ strict concurrency, `AnyClient` → `DefaultClient`, and more
- [Migrating from 2.x to 3.x](Migration/V3_0_0.md) - Native async/await APIs
- [Migrating from 1.x to 2.x](Migration/V2_0_0.md)
## Advanced Features
### Debug Logging
Enable debug logging to see detailed request and response information:
```swift
let session = Session(with: client, debug: true)
```
This will log:
- cURL representation of the request
- Response status and headers
- Response body data
### Request Body Encoding
Endpoints supports multiple body encoding strategies:
```swift
// JSON encoded body
let jsonBody = try JSONEncodedBody(encodable: myModel)
let request = Request(.post, "users", body: jsonBody)
// Form-urlencoded body
let formBody = FormEncodedBody(parameters: ["username": "john", "password": "secret"])
let request = Request(.post, "login", body: formBody)
// Multipart form data (for file uploads)
let multipartBody = MultipartBody(parts: [
MultipartBody.Part(name: "avatar", data: imageData, filename: "profile.jpg", mimeType: "image/jpeg"),
MultipartBody.Part(name: "name", data: "John Doe".data(using: .utf8)!)
])
let request = Request(.post, "upload", body: multipartBody)
```
### Custom Validation
Both `Call` and `Client` can implement custom validation logic:
```swift
struct MyCall: Call {
typealias Parser = JSONParser
var request: URLRequestEncodable {
Request(.get, "data")
}
// Custom validation for this specific call
func validate(response: HTTPURLResponse?, data: Data?) async throws {
guard let response = response else { return }
// Require a specific header for this call
guard response.value(forHTTPHeaderField: "X-Custom-Header") != nil else {
throw MyError.missingHeader
}
}
}
struct MyClient: Client {
private let client: Client
init() {
self.client = DefaultClient(url: URL(string: "https://api.example.com")!)
}
// ... encode and parse implementations ...
// Custom validation for all calls using this client
func validate(response: HTTPURLResponse?, data: Data?) async throws {
// First, do the default validation
try await client.validate(response: response, data: data)
// Then add custom validation
guard let response = response else { return }
// Example: Check for maintenance mode
if response.statusCode == 503 {
throw MaintenanceError()
}
}
}
```
### Error Handling
Endpoints wraps all errors in `EndpointsError`, which includes the `HTTPURLResponse` if available:
```swift
do {
let (body, response) = try await session.dataTask(for: call)
// Handle success
} catch let error as EndpointsError {
// Access the underlying error
print("Error: \(error.error)")
// Access the HTTP response if available
if let response = error.response {
print("Status code: \(response.statusCode)")
}
} catch {
// Handle other errors
print("Unexpected error: \(error)")
}
```