Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agent/packages/scaffold.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
## Tests as documentation

- `tests/Scaffold/Cli/ImportReceiptTest.php`
- `tests/Scaffold/Cli/OpenApiImportExtensionsTest.php`
- `tests/Scaffold/Cli/OpenApiImportRunnerTest.php`
- `tests/Scaffold/Cli/OpenApiImportScaffoldTest.php`
- `tests/Scaffold/Cli/PersistenceInferrerTest.php`
Expand All @@ -106,6 +107,7 @@
- `tests/Scaffold/Emitter/MessageEmitterTest.php`
- `tests/Scaffold/Emitter/MigrationEmitterTest.php`
- `tests/Scaffold/Emitter/NamingTest.php`
- `tests/Scaffold/Emitter/OpenApiEmitterExtensionsTest.php`
- `tests/Scaffold/Emitter/OpenApiEmitterTest.php`
- `tests/Scaffold/Emitter/RepositoryEmitterTest.php`
- `tests/Scaffold/Emitter/ResponderEmitterTest.php`
Expand All @@ -120,11 +122,13 @@
- `tests/Scaffold/Sdk/CompileIntegrationTest.php`
- `tests/Scaffold/Sdk/EmitSdkCommandTest.php`
- `tests/Scaffold/Sdk/EmitterRegistryTest.php`
- `tests/Scaffold/Sdk/OpenApiParserExtensionsTest.php`
- `tests/Scaffold/Sdk/OpenApiParserTest.php`
- `tests/Scaffold/Sdk/PythonEmitterTest.php`
- `tests/Scaffold/Sdk/TypeScriptEmitterTest.php`
- `tests/Scaffold/Spec/Emitter/EmittedSpecTest.php`
- `tests/Scaffold/Spec/Emitter/EmitterTest.php`
- `tests/Scaffold/Spec/Emitter/OperationMapperExtensionsTest.php`
- `tests/Scaffold/Spec/Emitter/OperationMapperTest.php`
- `tests/Scaffold/Spec/Emitter/PathDeriverTest.php`
- `tests/Scaffold/Spec/Emitter/SchemaMapperTest.php`
Expand Down
165 changes: 165 additions & 0 deletions docs/openapi/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# `x-altair-*` OpenAPI extensions

> The set of OpenAPI 3.1 specification extensions Univeros uses to carry
> framework-specific concerns that the base spec cannot express. Forward
> emit (`spec:emit-openapi`) writes them; reverse import
> (`openapi:import`) reads them back. The round-trip preserves
> persistence, queue, and domain identity that would otherwise be lost.

**Schemas:** [`docs/openapi/extensions/`](./extensions/)
**Issue:** [#163](https://github.com/univeros/framework/issues/163) ·
epic [#160](https://github.com/univeros/framework/issues/160)

## Why an extension family

OpenAPI 3.1 describes the wire shape — what the request looks like, what
each response looks like, what each status means. It deliberately does
not describe how that shape is satisfied: which class handles the
request, which entity persists it, which message gets dispatched after.

For Univeros those are first-class spec fields. If a round trip through
OpenAPI silently dropped them, the only safe import workflow would be
"import then hand-rewrite," and the import path stops being useful for
adoption. `x-altair-*` is the OpenAPI-idiomatic way out: unknown `x-*`
keys are explicitly permitted by the spec and ignored by tooling that
doesn't recognise them, so adding ours doesn't break the document for
non-Univeros consumers.

## The v1 keys

All keys live at the **operation** level (under
`paths.<path>.<method>`).

| Key | Round-trips | Schema |
|---|---|---|
| `x-altair-domain` | Yes — `spec.domain.{class, invocation}` | [x-altair-domain.schema.json](./extensions/x-altair-domain.schema.json) |
| `x-altair-persistence` | Yes — `spec.persistence` | [x-altair-persistence.schema.json](./extensions/x-altair-persistence.schema.json) |
| `x-altair-queue` | Yes — `spec.queue` | [x-altair-queue.schema.json](./extensions/x-altair-queue.schema.json) |
| `x-altair-idempotency` | Carried through; runtime ships later | [x-altair-idempotency.schema.json](./extensions/x-altair-idempotency.schema.json) |
| `x-altair-webhook` | Carried through; runtime ships later | [x-altair-webhook.schema.json](./extensions/x-altair-webhook.schema.json) |
| `x-altair-input-location` | Carried through; needs parameters-parser support | [x-altair-input-location.schema.json](./extensions/x-altair-input-location.schema.json) |

"Carried through" means the parser preserves the key on the
`OperationModel` so a downstream emitter can read it. The reverse
importer doesn't yet do anything with it because the corresponding
runtime piece hasn't landed; the schema is published now so authoring
tooling can lint a document that uses it.

## Round-trip example

A hand-authored Altair YAML spec:

```yaml
endpoint:
method: POST
path: /users
summary: Create a user
tags: [users]
input:
email:
type: string
rules: [required]
domain:
class: App\User\CreateUser
invocation: __invoke
persistence:
entity:
class: App\User\User
table: users
fields:
id: { type: uuid, primary: true }
email: { type: string, unique: true }
repository: App\User\UserRepository
queue:
on_create:
message: App\Messages\SendWelcomeEmail
fields: { email: string }
transport: redis
```

`spec:emit-openapi` produces the corresponding OpenAPI 3.1 fragment with
the `x-altair-*` blocks attached:

```yaml
paths:
/users:
post:
summary: Create a user
tags: [users]
x-altair-domain:
class: App\User\CreateUser
invocation: __invoke
x-altair-persistence:
entity:
class: App\User\User
table: users
fields:
id: { type: uuid, primary: true }
email: { type: string, unique: true }
repository: App\User\UserRepository
x-altair-queue:
- name: on_create
message: App\Messages\SendWelcomeEmail
fields: { email: string }
transport: redis
requestBody:
...
responses:
...
```

`openapi:import` reads those blocks and reconstructs the original spec:
`domain.class` matches (not the path-derived
`App\Users\CreateUsers`), the `persistence:` block is recovered
verbatim, and the `queue:` block comes back as a name-keyed map (the
extension is a list because OpenAPI's YAML serialisation reads more
naturally that way; the Altair spec uses a map because the dispatch
name is a stable identifier).

## Forward compatibility

Unknown `x-altair-*` keys are not an error. The parser captures any key
that starts with `x-altair-` onto `OperationModel::$extensions`; on the
reverse path, anything the runner does not know how to interpret
surfaces in `ImportReceipt::$warnings` so v1 imports never silently
drop a key a future Univeros release will rely on.

The warning is informational: the receipt's `ok` field stays `true` and
the import still succeeds. Agents can branch on the warning to refuse
imports that depend on yet-unsupported behaviour, or note the gap and
proceed.

## Validating the extensions

The schemas in [`docs/openapi/extensions/`](./extensions/) are Draft
2020-12 JSON Schemas. They can be used in two ways:

1. **At authoring time.** Editor tooling that supports
`$schema`-referencing extensions can validate `x-altair-*` blocks
inline as the document is edited.
2. **At CI time.** A linter step in the OpenAPI document's repository
can validate each `x-altair-*` block against the matching schema and
fail the build on drift — the same gate the round-trip test
([#164](https://github.com/univeros/framework/issues/164)) provides
from the framework side.

## What does not round-trip yet

- **`x-altair-input-location`**. The Altair flat `input:` block can
represent path / query / header / body inputs uniformly, but the
`OpenApiParser` does not currently parse `parameters[]` schemas, so
the location annotation has nowhere to land on the reverse path. The
forward emitter does not yet write this key either — both halves
land together when the parser gains `parameters[]` support.
- **`x-altair-idempotency`** / **`x-altair-webhook`**. The framework
pieces these refer to (idempotency middleware, the webhook
receiver / dispatcher) ship under separate issues. The keys are
reserved and the schemas are published so the wire format stays
stable across releases.

## See also

- [docs/openapi/import.md](./import.md) — the importer that consumes these keys
- [#162](https://github.com/univeros/framework/issues/162) — the CLI itself
- [#161](https://github.com/univeros/framework/issues/161) — the spec emitter (library)
- [#164](https://github.com/univeros/framework/issues/164) — round-trip drift gate
21 changes: 21 additions & 0 deletions docs/openapi/extensions/x-altair-domain.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://univeros.io/openapi/extensions/x-altair-domain.schema.json",
"title": "x-altair-domain",
"description": "Carries the FQCN of the domain service that handles this operation, so that openapi:import recovers the original spec's domain.class instead of synthesising one from the path + operationId.",
"type": "object",
"additionalProperties": false,
"required": ["class"],
"properties": {
"class": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$",
"description": "Fully-qualified PHP class name of the domain service, e.g. App\\User\\CreateUser."
},
"invocation": {
"type": "string",
"default": "__invoke",
"description": "Method on the domain class to invoke; defaults to __invoke."
}
}
}
21 changes: 21 additions & 0 deletions docs/openapi/extensions/x-altair-idempotency.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://univeros.io/openapi/extensions/x-altair-idempotency.schema.json",
"title": "x-altair-idempotency",
"description": "Reserved key for the idempotency primitive arriving with the dedicated framework work. Emitted by spec:emit-openapi when the spec carries an idempotency block; imported as-is on the reverse path. This release does not yet wire the runtime.",
"type": "object",
"additionalProperties": false,
"required": ["ttl"],
"properties": {
"ttl": {
"type": "string",
"description": "Duration the framework should remember the Idempotency-Key for, e.g. 24h, 7d.",
"pattern": "^[0-9]+(ms|s|m|h|d)$"
},
"scope": {
"type": "string",
"description": "Logical bucket the key is scoped to (e.g. tenant, user, global). Application-defined.",
"default": "tenant"
}
}
}
22 changes: 22 additions & 0 deletions docs/openapi/extensions/x-altair-input-location.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://univeros.io/openapi/extensions/x-altair-input-location.schema.json",
"title": "x-altair-input-location",
"description": "Per-input-field annotation that records where the value came from on the wire (path, query, header) so an imported spec preserves the location even though Altair's input: block stores all inputs flat. Reserved key — OpenApiParser does not yet read parameters[] schemas, so the round-trip is incomplete in this release; the schema is published so authoring tooling can lint a doc that uses it.",
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["in"],
"properties": {
"in": {
"type": "string",
"enum": ["path", "query", "header", "body"]
},
"name": {
"type": "string",
"description": "Override the field name as it appears on the wire (e.g. snake_case ↔ camelCase mapping)."
}
}
}
}
58 changes: 58 additions & 0 deletions docs/openapi/extensions/x-altair-persistence.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://univeros.io/openapi/extensions/x-altair-persistence.schema.json",
"title": "x-altair-persistence",
"description": "Mirrors the Altair spec's persistence: block at the operation level so the imported entity + table + fields survive a round trip through OpenAPI 3.1.",
"type": "object",
"additionalProperties": false,
"required": ["entity"],
"properties": {
"entity": {
"type": "object",
"additionalProperties": false,
"required": ["class", "table", "fields"],
"properties": {
"class": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$"
},
"table": {
"type": "string",
"minLength": 1
},
"fields": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": [
"uuid", "string", "text", "int", "integer",
"bigint", "smallint", "float", "decimal",
"bool", "boolean", "datetime", "date", "time",
"json", "enum"
]
},
"primary": { "type": "boolean" },
"nullable": { "type": "boolean" },
"unique": { "type": "boolean" },
"default": {},
"of": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$",
"description": "FQCN of the PHP enum for type=enum fields."
}
}
}
}
}
},
"repository": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$"
}
}
}
36 changes: 36 additions & 0 deletions docs/openapi/extensions/x-altair-queue.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://univeros.io/openapi/extensions/x-altair-queue.schema.json",
"title": "x-altair-queue",
"description": "Mirrors the Altair spec's queue: block, capturing one or more message dispatches per operation. Each entry is the same shape the Symfony Messenger handler factory consumes downstream.",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "message", "fields"],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Dispatch handle, e.g. on_create. Keys the message back to the queue: map after import."
},
"message": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)+$",
"description": "FQCN of the Message DTO to dispatch."
},
"fields": {
"type": "object",
"description": "Field name => PHP-ish type (string|int|float|bool) or FQCN.",
"additionalProperties": {
"type": "string"
}
},
"transport": {
"type": "string",
"description": "Symfony Messenger transport name; null = bus default routing."
}
}
}
}
29 changes: 29 additions & 0 deletions docs/openapi/extensions/x-altair-webhook.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://univeros.io/openapi/extensions/x-altair-webhook.schema.json",
"title": "x-altair-webhook",
"description": "Reserved key for the webhook primitive arriving with the dedicated framework work. Marks an operation as an inbound (in) or outbound (out) webhook endpoint and carries the verification settings the framework will need at runtime. This release does not yet wire the runtime.",
"type": "object",
"additionalProperties": false,
"required": ["direction", "signing"],
"properties": {
"direction": {
"type": "string",
"enum": ["in", "out"]
},
"signing": {
"type": "string",
"description": "Signing scheme expected on inbound requests / applied to outbound dispatches.",
"enum": ["hmac-sha256", "hmac-sha512", "ed25519"]
},
"header": {
"type": "string",
"description": "Header name carrying the signature (inbound). Defaults to X-Signature."
},
"dedupe_ttl": {
"type": "string",
"pattern": "^[0-9]+(ms|s|m|h|d)$",
"description": "How long to remember the request ID for replay protection."
}
}
}
Loading
Loading