Contents

diamirio/Endpoints

Type-Safe Swift Networking

Requirements

  • Swift 6.2+
  • iOS 13+
  • tvOS 12+
  • macOS 10.15+
  • watchOS 6+
  • visionOS 1+

Installation

Swift Package Manager:

.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<DataResponseParser>(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<DictionaryParser<String, Any>>(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<JSONParser<GiphyGif>>(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<GiphyGif>

    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<T: Decodable & Sendable>: 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<GiphyGif>

    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<String, Any>

    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<C>(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<RandomImage>

    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.

Migration Guides

If you're upgrading from a previous version, please refer to the migration guides:

Advanced Features

Debug Logging

Enable debug logging to see detailed request and response information:

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:

// 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:

struct MyCall: Call {
    typealias Parser = JSONParser<MyResponse>

    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:

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)")
}

Package Metadata

Repository: diamirio/Endpoints

Stars: 52

Forks: 7

Open issues: 0

Default branch: develop

Primary language: swift

License: MIT

Topics: generics, ios, macos, networking, parsing, swift, swift-5, swift-6, tvos, watchos

README: README.md