Skip to content

SwiftRest is a beginner-friendly Swift 6 REST client built with actor-based concurrency safety, typed decoding, and simple response/header inspection.

License

Notifications You must be signed in to change notification settings

ricky-stone/SwiftRest

SwiftRest

CI Swift License Swift Package Index GitHub stars

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:

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

60-Second Start (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)

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 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)

Chainable Paths (No Manual / Needed)

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, and UUID.

Headers Made Easy

Client default headers (every request)

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)

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

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:

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)
        }
    )
    .client

Providers 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 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:

{
  "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]
)

Advanced mode (custom refresh logic with safe bypass context)

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

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.

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.

HTTP Methods (All Supported)

// 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

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

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()

POST model body

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)

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()
}

Multipart upload (manual raw request)

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

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

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

.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

.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

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

Per-request overrides

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.

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)
}

Debug Logging

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

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

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.

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.8.0"

About

SwiftRest is a beginner-friendly Swift 6 REST client built with actor-based concurrency safety, typed decoding, and simple response/header inspection.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Sponsor this project

Packages

No packages published

Contributors 2

  •  
  •  

Languages