{"id":27173746,"url":"https://github.com/ricky-stone/swiftrest","last_synced_at":"2026-05-06T05:01:33.701Z","repository":{"id":284532455,"uuid":"953261482","full_name":"ricky-stone/SwiftRest","owner":"ricky-stone","description":"SwiftRest is a beginner-friendly Swift 6 REST client built with actor-based concurrency safety, typed decoding, and simple response/header inspection.","archived":false,"fork":false,"pushed_at":"2026-05-06T03:07:35.000Z","size":363,"stargazers_count":12,"open_issues_count":0,"forks_count":3,"subscribers_count":2,"default_branch":"main","last_synced_at":"2026-05-06T04:39:40.038Z","etag":null,"topics":["actor","async-await","beginner-friendly","http-client","ios","macos","rest-api","spm","swift","swift-concurrency","swift-package-manager","swift6"],"latest_commit_sha":null,"homepage":"https://swiftpackageindex.com/ricky-stone/SwiftRest","language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ricky-stone.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":null,"patreon":null,"open_collective":null,"ko_fi":"rickystone","tidelift":null,"community_bridge":null,"liberapay":null,"issuehunt":null,"lfx_crowdfunding":null,"polar":null,"buy_me_a_coffee":null,"thanks_dev":null,"custom":null}},"created_at":"2025-03-22T23:50:41.000Z","updated_at":"2026-05-06T03:07:32.000Z","dependencies_parsed_at":"2025-03-26T11:37:10.479Z","dependency_job_id":"fcba56bf-4c55-456c-b70a-15589c543059","html_url":"https://github.com/ricky-stone/SwiftRest","commit_stats":null,"previous_names":["ricky-stone/swiftrest"],"tags_count":46,"template":false,"template_full_name":null,"purl":"pkg:github/ricky-stone/SwiftRest","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ricky-stone%2FSwiftRest","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ricky-stone%2FSwiftRest/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ricky-stone%2FSwiftRest/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ricky-stone%2FSwiftRest/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ricky-stone","download_url":"https://codeload.github.com/ricky-stone/SwiftRest/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ricky-stone%2FSwiftRest/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32679444,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-06T02:33:58.958Z","status":"ssl_error","status_checked_at":"2026-05-06T02:33:39.611Z","response_time":117,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["actor","async-await","beginner-friendly","http-client","ios","macos","rest-api","spm","swift","swift-concurrency","swift-package-manager","swift6"],"created_at":"2025-04-09T11:23:17.017Z","updated_at":"2026-05-06T05:01:33.682Z","avatar_url":"https://github.com/ricky-stone.png","language":"Swift","funding_links":["https://ko-fi.com/rickystone"],"categories":[],"sub_categories":[],"readme":"# SwiftRest\n\n[![CI](https://github.com/ricky-stone/SwiftRest/actions/workflows/ci.yml/badge.svg)](https://github.com/ricky-stone/SwiftRest/actions/workflows/ci.yml)\n[![Swift](https://img.shields.io/badge/Swift-6.0+-F05138.svg)](https://www.swift.org/)\n[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/ricky-stone/SwiftRest/blob/main/LICENSE.txt)\n[![Swift Package Index](https://img.shields.io/badge/Swift%20Package%20Index-SwiftRest-111111)](https://swiftpackageindex.com/ricky-stone/SwiftRest)\n\nSwiftRest is a small Swift REST client.\n\nIt is for code like this:\n\n```swift\nlet profile: Profile = try await auth\n    .path(\"me\")\n    .get()\n    .value()\n```\n\nThat line means:\n\n1. Go to the `me` endpoint.\n2. Make a `GET` request.\n3. Decode the JSON response into `Profile`.\n\nSwiftRest 6 also has a simple auth client. It can:\n\n- save your login token in Keychain\n- add the token to requests\n- save a refresh token\n- refresh after a `401`\n- retry the failed request once\n- optionally add Apple App Attest\n- skip App Attest when App Attest is not available\n- optionally add Apple DeviceCheck\n- use DeviceCheck as a fallback when App Attest is not ready\n\nSwiftRest does not use SwiftKey. SwiftRest has its own built-in Keychain store.\n\n## Contents\n\n- [Install](#install)\n- [The Two Clients](#the-two-clients)\n- [Plain Requests](#plain-requests)\n- [Auth In 30 Seconds](#auth-in-30-seconds)\n- [Session Tokens](#session-tokens)\n- [Login](#login)\n- [Refresh Tokens](#refresh-tokens)\n- [Apple App Attest](#apple-app-attest)\n- [Apple DeviceCheck](#apple-devicecheck)\n- [Storage](#storage)\n- [Headers](#headers)\n- [Paths](#paths)\n- [Query](#query)\n- [HTTP Methods](#http-methods)\n- [JSON Settings](#json-settings)\n- [Responses](#responses)\n- [Errors](#errors)\n- [SwiftUI Example](#swiftui-example)\n- [Testing](#testing)\n- [Common Questions](#common-questions)\n\n## Install\n\nUse Swift Package Manager.\n\n```swift\n.package(url: \"https://github.com/ricky-stone/SwiftRest.git\", from: \"6.1.0\")\n```\n\nThen import it:\n\n```swift\nimport SwiftRest\n```\n\nRequirements:\n\n- Swift 6.0+\n- iOS 15+\n- macOS 12+\n\n## The Two Clients\n\nSwiftRest gives you two main clients.\n\n### 1. Plain client\n\nUse this when you do not need login tokens.\n\n```swift\nlet client = SwiftRest.client(baseURL: apiURL)\n```\n\n### 2. Auth client\n\nUse this when your API has login, tokens, refresh tokens, or App Attest.\n\n```swift\nlet auth = SwiftRest.auth(baseURL: apiURL).client\n```\n\nMost apps with accounts should use the auth client.\n\n## Plain Requests\n\nStart with a base URL:\n\n```swift\nlet apiURL = URL(string: \"https://api.example.com\")!\nlet client = SwiftRest.client(baseURL: apiURL)\n```\n\nMake a simple model:\n\n```swift\nstruct User: Decodable, Sendable {\n    let id: Int\n    let name: String\n}\n```\n\nCall an endpoint:\n\n```swift\nlet user: User = try await client\n    .path(\"users/1\")\n    .get()\n    .value()\n```\n\nThe full URL is:\n\n```text\nhttps://api.example.com/users/1\n```\n\nYou do not need to write `URLRequest`.\nYou do not need to manually decode `Data`.\nYou do not need to manually check the response body for this common case.\n\n## Auth In 30 Seconds\n\nThis is the common setup for an app with login.\n\n```swift\nlet apiURL = URL(string: \"https://api.example.com\")!\n\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .client\n```\n\nThis means:\n\n- save auth data in Keychain\n- read the main token from `sessionToken`\n- read the refresh token from `refreshToken`\n- call `auth/refresh` after a `401`\n- retry the original request one time after refresh works\n\nThen call protected endpoints like this:\n\n```swift\nlet profile: Profile = try await auth\n    .path(\"me\")\n    .get()\n    .value()\n```\n\nSwiftRest loads the saved token and adds this header for you:\n\n```http\nAuthorization: Bearer your-session-token\n```\n\n## Session Tokens\n\nMany APIs return a login response like this:\n\n```json\n{\n  \"sessionToken\": \"abc123\",\n  \"refreshToken\": \"refresh456\"\n}\n```\n\nUse this preset:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .client\n```\n\n`.sessionTokens()` is just a shortcut for:\n\n```swift\n.tokenField(\"sessionToken\")\n.refreshTokenField(\"refreshToken\")\n```\n\nIf your API uses `accessToken` instead, use:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .accessTokens()\n    .client\n```\n\n`.accessTokens()` is just a shortcut for:\n\n```swift\n.tokenField(\"accessToken\")\n.refreshTokenField(\"refreshToken\")\n```\n\nIf your API uses custom names, say the names out loud in code:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .tokenFields(token: \"token\", refresh: \"refresh\")\n    .client\n```\n\n## Login\n\nMake request and response models:\n\n```swift\nstruct LoginRequest: Encodable, Sendable {\n    let email: String\n    let password: String\n}\n\nstruct LoginResponse: Decodable, Sendable {\n    let sessionToken: String\n    let refreshToken: String\n}\n```\n\nCall login:\n\n```swift\nlet login = LoginRequest(\n    email: \"person@example.com\",\n    password: \"password\"\n)\n\nlet response: LoginResponse = try await auth\n    .path(\"auth/login\")\n    .noAuth()\n    .post(body: login)\n    .value()\n```\n\nWhy `.noAuth()`?\n\nBecause login usually happens before you have a token.\n\nAfter login succeeds, SwiftRest looks at the response. If the response has the token fields you configured, SwiftRest saves them.\n\nWith `.sessionTokens()`, SwiftRest saves:\n\n- `sessionToken`\n- `refreshToken`\n\nYou can check what was saved:\n\n```swift\nif let session = try await auth.session() {\n    print(session.token ?? \"no token\")\n    print(session.refreshToken ?? \"no refresh token\")\n}\n```\n\nYou can also ask simple yes/no questions:\n\n```swift\nlet hasToken = try await auth.hasSession()\nlet hasRefresh = try await auth.hasRefreshToken()\n```\n\n## Refresh Tokens\n\nA refresh token lets your app recover after the main token expires.\n\nUse this setup:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .client\n```\n\nHere is what happens:\n\n1. You call a protected endpoint.\n2. SwiftRest adds the saved session token.\n3. The server replies `401`.\n4. SwiftRest sends the saved refresh token to `auth/refresh`.\n5. The server returns a new session token.\n6. SwiftRest saves the new token.\n7. SwiftRest retries the original request once.\n\nThe default refresh request body is:\n\n```json\n{\n  \"refreshToken\": \"saved-refresh-token\"\n}\n```\n\nIf your server wants a different request field:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(\n        endpoint: \"session/refresh\",\n        requestRefreshField: \"refresh\"\n    )\n    .client\n```\n\nIf your server refreshes on `403` too:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(\n        endpoint: \"auth/refresh\",\n        triggerStatusCodes: [401, 403]\n    )\n    .client\n```\n\nIf refresh fails, log the user out:\n\n```swift\ndo {\n    let profile: Profile = try await auth.path(\"me\").get().value()\n    print(profile)\n} catch {\n    try? await auth.logout()\n}\n```\n\n## Apple App Attest\n\nApp Attest helps your server check that a request came from a real copy of your app.\n\nApp Attest is not a login system.\nApp Attest does not replace your session token.\nApp Attest sits next to your session token.\n\nYour normal auth still works like this:\n\n```http\nAuthorization: Bearer session-token\n```\n\nWhen App Attest is enabled and available, SwiftRest can also add App Attest headers.\n\n### Important default\n\nSwiftRest skips App Attest when App Attest is not available.\n\nThat means normal token auth still works on:\n\n- Simulator\n- macOS\n- unsupported devices\n- unsupported app extensions\n\nThis is the default because beginners should not have their whole app break just because App Attest is unavailable.\n\n### App Attest setup\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .appAttest(\n        challengeEndpoint: \"app-attest/challenge\",\n        registerEndpoint: \"app-attest/register\"\n    )\n    .client\n```\n\nThat is the whole client setup.\n\nAfter login, SwiftRest can:\n\n1. ask your server for a challenge\n2. create an App Attest key\n3. ask Apple to attest the key\n4. send the attestation to your server\n5. save the App Attest key ID beside the session token\n\nLater, when you make protected requests, SwiftRest can:\n\n1. ask your server for a fresh challenge\n2. create an App Attest assertion\n3. add App Attest headers to the request\n\n### What gets saved\n\nSwiftRest saves one more value in the same auth session:\n\n```swift\nSwiftRestAuthSession(\n    token: \"session-token\",\n    refreshToken: \"refresh-token\",\n    appAttestKeyID: \"apple-key-id\"\n)\n```\n\nThe current built-in Keychain store saves this session.\n\nOld saved sessions still work. If an old session does not have `appAttestKeyID`, SwiftRest reads it as `nil`.\n\n### Check App Attest state\n\n```swift\nlet hasAppAttestKey = try await auth.hasAppAttestKey()\n```\n\n### Register manually\n\nMost apps can let SwiftRest register after login.\n\nIf you want to be explicit:\n\n```swift\ntry await auth.ensureAppAttestRegistered()\n```\n\nA clear login flow can look like this:\n\n```swift\nlet login: LoginResponse = try await auth\n    .path(\"auth/login\")\n    .noAuth()\n    .post(body: LoginRequest(\n        email: \"person@example.com\",\n        password: \"password\"\n    ))\n    .value()\n\ntry await auth.ensureAppAttestRegistered()\n```\n\n### Disable App Attest for one request\n\nUse this if one endpoint must not include App Attest:\n\n```swift\nlet publicInfo: PublicInfo = try await auth\n    .path(\"public/info\")\n    .appAttest(false)\n    .get()\n    .value()\n```\n\n### Make App Attest required\n\nThe default is `.skip`.\n\nIf you want to throw when App Attest is unavailable:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .appAttest(\n        challengeEndpoint: \"app-attest/challenge\",\n        registerEndpoint: \"app-attest/register\",\n        unavailableBehavior: .fail\n    )\n    .client\n```\n\nMost apps should start with the default `.skip`.\n\n### Server endpoints SwiftRest expects\n\nSwiftRest expects a challenge endpoint.\n\nDefault request:\n\n```http\nPOST /app-attest/challenge\nContent-Type: application/json\nAuthorization: Bearer session-token\n```\n\nBody:\n\n```json\n{\n  \"purpose\": \"registration\"\n}\n```\n\nor:\n\n```json\n{\n  \"purpose\": \"assertion\"\n}\n```\n\nResponse:\n\n```json\n{\n  \"challenge\": \"a-unique-one-time-challenge\"\n}\n```\n\nThe challenge should be unique.\nThe challenge should be used once.\nThe server should reject old challenges.\n\nSwiftRest also expects a register endpoint.\n\nDefault request:\n\n```http\nPOST /app-attest/register\nContent-Type: application/json\nAuthorization: Bearer session-token\n```\n\nBody:\n\n```json\n{\n  \"keyId\": \"apple-app-attest-key-id\",\n  \"attestationObject\": \"base64-attestation-object\",\n  \"clientData\": \"base64-client-data\"\n}\n```\n\nThe server must verify the attestation with Apple App Attest rules.\nAfter verification, the server should store the public key for that user and device.\n\n### App Attest request headers\n\nAfter registration, protected requests can include:\n\n```http\nX-App-Attest-Key-ID: apple-app-attest-key-id\nX-App-Attest-Assertion: base64-assertion-object\nX-App-Attest-Client-Data: base64-client-data\n```\n\nThe server should:\n\n1. decode `X-App-Attest-Client-Data`\n2. check the challenge inside it\n3. check the method, path, query, and body hash\n4. verify `X-App-Attest-Assertion` using the public key stored at registration\n\n### Apple setup you still need\n\nIn your app target, enable the App Attest capability.\n\nFor development, Apple uses the App Attest sandbox unless you choose production in entitlements.\nFor TestFlight and App Store builds, Apple uses production.\n\nSwiftRest handles the client requests.\nYour server still must verify the attestation and assertions.\n\n## Apple DeviceCheck\n\nDeviceCheck helps your server ask Apple for a device token.\n\nDeviceCheck is not a login system.\nDeviceCheck does not replace your session token.\nDeviceCheck does not replace your refresh token.\nDeviceCheck does not sign the request body like App Attest does.\n\nDeviceCheck is simpler than App Attest.\nIt can be useful when App Attest is not available yet, or when your server wants Apple DeviceCheck tokens for its own rules.\n\n### Important default\n\nSwiftRest skips DeviceCheck when DeviceCheck is not available.\n\nThat means normal token auth still works.\n\nDeviceCheck tokens are not saved in Keychain.\nSwiftRest asks Apple for a fresh token when a request needs one.\nApple says you should treat the token as single-use.\n\n### DeviceCheck by itself\n\nUse DeviceCheck without App Attest like this:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .deviceCheck()\n    .client\n```\n\nSwiftRest will add this header when DeviceCheck is available:\n\n```http\nX-DeviceCheck-Token: base64-devicecheck-token\n```\n\nYour normal auth header is still there too:\n\n```http\nAuthorization: Bearer session-token\n```\n\n### DeviceCheck as App Attest fallback\n\nThis is the recommended setup when your server supports both:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .appAttest(\n        challengeEndpoint: \"app-attest/challenge\",\n        registerEndpoint: \"app-attest/register\"\n    )\n    .deviceCheck()\n    .client\n```\n\nThis means:\n\n1. Use App Attest when App Attest is working.\n2. Use DeviceCheck when App Attest is unavailable.\n3. Use DeviceCheck when App Attest has not registered a key yet.\n4. Keep normal session token auth working either way.\n\nIn this default fallback mode, SwiftRest does not send both proofs at the same time.\nIt sends App Attest first when it can.\nIt sends DeviceCheck when App Attest cannot be used.\n\n### Send DeviceCheck always\n\nIf your server wants DeviceCheck even when App Attest is also sent:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .appAttest(\n        challengeEndpoint: \"app-attest/challenge\",\n        registerEndpoint: \"app-attest/register\"\n    )\n    .deviceCheck(mode: .always)\n    .client\n```\n\n### Use only DeviceCheck\n\nIf you configured App Attest elsewhere but want this SwiftRest client to use only DeviceCheck:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .appAttest(\n        challengeEndpoint: \"app-attest/challenge\",\n        registerEndpoint: \"app-attest/register\"\n    )\n    .deviceCheck(mode: .only)\n    .client\n```\n\n### Disable DeviceCheck for one request\n\nUse this if one endpoint must not include DeviceCheck:\n\n```swift\nlet publicInfo: PublicInfo = try await auth\n    .path(\"public/info\")\n    .deviceCheck(false)\n    .get()\n    .value()\n```\n\n### Make DeviceCheck required\n\nThe default is `.skip`.\n\nIf you want to throw when DeviceCheck is unavailable:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .deviceCheck(unavailableBehavior: .fail)\n    .client\n```\n\nMost apps should start with the default `.skip`.\n\n### Custom DeviceCheck header\n\nIf your server wants a different header name:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .deviceCheck(\n        headers: SwiftRestDeviceCheckHeaders(\n            token: \"X-My-Device-Token\"\n        )\n    )\n    .client\n```\n\n### Server work you still need\n\nSwiftRest only gets the DeviceCheck token and sends it to your server.\n\nYour server must:\n\n1. read the `X-DeviceCheck-Token` header\n2. decode the base64 token\n3. validate the token with Apple DeviceCheck server APIs\n4. decide what the token means for your app\n\nDeviceCheck can also support Apple's two server-side per-device bits.\nSwiftRest does not manage those bits.\nYour server owns that logic.\n\n## Storage\n\nSwiftRest auth needs somewhere to save the session.\n\n### Keychain\n\nUse this for real apps.\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .client\n```\n\nKeychain is the default if you do not choose a store:\n\n```swift\nlet auth = SwiftRest.auth(baseURL: apiURL).client\n```\n\nYou can choose the Keychain service and key:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain(\n        service: \"com.example.myapp\",\n        key: \"auth.session\"\n    )\n    .client\n```\n\n### UserDefaults\n\nUseful for demos and non-sensitive test apps.\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .defaults()\n    .client\n```\n\n### Memory\n\nUseful for tests and previews.\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .memory()\n    .client\n```\n\nStart with a fake session:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .memory(session: SwiftRestAuthSession(\n        token: \"test-token\",\n        refreshToken: \"test-refresh\"\n    ))\n    .client\n```\n\n### No storage\n\nUse this when you do not want SwiftRest to save anything:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .none()\n    .client\n```\n\n### Custom storage\n\nMake your own store:\n\n```swift\nactor MySessionStore: SwiftRestSessionStore {\n    private var session: SwiftRestAuthSession?\n\n    func load() async throws -\u003e SwiftRestAuthSession? {\n        session\n    }\n\n    func save(_ session: SwiftRestAuthSession) async throws {\n        self.session = session\n    }\n\n    func clear() async throws {\n        session = nil\n    }\n}\n\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .store(MySessionStore())\n    .client\n```\n\n## Headers\n\nAdd one default header to every request:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .header(\"X-App-Version\", \"6.1.0\")\n    .client\n```\n\nAdd many default headers:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .headers([\n        \"X-App-Version\": \"6.1.0\",\n        \"X-Platform\": \"iOS\"\n    ])\n    .client\n```\n\nAdd one header to one request:\n\n```swift\nlet profile: Profile = try await auth\n    .path(\"me\")\n    .header(\"X-Trace-ID\", UUID().uuidString)\n    .get()\n    .value()\n```\n\n## Paths\n\nStart a request with `.path(...)`.\n\n```swift\nlet user: User = try await auth\n    .path(\"users/1\")\n    .get()\n    .value()\n```\n\nYou can build paths piece by piece:\n\n```swift\nlet user: User = try await auth\n    .path(\"users\")\n    .path(1)\n    .get()\n    .value()\n```\n\nThat makes:\n\n```text\n/users/1\n```\n\nYou can pass several parts:\n\n```swift\nlet event: Event = try await auth\n    .path(\"sessions\")\n    .paths(\"abc123\", \"events\", 7)\n    .get()\n    .value()\n```\n\nYou do not need to worry about extra slashes.\n\nThese are all fine:\n\n```swift\n.path(\"users\")\n.path(\"/users\")\n.path(\"users/\")\n.path(\"/users/\")\n```\n\n## Query\n\nUse simple query parameters:\n\n```swift\nlet users: [User] = try await auth\n    .path(\"users\")\n    .parameter(\"page\", \"1\")\n    .parameter(\"search\", \"ricky\")\n    .get()\n    .value()\n```\n\nThat makes:\n\n```text\n/users?page=1\u0026search=ricky\n```\n\nUse a model if you prefer:\n\n```swift\nstruct UserQuery: Encodable, Sendable {\n    let page: Int\n    let search: String\n}\n\nlet users: [User] = try await auth\n    .path(\"users\")\n    .query(UserQuery(page: 1, search: \"ricky\"))\n    .get()\n    .value()\n```\n\n## HTTP Methods\n\nGET:\n\n```swift\nlet user: User = try await auth.path(\"users/1\").get().value()\n```\n\nPOST:\n\n```swift\nstruct CreateUser: Encodable, Sendable {\n    let name: String\n}\n\nlet user: User = try await auth\n    .path(\"users\")\n    .post(body: CreateUser(name: \"Ricky\"))\n    .value()\n```\n\nPUT:\n\n```swift\nlet user: User = try await auth\n    .path(\"users/1\")\n    .put(body: CreateUser(name: \"Ricky Stone\"))\n    .value()\n```\n\nPATCH:\n\n```swift\nlet user: User = try await auth\n    .path(\"users/1\")\n    .patch(body: [\"name\": \"Ricky\"])\n    .value()\n```\n\nDELETE:\n\n```swift\ntry await auth\n    .path(\"users/1\")\n    .delete()\n    .send()\n```\n\nHEAD:\n\n```swift\nlet raw = try await auth\n    .path(\"health\")\n    .head()\n    .raw()\n```\n\nOPTIONS:\n\n```swift\nlet raw = try await auth\n    .path(\"users\")\n    .options()\n    .raw()\n```\n\n## JSON Settings\n\nDefault SwiftRest JSON uses Foundation defaults.\n\nIf your API uses `snake_case`, use `webAPI`:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL, config: .webAPI)\n    .client\n```\n\nOr set JSON behavior on the builder:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .jsonKeys(.snakeCase)\n    .jsonDates(.iso8601)\n    .client\n```\n\nUseful presets:\n\n- `SwiftRestConfig.standard`\n- `SwiftRestConfig.webAPI`\n- `SwiftRestJSONCoding.iso8601`\n- `SwiftRestJSONCoding.webAPI`\n- `SwiftRestJSONCoding.webAPIFractionalSeconds`\n\n## Responses\n\n### Decode only the value\n\n```swift\nlet user: User = try await auth\n    .path(\"users/1\")\n    .get()\n    .value()\n```\n\n### Get value and headers\n\n```swift\nlet result = try await auth\n    .path(\"users/1\")\n    .get()\n    .valueAndHeaders(as: User.self)\n\nprint(result.value.name)\nprint(result.headers[\"x-request-id\"] ?? \"no request id\")\n```\n\n### Get the whole response\n\n```swift\nlet response: SwiftRestResponse\u003cUser\u003e = try await auth\n    .path(\"users/1\")\n    .get()\n    .response()\n\nprint(response.statusCode)\nprint(response.data?.name ?? \"no user\")\nprint(response.header(\"x-request-id\") ?? \"no request id\")\n```\n\n### Get raw response\n\nUse this when you want to inspect status codes yourself.\n\n```swift\nlet raw = try await auth\n    .path(\"users/1\")\n    .get()\n    .raw()\n\nprint(raw.statusCode)\nprint(raw.rawValue ?? \"no body\")\n```\n\n### Send without a response model\n\nUse this for logout or delete calls:\n\n```swift\ntry await auth\n    .path(\"auth/logout\")\n    .post(body: [String: String]())\n    .send()\n```\n\n## Errors\n\nUse normal Swift `do/catch`.\n\n```swift\ndo {\n    let profile: Profile = try await auth\n        .path(\"me\")\n        .get()\n        .value()\n\n    print(profile)\n} catch let error as SwiftRestClientError {\n    print(error.userMessage)\n} catch {\n    print(error.localizedDescription)\n}\n```\n\nCommon errors:\n\n- invalid URL\n- network error\n- decoding error\n- HTTP error like `401` or `500`\n- refresh failed\n- Keychain storage failed\n- App Attest failed\n\n## SwiftUI Example\n\nThis is intentionally boring.\n\n```swift\nimport SwiftUI\nimport SwiftRest\n\nstruct Profile: Decodable, Sendable {\n    let name: String\n}\n\n@MainActor\nfinal class ProfileViewModel: ObservableObject {\n    @Published var name = \"\"\n    @Published var errorMessage = \"\"\n\n    private let auth: SwiftRestAuthClient\n\n    init(auth: SwiftRestAuthClient) {\n        self.auth = auth\n    }\n\n    func load() async {\n        do {\n            let profile: Profile = try await auth\n                .path(\"me\")\n                .get()\n                .value()\n\n            name = profile.name\n        } catch {\n            errorMessage = error.localizedDescription\n        }\n    }\n}\n\nstruct ProfileView: View {\n    @StateObject var model: ProfileViewModel\n\n    var body: some View {\n        VStack {\n            if !model.name.isEmpty {\n                Text(model.name)\n            } else if !model.errorMessage.isEmpty {\n                Text(model.errorMessage)\n            } else {\n                ProgressView()\n            }\n        }\n        .task {\n            await model.load()\n        }\n    }\n}\n```\n\n## Testing\n\nUse memory storage in tests:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: URL(string: \"https://api.example.com\")!)\n    .memory(session: SwiftRestAuthSession(\n        token: \"test-token\",\n        refreshToken: \"test-refresh\"\n    ))\n    .client\n```\n\nUse a custom `URLSession` when you want to mock networking:\n\n```swift\nlet configuration = URLSessionConfiguration.ephemeral\nconfiguration.protocolClasses = [MyMockURLProtocol.self]\nlet session = URLSession(configuration: configuration)\n\nlet auth = SwiftRest\n    .auth(baseURL: apiURL, session: session)\n    .memory()\n    .client\n```\n\nRun SwiftRest tests:\n\n```bash\nswift test\n```\n\n## Common Questions\n\n### Does SwiftRest use SwiftKey?\n\nNo.\n\nSwiftRest has its own built-in Keychain session store.\n\n### Do I need App Attest?\n\nNo.\n\nMost apps should start with normal session token auth.\nAdd App Attest when your server is ready to verify it.\n\n### Do I need DeviceCheck?\n\nNo.\n\nDeviceCheck is optional.\nAdd it when your server is ready to validate Apple DeviceCheck tokens.\n\n### Should I use App Attest or DeviceCheck?\n\nUse App Attest when your server supports it.\nUse DeviceCheck as a fallback when App Attest is not available.\n\nSwiftRest makes that setup simple:\n\n```swift\n.appAttest(\n    challengeEndpoint: \"app-attest/challenge\",\n    registerEndpoint: \"app-attest/register\"\n)\n.deviceCheck()\n```\n\n### What happens on devices that do not support App Attest?\n\nSwiftRest skips App Attest by default.\n\nYour normal bearer token auth still works.\n\n### What happens when DeviceCheck is not available?\n\nSwiftRest skips DeviceCheck by default.\n\nYour normal bearer token auth still works.\n\n### Does App Attest replace the refresh token?\n\nNo.\n\nThe refresh token still refreshes the session token.\nApp Attest helps prove the request came from your real app.\n\n### Does DeviceCheck replace App Attest?\n\nNo.\n\nDeviceCheck is simpler.\nIt gives your server a token to validate with Apple.\nApp Attest can prove more about your app and sign request data.\n\n### Where are tokens saved?\n\nBy default, tokens are saved in Keychain.\n\nThe saved session can contain:\n\n- token\n- refresh token\n- App Attest key ID\n\nDeviceCheck tokens are not saved.\nSwiftRest generates them when requests need them.\n\n### How do I log out?\n\n```swift\ntry await auth.logout()\n```\n\nThis clears the saved SwiftRest session.\n\n### What should I use for a real app?\n\nStart here:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .client\n```\n\nThen add App Attest when your server supports it:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .appAttest(\n        challengeEndpoint: \"app-attest/challenge\",\n        registerEndpoint: \"app-attest/register\"\n    )\n    .client\n```\n\nThen add DeviceCheck fallback when your server supports it:\n\n```swift\nlet auth = SwiftRest\n    .auth(baseURL: apiURL)\n    .keychain()\n    .sessionTokens()\n    .refresh(endpoint: \"auth/refresh\")\n    .appAttest(\n        challengeEndpoint: \"app-attest/challenge\",\n        registerEndpoint: \"app-attest/register\"\n    )\n    .deviceCheck()\n    .client\n```\n\n## License\n\nSwiftRest is released under the MIT License. See [LICENSE.txt](LICENSE.txt).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fricky-stone%2Fswiftrest","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fricky-stone%2Fswiftrest","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fricky-stone%2Fswiftrest/lists"}