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
Sessionas an actor - Sendable conformance: All core protocols require
Sendableconformance 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
Codabletypes
- Swift 6.2+
- iOS 13+
- tvOS 12+
- macOS 10.15+
- watchOS 6+
- visionOS 1+
Swift Package Manager:
.package(url: "https://github.com/diamirio/Endpoints.git", .upToNextMajor(from: "4.0.0"))Here's how to load a random image from Giphy.
// 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)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:
// 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)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)Look up the documentation in the code for further explanations of the types.
DataResponseParserDictionaryParserJSONParserNoContentParserStringConvertibleParserStringParser
Endpoints has built-in JSON Codable support.
The ResponseParser responsible for handling decodable types is the JSONParser.
The default JSONParser comes pre-configured with:
dateDecodingStrategy = .iso8601keyDecodingStrategy = .convertFromSnakeCase
// 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"])
}
}Every encodable is able to provide a JSONEncoder() to encode itself via the toJSON() method.
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:
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")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:
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)
}
}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:
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])
}
}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:
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 implementation can be found here.
If you're upgrading from a previous version, please refer to the migration guides:
- Migrating from 3.x to 4.x - Swift 6.2+ strict concurrency,
AnyClient→DefaultClient, and more - Migrating from 2.x to 3.x - Native async/await APIs
- Migrating from 1.x to 2.x
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
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)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()
}
}
}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)")
}