Skip to content

`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 into spec:scaffold to 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 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:scaffoldspec:emit-openapispec: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.yaml and 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-ship benchmark variant.
Terminal window
# Specs only — write Altair YAML to ./api/
bin/altair openapi:import openapi.yaml
# Custom output directory
bin/altair openapi:import openapi.yaml --out=specs/
# Full pipeline — specs + scaffold
bin/altair openapi:import openapi.yaml --scaffold
# Add a Cycle ORM entity + migration + repository for every POST collection
bin/altair openapi:import openapi.yaml --scaffold --persistence=cycle
# Agent mode: JSON receipt, no human prose
bin/altair openapi:import openapi.yaml --scaffold --format=json
# Plan only, write nothing
bin/altair openapi:import openapi.yaml --dry-run
# Overwrite existing files
bin/altair openapi:import openapi.yaml --force
# Import what maps; skip (don't abort on) operations the emitter can't express
bin/altair openapi:import openapi.yaml --skip-unmappable
FlagTypeEffect
<document>argumentPath to the OpenAPI 3.1 YAML document.
--out=<dir>stringOutput directory for emitted specs. Default api.
--scaffoldboolAfter writing specs, run spec:scaffold on each.
--dry-runboolReport planned changes; write nothing.
--forceboolOverwrite existing files (specs and scaffolded).
--skip-unmappableboolSkip operations whose schema the emitter cannot express (recording them in unmapped[] and warnings[]) instead of aborting the whole import.
--format=human|jsonenumOutput format. Default human.
--persistence=cycleenumInject a persistence: block for each POST-to-collection endpoint.
--queue=<transport>stringReserved for the x-altair-queue extension in #163; currently a no-op that surfaces a warning.
--root=<path>stringOverride the project root used as base for emitted paths.

--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_written lists the imported Altair specs (relative paths under the project root).
  • scaffolded is true when --scaffold was passed.
  • scaffold_files lists the artifacts the chained spec:scaffold phase produced.
  • unmapped carries {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-unmappable it lists every skipped operation while ok stays true.
  • journal_id / event_id are populated only when a Journal and RecorderInterface are bound, respectively. Under a NullRecorder they stay null, which keeps the receipt byte-stable for the same input — useful for golden-file CI gates.
  • error is set whenever ok is false.

The receipt is the agent-facing contract. Treat its shape as stable across patch releases; new fields are additive.

The mapping rules ship in Altair\Scaffold\Spec\Emitter\* (see #161). At a glance:

  • Filename: api/<resource>/<verb>.yaml. Verb is method-derived (POSTcreate, collection GETlist, item GETget, PUT/PATCHupdate, DELETEdelete). The first camelCase word of operationId overrides the verb when present (archivePostapi/posts/archive.yaml).
  • Path parameters become required string inputs.
  • Request-body object properties become inputs; the OpenAPI required array maps to the Altair required rule.
  • Nested objects become an input field with type: object and a recursive fields: map; the generated Input DTO types them as array with a PHPDoc array{…} shape. Arrays of objects become type: array with a fields: map describing the item, typed list<array{…}>. Both directions round-trip (openapi:roundtrip).
  • Enums without an FQCN render as type: string plus an in: rule.
  • Refs: scalars/enums resolve through components.schemas to the underlying type. In inputs, object refs (and nested object/array items) resolve and inline as nested fields:. In responses, object refs render as App\<Ref>\<Ref> so the responder gets a typed payload.
  • Top-level $ref responses 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 Pet body nests a category object and a tags array-of-objects, both of which now map to recursive fields:. Deep per-field validation rules for nested members are not generated yet (the nested shape and the object’s own required flag are); add them by hand if you need them.

--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 synthesised id UUID 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.

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.

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.

  • OpenAPI 3.0 / Swagger 2.0: not supported. 3.1 only. Convert first.
  • Parameters with schemas: OpenApiParser does not currently parse parameters[] schemas, so path parameters are coerced to type: string regardless 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 bearerHttpAuthentication and 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 collision error rather than silently overwriting. Set distinct operationIds to disambiguate.
  • 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)
  • #163x-altair-* extensions
  • #164 — round-trip drift gate
  • docs/packages/scaffold.md — the scaffold sub-package overall