`openapi:import` — OpenAPI 3.1 → Univeros project
The reverse of
spec:emit-openapi. Read an OpenAPI 3.1 document, write one Altair YAML spec per operation, and (optionally) chain straight intospec:scaffoldto produce a runnable project — Actions, Inputs, Responders, tests, entity + migration + repository, and a typed SDK source-of-truth.
Command: bin/altair openapi:import
Source: src/Altair/Scaffold/Cli/OpenApiImportCommand.php
Issue: #162 · epic #160
When to reach for it
Section titled “When to reach for it”When you already have an OpenAPI document — generated by your current
framework, hand-written, or supplied by a partner — and you want a
Univeros project that serves it. The forward chain
(spec:scaffold → spec:emit-openapi → spec:emit-sdk) requires you to
hand-author Altair YAML first; openapi:import lets you start from the
OpenAPI side instead.
The headline use cases:
- Adopting Univeros into an existing service. Point the importer at
your current
openapi.yamland get a parallel Univeros tree in one command, ready to diff against the legacy implementation. - Bootstrapping from a partner’s spec. A vendor hands you a
petstore.yaml; you want a server stub by lunch. - Agent-driven scaffolding. An agent that has the OpenAPI doc in
context skips the spec-authoring step entirely. See the
tokens-to-shipbenchmark variant.
# Specs only — write Altair YAML to ./api/bin/altair openapi:import openapi.yaml
# Custom output directorybin/altair openapi:import openapi.yaml --out=specs/
# Full pipeline — specs + scaffoldbin/altair openapi:import openapi.yaml --scaffold
# Add a Cycle ORM entity + migration + repository for every POST collectionbin/altair openapi:import openapi.yaml --scaffold --persistence=cycle
# Agent mode: JSON receipt, no human prosebin/altair openapi:import openapi.yaml --scaffold --format=json
# Plan only, write nothingbin/altair openapi:import openapi.yaml --dry-run
# Overwrite existing filesbin/altair openapi:import openapi.yaml --force
# Import what maps; skip (don't abort on) operations the emitter can't expressbin/altair openapi:import openapi.yaml --skip-unmappable| Flag | Type | Effect |
|---|---|---|
<document> | argument | Path to the OpenAPI 3.1 YAML document. |
--out=<dir> | string | Output directory for emitted specs. Default api. |
--scaffold | bool | After writing specs, run spec:scaffold on each. |
--dry-run | bool | Report planned changes; write nothing. |
--force | bool | Overwrite existing files (specs and scaffolded). |
--skip-unmappable | bool | Skip operations whose schema the emitter cannot express (recording them in unmapped[] and warnings[]) instead of aborting the whole import. |
--format=human|json | enum | Output format. Default human. |
--persistence=cycle | enum | Inject a persistence: block for each POST-to-collection endpoint. |
--queue=<transport> | string | Reserved for the x-altair-queue extension in #163; currently a no-op that surfaces a warning. |
--root=<path> | string | Override the project root used as base for emitted paths. |
JSON receipt
Section titled “JSON receipt”--format=json emits a structured envelope agents can branch on without
parsing prose:
{ "ok": true, "input": "openapi.yaml", "specs_written": ["api/users/create.yaml", "api/users/get.yaml"], "scaffolded": true, "scaffold_files": [ "app/Http/Actions/CreateUserAction.php", "app/Http/Inputs/CreateUserInput.php", "app/Http/Responders/CreateUserResponder.php", "..." ], "rolled_back": [], "unmapped": [], "warnings": [], "journal_id": "20260530T120000Z-abc12345", "event_id": "01J5F8R5Z9...", "error": null}specs_writtenlists the imported Altair specs (relative paths under the project root).scaffoldedistruewhen--scaffoldwas passed.scaffold_fileslists the artifacts the chainedspec:scaffoldphase produced.unmappedcarries{pointer, message}entries for any schema the emitter could not express — the JSON pointer is the location inside the source document. On a fail-fast run it holds the single schema that aborted the import; under--skip-unmappableit lists every skipped operation whileokstaystrue.journal_id/event_idare populated only when aJournalandRecorderInterfaceare bound, respectively. Under aNullRecorderthey stay null, which keeps the receipt byte-stable for the same input — useful for golden-file CI gates.erroris set wheneverokisfalse.
The receipt is the agent-facing contract. Treat its shape as stable across patch releases; new fields are additive.
What the importer maps
Section titled “What the importer maps”The mapping rules ship in Altair\Scaffold\Spec\Emitter\* (see
#161). At a glance:
- Filename:
api/<resource>/<verb>.yaml. Verb is method-derived (POST→create, collectionGET→list, itemGET→get,PUT/PATCH→update,DELETE→delete). The first camelCase word ofoperationIdoverrides the verb when present (archivePost→api/posts/archive.yaml). - Path parameters become required string inputs.
- Request-body object properties become inputs; the OpenAPI
requiredarray maps to the Altairrequiredrule. - Nested objects become an
inputfield withtype: objectand a recursivefields:map; the generated Input DTO types them asarraywith a PHPDocarray{…}shape. Arrays of objects becometype: arraywith afields:map describing the item, typedlist<array{…}>. Both directions round-trip (openapi:roundtrip). - Enums without an FQCN render as
type: stringplus anin:rule. - Refs: scalars/enums resolve through
components.schemasto the underlying type. In inputs, object refs (and nested object/array items) resolve and inline as nestedfields:. In responses, object refs render asApp\<Ref>\<Ref>so the responder gets a typed payload. - Top-level
$refresponses wrap as{<refLowerCamel>: App\<Ref>\<Ref>}to preserve the abstraction.
Anything the emitter still cannot express — ref cycles, dangling refs, a
non-object request-body root, oneOf/anyOf polymorphism — surfaces as
an entry in unmapped[] with a JSON pointer to the offending node. By
default the run fails fast on the first such schema (exit 1, nothing
written).
Pass --skip-unmappable to import everything that does map and skip
the rest: each skipped operation lands in unmapped[] (with its JSON
pointer) and as a skipped <METHOD> <path>: … line in warnings[], the
run succeeds (exit 0), and the mappable specs are written.
The canonical Swagger Petstore imports with zero skips: its
Petbody nests acategoryobject and atagsarray-of-objects, both of which now map to recursivefields:. Deep per-field validation rules for nested members are not generated yet (the nested shape and the object’s ownrequiredflag are); add them by hand if you need them.
Persistence inference
Section titled “Persistence inference”--persistence=cycle instructs the importer to emit a persistence:
block for every operation that creates an entity. V1 trigger: POST to a
collection (no path parameters). The block contains:
entity.class=<appNs>\<Resource>\<Resource>(e.g.App\User\User),entity.table= the resource path segment (e.g.users),entity.fields= a synthesisedidUUID primary key plus one field per request-body input mapped to a Cycle column type (string/int/float/bool/json),repository=<appNs>\<Resource>\<Resource>Repository.
Operations on the same resource (GET on item, PUT, DELETE) do not re-declare the entity; they consume it through their typed response bodies. This mirrors how a hand-authored Altair project shapes persistence — one entity declaration, multiple references.
Other ORMs (Doctrine, plain PDO) will land alongside additional
--persistence= values in a later release. Today, cycle is the only
supported value.
Reversibility
Section titled “Reversibility”When a Journal is bound in the container (via
ScaffoldJournalConfiguration plus EventsConfiguration), each
successful import writes one .altair/journal/<id>.json entry that
covers the whole operation — the imported specs and the chained
scaffold’s outputs. journal:rewind on that single entry undoes the
whole import in one step.
When the scaffold phase fails after the import phase succeeds, the
importer attempts a best-effort rollback: every spec it just wrote gets
deleted, and the relative paths surface in the receipt’s rolled_back
field so the agent (or human) knows exactly which files were touched
and reverted.
When an RecorderInterface is bound, a single openapi_import
mutation event lands in .altair/events.jsonl per run, regardless of
how many specs were imported. That gives the agent a one-line “what
just changed?” record across sessions without having to read 17
journal entries.
What does not round-trip yet
Section titled “What does not round-trip yet”OpenAPI 3.1 cannot natively express several Altair concerns:
persistence entity mappings, queue dispatchers, webhook contracts,
idempotency policies. Today these are simply not emitted by the
importer. Issue #163
introduces the x-altair-* extension family so the import / re-emit
loop preserves them; until that ships, treat --queue and the absence
of x-altair-persistence honouring as known limitations.
The companion drift gate
(#164) closes the
loop: openapi:roundtrip --check will fail CI when an OpenAPI doc
re-emitted from imported specs differs from the original beyond the
documented normalization.
Known limitations
Section titled “Known limitations”- OpenAPI 3.0 / Swagger 2.0: not supported. 3.1 only. Convert first.
- Parameters with schemas:
OpenApiParserdoes not currently parseparameters[]schemas, so path parameters are coerced totype: stringregardless of the source schema. Widening the parser to preserve{name: id, in: path, schema: {type: integer}}is follow-up work tracked under the same epic. - Custom auth schemes: only
bearerHttpAuthenticationand API-key schemes survive a round trip today. - Multiple operations on overlapping resources: when two operations
derive the same filename, the importer fails with a
collisionerror rather than silently overwriting. Set distinctoperationIds to disambiguate.
See also
Section titled “See also”- coverage.md — exactly which OpenAPI 3.1 features import maps, warns on, or errors on
- #214 — bidirectional OpenAPI fidelity (epic)
- #160 — epic
- #161 — spec emitter (library)
- #163 —
x-altair-*extensions - #164 — round-trip drift gate
- docs/packages/scaffold.md — the scaffold sub-package overall