An open API service indexing awesome lists of open source software.

https://github.com/ricky-stone/swiftrest

SwiftRest is a lightweight, easy-to-use Swift package for building REST API clients. It provides a flexible and robust solution for sending HTTP requests with built-in support for retries, base headers, and per-request authorization tokens—all while using a consistent JSON encoding/decoding strategy.
https://github.com/ricky-stone/swiftrest

async-await concurrency ios rest-api restapi restful-api spm swift swift6 swiftui xcode

Last synced: about 1 month ago
JSON representation

SwiftRest is a lightweight, easy-to-use Swift package for building REST API clients. It provides a flexible and robust solution for sending HTTP requests with built-in support for retries, base headers, and per-request authorization tokens—all while using a consistent JSON encoding/decoding strategy.

Awesome Lists containing this project

README

          

# SwiftRest

[![CI](https://github.com/ricky-stone/SwiftRest/actions/workflows/ci.yml/badge.svg)](https://github.com/ricky-stone/SwiftRest/actions/workflows/ci.yml)
[![Swift](https://img.shields.io/badge/Swift-6.0+-F05138.svg)](https://www.swift.org/)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/ricky-stone/SwiftRest/blob/main/LICENSE.txt)
[![Swift Package Index](https://img.shields.io/badge/Swift%20Package%20Index-SwiftRest-111111)](https://swiftpackageindex.com/ricky-stone/SwiftRest)
[![GitHub stars](https://img.shields.io/github/stars/ricky-stone/SwiftRest?style=social)](https://github.com/ricky-stone/SwiftRest/stargazers)

SwiftRest is a Swift 6 REST client with one clean chain-first API.

- Swift 6 concurrency-safe (`SwiftRestClient` is an `actor`)
- Simple setup chain (`SwiftRest.for(...).client`)
- Simple request chain (`client.path(...).get().value()`)
- Easy headers, typed results, and built-in auto refresh

## Requirements

- Swift 6.0+
- iOS 15+
- macOS 12+

## Installation

Use Swift Package Manager with:

- `https://github.com/ricky-stone/SwiftRest.git`

## Default Client Behavior

This is the minimum setup:

```swift
let client = try SwiftRest.for("https://api.example.com").client
```

Defaults used by this client:

- `Accept: application/json`
- `timeout = 30` seconds
- `retry = .standard` (3 attempts total)
- `json = .default` (Foundation key/date behavior)
- `logging = .off`
- no access token, no auto refresh

## Community

- Questions and ideas: [GitHub Discussions](https://github.com/ricky-stone/SwiftRest/discussions)
- Bugs and feature requests: [GitHub Issues](https://github.com/ricky-stone/SwiftRest/issues)
- Contributing guide: [`CONTRIBUTING.md`](./CONTRIBUTING.md)
- Security reports: [`SECURITY.md`](./SECURITY.md)

## 60-Second Start (Swift)

```swift
import SwiftRest

struct User: Decodable, Sendable {
let id: Int
let firstName: String
}

let client = try SwiftRest
.for("https://api.example.com")
.json(.default)
.jsonDates(.iso8601)
.jsonKeys(.useDefaultKeys)
.client

let user: User = try await client.path("users/1").get().value()
print(user.firstName)
```

## 60-Second Start (SwiftUI)

```swift
import SwiftUI
import SwiftRest

struct User: Decodable, Sendable {
let id: Int
let firstName: String
}

@MainActor
final class UserViewModel: ObservableObject {
@Published var name: String = ""
@Published var errorText: String?

private let client: SwiftRestClient

init() {
client = try! SwiftRest
.for("https://api.example.com")
.json(.default)
.jsonDates(.iso8601)
.client
}

func load() async {
do {
let user: User = try await client.path("users/1").get().value()
name = user.firstName
errorText = nil
} catch let error as SwiftRestClientError {
errorText = error.userMessage
} catch {
errorText = error.localizedDescription
}
}
}
```

## One Request Flow

SwiftRest V4 keeps one request chain with 3 clear outputs.

```swift
let value: User = try await client.path("users/1").get().value()

let response: SwiftRestResponse = try await client.path("users/1").get().response()

let result: SwiftRestResult =
await client.path("users/1").get().result(error: APIErrorModel.self)
```

## Headers Made Easy

### Client default headers (every request)

```swift
let client = try SwiftRest
.for("https://api.example.com")
.header("X-App", "SnookerLive")
.headers([
"X-Platform": "iOS",
"Accept-Language": "en-GB"
])
.client
```

### Per-request headers (one call only)

```swift
let result = try await client
.path("users/1")
.header("X-Trace-Id", UUID().uuidString)
.headers(["X-Experiment": "A"])
.get()
.valueAndHeaders(as: User.self)

print(result.value.firstName)
print(result.headers["x-request-id"] ?? "missing")
print(result.headers["x-rate-limit-remaining"] ?? "0")
```

Header scope recap:

- `.header(...)` or `.headers(...)` on `SwiftRest.for(...). ... .client` sets default headers for every request.
- `.header(...)` or `.headers(...)` on `client.path(...). ...` applies only to that one request.

## Setup Chain Reference

```swift
let client = try SwiftRest
.for("https://api.example.com")
.accessToken("initial-token")
.accessTokenProvider { await sessionStore.accessToken }
.autoRefresh(
endpoint: "auth/refresh",
refreshTokenProvider: { await sessionStore.refreshToken },
onTokensRefreshed: { accessToken, refreshToken in
await sessionStore.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
)
.json(.webAPI)
.jsonDates(.iso8601)
.jsonKeys(.snakeCase)
.retry(.standard)
.timeout(30)
.logging(.off)
.client
```

## Auto Refresh (Single Client, Safe)

Auto refresh is built-in and safe for single-client usage.

- On configured auth status codes (default: `401`), SwiftRest refreshes once and retries once.
- Refresh calls bypass normal auth middleware to avoid recursion.
- Concurrent auth-failure requests share one refresh (single-flight).

### Beginner mode (endpoint-driven)

Step 1, make a token store:

```swift
actor SessionStore {
private var accessTokenValue: String?
private var refreshTokenValue: String?

var accessToken: String? { accessTokenValue } // read
var refreshToken: String? { refreshTokenValue } // read

func setAccessToken(_ token: String) {
self.accessTokenValue = token
}

func setTokens(accessToken: String, refreshToken: String?) {
self.accessTokenValue = accessToken
self.refreshTokenValue = refreshToken
}

func clear() {
self.accessTokenValue = nil
self.refreshTokenValue = nil
}
}
```

Step 2, configure refresh:

```swift
let client = try SwiftRest
.for("https://api.example.com")
.accessTokenProvider { await sessionStore.accessToken }
.autoRefresh(
endpoint: "auth/refresh",
refreshTokenProvider: { await sessionStore.refreshToken },
refreshTokenField: "refreshToken",
tokenField: "accessToken",
refreshTokenResponseField: "refreshToken",
triggerStatusCodes: [401],
onTokensRefreshed: { accessToken, refreshToken in
await sessionStore.setTokens(accessToken: accessToken, refreshToken: refreshToken)
}
)
.client
```

Providers can also be simple closures when values are already available:

```swift
.accessTokenProvider { "token-value" }
.autoRefresh(endpoint: "auth/refresh", refreshTokenProvider: { "refresh-value" })
```

What each setting does:

- `accessTokenProvider`: reads your current access token before requests.
- `refreshTokenProvider`: reads your current refresh token when a `401` happens.
- `refreshTokenField`: JSON key sent to refresh endpoint in request body.
- `tokenField`: JSON key read from refresh response for the new access token.
- `refreshTokenResponseField`: optional key read from refresh response for rotated refresh token.
- `triggerStatusCodes`: status codes that should trigger refresh (default is `[401]`).
- `onTokensRefreshed`: callback to save refreshed token values to your store/keychain.

Example refresh response:

```json
{
"accessToken": "...",
"accessTokenExpiresUtc": "2026-02-18T23:10:04.5435334Z",
"refreshToken": "...",
"refreshTokenExpiresUtc": "2026-03-20T22:50:04.5435334Z",
"tokenType": "Bearer"
}
```

Matching config for that response:

```swift
.autoRefresh(
endpoint: "auth/refresh",
refreshTokenProvider: { await sessionStore.refreshToken },
refreshTokenField: "refreshToken",
tokenField: "accessToken",
refreshTokenResponseField: "refreshToken"
)
```

If your API uses different names, set exact key names:

```swift
.autoRefresh(
endpoint: "auth/refresh",
refreshTokenProvider: { await sessionStore.refreshToken },
refreshTokenField: "refresh_token",
tokenField: "token",
refreshTokenResponseField: "refresh_token",
triggerStatusCodes: [401, 403]
)
```

### Advanced mode (custom refresh logic with safe bypass context)

```swift
struct RefreshTokenBody: Encodable, Sendable {
let refreshToken: String
}

struct RefreshTokenResponse: Decodable, Sendable {
let accessToken: String
}

let refresh = SwiftRestAuthRefresh.custom { refresh in
let dto: RefreshTokenResponse = try await refresh.post(
"auth/refresh",
body: RefreshTokenBody(refreshToken: await sessionStore.refreshToken)
)
await sessionStore.setAccessToken(dto.accessToken)
return dto.accessToken
}.triggerStatusCodes([401, 403])

let client = try SwiftRest
.for("https://api.example.com")
.accessTokenProvider { await sessionStore.accessToken }
.autoRefresh(refresh)
.client
```

### If refresh fails: clear tokens and log out

```swift
do {
let profile: User = try await client.path("secure/profile").get().value()
print(profile.firstName)
} catch let error as SwiftRestClientError {
switch error {
case .authRefreshFailed:
await sessionStore.clear()
// Route user to login screen
case .httpError(let details) where [401, 403].contains(details.statusCode):
await sessionStore.clear()
// Route user to login screen
default:
print(error.userMessage)
}
}
```

## Per-Request Auth Overrides

Use these when one call needs different auth behavior.

```swift
let user: User = try await client
.path("users/1")
.authToken("one-off-token") // per-request access token
.get()
.value()
```

```swift
let publicInfo: PublicInfo = try await client
.path("public/info")
.noAuth() // skips Authorization header for this call
.get()
.value()
```

```swift
let raw = try await client
.path("secure/profile")
.autoRefresh(false) // skip auth refresh for this call
.get()
.raw()

print(raw.statusCode)
```

```swift
let user: User = try await client
.path("secure/profile")
.refreshTokenProvider { await sessionStore.temporaryRefreshToken }
.get()
.value()
```

`refreshTokenProvider` above is only used if that call hits `401` and refresh is enabled on the client.

## HTTP Methods (All Supported)

```swift
// GET
let users: [User] = try await client.path("users").get().value()

// POST
let created: User = try await client
.path("users")
.post(body: CreateUser(firstName: "Ricky"))
.value()

// PUT
let updated: User = try await client
.path("users/1")
.put(body: CreateUser(firstName: "Ricky Stone"))
.value()

// PATCH
let patched: User = try await client
.path("users/1")
.patch(body: ["firstName": "Ricky S."])
.value()

// DELETE (success/no payload style)
let deleted = try await client.path("users/1").delete().raw()
print(deleted.isSuccess)

// HEAD
let head = try await client.path("users/1").head().raw()
print(head.statusCode)
print(head.header("etag") ?? "missing")

// OPTIONS
let options = try await client.path("users").options().raw()
print(options.header("allow") ?? "missing")
```

## Query and Body Models

### Query model

```swift
struct UserQuery: Encodable, Sendable {
let page: Int
let search: String
let includeInactive: Bool
}

let users: [User] = try await client
.path("users")
.query(UserQuery(page: 1, search: "ricky", includeInactive: false))
.get()
.value()
```

### Query without a model

```swift
let users: [User] = try await client
.path("users")
.parameters([
"page": "1",
"search": "ricky",
"includeInactive": "false"
])
.get()
.value()
```

Single key style:

```swift
let users: [User] = try await client
.path("users")
.parameter("page", "1")
.parameter("search", "ricky")
.parameter("includeInactive", "false")
.get()
.value()
```

### POST model body

```swift
struct CreateUser: Encodable, Sendable {
let firstName: String
}

let created: User = try await client
.path("users")
.post(body: CreateUser(firstName: "Ricky"))
.value()
```

### Success-only call (no model needed)

```swift
let raw = try await client
.path("users/1")
.delete()
.raw()

if raw.isSuccess {
print("Delete worked")
}
```

### Multipart upload (manual raw request)

```swift
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()

func append(_ string: String) {
body.append(Data(string.utf8))
}

append("--\(boundary)\r\n")
append("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.jpg\"\r\n")
append("Content-Type: image/jpeg\r\n\r\n")
body.append(fileData) // Data loaded from disk/camera
append("\r\n--\(boundary)--\r\n")

let request = SwiftRestRequest(path: "uploads/avatar", method: .post)
.header("Content-Type", "multipart/form-data; boundary=\(boundary)")
.rawBody(body)

let upload = try await client.executeRaw(request)
print(upload.statusCode)
```

### Value + headers together

```swift
let result = try await client
.path("users/1")
.get()
.valueAndHeaders(as: User.self)

print(result.value.firstName)
print(result.headers["x-request-id"] ?? "missing")
```

### Pagination with headers

```swift
let firstPage: SwiftRestResponse<[User]> = try await client
.path("users")
.parameters(["page": "1", "pageSize": "20"])
.get()
.response()

let users = firstPage.data ?? []
let nextPage = firstPage.header("x-next-page")

if let nextPage {
let secondPage: [User] = try await client
.path("users")
.parameters(["page": nextPage, "pageSize": "20"])
.get()
.value()
print("Loaded \(secondPage.count) more users")
}
```

## JSON Options (Flexible)

### Common presets

```swift
.json(.default) // Foundation defaults
.json(.iso8601) // default keys + ISO8601 dates
.json(.webAPI) // snake_case keys + ISO8601 dates

// extra web API presets
.json(.webAPIFractionalSeconds) // snake_case + ISO8601 fractional seconds
.json(.webAPIUnixSeconds) // snake_case + Unix seconds
.json(.webAPIUnixMilliseconds) // snake_case + Unix milliseconds
```

### Key strategies

```swift
.jsonKeys(.useDefaultKeys) // decode+encode default keys
.jsonKeys(.snakeCase) // decode+encode snake_case
.jsonKeys(.snakeCaseDecodingOnly) // decode snake_case, encode default keys
.jsonKeys(.snakeCaseEncodingOnly) // decode default keys, encode snake_case
```

### Date strategies

```swift
.jsonDates(.iso8601)
.jsonDates(.iso8601WithFractionalSeconds)
.jsonDates(.secondsSince1970)
.jsonDates(.millisecondsSince1970)
.jsonDates(.formatted(format: "yyyy-MM-dd HH:mm:ss"))
```

### Per-request overrides

```swift
let config: AppConfig = try await client
.path("app-config")
.jsonDates(.iso8601)
.jsonKeys(.useDefaultKeys)
.get()
.value()
```

## Result-Style API

Result-style calls are great for UI state management.

```swift
struct APIErrorModel: Decodable, Sendable {
let message: String
let code: String?
}

let result: SwiftRestResult =
await client.path("users/1").get().result(error: APIErrorModel.self)

switch result {
case .success(let response):
print(response.value?.firstName ?? "none")

case .apiError(let decoded, let raw):
print(raw.statusCode)
print(decoded?.message ?? "Unknown API error")

case .failure(let error):
print(error.userMessage)
}
```

## Debug Logging

```swift
let client = try SwiftRest
.for("https://api.example.com")
.logging(.headers)
.client
```

Modes:

- `.logging(.off)` or `.logging(.disabled)`
- `.logging(.basic)`
- `.logging(.headers)`

Sensitive headers are redacted automatically.

## Retry Policy

```swift
let client = try SwiftRest
.for("https://api.example.com")
.retry(
RetryPolicy(
maxAttempts: 4,
baseDelay: 0.4,
backoffMultiplier: 2,
maxDelay: 5
)
)
.client
```

## Migration (V3 -> V4)

V4 preferred style:

- Setup: `SwiftRest.for(...). ... .client`
- Requests: `client.path(...).verb().value/response/result`

You can still keep your models (`Decodable & Sendable`, `Encodable & Sendable`) the same.

## License

SwiftRest is licensed under the MIT License. See `LICENSE.txt`.

Industry standard for MIT:

- You can use this in commercial/private/open-source projects.
- Keep the copyright + license notice when redistributing.
- Attribution is appreciated but not required by MIT.

## Author

Created and maintained by Ricky Stone.

## Acknowledgments

Thanks to everyone who tests, reports issues, and contributes improvements.

## Version

Current source version marker: `SwiftRestVersion.current == "4.4.0"`