@bagatka/openapi-swift
v0.1.1
Published
Writes a typed Swift 6 client of an HTTP API from its OpenAPI 3.1 document, such as Effect's HttpApi writes, on Foundation alone.
Maintainers
Readme
@bagatka/openapi-swift
Writes a typed Swift 6 client of an HTTP API from its OpenAPI 3.1 document, such as Effect's
HttpApi writes, on Foundation alone.
Install
npm install @bagatka/openapi-swift effectIts peer dependency is effect, at the minor version it is built with. It runs wherever a build
script does: Bun, Node, or Deno. The Swift it writes needs Swift 6.2 or later, and Foundation alone:
it runs on Apple's platforms and on Linux.
Use it
A build script that writes the Swift client of an API defined with Effect's HttpApi:
import { swiftClient } from "@bagatka/openapi-swift"
import { Effect, FileSystem, Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api"
// The API, as the server defines it: named schemas, which become Swift types.
class Note extends Schema.Class<Note>("Note")({ id: Schema.String, title: Schema.String }) {}
class NoteNotFound extends Schema.TaggedError<NoteNotFound>()(
"NoteNotFound",
{ id: Schema.String },
{ httpApiStatus: 404 },
) {}
const NotesApi = HttpApi.make("Notes")
.add(
HttpApiGroup.make("notes").add(
HttpApiEndpoint.get("getNote", "/notes/:id", { params: { id: Schema.String }, success: Note, error: NoteNotFound }),
),
)
.annotate(OpenApi.Title, "Notes")
const writeClient = Effect.gen(function* () {
const swift = yield* swiftClient(OpenApi.fromApi(NotesApi))
const fs = yield* FileSystem.FileSystem
yield* fs.writeFileString("Sources/NotesAPI/NotesAPI.swift", swift)
})
// writeClient: Effect<void, UnsupportedOpenApi | PlatformError, FileSystem>. Run it with your
// runtime's FileSystem, such as NodeServices.layer from @effect/platform-node.
// UnsupportedOpenApi lists every problem at once, where and why:
// { path: "#/components/schemas/NoteList/properties/page", reason: "an inline object: name it as a schema of its own" }The Swift it writes, for an API titled Notes:
func title(ofNote id: String, at url: URL, token: String) async -> String {
let api = NotesAPI(baseURL: url, bearerToken: token, userAgent: "Notes/1.0 (12) iOS/27.1")
do {
return try await api.getNote(id: id).title
} catch .noteNotFound(let missing) {
return "No note \(missing.id)" // An error the API names, as its own type.
} catch .unreachable {
return "Offline" // No connection, a timeout, or a cancelled call: keep showing what was there.
} catch {
return "\(error)" // .unreadable or .unwritable: a status or a shape this client doesn't know, and why.
}
}Contract
src/index.ts. swiftClient takes the document as parsed JSON, such as OpenApi.fromApi gives or
JSON.parse of a file, and returns the Swift file's text, or fails with
UnsupportedOpenApi, whose problems say where and what to change, every one at once.
Does not own
- Running it. A build script calls
swiftClientand writes the file where the app's Xcode project or Swift package takes it. - What to do when a call fails: one request per call, never retried; offline, the app decides.
- Authentication beyond a bearer token, and other headers; a
transportthe app passes can add them.
Depends on
effect.
Decisions and constraints
- Doesn't trust the document's text. A document is usually the product's own, but no title, description or name in it can write code, and what isn't supported is refused. Trusts its caller, who chooses the document.
- What it reads, as Effect's
HttpApiwrites it: OpenAPI 3.1; named objects, string enums, and unions of named objects told apart by the one constant property they share (such as_tag, and adiscriminatorthat names it); arrays, dictionaries, free-form JSON ({}), nullable values (anyOfwith null, or a type and null), dates (format: date-time), and Effect's numbers, which may be"Infinity","-Infinity"or"NaN"; path and query parameters of strings, numbers, booleans, dates and enums; JSON bodies of any of these; one success status; and errors, 4xx and 5xx answers of named objects with one constant, all told apart by the same property. Keywords that only describe or validate a value, such aspatternormaximum, are the server's to check, and so is anallOfof such keywords alone, which Effect writes for a Schema's second check or aformatbeside a check (allOf: [{ "format": "date" }], aString). Aninteger,numberorbooleanwith anenumof its own type's values, as Effect writesSchema.Literal(true)orSchema.Literals([1, 2]), is that type (Bool,Double;Intfor integers); which of its values, the server checks. - What it refuses, each with where and why: an inline object, enum or union, which needs a name
to become a Swift type; an
allOfwith any other keyword in it, such astypeor$ref; anenumof other values than its type's; any keyword beside a union's members, such as anallOfwith atype,anyOfandoneOftogether, and a null member with more than its type;not,const, tuples (prefixItems) and any keyword it doesn't know; header and cookie parameters, arrays in a query;defaultand ranged responses such as2XX; a body that isn't JSON or isn't required; an error without a JSON body; a type that holds itself other than in an array or a dictionary, which a Swift struct can't; names Swift can't hold, or that Swift, Foundation, or the client itself takes, such asTask,URLRequest,Self,KeyedDecodingContainer, or a propertyhashValueorRawValue; a schema nested more than 100 deep, references included; and a path that isn't/and segments of letters, digits,-,.,_,~and{parameters}, or holds a segment.or.., which could send a request, and its bearer token, to another address. - Limits. Time and memory linear in the document and the Swift it writes, whatever the document holds; a schema nests at most 100 deep, through references too, so no document exhausts the stack. The Swift can be longer than the document in one way: each method's doc comment lists its errors, so an error union many operations answer with is listed in each.
- Effect's names. Effect names a schema's encoded form
<Name>Encoded, which is<Name>in Swift, and its own errorseffect_HttpApiError_<Name>Encoded, which isEffectHttpApiErrorBadRequest. A schema named<Name>_<n>that is the same as<Name>, a copy a document names when an operation annotates it, is<Name>'s type. Effect 4.0.6 writes aDateTimeUtcFromStringas a plain string, withoutformat: date-time, so it is aStringin Swift; and query and path parameters as the strings they are encoded as. - Newer servers don't break older apps. An enum keeps a value it doesn't know, as a struct with
static values rather than a closed
enum; a union has anunknowncase; unknown properties are ignored. A missing optional property isnil; a required one that may be null is written asnull, never left out. An optional property that may also be null, such as Effect'soptionalKey(NullOr(...)), isnilwhen it is missing or null, and left out whennil: the client can't send itsnull. Constants are written and checked both ways, so a struct sent on its own carries its_tag. - Swift 6, in any default isolation: every type is
nonisolated, so the file compiles in an app target whose default is the main actor, as Xcode's new ones are, and in a library; value types areSendableandHashable; methods are@concurrent, so decoding never runs on the caller's actor; errors are typed (throws(APIError)). Names that aren't Swift identifiers are raw identifiers,.`1m`(Swift 6.2). The client's own names are raw identifiers too, and a document's that is one of them is refused: a property namedJSON keys, a parameter namedquery items,request bodyorthe answer. Any other name works, one of a type included: a property namedString,Dateor after one of the API's types reads as that property, since the client never writes a type where a property's or parameter's name could stand for it. - No text of the document can write code. Titles and descriptions become comments, their line breaks of every kind turned into comment lines; strings are literals with every control character escaped; every name, a constant's key included, is checked before it is written.
- A path parameter is a segment of its own: its value is escaped, and one that is empty,
.or.., which a URL reads as no segment or a step up, isunwritableand isn't sent. - Checked as Swift.
bun run check:swiftcompiles the clients its tests write with Swift 6, warnings as errors, in both default isolations, and runs a round trip that calls the Notes API's client through a transport answering as Effect's server does, checking every request, body, result, and error against the JSON Effect writes (test/fixtures/). - Linux too: it imports
FoundationNetworkingwhere that exists.
