SwiftRest is a Swift 6 REST client with one clean chain-first API.
- Swift 6 concurrency-safe (
SwiftRestClientis anactor) - Simple setup chain (
SwiftRest.for(...).client) - Simple request chain (
client.path(...).get().value()) - Easy headers, typed results, and built-in auto refresh
- Swift 6.0+
- iOS 15+
- macOS 12+
Use Swift Package Manager with:
https://github.com/ricky-stone/SwiftRest.git
This is the minimum setup:
let client = try SwiftRest.for("https://api.example.com").clientDefaults used by this client:
Accept: application/jsontimeout = 30secondsretry = .standard(3 attempts total)json = .default(Foundation key/date behavior)logging = .off- no access token, no auto refresh
- Questions and ideas: GitHub Discussions
- Bugs and feature requests: GitHub Issues
- Contributing guide:
CONTRIBUTING.md - Security reports:
SECURITY.md
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)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
}
}
}SwiftRest V4 keeps one request chain with 4 clear outputs.
let value: User = try await client.path("users/1").get().value()
let response: SwiftRestResponse<User> = try await client.path("users/1").get().response()
try await client.path("users/1").get().send() // success/failure only
let result: SwiftRestResult<User, APIErrorModel> =
await client.path("users/1").get().result(error: APIErrorModel.self)Build paths in small readable segments:
let settings: AppSettings = try await client
.path("v1")
.path("system")
.path(sessionId)
.path("app-settings")
.get()
.value()Variadic helper:
let settings: AppSettings = try await client
.path("v1")
.paths("system", sessionId, "app-settings")
.get()
.value()Primitive segments are supported directly:
let user: User = try await client
.path("v1")
.path("users")
.path(42) // Int
.get()
.value()You can also append path components from a URL:
let source = URL(string: "https://other.host/system/app-settings/88?env=prod")!
let settings: AppSettings = try await client
.path("v1")
.path(url: source) // appends only /system/app-settings/88
.get()
.value()Notes:
- You do not need to type
/between segments. - Extra leading/trailing slashes are normalized automatically.
- If you already have a full path string,
client.path("v1/system/app-settings")still works. - Supported segment types include
String,Substring, all integer types,Double,Float,Decimal,Bool, andUUID.
let client = try SwiftRest
.for("https://api.example.com")
.header("X-App", "SnookerLive")
.headers([
"X-Platform": "iOS",
"Accept-Language": "en-GB"
])
.clientlet 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(...)onSwiftRest.for(...). ... .clientsets default headers for every request..header(...)or.headers(...)onclient.path(...). ...applies only to that one request.
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)
.clientAuto 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).
Step 1, make a token store:
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:
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)
}
)
.clientProviders can also be simple closures when values are already available:
.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 a401happens.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:
{
"accessToken": "...",
"accessTokenExpiresUtc": "2026-02-18T23:10:04.5435334Z",
"refreshToken": "...",
"refreshTokenExpiresUtc": "2026-03-20T22:50:04.5435334Z",
"tokenType": "Bearer"
}Matching config for that response:
.autoRefresh(
endpoint: "auth/refresh",
refreshTokenProvider: { await sessionStore.refreshToken },
refreshTokenField: "refreshToken",
tokenField: "accessToken",
refreshTokenResponseField: "refreshToken"
)If your API uses different names, set exact key names:
.autoRefresh(
endpoint: "auth/refresh",
refreshTokenProvider: { await sessionStore.refreshToken },
refreshTokenField: "refresh_token",
tokenField: "token",
refreshTokenResponseField: "refresh_token",
triggerStatusCodes: [401, 403]
)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)
.clientdo {
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)
}
}Use these when one call needs different auth behavior.
let user: User = try await client
.path("users/1")
.authToken("one-off-token") // per-request access token
.get()
.value()let publicInfo: PublicInfo = try await client
.path("public/info")
.noAuth() // skips Authorization header for this call
.get()
.value()let raw = try await client
.path("secure/profile")
.autoRefresh(false) // skip auth refresh for this call
.get()
.raw()
print(raw.statusCode)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.
// 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")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()let users: [User] = try await client
.path("users")
.parameters([
"page": "1",
"search": "ricky",
"includeInactive": "false"
])
.get()
.value()Single key style:
let users: [User] = try await client
.path("users")
.parameter("page", "1")
.parameter("search", "ricky")
.parameter("includeInactive", "false")
.get()
.value()struct CreateUser: Encodable, Sendable {
let firstName: String
}
let created: User = try await client
.path("users")
.post(body: CreateUser(firstName: "Ricky"))
.value()try await client
.path("users/1")
.delete()
.send()If you want to inspect status/headers instead:
let raw = try await client
.path("users/1")
.delete()
.raw()
if raw.isSuccess {
print("Delete worked")
}Logout example:
func logout(_ request: LogoutRequest) async throws {
try await client
.path("v1/auth/logout")
.post(body: request)
.send()
}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)let result = try await client
.path("users/1")
.get()
.valueAndHeaders(as: User.self)
print(result.value.firstName)
print(result.headers["x-request-id"] ?? "missing")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(.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.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.jsonDates(.iso8601)
.jsonDates(.iso8601WithFractionalSeconds)
.jsonDates(.secondsSince1970)
.jsonDates(.millisecondsSince1970)
.jsonDates(.formatted(format: "yyyy-MM-dd HH:mm:ss"))let config: AppConfig = try await client
.path("app-config")
.jsonDates(.iso8601)
.jsonKeys(.useDefaultKeys)
.get()
.value()Result-style calls are great for UI state management.
struct APIErrorModel: Decodable, Sendable {
let message: String
let code: String?
}
let result: SwiftRestResult<User, APIErrorModel> =
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)
}let client = try SwiftRest
.for("https://api.example.com")
.logging(.headers)
.clientModes:
.logging(.off)or.logging(.disabled).logging(.basic).logging(.headers)
Sensitive headers are redacted automatically.
let client = try SwiftRest
.for("https://api.example.com")
.retry(
RetryPolicy(
maxAttempts: 4,
baseDelay: 0.4,
backoffMultiplier: 2,
maxDelay: 5
)
)
.clientV4 preferred style:
- Setup:
SwiftRest.for(...). ... .client - Requests:
client.path(...).verb().value/response/result
Legacy V3-style convenience methods on SwiftRestClient are now deprecated and show migration warnings.
You can still keep your models (Decodable & Sendable, Encodable & Sendable) the same.
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.
Created and maintained by Ricky Stone.
Thanks to everyone who tests, reports issues, and contributes improvements.
Current source version marker: SwiftRestVersion.current == "4.8.0"