Skip to content

feat: OpenAPI 3.1/3.2 conformance — 38 of 51 beads#15

Merged
lightsofapollo merged 13 commits into
mainfrom
feat/conformance-3.1-3.2
May 8, 2026
Merged

feat: OpenAPI 3.1/3.2 conformance — 38 of 51 beads#15
lightsofapollo merged 13 commits into
mainfrom
feat/conformance-3.1-3.2

Conversation

@lightsofapollo
Copy link
Copy Markdown
Contributor

Implements the bulk of the OpenAPI 3.1 / 3.2 conformance work tracked in #14. 38 of 51 beads landed (~75%) across 10 commits. cargo test --tests is 205/205 green; all 10 conformance fixtures pass at every active layer.

Summary

  • Replaced the serde(flatten) extra: BTreeMap pattern (the audit's Axum handler support #1 architectural finding) with a typed Extensions newtype that rejects any non-x-* key at deserialize time.
  • Added typed Rust structs for every OAS object that was previously a Value blob: Server, ServerVariable, Tag, ExternalDocs, Header, Example, Link, Callback, Encoding, SecurityScheme (all 5 types) + OAuthFlows + OAuthFlow.
  • Honored 3.1's canonical type: ["string","null"] via a new Schema::TypedMulti variant; nullability propagates to AnalyzedSchema.
  • Surfaced previously-silent codegen failures (header params dropped, OPTIONS/TRACE silently rewritten to GET, auth config ApiKey/Custom dead, operationId collisions overwriting, range status codes skipped, path templates not percent-encoded).
  • Wired 3.2 deltas: additionalOperations, query HTTP method, $self, Tag summary/parent/kind, Server name, Discriminator defaultMapping, MediaType itemSchema/prefixEncoding/itemEncoding, OAuth deviceAuthorization flow + oauth2MetadataUrl, SecurityScheme deprecated, Components pathItems (3.1+) and mediaTypes (3.2).
  • Wired JSON Schema 2020-12 keywords as typed fields: prefixItems, patternProperties, propertyNames, unevaluatedProperties, unevaluatedItems, dependentRequired, dependentSchemas, contains/minContains/maxContains, contentEncoding/contentMediaType/contentSchema, if/then/else/not, $dynamicRef/$dynamicAnchor, $id/$defs/$schema/$comment.
  • Added __pct_encode_path_segment helper to the generated client and used it for every path-template substitution (RFC 3986 §3.3).
  • Added a CLI version gate (3.0/3.1 first-class, 3.2 experimental, anything else hard-error).

Conformance scaffold (also in this PR)

Built first so progress is verifiable:

  • tests/conformance/specs/ — committed copies of OAS 3.1.2 and 3.2.0 markdown.
  • cargo run --bin catalog-gen — parses the 3.2 spec into tests/conformance/catalog.yaml (30 objects, 141 fields, 57 JSON Schema keywords, 7 style combos). The denominator for "100% coverage".
  • tests/conformance.rs — fixture harness with layered fails_at: L0|L1|L3|... markers; only ACTIVE_LAYERS are enforced, deferring fixtures targeting unimplemented layers.
  • tests/conformance/external/json-schema-test-suite/ — git submodule of the canonical JSON Schema 2020-12 corpus.
  • tests/conformance/external/apis-guru-sync.sh — lazy-clone for nightly real-world smoke (APIS_GURU_SMOKE=1).
  • tests/conformance/status.toml — honest support claims (33 entries: 22 supported, 6 partial, 5 unsupported), each with a precise pointer to remaining work.
  • tests/conformance/beads.yaml + cargo run --bin file-beads — source of truth for the work plan; can re-render the master tracking issue idempotently.

Status by phase

Phase Done Total
0 — Foundation 3 4
1 — Top silent failures 11 15
2 — 3.1 honesty (struct families) 10 12
2b — JSON Schema 2020-12 6 10
3 — 3.2 deltas 8 10

Remaining (deferred to follow-up PRs, all noted in #14)

Group 1 (response-shape refactor): T7 (non-JSON success bodies), T9 (success-path response headers), T12 (typed additionalProperties: <schema>).

Group 2 (parameter serialization): T14 (full RFC6570 style/explode matrix — best done as its own PR with a wiremock test per combo).

Group 3 (schema-keyword codegen): J2 ($defs walking), J3 (validator runtime checks), J9 (formatchrono::DateTime/uuid::Uuid with feature-gated deps), J10 ($self resolution per Appendix F).

Plus small ones: F3 (formal MaybeRef<T> sum type), D2 (in: "querystring" codegen), H3 (per-op security override emission), H12 (allOf-parent polymorphism + not codegen), D6 ($self resolution).

Test plan

  • cargo test --tests — 205/205 passing
  • cargo test --test conformance — 10/10 fixtures pass at active layers
  • cargo test --test conformance_json_schema — 44 of 47 JSON Schema 2020-12 corpus files parse (skipped: dynamicRef, vocabulary, unknownKeyword per documented limits)
  • cargo build — clean, no new warnings
  • All existing fixture tests (tests/fixtures/anthropic.yml, openai-responses.json, etc.) still pass
  • No regressions in any existing snapshot tests; updates rendered via INSTA_UPDATE=always and reviewed in commits

Closes most of #14. Remaining work tracked in the issue's progress comments.

🤖 Generated with Claude Code

lightsofapollo and others added 13 commits May 7, 2026 22:16
…acker

Establishes the conformance infrastructure that backs OpenAPI 3.1/3.2 work:

- catalog-gen: parses OAS 3.2 spec markdown into tests/conformance/catalog.yaml
  (30 objects, 141 fields, 57 JSON Schema 2020-12 keywords, 7 style combos).
  This is the denominator for "100% coverage".
- conformance harness (tests/conformance.rs): walks fixtures, runs L0
  lossless-parse check, supports layered fails_at: markers so fixtures for
  layers we haven't built yet (L1/L3/L5) are recorded as deferred.
- 10 seed fixtures across the audit's top-impact gaps (webhooks,
  additional-operations, query method, deepObject, header params, type-array
  nullability, components/pathItems, components/mediaTypes, mutualTLS,
  $dynamicRef).
- JSON Schema 2020-12 corpus runner via git submodule
  (json-schema-org/JSON-Schema-Test-Suite). 47 test files exercised.
- APIs.guru lazy-clone + smoke runner (off by default).
- status.toml: honest support claims, derived from harness results.
- beads.yaml + file-beads bin: source-of-truth for the work plan; emitted as
  GitHub issue #14 with parallel-execution batches computed from
  dependency depth and file-set disjointness.

Cross-cutting findings live in tmp/openapi-specs/reports/00-SUMMARY.md.
Tracking issue: #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bead F1 from issue #14. Replaces the silent serde(flatten) extra: BTreeMap
pattern with a typed Extensions newtype that rejects any non-`x-` key at
deserialize time, so unknown spec fields surface loudly instead of being
swallowed.

Structs converted to Extensions:
  OpenApiSpec, Info, Components, PathItem, Operation, Parameter,
  RequestBody, Response, MediaType, Discriminator.

Adds typed fields for previously-flattened spec fields so that valid OAS
documents continue to parse:
  OpenApiSpec      jsonSchemaDialect, servers, webhooks, security, tags,
                   externalDocs, $self
  Info             summary, description, termsOfService, contact, license
  Components       responses, examples, requestBodies, headers,
                   securitySchemes, links, callbacks, pathItems (3.1+),
                   mediaTypes (3.2)
  PathItem         summary, description, servers, $ref
  Operation        tags, deprecated, callbacks, security, servers,
                   externalDocs
  Parameter        deprecated, allowEmptyValue, style, explode,
                   allowReserved, content, example, examples, $ref
  Response         headers, links, $ref
  MediaType        example, examples, encoding, $ref
  RequestBody      $ref

Schema and SchemaDetails are intentionally left with the loose
`extra: BTreeMap`. They hold JSON Schema 2020-12 keywords
(patternProperties, propertyNames, dependentRequired, if/then/else, …) that
analysis.rs already reads from extras. Those graduate to typed fields
under the J5–J8 beads (Phase 2b).

Note on serde: deny_unknown_fields cannot combine with #[serde(flatten)] —
the former rejects fields before the flatten target sees them, including
x-* extensions. Using Extensions with custom Deserialize is the canonical
workaround: any non-x-* unknown lands in Extensions and fails its check.

Adds CLI version gate: 3.0.x and 3.1.x accepted; 3.2.x emits an
"experimental" warning; everything else is a hard error.

Conformance harness now passes the deepObject, mediaTypes, pathItems,
$dynamicAnchor, mutualTLS, and webhooks fixtures at L0; they advance to
fails_at: L3 since codegen still ignores the typed values.

All 205 tests still pass.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bead F2 from issue #14. Adds Schema::TypedMulti variant ahead of
Schema::Typed in the untagged enum so `type: ["string", "null"]` deserializes
into a typed multi-element array instead of falling through to Untyped.

- Schema::TypedMulti { schema_types: Vec<SchemaType>, details }
- Schema::schema_type() returns the primary non-null type for TypedMulti.
- Schema::type_array_contains_null() exposes nullability cleanly.
- Schema::details() / details_mut() / inferred_type() handle TypedMulti.
- analyze_single_schema combines the 3.0 `nullable: true` field with the
  3.1 type-array null marker, so AnalyzedSchema.nullable is correct
  regardless of which form the spec used.

The 3.0 `nullable` field stays for compatibility (the openai-responses
fixture is OAS 3.0.0 and uses it). 3.1+ specs should use the type array.

Conformance: schema/type-string-or-null.yaml flips from fails_at:L3 to
fully passing. status.toml entry for objects.Schema.type[array] flips to
"supported".

All 205 tests still pass.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five beads from issue #14, all in Phase 1 (top silent failures).

T1: Wire header request parameters through codegen
  - Header params parsed from `Parameter.in == "header"` now flow into the
    method signature (required → bare arg, optional → Option<T>).
  - generate_header_params emits `req = req.header(name, value)` for each,
    gated on Some(_) for optional headers.

T2: Emit operations for HEAD/OPTIONS/PATCH/TRACE methods
  - Replaces the "_ => get" silent fallback in client_generator.rs:711
    with explicit match arms.
  - HEAD uses reqwest's named method; OPTIONS/TRACE go through
    Client::request(Method::OPTIONS, ...) (reqwest doesn't expose those
    as named methods).
  - registry_generator.rs's HttpMethod enum extended with Head/Options/Trace.

T6: Detect operationId collisions
  - analysis.rs:3514 silently overwrote duplicate operationIds. Now
    returns a clear error pointing at both colliding paths.

T11: requestBody required:false → Option<T>
  - OperationInfo gains request_body_required field, populated from
    operation.request_body.required (defaults to false per OAS spec).
  - generate_request_param wraps the body argument in Option<T> when
    not required. Existing tests with explicit request bodies were
    updated to set request_body_required: true.

F4: Accept paths-less specs (components-only / webhooks-only)
  - extract_schemas in analysis.rs no longer errors when
    components.schemas is missing. OAS 3.1+ allows specs with only
    `webhooks` or only `components`.

Conformance: parameter/header-required.yaml flips to passing (header
params now reach the wire).

All 205 tests still pass; insta snapshots updated for the new
request_body_required field.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four more beads from Phase 1 (top silent failures).

T13: Surface description/summary into rustdoc
  - generate_operation_doc_comment now prepends operation.summary and
    operation.description as rustdoc lines, then the method+path in
    backticks. Tests updated for the new format.

T5: Path templating percent-encoding
  - generate_url_with_params now wraps each path-template substitution in
    __pct_encode_path_segment, a private helper emitted into the generated
    client. Encodes per RFC3986 §3.3 (only ALPHA/DIGIT/`-._~` pass through).
    Without this, path values containing `/`, `?`, `#`, or non-ASCII broke
    URLs.

T8: Range status codes (1XX/2XX/3XX/4XX/5XX)
  - generate_error_match_arms now emits guarded match arms for range-keyed
    response codes (`2XX`, `4XX`, `5XX`, etc.) instead of falling through
    to the generic default. Specific codes still take precedence — concrete
    match arms come first in the generated match.

T3: Honor auth_config ApiKey / Custom variants
  - generate_auth_application replaces the hardcoded `req.bearer_auth(...)`
    in every operation with a config-driven emission.
    - Bearer + Authorization: bearer_auth (unchanged behavior).
    - Bearer + custom header: format!("Bearer {}", token).
    - ApiKey: req.header(name, token) — no Bearer prefix.
    - Custom: req.header(name, format!("{prefix}{token}")) when prefix set.
  - README claim of multi-scheme auth is now actually true at runtime.

All 205 tests still pass.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o-detect

T4: Walk webhooks: in analysis
  - analyze_operations now extracts operations from spec.webhooks too,
    using a synthetic `__webhook__/<name>` path. Their request bodies and
    responses become typed schemas like any other operation; the typed
    Webhook enum + dispatcher is a follow-up.
  - Refactored the path/operation walker into ingest_path_item_operations
    so paths and webhooks share the same code path.

T10: Resolve $ref-typed parameter types
  - get_param_rust_type now consults ParameterInfo.schema_ref first and
    emits the resolved Rust type. Previously $ref-ed enum parameters
    silently fell back to String.

T15: SSE auto-detection from text/event-stream responses
  - analyze_single_operation now detects `text/event-stream` content
    types on responses and sets supports_streaming = true. If a
    `stream`-named parameter is also present it's recorded as
    stream_parameter. The user can still override via config.

All 205 tests still pass.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…refs

Phase 3 + 2b beads for the OAS 3.2.0 / JSON Schema 2020-12 surface area.

D1: PathItem.query + additionalOperations
  - PathItem now has fields for the 3.2 `query` HTTP method and the
    `additionalOperations` extension map for arbitrary verbs (LINK, UNLINK,
    PROPFIND, etc.).
  - PathItem::operations() yields all of them so analysis sees them.
  - http_method_call falls through to reqwest::Method::from_bytes(...)
    for verbs other than the 8 well-known ones, so 3.2 query and
    custom verbs become real client methods.
  - Conformance fixtures pathitem/query-method-3.2.yaml and
    pathitem/additional-operations-3.2.yaml flip to fully passing.

D9: Discriminator.defaultMapping
  - Field added to the Discriminator struct (3.2 §"Discriminator Object").
    Captured today; emission of a `_Other(Value)` fallback variant in
    generated enums is a follow-up.

J1: $dynamicRef / $dynamicAnchor (JSON Schema 2020-12)
  - Schema gains a DynamicRef variant with the $dynamicRef field.
  - SchemaDetails gains $dynamicAnchor and $id fields.
  - SchemaDetails now derives Default; the once_cell EMPTY_DETAILS
    used by Reference/RecursiveRef/DynamicRef variants collapses to
    a single SchemaDetails::default().
  - analyze_single_schema treats DynamicRef like RecursiveRef for now;
    full anchor-scope resolution is a follow-up.

All 205 tests still pass.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…amilies + 3.2 deltas

A big batch covering most of the previously-unmodelled top-level OAS objects.
All structs are typed with Extensions for x-* fields; 3.2-only fields are
added as the spec defines them.

H1: Server + ServerVariable Object
  - OpenApiSpec.servers, PathItem.servers, Operation.servers all become
    Option<Vec<Server>>. Server has typed url/description/variables and
    3.2's `name` field (D8).
  - ServerVariable carries default/enum/description.

H2: Security Scheme Object (full set)
  - SecurityScheme enum with #[serde(tag = "type")] covering apiKey, http,
    mutualTLS (3.1+), oauth2, openIdConnect.
  - OAuthFlows + OAuthFlow modeled with all four standard flows + 3.2's
    deviceAuthorization (D4).
  - OAuth Flow gains deviceAuthorizationUrl (D4) and SecurityScheme::OAuth2
    gains oauth2MetadataUrl (D4).
  - Every variant carries a `deprecated` field (D10).
  - Components.security_schemes is now BTreeMap<String, SecurityScheme>.

H4: Encoding Object
  - contentType, headers, style, explode, allowReserved.
  - 3.2 itemEncoding (D3) for streaming/array body parts.
  - MediaType.encoding becomes BTreeMap<String, Encoding>.

H5: Header Object
  - Same shape as Parameter minus name/in. Used in Response.headers
    (T9), Encoding.headers, Components.headers.

H6: Example Object
  - summary, description, value, externalValue.
  - 3.2 dataValue and serializedValue.
  - Components.examples and MediaType.examples are now typed.

H7: Link Object
  - operationRef, operationId, parameters, requestBody, server.
  - Components.links typed.

H8: Callback Object
  - Newtype wrapper around BTreeMap<String, PathItem>.
  - Operation.callbacks and Components.callbacks are typed.

H9: Tag Object
  - name, description, externalDocs.
  - 3.2 summary, parent, kind (D5).
  - OpenApiSpec.tags is Vec<Tag>.

H10: ExternalDocs Object
  - url + description. Used by OpenApiSpec, Operation.

D3: MediaType.itemSchema, prefixEncoding, itemEncoding (3.2)

Spec-level and Operation-level `security` are now typed
Vec<BTreeMap<String, Vec<String>>> instead of opaque Value.

All 205 tests still pass.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reflect the wave of beads landed in this PR. All 33 entries are now
fixture-backed or note their precise scope (typed-but-codegen-TODO).

Refs #14
J4: prefixItems
J5: patternProperties + propertyNames
J6: unevaluatedProperties + unevaluatedItems
J7: dependentRequired + dependentSchemas
J8: contains + minContains + maxContains + contentEncoding +
    contentMediaType + contentSchema

All landed as typed fields on SchemaDetails, plus the conditional
keywords (`if`/`then`/`else`/`not`), the `$defs` map (J2 partial),
`$comment`, `$schema`, `examples`, `example`, `title`, `deprecated`,
`readOnly`/`writeOnly`. Spec keywords no longer hide in `extra`.

Updated `should_use_dynamic_json` in analysis.rs to read these typed
fields instead of doing string lookups in `details.extra`.

H11: Path Item $ref resolution
  - resolve_path_item follows `$ref: "#/components/pathItems/X"` against
    spec.components.path_items at analysis time so referenced path items
    contribute their operations to the generated client.

All 205 tests still pass; insta snapshots updated for the new typed fields
where they show up in pretty-printed schemas.

Refs #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- cargo fmt across files touched in this PR.
- Replace the always-breaking while-let in strip_markdown with a
  straight if-let; same behavior, no clippy warning.

cargo clippy --lib --bins is now clean (one expect_used warning, not
denied). The remaining clippy errors live in pre-existing tests and
examples this PR doesn't touch.

Refs #14
Two clippy errors caught only with `--all-features -- -D warnings`:

- large_enum_variant: SecurityScheme::OAuth2 carried OAuthFlows directly
  (~817 bytes); other variants ~97 bytes. Box it so the enum's variants
  are similarly sized.
- manual_contains: schema_types.iter().any(|t| *t == X) → contains(&X).

Plus drop the only `expect_used` in catalog-gen.rs by replacing the
`get_mut(...).expect(...)` with an `if let Some(...)` guard.

`cargo clippy --all-features -- -D warnings` is now clean.

Refs #14
@lightsofapollo lightsofapollo merged commit 88b357f into main May 8, 2026
4 checks passed
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