SmartAPI

main

A Swift macro + runtime framework that turns JSON or OpenAPI specs into typed iOS API clients with auth refresh, retry, request deduplication, offline cache, observability, schema drift detection, lenient parsing, three pagination strategies, and SwiftUI integration.
AbhishekSuryawanshi/SmartAPI

SmartAPI

CI Swift 6.0 iOS 17+ macOS 14+ SPM License: MIT

Turn JSON or OpenAPI into a production-ready Swift API client. Typed models, auth, retry, caching, observability, and pagination — built in.

@SmartAPI(sample: """
{ "items": [{"id": 1, "title": "Hello"}], "total_count": 1247 }
""", paginated: .page(items: "items", total: "total_count"))
enum Posts {}

That's it. You now have:

  • Posts.Model — typed Codable + Sendable + Hashable + Identifiable struct
  • Posts.loader(url:)@Observable paginated loader with retry, cache, auth, dedup
  • SmartPaginatedView(loader:) — SwiftUI infinite-scroll list, pull-to-refresh, in one line
SmartPaginatedView(loader: Posts.loader(url: postsURL, fetcher: client)) { post in
    PostRowView(post: post)   // ← your view, your design system
}

No Codable conformances written by hand. No CodingKeys. No init(from:). No retry plumbing. No URL string-mangling. No pagination state machine.

Quick startThe three modesProduction featuresDemo


Four steps to a working screen

SmartAPI quickstart — the four things you do and what you get free


Why SmartAPI

Every iOS app builds the same networking layer: typed models, an HTTP client, auth refresh, retry policies, observability, pagination. Each one slightly different, each one a maintenance burden.

SmartAPI generates the typed API layer from a JSON sample (or an OpenAPI spec) and gives you a production-grade runtime to call it. Auto-generated SwiftUI views are optional — most teams keep their own design system and use SmartAPI purely for parsing and fetching.

How SmartAPI works in any project — the five-stage pipeline

// What you write
@SmartAPI(sample: realResponseFromYourAPI, scope: .parseOnly)
enum User {}

// What SmartAPI gives you
User.Model       // typed, Codable, snake_case → camelCase, Date/URL inferred
User.Loader      // @Observable, cache-aware, observable, retryable
                 // (and View / EditView / Draft / Mutator if you opt in via scope:)

Installation

Add to Package.swift:

dependencies: [
    .package(url: "https://github.com/AbhishekSuryawanshi/SmartAPI.git", from: "0.1.0")
]

Then in your target:

.target(
    name: "MyApp",
    dependencies: [.product(name: "SmartAPI", package: "SmartAPI")]
)

Requires Swift 6.0+ · Xcode 16+ · iOS 17+ · macOS 14+. Fully supports Swift 6.2's "main actor by default" mode (the modern Xcode app-target default).

First build: Xcode shows a one-time "SmartAPIMacros — package needs to enable a macro" prompt. Click Trust & Enable. This is Swift's standard security gate for package macros, not specific to SmartAPI. The first build also compiles swift-syntax once (~60–90s); subsequent builds are instant.


Quick start

Here's the full journey from a JSON sample and a URL to a rendered, paginated screen — compile-time generation on top, runtime data flow below:

SmartAPI data flow — from JSON sample and URL to a paginated SwiftUI list

1. Generate a model from a real API response

import SmartAPI

@SmartAPI(sample: """
{
  "id": 42,
  "name": "Ada Lovelace",
  "avatar_url": "https://example.com/ada.jpg",
  "is_active": true,
  "created_at": "2024-01-15T10:30:00Z",
  "tags": ["math", "computing"]
}
""", scope: .parseOnly)
enum User {}

You get a typed User.Model with:

JSON Swift
id: 42 let id: Int (→ Identifiable)
avatar_url: "..." let avatarURL: URL (URL inferred from _url suffix)
is_active: true let isActive: Bool (snake_case → camelCase)
created_at: "2024-..." let createdAt: Date (ISO-8601 inferred)
tags: ["math", "computing"] let tags: [String]

Plus CodingKeys, Codable + Sendable + Hashable + Identifiable conformances, public init. Zero hand-written code.

2. Catalog your endpoints

enum API {
    static let client = SmartClient(
        baseURL: URL(string: "https://api.example.com")!,
        authorization: BearerTokenProvider(initialToken: token) {
            try await refreshAPI()
        },
        retryPolicy: .standard,                    // safe by default — no POST auto-retry
        observer: SmartAPILogger.shared,           // os.Logger; swap in your analytics
        coalescer: RequestCoalescer()              // dedup concurrent GETs
    )

    static let getUser    = SmartEndpoint<User.Model>(path: "/users/{id}")
    static let listUsers  = SmartEndpoint<[User.Model]>(path: "/users")
    static let createUser = SmartEndpoint<User.Model>(path: "/users", method: .post, requiresAuth: true)
    static let deleteUser = SmartEndpoint<Empty>(path: "/users/{id}", method: .delete, requiresAuth: true)
}

3. Call them from anywhere

let user  = try await API.client.call(API.getUser, pathParams: ["id": "42"])
let users = try await API.client.call(API.listUsers)
let saved = try await API.client.call(API.createUser, body: draft)
try await API.client.call(API.deleteUser, pathParams: ["id": "42"])

Path-template substitution, query parameters, custom headers, auth, 401 refresh, retry on 5xx, request deduplication — all automatic.

4. Add pagination when you need it

@SmartAPI(
    sample: """
    { "data": [{"id": 1}], "next_cursor": "abc", "has_more": true }
    """,
    paginated: .cursor(items: "data", nextCursor: "next_cursor", hasMore: "has_more")
)
enum Feed {}

let loader = Feed.loader(url: feedURL, fetcher: API.client)
await loader.load()              // first page
await loader.loadMore()          // append next
loader.items                     // [Feed.Model]
loader.hasMore                   // false when exhausted

Three strategies supported: .cursor, .page, .offset. All three behave identically for the caller — only the configuration differs.

5. Drop into SwiftUI

struct FeedScreen: View {
    var body: some View {
        SmartPaginatedView(loader: Feed.loader(url: feedURL, fetcher: API.client)) { post in
            MyCustomPostRowView(post: post)
        }
        .navigationTitle("Feed")
    }
}

Infinite scroll, pull-to-refresh, empty state, stale-data banner when the cache served while the network was down — all built in.


The three modes

SmartAPI scales from "I just want typed parsing" to "generate the whole screen." Pick what you need; everything is opt-in.

Mode 1: Inline JSON sample

@SmartAPI(sample: """
{ "id": 1, "title": "Hello" }
""")
enum Post {}

For most cases. The sample is right there in source — easy to read, easy to update, easy to diff.

Mode 2: JSON file via CLI

Sources/MyApp/Models/post.smartapi.json   ← you commit this

Then run:

swift run smartapi-bundle Sources/MyApp/Models
# generated Sources/MyApp/Models/post+SmartAPI.swift

The generated .swift is a one-line @SmartAPI wrapper. Commit both. Re-run when the JSON changes; it's idempotent.

Mode 3: OpenAPI spec → entire API

swift run smartapi-bundle openapi github-spec.json Sources/MyApp/Generated/
# generated Sources/MyApp/Generated/Repository+SmartAPI.swift
# generated Sources/MyApp/Generated/User+SmartAPI.swift
# generated Sources/MyApp/Generated/Issue+SmartAPI.swift
# ... 200+ more
# smartapi-bundle openapi: 203 schemas imported

Every schema in components.schemas becomes a typed Swift model. Handles $ref, format hints (uri, date-time, uuid, email), composition (allOf/oneOf/anyOf), enum values, recursive schemas.


Production features

Not just demoware. Every feature below is verified by URLProtocol-based integration tests against a real URLSession.

Feature What you get
Auth + refresh AuthorizationProvider protocol; ships BearerTokenProvider with concurrent-refresh deduplication (no double-burning refresh tokens)
Retry policy .standard / .aggressive / .allowsUnsafeRetries; exponential backoff with maxTotalDelay cap; refuses POST/PATCH by default (no duplicate-write hazard)
Per-call override client.call(endpoint, retryPolicy: .none) for audit logs / legal writes
Offline cache JSONFileCache per type; loader reads cache → emits state → refreshes in background
Stale-data banner SmartView shows when refresh failed but cache served — built in
Request deduplication RequestCoalescer folds N concurrent identical GETs into 1 network call
Schema drift detection Fingerprint baked at compile time; loader.detectSchemaDrift() compares against live
Lenient parsing strict: false survives missing/null/wrong-type fields; observer reports every default
Observability SmartAPIObserver fires on loader/mutator lifecycle, retries, auth refresh, cache, lenient defaults
Pagination Cursor / page-number / offset — all three; auto-null cursor on last page; SwiftUI infinite scroll
POST / GraphQL SmartQuery.post(url, body: typed) — eager body encoding so retries use identical bytes
Cancellation Task.isCancelled checkpoints throughout; SwiftUI .task auto-cancellation propagates

Architecture

Four SPM targets, ~5,000 lines total:

SmartAPI              (library)    — Runtime: client, cache, observer, query, flow, view
SmartAPIMacros        (plugin)     — @SmartAPI macro: JSON inference + Swift codegen
SmartAPIImporter      (library)    — OpenAPI 3.0 parser
SmartAPIBundle        (CLI)        — swift run smartapi-bundle ...

The compiler plugin and runtime library can't import each other (different target types). Runtime type names referenced by codegen are centralized in one RuntimeSymbols file — rename surfaces as a build error, not a silent runtime crash.


Production-readiness checklist

Verified by 78 tests, including 17 URLProtocol-based integration tests:

  • ✅ Swift 6 strict mode + ExistentialAny upcoming feature enforced via Package.swift
  • -warnings-as-errors clean across all targets
  • ✅ Zero @unchecked Sendable in production runtime
  • ✅ POST/PATCH refused by default retry policy (no duplicate writes)
  • ✅ Concurrent refresh tokens coalesced (no double-burning)
  • ✅ Cumulative backoff cap (no hung UI)
  • ✅ Mid-pagination errors preserve existing items
  • ✅ Null cursor handled correctly on last page
  • ✅ Concurrent loadMore() guarded (no thundering herd)
  • ✅ Cache write failures surface to observer
  • ✅ Lenient mode defaults surface to observer with field + reason
  • ✅ Auth 401 refresh + retry verified end-to-end

Examples

See Examples/:


Testing your own code

SmartAPI ships a MockURLProtocol pattern in the test target. Use it (or your own) to script HTTP responses without standing up a server:

import XCTest
@testable import SmartAPI

let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let client = SmartClient(session: URLSession(configuration: config), ...)
MockURLProtocol.script.enqueue(.success(status: 200, body: payload))
let user = try await client.call(API.getUser, pathParams: ["id": "42"])

For your loaders, inject a mock SmartFetching directly — protocol-based, no URLSession required.


Why not just a proven HTTP client?

SmartAPI isn't competing with Alamofire or URLSession — it sits above the transport layer (and uses URLSession under the hood). They give you a great HTTP client; you still hand-write the models, retry, refresh, cache, pagination, and observability on top. SmartAPI generates the models and ships that runtime.

So the real comparison isn't SmartAPI vs Alamofire. It's SmartAPI vs (an HTTP client + everything you build on top of it):

SmartAPI versus a hand-rolled stack on top of an HTTP client

The same task, both ways

Goal: a typed, paginated list of users, with retry on 5xx and an offline cache.

// Hand-rolled on top of an HTTP client — you write and maintain all of this:
struct User: Codable, Identifiable {
    let id: Int; let name: String; let avatarURL: URL
    enum CodingKeys: String, CodingKey {
        case id, name
        case avatarURL = "avatar_url"        // map every snake_case key by hand
    }
}
struct UserPage: Codable { let users: [User]; let total: Int }

final class Retry: RequestInterceptor { /* exponential backoff, retry-count guard… */ }

@MainActor final class UsersVM: ObservableObject {
    @Published var users: [User] = []
    private var page = 1; private var total = 0
    func loadMore() async {
        guard users.count < total || total == 0 else { return }
        let r = try? await AF.request(usersURL, parameters: ["page": page, "per_page": 20],
                                      interceptor: Retry()).serializingDecodable(UserPage.self).value
        guard let r else { return }
        users += r.users; total = r.total; page += 1
    }
    // …plus the cache layer, the auth refresh, the observability — still TODO
}
// With SmartAPI — the same outcome:
@SmartAPI(
    sample: #"{ "users": [{"id":1,"name":"Ada","avatar_url":"https://…"}], "total": 194 }"#,
    cache: true,
    paginated: .page(items: "users", total: "total")
)
enum Users {}

let loader = Users.loader(url: usersURL, fetcher: client)
// .items · .loadMore() · .hasMore · retry · cache · observability — all built in

When to reach for each

This is the honest part — and the two gates that should send you away from SmartAPI:

When to choose SmartAPI versus a proven HTTP client — a decision flowchart

If your app is mission-critical and shipping very soon, or needs deep low-level control (cert pinning, custom TLS, multipart streaming, request interceptors), reach for a battle-tested client — Alamofire's decade of hardening wins, and a v0.1.0 is the wrong bet. Otherwise — a new project, internal tool, or prototype — SmartAPI deletes the week of boilerplate above the HTTP layer.

Two honest gates: if your app is mission-critical and shipping very soon, or needs deep low-level control (cert pinning, custom TLS, multipart streaming), reach for a battle-tested client — a v0.1.0 package is the wrong bet there. Otherwise — a new project, internal tool, or prototype — SmartAPI deletes a week of boilerplate you'd write by hand.

Why not just OpenAPI Generator?

The obvious comparison is swift-openapi-generator (Apple's official) or generic OpenAPI Generator. Honest take on when each tool fits:

SmartAPI swift-openapi-generator
Works without a published OpenAPI spec ✅ inline JSON sample or .smartapi.json file ❌ requires a full spec
snake_casecamelCase automatic ✅ heuristic-driven ⚠️ relies on x-swift-name hints
URL / Date / Bool inferred from sample ✅ from values + key suffixes ⚠️ relies on format annotations
Auth + token-refresh + 401 retry ✅ shipped (AuthorizationProvider) ❌ bring your own
Retry policy with idempotency safety ✅ shipped (RetryPolicy.standard) ❌ bring your own
Offline cache + stale-data banner ✅ shipped (SmartCache, SmartView) ❌ bring your own
Request deduplication on concurrent GETs ✅ shipped (RequestCoalescer) ❌ bring your own
Pagination strategies (cursor / page / offset) ✅ shipped + macro-typed ❌ bring your own
Lenient decoding with observer-visible defaults strict: false ❌ bring your own
Observability protocol ✅ shipped (SmartAPIObserver) ❌ bring your own
Optional SwiftUI views (off by default) scope: .full opts in ❌ models only
Full OpenAPI 3.x feature coverage ⚠️ pragmatic subset (see Known limitations) ✅ comprehensive
Apple backing / official status

Use SmartAPI when you want one SPM that gives you the entire iOS networking layer — typed models + a production HTTP runtime — without composing 4–5 separate libraries. Especially valuable for projects that don't have an OpenAPI spec at all (most internal APIs).

Use swift-openapi-generator when you need exact OpenAPI 3.x semantics across a polyglot team where Swift is one consumer of a shared contract, and you already have a separate solution for auth/retry/caching/observability.

The two tools are not mutually exclusive — SmartAPI's OpenAPI ingestion mode treats components.schemas as the source of truth, so an OpenAPI-first team can use SmartAPI for the iOS client surface while keeping the spec as the contract.


FAQ

"If I have to paste a JSON sample, why not just use Alamofire?"

You need the JSON shape with any tool — you can't write a Codable model for a response you've never seen. So you always look at the JSON first. The only difference is who turns it into Swift:

  • With Alamofire / URLSession: you read the JSON, then hand-write the struct, the CodingKeys, the decoder, the retry, the pagination, the cache — and maintain all of it as the API evolves.
  • With SmartAPI: you paste the same JSON once, and the macro generates all of that.

Pasting the sample isn't extra work — it replaces the much larger work of transcribing the JSON into typed Swift by hand. It's the minimum possible way to describe an API's shape: less effort than even one hand-written struct.

"What if I don't have a JSON sample?"

Then you can't integrate the API with any library — that's a precondition for writing a model anywhere, not a SmartAPI limitation. In practice you always have one, or can get it in seconds:

  • curl / Postman / the browser — hit the endpoint once, copy the response. (You'd do this anyway before writing any code.)
  • The API's docs — most publish example responses. Copy-paste.
  • An OpenAPI spec → paste nothing. If the API has a spec, the CLI generates every model from it: swift run smartapi-bundle openapi spec.json Generated/.

"Isn't a pasted sample fragile when the API changes?"

It's actually more resilient than hand-written models, not less:

  • strict: false — missing / null / wrong-type fields fall back to defaults instead of crashing the screen.
  • Schema-drift detection — the shape is fingerprinted at compile time; loader.detectSchemaDrift() warns you when the live API diverges from your sample. A hand-rolled Codable gives you no such warning — it just fails at runtime in front of a user.

So the sample isn't a fragile dependency — it's a contract the package can validate against.

"Do I have to use the generated SwiftUI views?"

No. scope: .parseOnly (the recommended default) generates only the model + loader — you bind them to your own design system. Generated views are opt-in via scope: .full.

"Does it support POST / GraphQL / authenticated endpoints?"

Yes — SmartQuery.post(url, body:) for typed bodies (including GraphQL), AuthorizationProvider for auth + refresh, and a SmartEndpoint catalog for mixed GET/POST/PUT/DELETE. See Production features.


Known limitations

Honest about what's not yet shipped — most of these are on the roadmap, some are deliberate design choices.

  • Pagination + cache don't combine. Caching a PaginatedLoader's accumulated items requires invalidation decisions (refresh first page only? whole list? per-page entries with TTL?) that need design work before shipping safely. For now, cache: true is supported on single-resource @SmartAPI types — not on paginated: ones. Tracking issue: #2 (planned).
  • Link-header pagination is not yet supported. The three shipped strategies (cursor, page-number, offset) cover the vast majority of modern REST APIs. GitHub's legacy Link: <url>; rel="next" style is the most common omission. Planned as a fourth strategy.
  • OpenAPI 3.x subset. The CLI handles components.schemas with $ref, format hints, composition (allOf / oneOf / anyOf), enum values, and recursive schemas via depth-limited unrolling. Discriminators, complex polymorphism, security schemes, and paths operations are not yet ingested — only schemas become models.
  • CodeGenerator is string-based. The macro emits Swift source as raw strings rather than SwiftSyntaxBuilder AST nodes. It works reliably and is heavily tested, but means a typo in generated code surfaces as a compile error in your project rather than at macro-expansion time. Migration to SwiftSyntaxBuilder is on the roadmap.
  • Examples/Demo/ is a copy-paste-into-your-own-project file, not a self-contained Xcode project. Drop it into any iOS 17+ app, and it runs against the public GitHub API. A standalone runnable Xcode project demo is a near-term wishlist item.
  • No built-in mocking / record-replay infrastructure for consumer tests. SmartFetching is protocol-based so you can inject a mock, and a MockURLProtocol pattern is documented above, but there's no opinionated SmartAPITesting module yet.
  • Lenient mode reports wrongType without saying which type. The observer event includes the field name and a .wrongType reason; the actual received type isn't surfaced. Worth adding.

If any of these are blockers for your project, file an issue and I'll prioritize accordingly.


Roadmap

Shipped:

  • ✅ Macro-based model generation from JSON
  • ✅ OpenAPI 3.0 ingestion
  • ✅ Full HTTP client with auth + retry + dedup
  • ✅ Cursor / page / offset pagination
  • ✅ SwiftUI integration with infinite scroll
  • ✅ Schema drift + lenient parsing
  • ✅ Observability protocol
  • ✅ URLProtocol-based test infra

Considered for next:

  • Link-header pagination (GitHub legacy)
  • Pagination + cache integration (currently independent)
  • SwiftSyntaxBuilder-based codegen (currently string composition)
  • Backend Swift companion (Vapor/Hummingbird models)

License

MIT.


Contributing

Issues and PRs welcome. Run the tests with:

swift test

All PRs must build clean under -Xswiftc -warnings-as-errors in Swift 6 strict mode.

Description

  • Swift Tools 6.0.0
View More Packages from this Author

Dependencies

Last updated: Sun Jul 12 2026 07:53:51 GMT-0900 (Hawaii-Aleutian Daylight Time)