Turns an OpenAPI document into a compile-checked BackendSpec for swift-declarative-requests: the whole backend as one closed Swift type with typed operations, models, and security gates. A small runtime ships alongside the generator to handle what a spec can't fully express (status-gated error handling, token refresh), so those stay as generated facts wired into shared mechanics rather than hand-rolled per app.
- 🚫 Networking disappears from app code: no
URLSession,URLRequest,JSONDecoder, or status codes at the call site; the whole backend is one struct of typed closures (Why) - 🧱 Requests are declared, not assembled: each operation is a request block, built by a result builder (Requests)
- 🔌 Simple backend wiring: base URL, transport, decoder. That's it (Wiring)
- 🔄 Auth and refresh are per operation: public calls never carry a token, never refresh one — Apple's generator drops security schemes and tells you to inject headers into every request (Refresh)
- 🧵 The main actor stays free: URL composition, body encoding, and JSON decoding run off the caller by
@concurrentcontract — the call site declares what it wants, never where it runs (Main) - ✍️ Even HMAC signing is one more composable block: Binance's signing slots filled by the wiring, dead values at call sites (HMAC)
To erase networking from app code. The endgame client is pure data, a struct of typed closures:
let pet = try await api.showPetById("42") // petId in, Pet out. That is the whole API.No URLSession. No URLRequest. No JSONDecoder. No status codes, no headers, no protocols. The client needs none of it. Those concepts are real, but each one lives in exactly one layer below, and never leaks up:
| networking concept | its only home |
|---|---|
URLSession |
the app's transport closure ((URLRequest) async throws -> (Data, URLResponse)) |
URLRequest composition |
the DSL blocks + the NetworkExecution seam |
| status codes | the generated successStatuses table + ResponseError.evaluate |
JSONDecoder |
the ClientBuilder decode step |
| tokens, refresh, auth headers | the wiring + RefreshingExecutor |
Expand that one line and it is always the same chain, input to model. Every step is mechanical, every step derivable from the spec, so not one of them is a decision app code should be making per call:
// input → operation → request → transport → evaluate → decode, each step separable
let petId = "42"
let operation = SwaggerPetstore.Operation.showPetById(petId: petId)
let request = try client.request(operation)
let response = try await session.data(for: request)
let data = try ResponseError.evaluate(response, successStatuses: SwaggerPetstore.Responses.successStatuses(operation))
let pet = try JSONDecoder().decode(Pet.self, from: data)Generating every step is what collapses that chain back down to the one line — the whole petstore backend, three fields:
struct Client {
var listPets: (_ limit: Int?) async throws -> Pets
var createPets: (_ body: Pet) async throws -> Void // 204 → Void
var showPetById: (_ petId: String) async throws -> Pet
}Output types are read from responses:, so the ladder above is what each field is. And because it is a plain struct of closures, a whole backend is a value you write by hand — no network in sight:
var api = Client.unimplemented // every field traps, by name
api.showPetById = { Pet(id: 1, name: "Rex", tag: $0) } // state only what you needThat is the mocking story too — no protocols, just field assignment — and api.showPetById("42") from the top now answers from your closure. Swap the value for Client.wired(execute:decoder:) when it should reach a real server and not one call site changes; wrap the (Operation) → Data seam it wires over and you have middleware. Traps can't reach production, because wired fills every field.
The generator and runtime exist to manufacture that pure client from the spec, nothing more:
swift run declarative-openapi petstore.yamlEach operation is a request block: a switch over the enum whose branches list the request's parts — method, path, query, body — with no URLRequest mutated and no strings concatenated. An optional query item is an if let, a path parameter is interpolation, a JSON body is one line, and the compiler checks all of it — every line below generated from paths::
enum SwaggerPetstore {
enum Operation: RequestBuildable {
case listPets(limit: Int?)
case createPets(body: Pet)
case showPetById(petId: String)
var body: some RequestBuildable {
switch self {
case let .listPets(limit):
Method.GET
Endpoint("pets")
if let limit {
Query("limit", String(limit))
}
case let .createPets(body):
Method.POST
Endpoint("pets")
RequestBody.json(body)
case let .showPetById(petId):
Method.GET
Endpoint("pets/\(petId)")
}
}
}
}Consuming it is a small hand-written wiring stating environment and policy, essentially petstore's entire hand-written layer:
struct PetstoreClient {
var baseURL: URL
var execution: NetworkExecution<SwaggerPetstore.Operation> {
NetworkExecution(
request: { try $0.base(baseURL).request() }, // this app's environment
transport: { try await URLSession.shared.data(for: $0) }, // this app's transport policy
successStatuses: SwaggerPetstore.Responses.successStatuses // generated fact table
)
}
var api: SwaggerPetstore.Client {
.wired(execute: execution.execute) { _ in JSONDecoder() }
}
}Each operation is itself a RequestBuildable block, so the bare chain works too: try SwaggerPetstore.Operation.showPetById(petId: "42").base(url).request().
The top of the ladder is the generated Client: the backend as one struct of typed closures, output types read from responses: (204 → Void, image/png → Data, text/* → UTF-8 String, json $ref → the model). Field names carry the operation; wrong pairings are unrepresentable; any field swaps for a stub (the mock up top). Its three fields are up top.
Client.wired(execute:decoder:) fills them from a single (Operation) async throws -> Data seam — a generated fact table over the runtime's four builder helpers (endpoint/fire/raw/text), one per response kind — with no parameter defaults and no opinion about realness: build the seam with NetworkExecution(request:transport:successStatuses:), wrap it in middleware or replace it with a stub, and the fields can't tell the difference. The wiring hands the finished value over as PetstoreClient(baseURL: url).api.
Every CPU step of the wire runs off the caller — by contract, not by scheduler luck. The caller is usually the main actor, and it stays completely relaxed: it constructs an enum case, suspends, and receives a decoded model. Everything between — walking the DSL blocks, composing the URL, JSONEncoder on the body, the transport, the status gate, JSONDecoder on the payload — happens on the concurrent pool:
MainActor concurrent pool
───────── ───────────────
try await api.showPetById("42")
suspends ──────────────────► build URLRequest (DSL blocks, JSON body encoding)
transport (URLSession)
status gate (ResponseError.evaluate)
decode (JSONDecoder)
Pet ◄────────────────────── done
Most async code gets this off-main behavior by accident of the current language default (SE-0338) — and silently loses it the day a module adopts NonisolatedNonsendingByDefault, when plain async closures start inheriting the caller's isolation and a multi-megabyte decode lands on the UI thread mid-scroll. Here both directions carry the guarantee in their declarations (@concurrent, SE-0461): NetworkExecution.execute owns the outbound half, the ClientBuilder decode step owns the inbound half, and the pairing is deliberate — the refresh gate goes the opposite way: it is @MainActor, because its correctness needs the actor that hosts the token bindings — and a SwiftUI Binding answers only on the actor that made it. Leave the caller where CPU work lives, come home where state lives.
Tests pin both halves from a @MainActor caller: the request closure and the decoder factory each execute inside their step and assert they are off the main thread.
Every spec also gets a Responses section: a pure fact table of the operation's spec-declared statuses (deleteSpecialEvent expects 204, createPets 201, …). The runtime's ResponseError.evaluate is the evaluate step of the chain up top: it gates transport results through that table and throws one lossless error; the layer that cares decodes the spec's typed error model from error.data:
struct ResponseError: Error {
let data: Data
let response: URLResponse
var status: Int? { computed from response }
}Petstore declares no security:, so nothing above mentioned it — absence mirrors absence. Specs that do declare it get a Security section: one gate and one attachment factory per scheme, both generated from components.securitySchemes.
Token refresh is the same seam wrapped once more: the runtime's RefreshingExecutor turns 401 into a single-flight refresh and one retry, and the wiring only states how to mint a new token. The generated gates decide which operations ride it:
let refreshing = RefreshingExecutor(
refreshTask: $refreshTask, // non-nil while a refresh is in flight — concurrent 401s join it
accessToken: $accessToken,
executeOnce: executeOnce, // the plain (Operation) → Data seam
makeRefreshTask: { Task { try await refresh() } }, // rethrows: the app learns offline from expired
isUnauthorized: { $0.status == 401 },
needsAuth: Security.needsUserAuth // generated gate: public operations bypass refresh entirely
)
let data = try await refreshing.executeWithRefresh(operation)No member of the client names an actor; each binding touch is wrapped in MainActor.run instead, so only the touch hops and the surrounding network and store work stays off main. Credentials are read in one hop rather than two — two would let a refresh land between them and pair a stale key with a rotated token — and handed to the request builder as plain values, so the URL and body are still composed off the actor. Reading a binding straight from the builder compiles and then traps at runtime, and only against a real Binding(get:set:): .constant carries no isolation, which is why every test missed it until one was written with a real binding driven from a detached task.
That needsAuth gate is the whole difference, and it is not something a lower layer can do — it needs a fact that only the spec has:
| where auth usually lives | what it can know | result |
|---|---|---|
| a middleware injecting headers | that a request is going out | every call carries the token, public endpoints included |
| a blanket 401 interceptor | that some request got a 401 | every call pays the refresh machinery; a stray 401 stampedes the refresh endpoint |
generated Security.needsUserAuth |
which operations the document declares security: for |
public calls never join a refresh, never retry, never read the token |
The first row is not a straw man — it is the state of the art. Apple's own swift-openapi-generator does not generate security schemes at all: issue #37, "Support for security scheme/auth", has been open since May 2023 and sits on the Post-1.0 milestone, and the documented workaround is custom middleware that "injects appropriate headers to every outgoing request." Every outgoing request — the generator has read your security: declarations and thrown them away, so the middleware has no way to tell /signup from /user.
That is the whole difference here. security: is a fact in the document, so it becomes a generated table rather than a discarded one: Security.needsUserAuth(operation) answers per operation, authorized(_:apiKeyAuth:userAuth:) demands exactly the credentials that operation requires and fails the build when one is missing, and the refresh gate reads the same table. A test pins the bypass.
That table is also what makes refresh-through-the-client safe. refresh() spends the stored token by calling api.postToken — the same typed client that carries the refresh middleware — which looks like recursion and is not: the spec declares /token under APIKeyAuth only, so needsUserAuth(.postToken) is false and the call short-circuits to the bare seam before reaching the gate. Nobody wrote that exemption; it falls out of the document. Worth knowing if you copy this wiring to a backend whose refresh endpoint is user-authenticated — there postToken would enter the gate and await the very task running refresh(), which hangs. The runtime does not guard it; the spec does.
The gate is also pinned to where the tokens live: gate is @MainActor, so the single-flight check and every binding access happen on main no matter which thread fires the call — a background Task calling through the typed client still refreshes on main. Only the gate is annotated, so operations the spec declares public skip it without paying a hop. This matters because SwiftUI's Binding is @unchecked Sendable while its init(get:set:) stores @isolated(any) closures that inherit the construction actor: the value is free to travel, but touching it anywhere else traps. A witness test drives the full 401 → refresh → retry path from a detached task and asserts every token touch lands on main.
swift run declarative-openapi Specs/petstore.yaml # generated Swift on stdout
swift run declarative-openapi Specs/petstore.yaml -o Petstore.swift
swift run declarative-openapi Specs/supabase-auth.yaml --exclude-scheme AdminAuthFlags: -o/--output <file>, --enum-name <Name> (overrides the namespace name derived from info.title), --exclude-scheme <Scheme> (repeatable, drops every operation whose security requires that scheme, e.g. a server-only admin scheme, keeping the output client-only; recorded in the header comment), -h/--help. Missing/unreadable input or invalid YAML produces a clear error on stderr and exit code 1.
Four reference specs are checked in: canonical upstream files (binance scoped to a subset, noted below), each with its generated output compiled on every build:
Specs/petstore.yaml: the classic petstore example (OpenAPI 3.0).PetstoreWiring.swiftis the degenerate client: no security in the spec, so the client carries only the base URL.Specs/museum.yaml: the Redocly Museum API (OpenAPI 3.1):$refparameters, scalar/enum component schemas,allOf, nested paths, and awebhookssection (webhooks aren't client-callable endpoints, so they're ignored).MuseumWiring.swiftis the minimal client: document-wide basic auth, gated once through the generatedSecurity.museumPlaceholderAuth(username:password:)factory.Specs/supabase-auth.yaml: the Supabase Auth REST API (OpenAPI 3.0.3, ~60 operations): no-operationIdfallback naming, templated server URLs, and three security schemes. Generated client-only (--exclude-scheme AdminAuth).SupabaseAuthWiring.swiftshows the hand-written layer:SupabaseAuthClient(baseURL:apikey:accessToken:)wires session + environment once;request(_ operation:)composes one flatRequestBlockgated on the generatedSecuritysection, andRefreshingExecutorwraps the seam for 401 → single-flight refresh → one retry.Specs/binance.yaml: the official binance/binance-api-swagger spot API, scoped to the Market + Wallet tags (49 of ~340 operations, transitively$ref-complete, every kept definition byte-identical to upstream). It exists for HMAC request signing: the slot-fillingHMACSignatureblock shown below lives inBinanceWiring.swift, with its own test suite (BinanceSigningTests). The API-key header, a realsecurityScheme, rides the generatedSecuritygates like every other spec.
- The output is one namespace enum (from
info.title) whose sections mirror the OpenAPI document: schemas,Operation(aRequestBuildableenum, each operation IS a block),Security, and the server URL. OneOperationcase per operation, named fromoperationId(camelCase-sanitized); falls back to method + path (e.g.getPetsPetId) whenoperationIdis missing. - Path params are interpolated into
Endpoint(...); leading/is stripped becauseEndpointpaths are joined onto the base URL by.base(url). Path-item-levelparametersare merged into each operation (operation-level entries win by(name, in)). - Query params: required →
Query("name", value); optional → wrapped inif let; non-Stringtypes stringified viaString(...); array-typed params emit aforloop of repeatedQueryblocks (the DSL'sbuildArray). requestBody:$ref→ associated value of that model type; inline schemas get a generated<OperationId>Bodymodel with real properties. The content type picks the block —application/json→RequestBody.json(body),application/x-www-form-urlencoded→RequestBody.urlEncoded(body). A content type the generator does not map (multipart, today) emits no body rather than a wrong one.- Parameters written as
$ref: "#/components/parameters/X"are resolved to their component definitions (unresolvable refs are dropped). security:declarations generate theSecuritysection (operation-level overrides the document default;security: []marks an operation public; OR-alternatives are flattened):Security.schemes(_ operation:) -> Set<String>, oneSecurity.needs<Scheme>(_ operation:)gate per scheme, and one attachment factory per scheme derived fromcomponents.securitySchemes:http bearer→Security.userAuth(token:)wrappingAuthorization.bearer,apiKey in: header→Security.apiKeyAuth(_:)wrappingHeader.custom(name),http basic→Authorization.basic. Omitted entirely when the spec declares no security. The composition itself is also generated, as theAuthorizedsection:static func authorized(_ operation:apiKeyAuth:userAuth:)takes one optional per used scheme (parameter types derived from the scheme definitions: bearer →String, basic →(username:password:)tuple, apiKey →String) and returns the operation + gated credentials as one block, throwing the generated per-scheme error (MissingAPIKeyAuth,MissingUserAuth) at materialization when a required credential is nil. Environment comes last, per the DSL contract: the wiring binds stored credentials and applies.base(baseURL).request(). The checked-in Supabase target is client-only: generated with--exclude-scheme AdminAuth, so server-side operations (user management, SSO provider management, those needing the service-role JWT) are not generated at all.components.schemas:object(orallOf, flattened) →struct X: Codablewith real properties,array→typealias X = [Element], scalars →typealias X = String/Int/Double/Bool(format: binary→Data), string enums →enum X: String, Codable. Names are sanitized to valid Swift identifiers (thing-request→ThingRequest); names that would shadow stdlib types are prefixed (Error→APIError,Date→APIDate). The rename carries no semantics:APIErrordoes not conform toSwift.Error, because OpenAPI has no way to mark a schema as an error model and we don't infer that from names. Apps that want to throw it addextension APIError: Swift.Error {}in hand-written code.- Type mapping:
string→String,integer→Int,number→Double,boolean→Bool,array→[Element],$ref→model type. String schemas/parameters/properties withenum:values generateenum X: String, Codable(schemas at top level, parameters nested in the endpoint enum (grant_type→GrantType, used via.rawValue), and object properties nested in their struct, e.g.PostTokenBody.Provider); digit-leading values get a_prefix (_1080p); a parameter name reused with a different value set falls back toString. servers[0].urlbecomesstatic let defaultBaseURL. Header/cookie params are not generated (a// TODO:comment is emitted in the case instead). Specs with zero operations still produce compiling output (placeholder body instead of an illegal emptyswitch).- Output is deterministic (schemas alphabetical, paths sorted, fixed method order) so it's golden-testable.
Some credentials can't be values. Binance's signature is an HMAC digest of the final query string and its timestamp is the send instant. OpenAPI can't model that as a securityScheme, so the spec declares both as required query parameters. Read those as slots: their values are unknowable at the call site, so the wiring (which owns the clock and the key) fills them with a DSL block composed after the operation like any other:
struct HMACSignature: RequestBuildable {
let secretKey: String?
let now: () -> Date
var body: some RequestBuildable {
RequestBlock { state in
guard state.queryItems.contains(where: { $0.name == "signature" }) else { return }
guard let secretKey else { throw MissingSecretKey() }
let stamped = state.queryItems.map {
$0.name == "timestamp" ? .init(name: "timestamp", value: nowInMillis) : $0 // now(), epoch ms
}
state.queryItems = stamped + [.init(name: "signature", value: hexDigest)] // HMAC-SHA256 of stamped's encoded query, keyed by secretKey
}
}
}RequestBlock { state in … } is the DSL's own state-reading primitive, so signing is one more composable block in the chain. No slot → untouched, slot without a key → the build fails like any credential gate:
try RequestBlock {
BinanceSpotAPI.authorized(operation, apiKeyAuth: apiKey)
HMACSignature(secretKey: secretKey, now: now)
}
.base(baseURL)
.request()Call sites pass placeholders and never think about signing again; a test proves the slot values are dead:
let account = try await binance.api.getSapiV1AccountInfo(nil, 0, "") // timestamp/signature: dead values, the wiring owns themSettled over the project's evolution, enforced across every generated file:
- The authority ladder. The spec decides everything it states: shapes, statuses, schemes, attachment mechanics, and all of it is generated. The wiring decides bindings and policy: credentials, transport, decoder, and all of it is hand-written. The caller materializes. Nothing lives below its authority:
URLSession/JSONDecodernever appear in generated code, and spec knowledge is never hand-maintained. - Sections mirror the document; absence mirrors absence. No
security:→ no Security section, noauthorizedbuilder. No text responses → notexthelper. What the spec doesn't say, the output doesn't contain. - No parameter defaults on the seams.
wiredandauthorizeddemand every dependency explicitly; standard bindings are named options the caller passes, never silent choices. Nil is never the spelling for "not needed" — absence of the block is. - One lossless error per boundary; store each fact once.
ResponseErrorcarries data + raw response;statusis a projection, not a field. The next layer decodes the spec's error model fromdataonly when it wants it. - Facts as tables, mechanics once.
wiredis a one-line-per-operation fact table over the runtime's pack-genericClientBuilder;schemes(_:)is a grouped switch. Cleverness is quarantined in mechanics; generated facts stay boring. - The operation→type problem is solved at generation time, in a table, never with phantoms,
Any, or mirrored payload enums. Closures take the case's payload and construct the case inside, making wrong pairings unrepresentable. - Every ladder rung stays public. The typed Client is sugar, not a gate:
request/authorized/evaluate/rawDataall remain directly usable, decode-later end to end. - Base comes last. Spec composes (
authorizedreturns a block), wiring situates (.base(url)), caller materializes (.request()). - The client is pure data; networking concepts never leak up. A struct of typed closures is the entire app-facing surface;
URLSession/URLRequest/JSONDecoder/status codes each live in exactly one lower layer. Runtime types follow one shape: dependencies as stored closures, behavior as the output function. - Universal mechanics live once, in the runtime; generated files carry facts only. Every generated one-liner was audited by one test — does deleting it create a place where hand-written code could contradict the spec and still compile? — and none survived: inject the generated fact (
successStatuses,schemes) into runtime mechanics (NetworkExecution,ResponseError.evaluate,ClientBuilder) rather than generating pre-composed conveniences. - Names describe mechanics, not claims (
wired, notliveorreal; realness is decided by the closures passed), and sugar that duplicates an existing spelling gets deleted.
Sources/DeclarativeOpenAPI: all parsing (via Yams) and codegen;SpecGenerator(enumNameOverride:).generate(yaml:) -> String.Sources/DeclarativeOpenAPIRuntime: the small shared runtime generated code imports, four witness structs (dependencies as properties, behavior as the output function): the universal non-genericResponseErrorwith itsevaluategate (typed throws),NetworkExecution<Operation>(the(Operation) → Dataseam: request → transport → gate over the injectedsuccessStatusestable),ClientBuilder<Operation>(the pack-generic typed-closure mechanicswiredtables build over), andRefreshingExecutor<Operation>(401 → single-flight refresh → one retry), ready to slap onto any backend's seam.Sources/DeclarativeOpenAPICLI: theswift-declarative-openapiexecutable (plainCommandLine.arguments, no argument-parser dependency).Sources/PetstoreAPI,Sources/MuseumAPI,Sources/SupabaseAuthAPI, andSources/BinanceAPI: the checked-in generated outputs (plus each backend's hand-written wiring), compiled against DeclarativeRequests on everyswift build, so compilability of generated code is proven by the build itself.Specs/: the canonical spec files.Tests/DeclarativeOpenAPITests, 82 tests:- Golden (parameterized over all four specs): generator output must equal the checked-in
*.generated.swiftbyte-for-byte. Those files are the sources of the four<Name>APItargets, so everyswift buildcompiles them against the real DeclarativeRequests dependency — golden equality plus a green build is the end-to-end proof that generator output compiles against the DSL, no temp-package harness needed. - Request shape: builds actual
URLRequests from the generated enums and asserts URLs, methods, query items, headers, and JSON bodies, including the Supabase refresh flow (POST …/token?grant_type=refresh_tokenwith the real refresh-token payload andapikeyheader). - Unit: name sanitization, type mapping, slash stripping, required/optional/array query params, operationId fallback, enum-name override, header-param TODOs, invalid-YAML errors.
- Golden (parameterized over all four specs): generator output must equal the checked-in
- Model generation covers the easy tier only: inline object properties,
oneOf/anyOf, and recursion are out of scope and fall back toStringvia the tolerant type mapper (see TODO.md for the hard tail). - Declaring an error-model schema is bad API practice. One shape rarely fits every failure. The generator stays neutral:
ResponseErroris lossless, so callers who want the spec's error model decode it fromerror.data, and everyone else loses nothing. - Depends on published swift-declarative-requests tags (
from: "2.0.0"), not on a sibling checkout — clone and build anywhere. - Toolchain: swift-tools-version 6.3, macOS 14+ (matching the DSL package). First build fetches Yams and DeclarativeRequests from the network.
- The initial implementation was hardened by an adversarial review pass that caught and fixed: ignored path-item-level
parameters(literal{petId}left in URLs), unsanitized schema names, non-compilingString([T])for array params, empty-switchoutput for operation-less specs, and theErrorschema shadowingSwift.Error.