Files
alkhttp/docs/architecture/decisions/051-yaml-input-for-from-openapi.md
T
glm-5.3-flash 67ae7cd9fe docs(adr-051): YAML/JSON parity contract for the OAI-12 normalization pipeline
§5 records the post-OAI-12 from_yaml contract: duplicate keys rejected
loudly on YAML (with the empirical correction that serde_json 1.0.151's
Value path last-wins rather than errors — the YAML side is the stricter
one), non-finite floats rejected with JSON-pointer context, merge keys
applied via apply_merge (shallow, referencing keys win; the one
deliberate YAML 1.2 deviation), scalar-key stringification matching the
core schema, and the no-new-bounds note (the walk stays inside
yaml_serde's parse-time limits).

Verification: cargo doc --no-deps clean; module-doc cross-check in
openapi_spec.rs matches this contract.
2026-08-30 22:38:04 +00:00

16 KiB

ADR-051: YAML Input Format for from_openapi

Ported from alknet ADR-051 (YAML Input Format for from_openapi); re-targeted to alkhttp.

Status

Accepted

Context

from_openapi imports external HTTP APIs as call-protocol operations by parsing an OpenAPI document. The http-adapters.md spec (now in this crate's docs/architecture/) already states the one-way constraint as "from_openapi accepts a standard OpenAPI 3.x JSON/YAML doc" — YAML was always part of the intended input contract. The implementation, however, only ever delivered the JSON half: OpenAPISpec::from_json(&str). This was fine for providers that publish JSON OpenAPI schemas (e.g., runpod's openapi.json) but blocks providers that publish YAML schemas (e.g., vast.ai's openapi.yaml). A coming consumer crate needs to import vast.ai's operations, which surfaces the gap.

This is gap-filling against an existing constraint, not a new architectural direction. None of the architecture invariants are touched: OpenAPISpec stays serde_json::Value-based, the forwarding handler is unchanged, the no-env-vars credential injection is unchanged, error fidelity is unchanged, to_openapi output stays JSON. The change is a new parse path into the same internal type.

Two decisions need recording: the parse strategy (JSON-first, not parse-everything-as-YAML) and the dependency choice (the maintained yaml_serde fork, not the deprecated serde_yaml).

Decision

1. from_openapi accepts YAML via a from_yaml constructor and a format-detecting from_str

OpenAPISpec gains two constructors alongside the existing from_json:

impl OpenAPISpec {
    pub fn from_json(doc: &str) -> Result<Self, AdapterError>;   // existing
    pub fn from_yaml(doc: &str) -> Result<Self, AdapterError>;   // new — parses YAML
    pub fn from_str(doc: &str) -> Result<Self, AdapterError>;    // new — detects format
    pub fn from_value(raw: Value) -> Result<Self, AdapterError>; // existing, unchanged
}

from_str is the convenience for callers that have a raw doc string of unknown format (e.g., fetched from a URL with no Content-Type hint). The detection rule is JSON-first, YAML-fallback (see §2 for why the order matters): attempt serde_json::from_str; if it parses, use the result; if it fails, attempt YAML. from_json and from_yaml remain for callers that know the format and want a precise error on mismatch.

This is an additive API surface change (two-way door — constructors can be renamed/added; nothing downstream breaks). The constructors produce the same OpenAPISpec; the rest of the adapter is format-agnostic.

2. Format detection is JSON-first, YAML-fallback — a defensive default, not a style preference

Amendment (2026-07-06): The original §2 cited YAML 1.1 boolean-coercion (yes/no/on/off → booleans) as a present hazard with the maintained Rust YAML crates, framing JSON-first as a correctness guard against silent string→boolean mutation. A probe during implementation verified this is factually wrong for the chosen dependency: yaml_serde 0.10.x (and the deprecated serde_yaml 0.9) implement the YAML 1.2 core schema, where only true/false (and case variants) are booleans — the bare tokens yes/no/on/off/ y/n are plain strings. The coercion hazard the original rationale cited does not exist with this dependency version. The JSON-first rule is retained (Accepted ADR) — the rationale is reframed below as a defensive default, not a guard against a present hazard. The decision did not change; the rationale did.

JSON's grammar is a strict subset of YAML (under YAML 1.2) and never exhibits any YAML-specific type interpretation. Running a JSON document through a YAML parser is currently safe with yaml_serde 0.10.x — a JSON doc like {"active": "yes"} parses through the YAML path with "yes" intact as a string (YAML 1.2 core schema, verified by the from_yaml_preserves_bare_yes_as_string_yaml_1_2_behavior test). JSON-first detection is therefore not guarding against a present hazard with this dependency; it is a defensive default that locks in the contract against a future YAML-parser swap. If yaml_serde is ever swapped for a YAML 1.1 crate (where yes/no/on/off coerce to booleans), or if a future yaml_serde version tightens its core schema in a way that introduces type interpretation JSON doesn't have, the JSON-first rule ensures JSON input cannot be silently mutated by the YAML path. The contract is durable; the dependency is a two-way door (§3).

The rule is cheap: from_str tries serde_json::from_str first (strict grammar, no YAML-specific interpretation), and only on JSON parse failure falls back to the YAML parser. A YAML-only document (with openapi: 3.0.0 at the top, no JSON braces) fails JSON parse immediately and goes to the YAML path. The cost is one wasted parse attempt for YAML docs, paid once at adapter-import time (not per forwarded call — see Consequences).

from_yaml (the explicit constructor) does not try JSON first — the caller has declared the format. This is correct: a caller that explicitly says "this is YAML" wants the YAML parse, including whatever type interpretation the YAML parser applies. If the caller is wrong (passes JSON to from_yaml), the YAML parser handles it — JSON is a syntactic subset of YAML, so it parses, with whatever interpretation the YAML parser's schema applies (currently none for yes/no under YAML 1.2; a future YAML 1.1 swap would coerce). The caller opted in by naming the format; from_str exists for the unsure caller.

3. The YAML dependency is yaml_serde (the official YAML org fork of serde_yaml), not the deprecated serde_yaml

The original serde_yaml crate (dtolnay) is no longer maintained. The official YAML organization maintains a continuation published as yaml_serde (crate name yaml_serde, v0.10), a drop-in fork with full API compatibility. The migration path is either serde_yaml = { package = "yaml_serde", version = "0.10" } (keeps use serde_yaml:: imports) or direct yaml_serde = "0.10" with updated imports. alkhttp uses the direct form (yaml_serde = "0.10", use yaml_serde::).

The dependency is a two-way door: yaml_serde can be swapped for another maintained YAML-serde fork (or a future replacement) by changing the Cargo line and the imports. The one-way constraint is that alkhttp owns its YAML parse and produces serde_json::Value (the shared internal type) — which dependency does the parse is an implementation detail. yaml_serde is chosen because it is the maintained continuation under the official YAML umbrella, not because its API is irreversibly load-bearing.

The dependency is not feature-gated. YAML OpenAPI schemas are a first-class input format (vast.ai publishes one), not an edge case. Gating it behind a feature would mean a deployment that imports vast.ai must remember to enable the feature — the kind of friction the no-surprises default-features model avoids. The dependency is small (a pure-Rust YAML parser, no native code), consistent with the existing default-features philosophy of the crate.

5. YAML/JSON parity contract (review 002 OAI-12)

Amendment (2026-08-30, review 002 OAI-12): the original delivery fed yaml_serde::from_str::<serde_json::Value> straight into from_value with zero post-parse normalization, and three verified corruptions flowed through unimpeded: duplicate keys silently last-won; .inf/ .nan scalars silently became null (serde_json::Number::from_f64 is None for non-finite floats); merge keys (<<: *anchor) survived as literal << properties. from_yaml now runs a normalization pipeline; this section records the resulting contract. The same document may still mean different things through from_json and from_yaml only where explicitly stated below — every difference is loud on the YAML side.

The from_yaml pipeline is: an explicit parse into yaml_serde::Valueapply_merge() → one structural normalization pass into serde_json::Value → the shared from_value path. Per-construct rules:

  1. Duplicate mapping keys are rejected loudly on the YAML path — the explicit yaml_serde::Value parse rejects them natively with the key and its line/column, naming the document position. The JSON path silently last-wins (serde_json::Value's visit_map inserts into a map; verified empirically against serde_json 1.0.151 — the oft-assumed "JSON errors on duplicates" behavior does not hold for the Value target). The YAML path is deliberately the stricter one: a duplicate-key document fails YAML import instead of silently meaning different things through the two entry points. Callers who need JSON-path tolerance of duplicates have it by construction; the asymmetry is documented and tested, not hidden.
  2. Non-finite floats are rejected loudly. YAML 1.2 core schema has .inf/-.inf/.INF/.nan (and case variants) as floats; serde_json::Number cannot represent them, and an unguarded conversion would advertise null where the document declares e.g. maximum: .inf — silently dropping a declared constraint from the schema. The normalization pass fails import with the offending value's JSON pointer and the rendered value.
  3. Merge keys are applied, not advertised. <<: *anchor resolves per the YAML merge type before conversion; << never survives as a literal property in the parsed spec. The semantics are yaml_serde's apply_merge(): shallow entry().or_insert() into the referencing mapping — the referencing mapping's explicit keys win, and a << value that is neither a mapping nor a sequence of mappings fails loudly. This is the one deliberate, tested deviation from strict YAML 1.2 core-schema processing (§2): merge keys are a YAML 1.1 mechanism that YAML tooling and authors universally expect to work, and the alternative (rejecting << loudly) would refuse anchor-heavy real-world documents that JSON cannot even express. A producer wanting strict 1.2 processing can avoid << in favor of explicit repetition.
  4. Non-string mapping keys are stringified on both paths by construction (JSON keys are strings; YAML scalar keys are rendered exactly as the YAML 1.2 core schema renders the scalar: 200:"200" matches JSON {"200": ...}, true:"true", 1.5:"1.5"). Keys with no round-trip-stable string form — nullish keys (~, empty) and collection keys (sequences, mappings) — are rejected loudly with the key's JSON pointer instead of being stringified through YAML's debug rendering.
  5. Scalar interpretation is identical by dependency version. Both paths go through the YAML 1.2 core schema or JSON's stricter grammar (§2 as amended): bare yes/no/on/off are strings, !!str 200 is the string "200", and unknown tags fail loudly on both paths. The JSON-first rule of §2 remains the defensive lock against a future YAML-parser swap changing this.

The normalization pass adds no new resource bounds: it walks structure already accepted by yaml_serde's parse-time limits (recursion limit, alias jump limit, repetition limit — re-verified during review 002), is linear in document size, and allocates only the converted document plus error messages. Fixtures for the normalization are all linear or bounded; the $ref resolver's budgets (OAI-01/OAI-11) are downstream and unchanged.

4. Scope boundary: to_openapi output is not affected

to_openapi generates the published gateway doc, served at GET /openapi.json. It stays JSON. This ADR fills a gap on the consume side (importing external YAML schemas); the publish side serves our own gateway contract and JSON is the standard exchange format for OpenAPI tooling (code generators, validators, fetch-based clients all consume JSON). A GET /openapi.yaml additive output is not part of this decision: it is a separate scope (publish-side format, not consume-side), would be a separate ADR if a concrete consumer requires YAML output, and is additive (a new endpoint, no breaking change to the JSON path). The OpenAPISpec type is shared, but the output serialization is JSON-only.

Consequences

Positive:

  • from_openapi consumes both JSON and YAML OpenAPI schemas — the intended contract (spec line: "JSON/YAML doc") is finally delivered. vast.ai and any other YAML-publishing provider can be imported.
  • Format detection (from_str) makes fetch-and-import ergonomic: a caller that fetched a schema from a URL with no reliable Content-Type doesn't have to sniff the format itself.
  • JSON-first detection is a defensive default that locks in the contract against a future YAML-parser swap. With yaml_serde 0.10.x (YAML 1.2 core schema) the coercion hazard the original rationale cited is not present; JSON-first nonetheless ensures JSON input is never exposed to YAML-specific type interpretation, present or future. The rule is cheap (one wasted parse for YAML docs, paid once at import time) and the contract is durable.
  • The maintained yaml_serde fork keeps the dependency off the archived serde_yaml; the swap is documented so a future maintainer doesn't re-derive why the crate name doesn't match the obvious name.

Negative:

  • A new pure-Rust dependency (yaml_serde) in alkhttp. Small, but non-zero. The trade is first-class YAML support without a feature gate — accepted because YAML OpenAPI is a real input format, not an edge case.
  • from_str's JSON-first detection does one wasted parse attempt for YAML docs (the JSON parse fails, then the YAML parse runs). The cost is trivial — from_openapi runs once at adapter-import time (not per forwarded call), so the double-parse happens once per imported service, not per request. Callers that know the format use from_json/from_yaml directly and pay no double-parse. The defensive benefit (JSON input never reaches the YAML parser, immune to any YAML-specific interpretation present or future) is worth the one-time cost.

Assumptions

  1. The OpenAPISpec internal type stays serde_json::Value-based. YAML parses to serde_json::Value via yaml_serde, then feeds the existing from_value path. No second internal representation. If a future switch to openapiv3::OpenApi happens (the two-way-door the spec already notes), both JSON and YAML constructors adapt in lockstep — the constructor is the adapter between wire format and internal type.

  2. yaml_serde 0.10.x implements the YAML 1.2 core schema. Verified by a probe during implementation: bare yes/no/on/off/y/n are plain strings, not booleans (codified by the from_yaml_preserves_bare_yes_as_string_yaml_1_2_behavior test). The original §2 rationale cited YAML 1.1 coercion as a present hazard; it is not, with this dependency version. JSON-first detection is retained as a defensive default (§2 as amended): a future swap to a YAML 1.1 crate, or a future yaml_serde schema tightening, cannot silently regress JSON input because JSON never reaches the YAML path under from_str. If the dependency swaps to a YAML 1.1 crate, the defensive default becomes a load-bearing correctness guard — the rule is the same either way, which is why it is stated as a contract rather than as a workaround for a specific crate version.

References

  • ADR-017from_openapi is an OperationAdapter; published to_* specs are compatibility contracts (the publish side stays JSON; the decision record is alkcall ADR-022)
  • ADR-023 — error fidelity is unaffected (error schemas come from the parsed OpenAPISpec, format-independent; the decision record is alkcall ADR-016)
  • ADR-039 — alkhttp owns both HTTP directions and their dependencies
  • http-adapters.md — the spec that states the "JSON/YAML doc" constraint, the OpenAPISpec type, and the Constraints/Design Decisions entries this ADR backs (see the "Input formats" doc-comment, the Constraints §"from_openapi accepts JSON and YAML", and the Design Decisions table row; now in docs/architecture/)
  • yaml_serde crate (https://github.com/yaml/yaml-serde) — the maintained official-YAML-org fork of the deprecated serde_yaml