A type-safe HTTP client library for Swift, inspired by swift-openapi-generator. Built on Apple's swift-http-types for interoperability with the broader Swift HTTP ecosystem.
- Type-safe requests with associated
RequestBody,SuccessResponseBody, andFailureResponseBodytypes - Built on Apple's
swift-http-types— usesHTTPRequest.Method,HTTPResponse.Status, andHTTPFieldsthroughout - Middleware chain for request/response interception (auth, logging, retries, etc.)
- Configurable cache policies with manual cache management
- File uploads with
UploadRequest - Built-in encoders for JSON, URL form, and multipart form data
- Full Swift 6 concurrency support with typed throws
- Swift 6.2+
- iOS 17.0+ / macOS 14.0+ / tvOS 17.0+ / watchOS 10.0+ / Mac Catalyst 17.0+ / visionOS 1.0+
Add Simplicity to your Package.swift:
dependencies: [
.package(url: "https://github.com/BrentMifsud/Simplicity.git", from: "2.0.0")
]Then add Simplicity to your target dependencies:
.target(
name: "YourTarget",
dependencies: ["Simplicity"]
)Note: You do not need to add
swift-http-typesas a separate dependency. Simplicity re-exports theHTTPTypesmodule, so types likeHTTPRequest.Method,HTTPResponse.Status, andHTTPFieldsare available directly viaimport Simplicity.
import Simplicity
struct LoginRequest: Request {
// Request body type
struct Body: Encodable, Sendable {
var username: String
var password: String
}
// Response body types
struct Success: Decodable, Sendable { var token: String }
struct Failure: Decodable, Sendable { var error: String }
// Associate the types with the Request protocol
typealias RequestBody = Body
typealias SuccessResponseBody = Success
typealias FailureResponseBody = Failure
// Endpoint metadata
static var operationID: String { "login" }
var path: String { "/login" }
var method: HTTPRequest.Method { .post }
var headerFields: HTTPFields { [.contentType: "application/json"] }
var queryItems: [URLQueryItem] { [] }
// The actual body instance to send
var body: Body
}let client = URLSessionClient(
baseURL: URL(string: "https://api.example.com")!,
middlewares: []
)
let response = try await client.send(
LoginRequest(body: .init(username: "user", password: "pass"))
)
// Decode the typed success or failure body on demand
if response.status.kind == .successful {
let model = try response.decodeSuccessBody()
print(model.token)
} else {
let failure = try response.decodeFailureBody()
print(failure.error)
}Use Never? as RequestBody for GET/DELETE requests:
struct GetProfileRequest: Request {
typealias RequestBody = Never?
typealias SuccessResponseBody = UserProfile
typealias FailureResponseBody = APIError
static var operationID: String { "getProfile" }
var path: String { "/user/profile" }
var method: HTTPRequest.Method { .get }
var headerFields: HTTPFields { HTTPFields() }
var queryItems: [URLQueryItem] { [] }
var body: Never? { nil }
}Middleware intercepts requests and responses, enabling cross-cutting concerns like authentication, logging, retries, and caching.
Middleware operates on MiddlewareRequest and MiddlewareResponse structs that embed Apple's HTTPRequest and HTTPResponse types:
MiddlewareRequest contains:
httpRequest: HTTPRequest— Apple's type (method, URL components, header fields)body: Data?— Request body dataoperationID: String— Unique identifier for the operationbaseURL: URL— Base URL for the requestcachePolicy: CachePolicy— Cache policy for the requesturl: URL— Computed full request URL
MiddlewareResponse contains:
httpResponse: HTTPResponse— Apple's type (status, header fields)url: URL— Final response URLbody: Data— Response body data
struct AuthMiddleware: Middleware {
let tokenProvider: () -> String
func intercept(
request: MiddlewareRequest,
next: nonisolated(nonsending) @Sendable (MiddlewareRequest) async throws -> MiddlewareResponse
) async throws -> MiddlewareResponse {
var req = request
req.httpRequest.headerFields[.authorization] = "Bearer \(tokenProvider())"
return try await next(req)
}
}
struct LoggingMiddleware: Middleware {
func intercept(
request: MiddlewareRequest,
next: nonisolated(nonsending) @Sendable (MiddlewareRequest) async throws -> MiddlewareResponse
) async throws -> MiddlewareResponse {
print("Request: \(request.httpRequest.method) \(request.url)")
let response = try await next(request)
print("Response: \(response.httpResponse.status)")
return response
}
}
// Add middlewares to the client
let client = URLSessionClient(
baseURL: baseURL,
middlewares: [AuthMiddleware(tokenProvider: { token }), LoggingMiddleware()]
)Control caching behavior per-request using CachePolicy:
// Use server-provided cache directives (default)
let response = try await client.send(request, cachePolicy: .useProtocolCachePolicy)
// Return cached data if available, otherwise fetch from network
let response = try await client.send(request, cachePolicy: .returnCacheDataElseLoad)
// Only return cached data, never fetch (offline mode)
let response = try await client.send(request, cachePolicy: .returnCacheDataDontLoad)
// Always fetch fresh data, ignoring cache
let response = try await client.send(request, cachePolicy: .reloadIgnoringLocalCacheData)The Client protocol provides methods for manual cache control:
// Store a response in the cache
try await client.setCachedResponse(subscriptions, for: GetSubscriptionsRequest())
// Retrieve a cached response
let cached = try await client.cachedResponse(for: GetSubscriptionsRequest())
// Remove a cached response
await client.removeCachedResponse(for: GetSubscriptionsRequest())
// Clear all cached responses
await client.clearNetworkCache()For more control over caching (especially with authenticated requests), use CacheMiddleware:
let cache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 50_000_000)
let cacheMiddleware = CacheMiddleware(urlCache: cache)
// Place after auth middleware so cache keys include the final URL
let client = URLSessionClient(
baseURL: baseURL,
middlewares: [authMiddleware, cacheMiddleware]
)
// Manual cache operations via middleware
await cacheMiddleware.setCached(data, for: url)
await cacheMiddleware.removeCached(for: url)
await cacheMiddleware.clearCache()Use UploadRequest for file uploads:
struct UploadAvatarRequest: UploadRequest {
typealias SuccessResponseBody = UploadResponse
typealias FailureResponseBody = APIError
static var operationID: String { "uploadAvatar" }
var path: String { "/user/avatar" }
var method: HTTPRequest.Method { .post }
var headerFields: HTTPFields { [.contentType: "image/jpeg"] }
var queryItems: [URLQueryItem] { [] }
let imageData: Data
func encodeUploadData() throws -> Data {
imageData
}
}
let response = try await client.upload(
UploadAvatarRequest(imageData: imageData),
timeout: .seconds(60)
)For application/x-www-form-urlencoded requests, override encodeBody():
struct FormLoginRequest: Request {
typealias RequestBody = Credentials
typealias SuccessResponseBody = AuthToken
typealias FailureResponseBody = APIError
static var operationID: String { "formLogin" }
var path: String { "/login" }
var method: HTTPRequest.Method { .post }
var headerFields: HTTPFields { [.contentType: "application/x-www-form-urlencoded"] }
var queryItems: [URLQueryItem] { [] }
var body: Credentials
func encodeBody() throws -> Data? {
try URLFormEncoder().encode(body)
}
}Or use URLFormEncoder directly:
let encoder = URLFormEncoder()
let data = try encoder.encode(MyFormData(field1: "value1", field2: "value2"))For file uploads with additional fields:
let encoder = try MultipartFormEncoder()
let parts: [MultipartFormEncoder.Part] = [
.text(name: "description", value: "Profile photo"),
.file(name: "avatar", filename: "photo.jpg", data: imageData, mimeType: "image/jpeg")
]
let body = try encoder.encode(parts: parts)If you're upgrading from Simplicity 1.x, here's a summary of the API changes:
| 1.x | 2.x | Reason |
|---|---|---|
HTTPClient |
Client |
Avoids conflict with other libraries; module-scoped as Simplicity.Client |
HTTPRequest (protocol) |
Request |
Conflicts with HTTPTypes.HTTPRequest struct |
HTTPUploadRequest |
UploadRequest |
Consistent naming |
HTTPResponse<S,F> |
Response<S,F> |
Conflicts with HTTPTypes.HTTPResponse struct |
URLSessionHTTPClient |
URLSessionClient |
Consistent naming |
HTTPMethod (enum) |
HTTPRequest.Method |
Apple's extensible struct from swift-http-types |
HTTPStatusCode (enum) |
HTTPResponse.Status |
Apple's type with .kind categorization |
| 1.x | 2.x |
|---|---|
httpMethod |
method |
headers: [String: String] |
headerFields: HTTPFields |
httpBody |
body |
statusCode |
status |
encodeHTTPBody() |
encodeBody() |
createURLRequest(baseURL:) |
makeHTTPRequest(baseURL:) |
decodeSuccessResponseData(_:) |
decodeSuccessBody(from:) |
decodeFailureResponseData(_:) |
decodeFailureBody(from:) |
send(request:) |
send(_:) |
upload(request:) |
upload(_:) |
statusCode.isSuccess |
status.kind == .successful |
Middleware request/response changed from named tuples to structs wrapping Apple's types:
// 1.x — tuple fields accessed directly
req.headers["Authorization"] = "Bearer ..."
print(response.statusCode)
// 2.x — Apple types accessed through embedded structs
req.httpRequest.headerFields[.authorization] = "Bearer ..."
print(response.httpResponse.status)MIT License. See LICENSE for details.