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— typedCodable + Sendable + Hashable + IdentifiablestructPosts.loader(url:)—@Observablepaginated loader with retry, cache, auth, dedupSmartPaginatedView(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 start — The three modes — Production features — Demo
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.
// 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:)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-syntaxonce (~60–90s); subsequent builds are instant.
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:
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.
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)
}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.
@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 exhaustedThree strategies supported: .cursor, .page, .offset. All three behave identically for the caller — only the configuration differs.
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.
SmartAPI scales from "I just want typed parsing" to "generate the whole screen." Pick what you need; everything is opt-in.
@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.
Sources/MyApp/Models/post.smartapi.json ← you commit this
Then run:
swift run smartapi-bundle Sources/MyApp/Models
# generated Sources/MyApp/Models/post+SmartAPI.swiftThe generated .swift is a one-line @SmartAPI wrapper. Commit both. Re-run when the JSON changes; it's idempotent.
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 importedEvery 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.
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 |
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.
Verified by 78 tests, including 17 URLProtocol-based integration tests:
- ✅ Swift 6 strict mode +
ExistentialAnyupcoming feature enforced viaPackage.swift - ✅
-warnings-as-errorsclean across all targets - ✅ Zero
@unchecked Sendablein 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
See Examples/:
JSONSampleMode.swift— inline sample, one type, three minutesOpenAPIMode.swift— CLI workflow for entire APIsCustomClientMode.swift— full production config: auth, retry, observer, coalescerDemo/— runnable GitHub API demo: search, paginated list, user detail, offline cache
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.
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):
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 inThis is the honest part — and the two gates that should send you away from SmartAPI:
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.
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_case → camelCase automatic |
✅ heuristic-driven | x-swift-name hints |
URL / Date / Bool inferred from sample |
✅ from values + key suffixes | 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 | ✅ 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.
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.
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/.
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-rolledCodablegives 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.
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.
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.
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: trueis supported on single-resource@SmartAPItypes — not onpaginated: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.schemaswith$ref, format hints, composition (allOf/oneOf/anyOf), enum values, and recursive schemas via depth-limited unrolling. Discriminators, complex polymorphism, security schemes, andpathsoperations are not yet ingested — only schemas become models. CodeGeneratoris string-based. The macro emits Swift source as raw strings rather thanSwiftSyntaxBuilderAST 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 toSwiftSyntaxBuilderis 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.
SmartFetchingis protocol-based so you can inject a mock, and aMockURLProtocolpattern is documented above, but there's no opinionatedSmartAPITestingmodule yet. - Lenient mode reports
wrongTypewithout saying which type. The observer event includes the field name and a.wrongTypereason; 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.
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)
MIT.
Issues and PRs welcome. Run the tests with:
swift testAll PRs must build clean under -Xswiftc -warnings-as-errors in Swift 6 strict mode.