Skip to content

OpenAPI → Endpoints generator: build plugin, core pipeline, and runtime hooks - #23

Open
zac wants to merge 14 commits into
mainfrom
claude/openapi-endpoint-generation-f58e25
Open

zac wants to merge 14 commits into
mainfrom
claude/openapi-endpoint-generation-f58e25

Conversation

@zac

@zac zac commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds an OpenAPI → Endpoints generator as a SwiftPM build tool plugin. You put Petstore.openapi.yaml in a target, and every build generates the server, models, and endpoints for that API. Output is deterministic: the same spec, config, file stem, and generator version produce byte-identical files on any machine or OS. It also adds the small set of runtime hooks the generated code needs.

Changes

Package

  • Swift tools 6.1. A default-on OpenAPIGenerator trait guards the generator's dependencies, OpenAPIKit 6.4.0 and Yams 6.2.2, both pinned exactly. Apps that only use the runtime can pass traits: [], and on SwiftPM 6.2+ they then don't fetch either package. With the trait off, the CLI is a stub that tells you to enable it.
  • New products and targets: the EndpointsGeneratorPlugin product, the EndpointsGeneratorCore target, the endpoints-generate CLI, and two test targets.

Generator (Sources/EndpointsGeneratorCore)

  • Pipeline: loader (3.0 specs are converted to 3.1) → validation → IR → naming → rendering into four fixed files per spec. Diagnostics carry file:line:col and the JSON pointer.

  • Naming is a frozen, ASCII-only contract. Collisions and reserved names are errors that point you to a renames entry in the optional <Stem>.endpoints.json config. There's no auto-suffixing.

  • Mapping covered so far:

    • Servers and environments.
    • Object schemas. Properties keep spec order, and required-nullable fields encode an explicit null.
    • Open enums by default, with closed enums as a config option.
    • Typed path, query, and header parameters, including arrays and optional headers.
    • JSON bodies.
    • Status-tagged Response and ErrorResponse enums.
    • RFC 3339 dates.

    Constructs not mapped yet (oneOf/anyOf/allOf, recursion, multipart and form bodies) report "unsupported", which is an error by default and a warning with strict: false.

Runtime hooks (Sources/Endpoints, all additive)

  • DecoderType.decode(_:from:response:) with a default implementation, plus the CodingUserInfoKey.httpStatusCode and .httpHeaders keys.
  • HTTPResponseJSONDecoder: configures a fresh JSONDecoder for each call, decodes an empty body as null, and can decode selected statuses as Data.
  • MultiValueParameterRepresentable: arrays are sent as repeated query or form items. If you already have your own ParameterRepresentable conformance, it still wins.
  • Int32, Int64, UUID, and RawRepresentable types work as path and query parameters.

Docs

  • docs/OpenAPIGenerator-Design.md (design and decisions) and docs/OpenAPIGenerator-SSE.md (an accepted design for Server-Sent Events behind a default-off ServerSentEvents trait, planned as a later phase).
  • Verification records, updated DocC and Examples, and a "Migrating from 0.5" section in the README.

Behaviour changes for existing users

  • Swift 6.1 is now the minimum.
  • If you declared your own UUID, Int32, or Int64 parameter conformances, delete them. Swift now warns that they're redundant, and request building uses the library's version. A UUID is sent as uuidString (uppercase).

Naming and configuration

  • Idiomatic names by default. IN_PROGRESS becomes .inProgress, pet_record becomes PetRecord, owner_name becomes ownerName, and get_pet_by_id becomes GetPetByIdEndpoint. Wire names never change: CodingKeys and raw values keep the server spelling.

  • A typed Swift configuration file, <Stem>.endpoints.swift, in the target. It's type-checked by Xcode, and the generator evaluates it at build time with no extra steps:

    // endpoints-configuration-version: 1
    import EndpointsGeneratorConfig
    
    let petstoreConfiguration = EndpointsConfiguration(
        namespace: "PetStore",
        accessLevel: .public,
        enumCaseNamingStrategy: .verbatim,
        operationNamingStrategy: .custom { $0.removingPrefix("legacy_").upperCamelCased },
        acronyms: ["ID", "URL"],
        renames: [.property("owner_name", in: "Pet"): "owner"]
    )

    It supports per-category strategies (.verbatim, .camelCase, .upperCamelCase and .custom), typed renames with parameter locations, and x-swift-name/x-enum-varnames. Collisions and invalid names are errors, and unused renames are warnings.

  • Determinism for configs that run code. A pre-compile check rejects extra imports, # directives, regex literals, key paths, and nondeterministic standard-library APIs. The config program runs with an empty environment and deterministic hashing, and every evaluation runs it twice in separate processes and requires identical results.

  • Verification: macOS with the plugin sandbox on, Linux swift:6.1 (175 tests), and native Xcode macOS and iOS simulator targets. Native Xcode re-runs the generate step on every build, because Xcode gives the plugin command a new script GUID each time. The cached run takes about 0.2 s and leaves outputs untouched.

  • Docs: a new Generating DocC article and a README quick start.

Relationship to #24

The path-building fixes from #24 are now in main. With them in place, embedded path templates such as /files/{id}.json encode each value as its own segment through an internal EncodedPath type.

Two tests are wrapped in withKnownIssue for runtime bugs that are still to be fixed separately:

  • optionalHeadersOmitNilAndDescribePresentValues: optional header components throw invalidHeader instead of being omitted.
  • emptyBodyErrorUsesDefaultEmptyCodable: an empty error body fails to decode as EmptyCodable.

Each wrapper starts failing once its fix lands, which flags it for removal.

Testing

  • macOS (Swift 6.4): swift build --build-tests -Xswiftc -warnings-as-errors is clean, and all suites pass with exactly the 2 known issues. Generated code produces zero warnings.
  • Linux (swift:6.1 in Docker): builds, and 122 tests pass with the same 2 known issues.
  • Determinism: compiled golden files compared byte-for-byte, generation in separate processes with randomized hash seeds, a banned-API lint, and a 20-level shared-schema graph that generates in about 0.1 s.
  • End to end: Examples/PetstoreClient (two modules, a public auth extension, a status-union endpoint) builds and tests with the plugin sandbox on, and Examples/RuntimeOnlyClient (traits: []) resolves without OpenAPIKit or Yams.
  • Each phase was reviewed until it passed: the foundation took four rounds, the runtime hooks three.
  • Not yet checked: a native .xcodeproj using the plugin.

Coverage (line, macOS)

  • EndpointsGeneratorCore: 94.0%. The lowest file is SecurityDocumentation.swift at 71.7%.
  • Endpoints runtime: 76.0% overall. The new files are well covered; the low spots are older code (Server.swift 10%, URLSession+Endpoints.swift 45%, MultipartFormEncoder.swift 47%).

zac added 14 commits October 24, 2026 13:01
Adds an EndpointsGeneratorPlugin build tool plugin that turns
<Stem>.openapi.{yaml,yml,json} files in a target into Endpoints code at
build time, with byte-identical output for the same spec, config, stem,
and generator version.

- Swift tools 6.1. A default-on OpenAPIGenerator trait guards the exact
  OpenAPIKit 6.4.0 and Yams 6.2.2 pins, so runtime-only consumers can
  drop them with traits: []. With the trait off, the CLI is a stub that
  says to enable it.
- EndpointsGeneratorCore: loader with 3.0 to 3.1 conversion, a closed
  versioned config, IR, the frozen naming contract (collisions and
  reserved names are errors, fixed with renames), Tarjan cycle analysis,
  a code writer, and sorted diagnostics with source positions.
- Mapping subset: servers and environments, object schemas, required
  nullable fields, open and closed enums, simple path/query/header
  parameters, JSON bodies, and single success and error responses.
  Constructs that need runtime hooks report as unsupported for now.
- Compiled golden tests, cross-process determinism tests, a banned-API
  lint, and example packages covering the plugin end to end and the
  trait-disabled runtime-only fetch.
- The design and verification records are in docs/.
String.pathSafe percent-encoded each value, and urlRequest(in:) then
assigned the joined path to URLComponents.path, which encodes again. A
value like "a b" went out as "a%2520b". This has been the case since the
first commit; 5077dd2 fixed the same problem for query and form values in
2019, but the path side was missed because every path test used plain
values.

- String.pathSafe encodes a value as one segment, including "/", "?" and
  "#", and keeps "." and ".." as encoded segments so URL resolution
  doesn't collapse them.
- PathTemplate passes every component through a normalizer that keeps
  valid %XX escapes and encodes anything else not allowed in a path. The
  result is assigned to percentEncodedPath, so nothing is encoded twice
  and custom PathRepresentable values that return raw text don't trap.
- Literal template text keeps its slashes and honours %XX escapes.
- Form body encoding is byte-identical to before.
Runtime (additive):
- DecoderType.decode(_:from:response:) with a default that forwards to
  decode(_:from:), and CodingUserInfoKey.httpStatusCode/.httpHeaders.
- HTTPResponseJSONDecoder: a Sendable decoder that configures a fresh
  JSONDecoder per call, decodes an empty body as null, exposes the status
  and headers to Decodable types, and can decode selected statuses as Data.
- Every request path forwards the HTTPURLResponse to the decoders.
- MultiValueParameterRepresentable: arrays become repeated query and form
  items. An existing ParameterRepresentable conformance still wins.
- Int32, Int64, UUID, and RawRepresentable values work as path and query
  parameters.

Generator:
- Status-tagged Response and ErrorResponse enums for operations with
  several outcomes, including body-less statuses, ranges, and default.
- Typed path and query parameters, array query parameters, optional
  headers, and path-item/operation parameter merging.

Three tests that depend on runtime bugs fixed separately (path
double-encoding, optional headers throwing, empty error bodies) are
wrapped in withKnownIssue.

Also adds the SSE design addendum and a "Migrating from 0.5" README
section.
With the path double-encoding fix merged in, a template like
/files/{id}.json, where the parameter shares a segment with literal text,
broke: the generator built the whole path as one raw String, and the
runtime now encodes a String as a single segment, so the literal "/"
went out as %2F. The raw interpolation was also a latent bug, because a
value containing "/" or "?" could add path structure.

Embedded paths now produce an internal EncodedPath: PathRepresentable
assembled from the literal text and each value's pathSafe, so the
separators stay and every value is encoded once. Also removes the
withKnownIssue wrapper from pathSpecialCharactersEncodeOnce, which
passes now.
includesSlash: false removed whatever slash came before the value,
including one written in the template, so
"testing/\(path: \.name, includesSlash: false).test" built
"testingfirst.test". It now removes only a slash the template inserted
automatically, which is what the 2021 failing test on feature/path-slash-fix
expected.

Default interpolations are unchanged: each value is its own segment, so
"files/\(path: \.id).json" still builds "files/42/.json". Embed a value with
includesSlash: false ("files/42.json"). The PathTemplate docs and
Examples.md now state the rule.
Adds EndpointsGeneratorConfig, a dependency-free library product with
the public configuration API: EndpointsConfiguration (stored, defaulted
settings with a labelled initializer), NamingStrategy (.verbatim,
.camelCase, .upperCamelCase, .custom), NameContext, and typed name
identities (NameTarget, TypeID, OperationID, ParameterLocation).
Internals are package-scoped.

Naming v2 treats every non-alphanumeric byte as a word separator, so
IN_PROGRESS becomes inProgress, pet_record becomes PetRecord, and
get_pet_by_id becomes GetPetByIdEndpoint. Acronym runs the server wrote
are kept, and a digit followed by an uppercase letter and then a
lowercase one starts a new word (HTTP2Status, UTF8String, 2FA ->
_2fa). The reference algorithm is table-tested.

Core now collects a name-independent inventory, resolves every name
once, and lowers once. Renames use typed targets with parameter
locations and structured owners. Ambiguous and invalid renames are
errors, and unused renames are warnings. x-swift-name and
x-enum-varnames are honoured. Wire names are unchanged.

There is no user-facing JSON config: the Swift config file arrives in
the next phase. A private --configuration-json flag feeds tests.
A target can now declare its generator settings in <Stem>.endpoints.swift:

    // endpoints-configuration-version: 1
    import EndpointsGeneratorConfig

    let petstoreConfiguration = EndpointsConfiguration(
        accessLevel: .public,
        enumCaseNamingStrategy: .verbatim,
        operationNamingStrategy: .custom { $0.upperCamelCased }
    )

The file is an ordinary source of the target, so Xcode type-checks and
autocompletes it. At build time the generator compiles it with the
toolchain swiftc, together with an embedded copy of the config library,
into a small config program, runs it, and uses its answers for every name.

- Determinism: a lexical check before compiling rejects imports other
  than EndpointsGeneratorConfig, # directives, regex literals, key paths,
  randomness, and identity and address channels. The program runs with an
  empty environment and deterministic hashing, and every evaluation runs
  it twice in separate processes and requires identical results.
- The cache key covers the config, DSL, runner, compiler and SDK
  identity. Entries are published atomically under a lock, each execution
  gets its own files, and eviction is lease-safe. A cached run takes about
  0.2 s.
- Diagnostics point at the user's file: compiler errors and warnings are
  forwarded verbatim, entry-symbol and lexical problems are reported at
  their line, and warnings don't fail generation.
- The plugin finds swiftc through the plugin context in SwiftPM and
  Xcode, always compiles against the host macOS SDK (so iOS targets
  work), and checks the EndpointsGeneratorConfig dependency where SwiftPM
  exposes it.
- Verified on macOS (sandbox on), Linux swift:6.1, and native Xcode macOS
  and iOS simulator targets. Xcode re-runs the plugin command on every
  build, because of a fresh script GUID; outputs stay untouched.
Adds a Generating DocC article and a README quick start covering setup in
SwiftPM and Xcode, package traits, the generated code, idiomatic naming,
every EndpointsConfiguration setting with examples, the rules for
configuration files, diagnostics, and current limitations. Code samples
were compiled against the generator, and quoted output and messages come
from real runs.

Configuration checks now match nondeterministic standard-library APIs by
exact name, so ordinary helpers such as randomPrefix or streetAddress
are allowed. capacity is banned, since it varies with the host allocator.
The two-run determinism error points at the entry declaration, and
diagnostics use the configuration path as given.

The design doc now notes that its mapping tables describe the full plan,
and points to the guide for what is implemented today.
FileHandle.seekToEnd() and read(upToCount:) need macOS 10.15.4, which
broke the macOS CI builds. Use them when available and fall back to
seekToEndOfFile() and readData(ofLength:) otherwise.
Generated Petstore.Endpoints.swift and the user's Petstore.endpoints.swift
differ only by case, so on a case-insensitive filesystem both compiled to
the same object file and the link failed with duplicate symbols. Generated
files are now <Stem>.<Part>.generated.swift. The plugin reports an error if
any other file in the target collides with a generated name ignoring case,
and outputs left over from the old names are removed.
SwiftPM 6.1 resolves dependencies behind disabled traits, as the design
and the Generating guide note, so the Xcode 16.4 job fetched OpenAPIKit
and Yams for the runtime-only example. Skip the assertion on 6.1.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant