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 focused on clear APIs and safe concurrency.

  • SwiftRestClient is an actor.
  • Public models are Sendable.
  • You can decode payloads and inspect headers in the same call.
  • You can choose throw-based APIs or result-style APIs.

Requirements

  • Swift 6.0+
  • iOS 15+
  • macOS 12+

Installation

Use Swift Package Manager with:

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

Community

Quick Start (Beginners)

Swift

import SwiftRest

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

let client = try SwiftRestClient("https://api.example.com")
let user: User = try await client.get("users/1")
print(user.name)

SwiftUI

import SwiftUI
import SwiftRest

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

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

    private let client = try? SwiftRestClient("https://api.example.com")

    func load() async {
        guard let client else {
            errorText = "Client setup failed"
            return
        }

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

Data + Headers in One Call

Swift

let response: SwiftRestResponse<User> = try await client.getResponse("users/1")

if let user = response.data {
    print("Name:", user.name)
}

print("Status:", response.statusCode)
print("Request-Id:", response.headers["x-request-id"] ?? "missing")

SwiftUI

@MainActor
final class ProfileViewModel: ObservableObject {
    @Published var name: String = ""
    @Published var requestId: String = ""

    private let client = try! SwiftRestClient("https://api.example.com")

    func load() async {
        do {
            let response: SwiftRestResponse<User> = try await client.getResponse("users/1")
            name = response.data?.name ?? ""
            requestId = response.headers["x-request-id"] ?? ""
        } catch {
            name = ""
            requestId = ""
        }
    }
}

Write Calls with Model Bodies

struct CreateUser: Encodable, Sendable {
    let name: String
}

let payload = CreateUser(name: "Ricky")

let created: User = try await client.post("users", body: payload)
let updated: User = try await client.put("users/1", body: CreateUser(name: "Ricky Stone"))
let patched: User = try await client.patch("users/1", body: ["name": "Ricky S."])

Success/Failure only (no payload needed)

let _: NoContent = try await client.delete("users/1")

let raw = try await client.deleteRaw("users/1", allowHTTPError: true)
print(raw.statusCode, raw.isSuccess)

Authentication

1) Global token

let client = try SwiftRestClient(
    "https://api.example.com",
    config: .standard.accessToken("YOUR_ACCESS_TOKEN")
)

Runtime update:

await client.setAccessToken("NEW_TOKEN")
await client.clearAccessToken()

2) Per-request token override

let adminUser: User = try await client.get("users/1", authToken: "ONE_OFF_TOKEN")

3) Rotating token provider

await client.setAccessTokenProvider {
    // Return latest token (refresh from keychain/service if needed)
    return "LATEST_TOKEN"
}

Auth precedence:

  1. per-request token (authToken: / request.authToken(...))
  2. provider token (setAccessTokenProvider / config.accessTokenProvider(...))
  3. global token (setAccessToken / config.accessToken(...))

4) Built-in 401 refresh + retry once (opt-in)

When enabled, SwiftRest will:

  1. receive 401
  2. call your refresh callback
  3. retry the same request once with the refreshed token
let refresh = SwiftRestAuthRefresh {
    // Call refresh endpoint and return new access token
    return "fresh-token"
}

let client = try SwiftRestClient(
    "https://api.example.com",
    config: .standard
        .accessToken("expired-token")
        .authRefresh(refresh)
)

let profile: User = try await client.get("users/me")

By default, refresh does not run for explicit per-request tokens.

Enable it if needed:

let refresh = SwiftRestAuthRefresh {
    return "fresh-token"
}.appliesToPerRequestToken(true)

SwiftUI token store pattern

import SwiftUI
import SwiftRest

@MainActor
final class SessionStore: ObservableObject {
    @Published private(set) var accessToken: String = "expired-token"

    func updateToken(_ token: String) {
        accessToken = token
    }
}

func makeClient(session: SessionStore) throws -> SwiftRestClient {
    let refresh = SwiftRestAuthRefresh {
        // Example only. Replace with your refresh API call.
        let newToken = "fresh-token"
        await session.updateToken(newToken)
        return newToken
    }

    return try SwiftRestClient(
        "https://api.example.com",
        config: .standard
            .accessToken(session.accessToken)
            .authRefresh(refresh)
    )
}

Query Models (No Manual Dictionaries)

Swift

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

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

SwiftUI

@MainActor
final class SearchViewModel: ObservableObject {
    @Published var users: [User] = []

    private let client = try! SwiftRestClient("https://api.example.com")

    func search(term: String) async {
        struct UserQuery: Encodable, Sendable {
            let page: Int
            let search: String
            let includeInactive: Bool
        }

        do {
            let query = UserQuery(page: 1, search: term, includeInactive: false)
            users = try await client.get("users", query: query)
        } catch {
            users = []
        }
    }
}

Notes:

  • Query models follow your client JSON key encoding strategy.
    • Example: .webAPI converts includeInactive to include_inactive.
  • You can also encode query models manually with SwiftRestQuery.encode(...).

Result-Style API (Great for UI State)

Use result APIs when you want explicit branches for success, API errors, and transport failures.

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

let result: SwiftRestResult<User, APIErrorModel> =
    await client.getResult("users/1")

switch result {
case .success(let response):
    print(response.data?.name ?? "none")

case .apiError(let decoded, let response):
    print("Status:", response.statusCode)
    print(decoded?.message ?? "No typed API error body")

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

SwiftUI result-state example

@MainActor
final class ResultViewModel: ObservableObject {
    enum State {
        case idle
        case loading
        case loaded(String)
        case apiError(String)
        case transportError(String)
    }

    @Published var state: State = .idle
    private let client = try! SwiftRestClient("https://api.example.com")

    struct APIErrorModel: Decodable, Sendable {
        let message: String
    }

    func load() async {
        state = .loading

        let result: SwiftRestResult<User, APIErrorModel> =
            await client.getResult("users/1")

        switch result {
        case .success(let response):
            state = .loaded(response.data?.name ?? "No data")
        case .apiError(let decoded, _):
            state = .apiError(decoded?.message ?? "Request failed")
        case .failure(let error):
            state = .transportError(error.userMessage)
        }
    }
}

Available result APIs:

  • executeResult(...)
  • getResult(...)
  • deleteResult(...)
  • postResult(...)
  • putResult(...)
  • patchResult(...)

Debug Logging (Secrets Redacted)

Quick toggle

let client = try SwiftRestClient(
    "https://api.example.com",
    config: .standard.debugLogging(true)
)

Include headers (with redaction)

let client = try SwiftRestClient(
    "https://api.example.com",
    config: .standard.debugLogging(.headers)
)

Custom log handler

let logging = SwiftRestDebugLogging(
    isEnabled: true,
    includeHeaders: true,
    handler: { line in
        print("NETWORK:", line)
    }
)

let client = try SwiftRestClient(
    "https://api.example.com",
    config: .standard.debugLogging(logging)
)

Sensitive headers are redacted by default (for example: Authorization, cookies, token/secret-like headers).

JSON Strategies

Standard default

let client = try SwiftRestClient("https://api.example.com")

ISO8601 dates

let client = try SwiftRestClient(
    "https://api.example.com",
    config: .standard.dateDecodingStrategy(.iso8601)
)

Common web API preset (snake_case + ISO8601)

let client = try SwiftRestClient("https://api.example.com", config: .webAPI)

Per-request override

var request = SwiftRestRequest(path: "legacy-endpoint", method: .get)
request.configureDateDecodingStrategy(.formatted(format: "yyyy-MM-dd HH:mm:ss"))

let legacy: User = try await client.execute(request, as: User.self)

Request Builder Styles

Mutating style

var request = SwiftRestRequest(path: "users", method: .get)
request.addHeader("X-App", "Demo")
request.addParameter("page", "1")
request.configureRetries(maxRetries: 2, retryDelay: 0.5)

Chainable style

let request = try SwiftRestRequest.get("users")
    .header("X-App", "Demo")
    .query(UserQuery(page: 1, search: "ricky", includeInactive: false))

Error Handling (Throw-based)

do {
    let user: User = try await client.get("users/does-not-exist")
    print(user)
} catch let error as SwiftRestClientError {
    print(error.userMessage)

    if case .httpError(let details) = error {
        print(details.statusCode)
        print(details.headers["content-type"] ?? "n/a")
        print(details.rawPayload ?? "")
    }
} catch {
    print(error.localizedDescription)
}

Default Config (.standard)

When no config is passed, SwiftRest uses SwiftRestConfig.standard:

  • Base header: Accept: application/json
  • Timeout: 30 seconds
  • Retry policy: RetryPolicy.standard
    • Max attempts: 3
    • Base delay: 0.5 seconds
    • Retryable status codes: 408, 429, 500, 502, 503, 504
  • JSON coding: SwiftRestJSONCoding.foundationDefault
  • Global token: none
  • Debug logging: disabled
  • Auth refresh: disabled

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 == "3.4.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