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.
- Host: GitHub
- URL: https://github.com/ricky-stone/swiftrest
- Owner: ricky-stone
- Created: 2025-03-22T23:50:41.000Z (12 months ago)
- Default Branch: main
- Last Pushed: 2025-03-29T20:15:10.000Z (12 months ago)
- Last Synced: 2025-04-05T10:04:16.318Z (12 months ago)
- Topics: async-await, concurrency, ios, rest-api, restapi, restful-api, spm, swift, swift6, swiftui, xcode
- Language: Swift
- Homepage:
- Size: 52.7 KB
- Stars: 6
- Watchers: 1
- Forks: 1
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# SwiftRest
[](https://github.com/ricky-stone/SwiftRest/actions/workflows/ci.yml)
[](https://www.swift.org/)
[](https://github.com/ricky-stone/SwiftRest/blob/main/LICENSE.txt)
[](https://swiftpackageindex.com/ricky-stone/SwiftRest)
[](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"`