From 193f3a0e2446b4198eacfe7b8f1ee8cbd7165751 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Mon, 6 Jul 2026 12:41:46 +0000 Subject: [PATCH] =?UTF-8?q?docs(arch):=20ADR-051=20=E2=80=94=20YAML=20inpu?= =?UTF-8?q?t=20format=20for=20from=5Fopenapi=20(gap-fill;=20JSON-first=20d?= =?UTF-8?q?etection,=20yaml=5Fserde=20dep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_openapi's spec already stated the contract as "JSON/YAML doc" but the implementation only delivered the JSON half. vast.ai publishes a YAML OpenAPI schema, which surfaces the gap. This is gap-filling against an existing constraint — no architecture invariants touched (OpenAPISpec stays serde_json::Value-based, forwarding handler / no-env-vars / error fidelity / to_openapi output unchanged). ADR-051 records: (1) from_yaml + format-detecting from_str constructors; (2) JSON-first/YAML-fallback detection as a correctness rule (YAML 1.1 coerces yes/no/on/off to booleans — running JSON through YAML would silently corrupt string fields); (3) yaml_serde dependency (maintained official-YAML-org fork of deprecated serde_yaml, not feature-gated); (4) to_openapi output stays JSON (scope boundary). Specs updated to reference ADR-051 by number (http-adapters, overview, both README ADR tables). Implementation task added at tasks/http/adapters/from-openapi-yaml-input.md. --- docs/architecture/README.md | 5 +- docs/architecture/crates/http/README.md | 6 +- .../architecture/crates/http/http-adapters.md | 45 ++++- docs/architecture/crates/http/overview.md | 9 +- .../051-yaml-input-for-from-openapi.md | 187 ++++++++++++++++++ .../http/adapters/from-openapi-yaml-input.md | 180 +++++++++++++++++ 6 files changed, 420 insertions(+), 12 deletions(-) create mode 100644 docs/architecture/decisions/051-yaml-input-for-from-openapi.md create mode 100644 tasks/http/adapters/from-openapi-yaml-input.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4345677..f8289ed 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-07-05 +last_updated: 2026-07-06 --- # Alknet Architecture @@ -41,7 +41,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c | [crates/http/overview.md](crates/http/overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map | | [crates/http/http-server.md](crates/http/http-server.md) | draft | HttpAdapter for h2/http1.1 + WebSocket upgrade route, axum over QUIC, Bearer auth, stealth, /healthz | | [crates/http/websocket.md](crates/http/websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape); framing, dispatch, bidirectionality, connection-local overlay, browsers-are-not-peers, deferred `from_wss` | -| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest) and to_openapi (projection); no-env-vars injection point | +| [crates/http/http-adapters.md](crates/http/http-adapters.md) | draft | from_openapi (reqwest; JSON + YAML input per ADR-051) and to_openapi (projection); no-env-vars injection point | | [crates/http/http-mcp.md](crates/http/http-mcp.md) | draft | from_mcp / to_mcp (feature-gated), streamable-HTTP-only, stdio exclusion | | [crates/http/webtransport.md](crates/http/webtransport.md) | deferred | h3/WebTransport handler — deferred per ADR-044; browser bidirectional path uses WebSocket (see http-server.md). Spec kept intact for revival. | | [crates/vault/README.md](crates/vault/README.md) | stable | alknet-vault crate index | @@ -104,6 +104,7 @@ The alknet-call crate is **implemented and reviewed** — both the server-side c | [048](decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | Accepted | | [049](decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | Accepted | | [050](decisions/050-dynamic-resource-ownership-for-runtime-spawned-resources.md) | Dynamic Resource Ownership for Runtime-Spawned Resources | Accepted | +| [051](decisions/051-yaml-input-for-from-openapi.md) | YAML Input Format for from_openapi | Accepted | ## Open Questions diff --git a/docs/architecture/crates/http/README.md b/docs/architecture/crates/http/README.md index c5eb7c2..090f748 100644 --- a/docs/architecture/crates/http/README.md +++ b/docs/architecture/crates/http/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-30 +last_updated: 2026-07-06 --- # alknet-http @@ -19,7 +19,7 @@ protocol), and hosts the HTTP-backed call-protocol adapters | [overview.md](overview.md) | draft | Crate purpose, two roles (server + client host), dependencies, adapter location map | | [http-server.md](http-server.md) | draft | `HttpAdapter` (`ProtocolHandler` for `h2`/`http/1.1` + WS upgrade route), axum over QUIC, Bearer auth, stealth, `/healthz`; WS hands off to the native session spec | | [websocket.md](websocket.md) | draft | WebSocket browser bidirectional path — native `EventEnvelope` call-protocol session (not the gateway shape, ADR-048); framing, dispatch, bidirectionality, connection-local Layer 2 overlay, browsers-are-not-peers rationale, streaming (native `call.responded`, no SSE), deferred `from_wss` adapter | -| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client) and `to_openapi` (OpenAPI projection); no-env-vars invariant point | +| [http-adapters.md](http-adapters.md) | draft | `from_openapi` (reqwest client; JSON + YAML input per ADR-051) and `to_openapi` (OpenAPI projection); no-env-vars invariant point | | [http-mcp.md](http-mcp.md) | draft | `from_mcp` / `to_mcp` (feature-gated), streamable-HTTP-only, stdio exclusion | | [webtransport.md](webtransport.md) | deferred | `h3`/WebTransport handler — **deferred per ADR-044**; spec kept intact for revival | @@ -53,6 +53,8 @@ protocol), and hosts the HTTP-backed call-protocol adapters | [046](../../decisions/046-assembly-layer-custom-http-routes.md) | Assembly-Layer Custom HTTP Routes on HttpAdapter | `extra_routes: Option` at construction; deployments add raw HTTP endpoints (e.g., OAI-compatible proxy, or a REST-like per-operation projection) that coexist with the default surface; default surface takes precedence on collision | | [047](../../decisions/047-remove-direct-call-http-surface.md) | Remove the Direct-Call HTTP Surface; Gateway Is the Sole Invoke Path | `POST /{service}/{op}` direct-call surface removed; the 5 gateway endpoints are the sole invoke path; per-caller `AccessControl`-filtered `/search` is the discovery; ADR-036's non-routing clauses survive | | [048](../../decisions/048-websocket-native-session-not-gateway.md) | WebSocket Carries the Native Call-Protocol Session, Not the Gateway Shape | WS is the native `EventEnvelope` session; the gateway endpoints (`/search`/`/schema`/`/call`/`/batch`/`/subscribe`) are HTTP-only and do not appear on WS; discovery via `services/list`/`services/schema` as call-protocol ops | +| [049](../../decisions/049-streaming-handler-for-subscriptions.md) | Streaming Handler for Subscription Operations | `from_openapi` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`); SSE → `BoxStream` | +| [051](../../decisions/051-yaml-input-for-from-openapi.md) | YAML Input Format for from_openapi | `from_openapi` accepts JSON and YAML (`from_json`/`from_yaml`/`from_str`); `from_str` is JSON-first/YAML-fallback (correctness rule, §2); YAML dep is `yaml_serde` (maintained fork of deprecated `serde_yaml`); `to_openapi` output stays JSON (out of scope, §4) | ## Relevant Open Questions diff --git a/docs/architecture/crates/http/http-adapters.md b/docs/architecture/crates/http/http-adapters.md index aecbd11..a5cc0b8 100644 --- a/docs/architecture/crates/http/http-adapters.md +++ b/docs/architecture/crates/http/http-adapters.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-07-02 +last_updated: 2026-07-06 --- # HTTP Adapters — from_openapi and to_openapi @@ -46,15 +46,27 @@ impl OperationAdapter for FromOpenAPI { #### Type definitions ```rust -/// A parsed OpenAPI document. The concrete type is a two-way-door -/// implementation detail (openapiv3::OpenApi, a local alknet-http -/// type, or a serde_json::Value-based parse); the one-way constraint is -/// that `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and +/// A parsed OpenAPI document. The internal representation is +/// `serde_json::Value`-based (ADR-051 §Assumptions #1) — both JSON and +/// YAML parse paths produce the same `serde_json::Value` tree, then feed +/// the existing `from_value` constructor. A future swap to +/// `openapiv3::OpenApi` is a two-way door: both JSON and YAML constructors +/// adapt in lockstep, since the constructor is the adapter between wire +/// format and internal type. The one-way constraint is that +/// `from_openapi` accepts a standard OpenAPI 3.x JSON/YAML doc and /// `to_openapi` produces one. Both directions share the same Rust type, /// but not the same document shape: `from_openapi` consumes traditional /// per-operation-paths docs (one path per operation), while `to_openapi` /// produces the 5-endpoint gateway doc (ADR-042). The type is shared; /// the shape is not. +/// +/// Input formats (ADR-051): `from_openapi` accepts both JSON and YAML. +/// JSON is parsed via `serde_json`; YAML via `yaml_serde` (the maintained +/// fork of the deprecated `serde_yaml`). Both paths produce the same +/// `serde_json::Value`-based internal type — there is one +/// `OpenAPISpec`, not a JSON and a YAML variant. `from_str` detects +/// format by trying JSON first and falling back to YAML (correctness +/// rule — ADR-051 §2). pub struct OpenAPISpec { pub info: OpenAPIInfo, pub paths: BTreeMap, @@ -62,6 +74,13 @@ pub struct OpenAPISpec { // ... OpenAPI 3.x fields as needed } +impl OpenAPISpec { + pub fn from_json(doc: &str) -> Result; // JSON input + pub fn from_yaml(doc: &str) -> Result; // YAML input + pub fn from_str(doc: &str) -> Result; // format-detecting (JSON-first, YAML-fallback — ADR-051 §2) + pub fn from_value(raw: Value) -> Result; // pre-parsed serde_json::Value +} + /// Configuration for an HTTP-backed adapter (`from_openapi`). Carries /// the base URL, auth credentials (from `Capabilities` at registration, /// not env vars — the no-env-vars invariant), and optional headers. The @@ -85,8 +104,10 @@ pub enum HttpAuthScheme { The adapter: 1. Parses the OpenAPI document (`OpenAPISpec` — `paths`, `components`, - `$ref` resolution). On parse failure, returns - `AdapterError::SchemaParse`. The TS prior art + `$ref` resolution). Accepts JSON or YAML (ADR-051 — JSON via + `serde_json`, YAML via `yaml_serde`; `from_str` detects format + JSON-first/YAML-fallback, a correctness rule — ADR-051 §2). On parse + failure, returns `AdapterError::SchemaParse`. The TS prior art (`@alkdev/operations/src/from_openapi.ts`) shows the parsing patterns: `resolveRef` for `$ref`, `resolveRefsRecursive` for nested refs, `buildInputSchema` (parameters + request body → input JSON Schema), @@ -396,8 +417,17 @@ once published, the 5-endpoint gateway shape is one-way. The handler that composes them is `External`. - **`from_openapi` error codes are prefixed `HTTP_`.** No collision with protocol-level codes (ADR-023, review #002 W20). +- **`from_openapi` accepts JSON and YAML; `from_str` detects format + JSON-first.** JSON-first is a correctness rule (ADR-051 §2), not a + style preference. `from_str` tries JSON first (strict grammar), falls + back to YAML only if JSON parse fails. `from_json`/`from_yaml` are + the explicit constructors for callers that know the format. - **`to_openapi` is a pure projection.** It consumes the registry, does not produce entries for it. Not an `OperationAdapter`. +- **`to_openapi` output is JSON.** The published gateway doc is served at + `GET /openapi.json`. YAML output is out of scope (ADR-051 §4); the gap + this fills is on the consume side (importing external YAML schemas), + not the publish side. - **Published `to_openapi` specs are compatibility contracts.** The generated gateway doc carries `info.version` (semver) tracking the **gateway endpoint contract**, not the operation set — per-caller @@ -432,6 +462,7 @@ once published, the 5-endpoint gateway shape is one-way. | `to_openapi` gateway pattern | [ADR-042](../../decisions/042-openapi-gateway-pattern.md) | 5 fixed gateway endpoints (search/schema/call/batch/subscribe), not one path per operation; per-caller AccessControl-filtered. Supersedes ADR-036's original `to_openapi` "paths mirror `/{service}/{op}`" clause | | `to_openapi` published-spec versioning | [ADR-045](../../decisions/045-to-openapi-gateway-spec-versioning.md) | `info.version` semver tracks the gateway endpoint contract, not the operation set; consumers detect breaking changes via the major version | | Streaming handler for subscriptions | [ADR-049](../../decisions/049-streaming-handler-for-subscriptions.md) | `from_openapi` `Subscription` ops register a `StreamingHandler` (`HandlerKind::Stream`); SSE response → `BoxStream`; `Query`/`Mutation` stay `HandlerKind::Once` | +| YAML input + JSON-first format detection | [ADR-051](../../decisions/051-yaml-input-for-from-openapi.md) | `from_openapi` accepts JSON and YAML (`from_json`/`from_yaml`/`from_str`); `from_str` is JSON-first/YAML-fallback (correctness rule, §2); YAML dep is `yaml_serde`; `to_openapi` output stays JSON (out of scope, §4) | ## Open Questions diff --git a/docs/architecture/crates/http/overview.md b/docs/architecture/crates/http/overview.md index ac41f30..4322dec 100644 --- a/docs/architecture/crates/http/overview.md +++ b/docs/architecture/crates/http/overview.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-06-30 +last_updated: 2026-07-06 --- # alknet-http — Overview @@ -90,6 +90,7 @@ alknet-http ├── axum (HTTP server — Router, extractors, middleware, WebSocket upgrade) ├── reqwest (HTTP client — from_openapi/from_mcp forwarding) ├── hyper (HTTP/1.1 + HTTP/2 framing; axum is built on hyper) +├── yaml_serde (YAML parse for from_openapi YAML input — ADR-051; maintained fork of serde_yaml) └── rmcp (MCP streamable HTTP — feature-gated behind `mcp`) ``` @@ -199,6 +200,12 @@ browser bidirectional path is WebSocket, native to axum, no `h3` feature gate needed. A deployment that wants MCP tool import/export enables `mcp`. +**`yaml_serde` is not feature-gated** — YAML OpenAPI is a first-class +input format (vast.ai publishes a YAML schema), not an edge case. A +deployment that imports a YAML-publishing provider must not have to +remember a feature flag (ADR-051 §3). The dependency is small (pure +Rust, no native code), consistent with the default-features philosophy. + ## The No-Env-Vars Invariant The `from_openapi`/`from_mcp` forwarding handlers are the **credential diff --git a/docs/architecture/decisions/051-yaml-input-for-from-openapi.md b/docs/architecture/decisions/051-yaml-input-for-from-openapi.md new file mode 100644 index 0000000..498dab2 --- /dev/null +++ b/docs/architecture/decisions/051-yaml-input-for-from-openapi.md @@ -0,0 +1,187 @@ +# ADR-051: YAML Input Format for from_openapi + +## Status + +Accepted + +## Context + +`from_openapi` imports external HTTP APIs as call-protocol operations by +parsing an OpenAPI document. The `http-adapters.md` spec 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`: + +```rust +impl OpenAPISpec { + pub fn from_json(doc: &str) -> Result; // existing + pub fn from_yaml(doc: &str) -> Result; // new — parses YAML + pub fn from_str(doc: &str) -> Result; // new — detects format + pub fn from_value(raw: Value) -> Result; // 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 correctness rule, not a style preference + +YAML 1.1 (the version the maintained Rust YAML crates implement — see §3) +treats the bare tokens `yes`/`no`/`on`/`off`/`y`/`n` as booleans, and `1.0` +as a float. OpenAPI specs routinely have **string** fields with these values +(e.g., a query parameter named `active` with value `"yes"`, or an enum of +`["on", "off", "auto"]`). If a JSON document is parsed through the YAML +parser, `{"active": "yes"}` silently becomes `{"active": true}` — the +string is lost, the schema is wrong, and the failure is silent (no error, +just a mutated value that the forwarding handler then sends to the external +API as a boolean where a string was expected). + +This is a correctness footgun, not a matter of error-message quality. +`from_str` therefore tries JSON first: a JSON parse succeeds for any valid +JSON document, and JSON's stricter grammar (`"yes"` is a string, `true` is a +boolean — no ambiguity) avoids the YAML 1.1 coercion. Only if JSON parse +fails does `from_str` fall back to YAML. 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 net result: JSON docs are never exposed to YAML's type +coercion, and YAML docs are parsed as YAML. + +`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 its coercion rules. 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, but with the same +silent string→boolean coercion as the `from_str` footgun above +(`{"active": "yes"}` becomes `{"active": true}`, no error). 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](https://github.com/yaml) 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. `alknet-http` 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 `alknet-http` +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. + +### 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 prevents the YAML 1.1 boolean-coercion footgun from + silently corrupting JSON docs. The rule is a correctness guard, not + cosmetic. +- 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 `alknet-http`. 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 correctness benefit (never running + JSON through YAML coercion) 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 1.1 coercion is the only material difference for OpenAPI parsing.** + YAML 1.2 (where JSON is a true subset and `yes`/`no` are strings) would + make parse-everything-as-YAML safe. The maintained Rust crates are YAML + 1.1; this ADR's JSON-first rule is the workaround. If a YAML 1.2 Rust + parser becomes the obvious choice later, the JSON-first rule becomes a + style preference rather than a correctness guard — but the rule stays + (no reason to run JSON through a YAML parser). + +## References + +- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — + `from_openapi` is an `OperationAdapter`; published `to_*` specs are + compatibility contracts (the publish side stays JSON) +- [ADR-023](023-operation-error-schemas.md) — error fidelity is unaffected + (error schemas come from the parsed `OpenAPISpec`, format-independent) +- [ADR-039](039-http-server-and-client-host-colocated.md) — `alknet-http` + owns both HTTP directions and their dependencies +- [http-adapters.md](../crates/http/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) +- `yaml_serde` crate (https://github.com/yaml/yaml-serde) — the maintained + official-YAML-org fork of the deprecated `serde_yaml` \ No newline at end of file diff --git a/tasks/http/adapters/from-openapi-yaml-input.md b/tasks/http/adapters/from-openapi-yaml-input.md new file mode 100644 index 0000000..87586ef --- /dev/null +++ b/tasks/http/adapters/from-openapi-yaml-input.md @@ -0,0 +1,180 @@ +--- +id: http/adapters/from-openapi-yaml-input +name: Add YAML input format to from_openapi (from_yaml + format-detecting from_str, JSON-first per ADR-051) +status: pending +depends_on: [] +scope: narrow +risk: low +impact: component +level: implementation +--- + +## Description + +Add YAML OpenAPI document support to `from_openapi`. The spec already +stated the contract as "JSON/YAML doc" but the implementation only ever +delivered the JSON half (`OpenAPISpec::from_json`). YAML was always +intended; vast.ai's `openapi.yaml` surfaces the gap. This is gap-filling +against an existing constraint, not a new direction — no architecture +invariants are touched (`OpenAPISpec` stays `serde_json::Value`-based, +forwarding handler unchanged, no-env-vars unchanged, error fidelity +unchanged, `to_openapi` output stays JSON). See ADR-051 for the full +rationale. + +### The three new constructors (ADR-051 §1) + +`OpenAPISpec` gains two constructors alongside the existing `from_json`: + +```rust +impl OpenAPISpec { + pub fn from_json(doc: &str) -> Result; // existing + pub fn from_yaml(doc: &str) -> Result; // new — parses YAML + pub fn from_str(doc: &str) -> Result; // new — detects format + pub fn from_value(raw: Value) -> Result; // existing, unchanged +} +``` + +`from_yaml` parses YAML to `serde_json::Value` via `yaml_serde`, then +feeds the existing `from_value` path — there is one `OpenAPISpec`, not a +JSON and a YAML variant. `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). + +### JSON-first format detection — a correctness rule, not style (ADR-051 §2) + +`from_str`'s detection rule is **JSON-first, YAML-fallback**: attempt +`serde_json::from_str`; if it parses, use the result; if it fails, +attempt YAML. This ordering is a correctness guard, not a style preference. +YAML 1.1 (the version the maintained Rust YAML crates implement) coerces +the bare tokens `yes`/`no`/`on`/`off`/`y`/`n` to booleans. OpenAPI specs +routinely have **string** fields with these values (e.g., a query +parameter named `active` with value `"yes"`, or an enum of +`["on", "off", "auto"]`). If a JSON document is parsed through the YAML +parser, `{"active": "yes"}` silently becomes `{"active": true}` — the +string is lost, the schema is wrong, and the failure is silent (no error, +just a mutated value the forwarding handler then sends as a boolean +where a string was expected). JSON-first avoids this: a JSON parse +succeeds for any valid JSON doc, and JSON's stricter grammar (`"yes"` is +a string, `true` is a boolean — no ambiguity) avoids YAML 1.1 coercion. +Only if JSON parse fails does `from_str` fall back to YAML. A YAML-only +doc (with `openapi: 3.0.0` at the top, no JSON braces) fails JSON parse +immediately and goes to the YAML path. Net result: JSON docs are never +exposed to YAML's type coercion; YAML docs are parsed as YAML. + +`from_yaml` (the explicit constructor) does **not** try JSON first — the +caller has declared the format. 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) but with the same silent string→boolean coercion as +the `from_str` footgun above. The caller opted in by naming the format; +`from_str` exists for the unsure caller. + +### Dependency: yaml_serde (ADR-051 §3) + +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. `alknet-http` uses the direct form +(`yaml_serde = "0.10"`, `use yaml_serde::`). + +Add to `crates/alknet-http/Cargo.toml`: + +```toml +yaml_serde = "0.10" +``` + +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 (pure Rust, no +native code). + +### to_openapi output stays JSON (ADR-051 §4 — scope boundary) + +This task is consume-side only. `to_openapi` generates the published +gateway doc, served at `GET /openapi.json`, and stays JSON. YAML publish +output is out of scope; it would be a separate ADR if a concrete consumer +requires it. Do not add a `GET /openapi.yaml` endpoint or a YAML serializer +to `to_openapi`. + +### What this task does NOT do + +- **No forwarding handler changes.** The handler operates on the parsed + `OpenAPISpec`, which is format-independent. JSON and YAML both produce + the same `serde_json::Value`-based internal type. +- **No `to_openapi` changes.** Output stays JSON. +- **No `from_mcp`/`to_mcp` changes.** Unrelated adapters. +- **No provenance / visibility / error-fidelity changes.** All + unchanged — they operate on the parsed spec, not the wire format. +- **No new ADRs or OQs.** ADR-051 is Accepted; this task implements it. + +## Acceptance Criteria + +- [ ] `yaml_serde = "0.10"` added to `crates/alknet-http/Cargo.toml` + `[dependencies]` (not feature-gated) +- [ ] `OpenAPISpec::from_yaml(doc: &str) -> Result` + — parses YAML to `serde_json::Value` via `yaml_serde::from_str`, + then feeds the existing `from_value` path +- [ ] `OpenAPISpec::from_str(doc: &str) -> Result` + — tries `serde_json::from_str` first; on failure, falls back to + `from_yaml`. This is the correctness guard. +- [ ] `from_json` and `from_value` unchanged (existing behavior preserved) +- [ ] New constructors re-exported from `adapters/mod.rs` if needed + (they're methods on the already-re-exported `OpenAPISpec`, so likely + no change required — verify) +- [ ] YAML parse failure → `AdapterError::SchemaParse` (same variant as + JSON parse failure; the error message should name YAML) +- [ ] Unit test: `from_yaml` parses a minimal YAML OpenAPI doc → one + `HandlerRegistration` (mirror of `import_minimal_doc_yields_one_registration` + with a YAML fixture) +- [ ] Unit test: `from_yaml` parses a YAML doc with `$ref` resolution + (mirror of `ref_resolution_in_input_schema` with YAML) +- [ ] Unit test: `from_str` on a JSON doc → succeeds via the JSON path +- [ ] Unit test: `from_str` on a YAML doc (no JSON braces) → succeeds via + the YAML fallback path +- [ ] Unit test: **the correctness guard** — `from_str` on a JSON doc + with a string field `"yes"` preserves it as a string (not coerced + to boolean by the YAML path). E.g., + `{"active": "yes"}` → `input_schema` has `"active"` as a string, + not a boolean. This is the test that proves JSON-first works. +- [ ] Unit test: `from_yaml` on a JSON doc with `"yes"` string → the + string IS coerced to boolean (documents the opt-in behavior — the + caller explicitly chose YAML, so YAML's rules apply) +- [ ] Unit test: malformed YAML → `AdapterError::SchemaParse` +- [ ] Unit test: `from_str` on malformed-both (not valid JSON, not valid + YAML) → `AdapterError::SchemaParse` +- [ ] Existing JSON tests still pass (no regression) +- [ ] `cargo test -p alknet-http` succeeds +- [ ] `cargo clippy -p alknet-http --all-targets` succeeds with no warnings +- [ ] `cargo fmt --check -p alknet-http` passes + +## References + +- docs/architecture/decisions/051-yaml-input-for-from-openapi.md — ADR-051 + (full rationale: API shape, JSON-first correctness rule, yaml_serde + choice, to_openapi stays JSON) +- docs/architecture/crates/http/http-adapters.md — §"Type definitions" + (constructor signatures), §Constraints (the two new bullets), §Design + Decisions table row +- docs/architecture/crates/http/overview.md — dependency list (yaml_serde + added), Feature Gates section (yaml_serde not feature-gated) +- crates/alknet-http/src/adapters/from_openapi.rs — existing `from_json` + and `from_value` to mirror; existing tests to mirror with YAML fixtures +- crates/alknet-http/Cargo.toml — add the `yaml_serde` dependency +- https://github.com/yaml/yaml-serde — `yaml_serde` crate (maintained + official-YAML-org fork of the deprecated `serde_yaml`) + +## Notes + +> The non-obvious part is the JSON-first detection rule — it's a +> correctness guard against YAML 1.1 boolean coercion, not a style +> preference. The test that matters most is the one proving a JSON doc +> with `"yes"` string fields survives `from_str` unchanged (JSON path +> wins, no YAML coercion). The complementary test documents that +> `from_yaml` (explicit) *does* coerce — the caller opted in. The +> existing `from_json`/`from_value` constructors and the entire +> forwarding-handler / error-fidelity / no-env-vars path are unchanged — +> this task only adds new parse entry points into the same internal type. +> `to_openapi` output stays JSON (the gap is consume-side, not +> publish-side). No feature gate on `yaml_serde` — YAML OpenAPI is +> first-class. \ No newline at end of file