feat(adapters): YAML input normalization — duplicates, .inf/.nan, merge keys, non-string keys (OAI-12)

from_yaml previously went straight yaml_serde::from_str::<serde_json::Value>
with zero post-parse normalization. Three verified corruptions flowed
through unimpeded:

- duplicate mapping keys silently last-won (serde_json's visit_map
  insert semantics — verified against 1.0.151: the JSON path last-wins
  too, so the YAML path is now deliberately the stricter one);
- .inf/-.inf/.nan scalars silently became Value::Null because
  serde_json::Number::from_f64(non-finite) is None;
- YAML 1.1 merge keys (<<: *anchor) survived as literal '<<' properties;
- non-string mapping keys (null keys, collection keys) either stringified
  through YAML's debug rendering or panicked the conversion.

The new from_yaml pipeline: explicit yaml_serde::Value parse (native
loud duplicate-key rejection with line/column) -> apply_merge() (merge
keys applied, shallow per yaml_serde semantics; scalar/invalid merge
values fail loudly) -> one structural normalization pass into
serde_json::Value that rejects non-finite floats and non-string keys
with the offending JSON pointer, and stringifies scalar keys exactly as
the YAML 1.2 core schema renders them (200: -> "200", matching the
JSON path's {"200": ...}).

The walk stays inside yaml_serde's own parse-time bounds (recursion
limit 128, alias jump limit, RepetitionLimitExceeded); no new budgets
and no resolver changes. 14 seam tests added at the from_yaml boundary.

Verification: cargo test 366 passed / 0 failed; clippy
--all-targets -D warnings clean; fmt --check clean (scripts/verify.sh).
This commit is contained in:
2026-08-30 22:37:34 +00:00
parent 8261fefd8f
commit 1728b4881e
+540 -1
View File
@@ -3,11 +3,41 @@
//! (produce). JSON + YAML parsing (ADR-051: `yaml_serde` 0.10.x is YAML //! (produce). JSON + YAML parsing (ADR-051: `yaml_serde` 0.10.x is YAML
//! 1.2; JSON-first detection under `from_str`), plus `$ref` resolution //! 1.2; JSON-first detection under `from_str`), plus `$ref` resolution
//! against the raw document. //! against the raw document.
//!
//! # YAML/JSON parity contract (review 002 OAI-12)
//!
//! Both entry points must mean the same thing for the same document, or
//! the difference must fail loudly:
//!
//! - **Duplicate keys** — rejected on the YAML path, loudly, with the
//! key and its line/column. The JSON path inherits `serde_json`'s
//! `Map::insert` semantics and silently last-wins; the YAML path is
//! deliberately stricter, so a duplicate-key document can never
//! silently mean different things through the two entry points (the
//! YAML side fails instead of agreeing with the last value).
//! - **Merge keys** (`<<: *anchor`) — YAML 1.1-style merge keys are
//! *applied* via `apply_merge()` and never advertised as literal
//! properties. This is a deliberate, tested deviation from strict
//! YAML 1.2 core-schema processing.
//! - **Non-finite floats** (`.inf`/`-.inf`/`.nan`) — rejected loudly.
//! `serde_json::Number` cannot represent them; the naive passthrough
//! silently nulls the value, so [`from_yaml`](OpenAPISpec::from_yaml)
//! errors with the JSON pointer of the offending value instead.
//! - **Non-string mapping keys** — a non-string key that has no
//! round-trip-stable string form (e.g. `~`, `[a]: 1`) is rejected;
//! scalar keys are stringified exactly as the YAML 1.2 core schema
//! renders them (`200:` → `"200"`), matching what the JSON path
//! requires (`{"200": ...}`) — so response codes, the shape OpenAPI
//! overwhelmingly uses, mean the same thing in both formats.
//! - **Bare `yes`/`no`/`on`/`off` and tags** — identical behavior on
//! both paths by YAML 1.2 core schema (strings, `!!str 200` →
//! `"200"`); unknown tags fail loudly on both.
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use alkcall::client::AdapterError; use alkcall::client::AdapterError;
use serde_json::Value; use serde_json::Value;
use yaml_serde::Value as YamlValue;
/// Maximum structural nesting depth for recursive `$ref` resolution /// Maximum structural nesting depth for recursive `$ref` resolution
/// (review 001 OAI-01). Bounds schema object/array height so a /// (review 001 OAI-01). Bounds schema object/array height so a
@@ -138,6 +168,105 @@ pub struct Components {
pub request_bodies: HashMap<String, Value>, pub request_bodies: HashMap<String, Value>,
} }
/// Converts a merge-applied YAML document into the shared
/// `serde_json::Value` representation, rejecting loudly what the
/// conversion would otherwise silently corrupt (review 002 OAI-12):
///
/// - **Duplicate keys** are rejected before conversion — the input
/// comes from an explicit `yaml_serde::Value` parse, whose `Mapping`
/// insertion rejects duplicates natively (with line/column) where the
/// direct `serde_json::Value` path silently last-wins.
/// - **Non-finite floats** (`yaml_serde::Number` carries
/// `.inf`/`-.inf`/`.nan`, `serde_json::Number` cannot) fail with the
/// value's JSON pointer instead of silently becoming `null`.
/// - **Non-string mapping keys** are stringified exactly as the YAML 1.2
/// core schema renders the scalar (`200:` → `"200"`, matching the JSON
/// path's `{"200": ...}`); keys with no round-trip-stable string form
/// (null keys, sequence/map keys) fail with the key's JSON pointer.
///
/// Recursion follows document structure (already bounded by
/// yaml_serde's parse-time recursion limit) and terminates because
/// YAML mappings are acyclic after alias expansion.
fn yaml_to_json_value(value: &YamlValue) -> Result<Value, String> {
match value {
YamlValue::Null => Ok(Value::Null),
YamlValue::Bool(b) => Ok(Value::Bool(*b)),
YamlValue::Number(n) => yaml_number_to_json(n),
YamlValue::String(s) => Ok(Value::String(s.clone())),
YamlValue::Tagged(tagged) => yaml_to_json_value(&tagged.value),
YamlValue::Sequence(items) => {
let mut out = Vec::with_capacity(items.len());
for (index, item) in items.iter().enumerate() {
out.push(
yaml_to_json_value(item)
.map_err(|detail| format!("/<sequence-element-{index}>: {detail}"))?,
);
}
Ok(Value::Array(out))
}
YamlValue::Mapping(map) => {
let mut out = serde_json::Map::new();
for (key, val) in map {
let ptr = yaml_key_pointer(key)?;
let converted =
yaml_to_json_value(val).map_err(|detail| format!("{ptr}: {detail}"))?;
out.insert(ptr, converted);
}
Ok(Value::Object(out))
}
}
}
/// Converts one YAML number into a `serde_json::Number`, rejecting
/// non-finite floats loudly (OAI-12): `Number::from_f64(∞)` is `None`,
/// so an unguarded conversion would advertise `null` where the document
/// declares `maximum: .inf`.
fn yaml_number_to_json(number: &yaml_serde::Number) -> Result<Value, String> {
if let Some(integer) = number.as_i64() {
return Ok(Value::Number(serde_json::Number::from(integer)));
}
if let Some(unsigned) = number.as_u64() {
return Ok(Value::Number(serde_json::Number::from(unsigned)));
}
let float = number.as_f64().ok_or_else(|| {
"number is out of range for JSON (neither i64, u64, nor a finite f64)".to_string()
})?;
let finite = serde_json::Number::from_f64(float)
.ok_or_else(|| format!("{float} is not representable in JSON (.inf/.nan)"))?;
Ok(Value::Number(finite))
}
/// Renders one YAML mapping key as its `serde_json::Map` string key,
/// preserving the YAML 1.2 core schema's scalar rendering so numeric
/// keys match what the JSON path requires (`200:` → `"200"`). Keys
/// without a round-trip-stable string form are rejected loudly:
/// nullish keys (`~`, empty) and collection keys (sequences, mappings).
fn yaml_key_pointer(key: &YamlValue) -> Result<String, String> {
match key {
YamlValue::String(s) => Ok(s.clone()),
YamlValue::Bool(b) => Ok(b.to_string()),
YamlValue::Number(n) => {
if let Some(integer) = n.as_i64() {
return Ok(integer.to_string());
}
if let Some(unsigned) = n.as_u64() {
return Ok(unsigned.to_string());
}
match n.as_f64().and_then(serde_json::Number::from_f64) {
Some(finite) => Ok(finite.to_string()),
None => Err(format!(
"mapping key {n:?} is a non-finite number with no string form"
)),
}
}
YamlValue::Tagged(tagged) => yaml_key_pointer(&tagged.value),
other => Err(format!(
"mapping key {other:?} has no string form; only string, number, and boolean \
keys can be represented in an OpenAPI document"
)),
}
}
fn index_component_map(raw: Option<&Value>) -> HashMap<String, Value> { fn index_component_map(raw: Option<&Value>) -> HashMap<String, Value> {
let mut map = HashMap::new(); let mut map = HashMap::new();
if let Some(obj) = raw.and_then(|m| m.as_object()) { if let Some(obj) = raw.and_then(|m| m.as_object()) {
@@ -186,10 +315,33 @@ impl OpenAPISpec {
/// to a `serde_json::Value` and then fed through /// to a `serde_json::Value` and then fed through
/// [`from_value`](Self::from_value), so there is one internal /// [`from_value`](Self::from_value), so there is one internal
/// `OpenAPISpec` representation shared with the JSON path. /// `OpenAPISpec` representation shared with the JSON path.
///
/// The parse runs in two steps, both bounded by `yaml_serde`'s own
/// recursion/alias limits (OAI-12): an explicit
/// `yaml_serde::Value` parse — so duplicate keys are rejected
/// natively with line/column, instead of silently last-winning
/// through the direct-to-`serde_json::Value` path — followed by
/// [`apply_merge`](yaml_serde::Value::apply_merge) (YAML 1.1-style
/// `<<: *anchor` merge keys are **applied**, matching the
/// merge-key.html behavior users authoring YAML anchors expect; ADR-051
/// declares YAML 1.2 core schema for scalars, and this is the one
/// deliberate deviation), then a normalization pass into
/// `serde_json::Value` that rejects loudly what `serde_json::Number`
/// cannot represent: non-finite floats (`.inf`/`.nan` would silently
/// null) and non-string mapping keys with no round-trip-stable
/// string form. Every rejection names the JSON pointer of the
/// offending value.
pub fn from_yaml(doc: &str) -> Result<Self, AdapterError> { pub fn from_yaml(doc: &str) -> Result<Self, AdapterError> {
let raw: Value = yaml_serde::from_str(doc).map_err(|e| AdapterError::SchemaParse { let yaml: YamlValue = yaml_serde::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid YAML: {e}"), message: format!("invalid YAML: {e}"),
})?; })?;
let mut yaml = yaml;
yaml.apply_merge().map_err(|e| AdapterError::SchemaParse {
message: format!("invalid YAML merge key: {e}"),
})?;
let raw = yaml_to_json_value(&yaml).map_err(|message| AdapterError::SchemaParse {
message: format!("invalid YAML: {message}"),
})?;
Self::from_value(raw) Self::from_value(raw)
} }
@@ -1551,6 +1703,393 @@ mod tests {
); );
} }
// --- OAI-12: YAML input normalization -----------------------------------
const OAI12_HEADER: &str = r#"
openapi: 3.0.0
info:
title: T
version: "1"
"#;
#[test]
fn from_yaml_duplicate_keys_fail_loudly_not_last_win() {
let doc = r#"
openapi: 3.0.0
info:
title: T
version: "1"
description: first
description: second
paths:
/x:
get:
operationId: x
responses:
"200":
content:
application/json:
schema: {}
"#;
match OpenAPISpec::from_yaml(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("duplicate"),
"the error must name the duplicate: {message}"
);
assert!(
message.contains("line") || message.contains("column"),
"the error must carry the document position: {message}"
);
}
Ok(_) => panic!("duplicate YAML keys must fail loudly, not last-win"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_duplicate_keys_stricter_than_json_path() {
let dup_json = r#"{"a":1,"a":2}"#;
let json_last_wins = serde_json::from_str::<Value>(dup_json)
.expect("serde_json's visit_map last-wins (verified against 1.0.151)");
assert_eq!(
json_last_wins["a"], 2,
"the JSON path silently last-wins; this assertion documents the asymmetry \
the YAML path refuses to reproduce"
);
let yaml_doc = "a: 1\na: 2";
match OpenAPISpec::from_yaml(yaml_doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("duplicate"), "message was: {message}");
}
Ok(_) => panic!("YAML duplicate keys must fail loudly, not last-win"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_inf_maximum_fails_loudly_not_silent_null() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Price:
type: object
properties:
cap:
type: number
maximum: .inf
"#
);
match OpenAPISpec::from_yaml(&doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains(".inf") || message.contains("inf"),
"the error must name the non-finite value: {message}"
);
assert!(
message.contains("maximum") || message.contains("schemas"),
"the error must carry the value's JSON pointer: {message}"
);
}
Ok(spec) => {
let maximum =
&spec.raw["components"]["schemas"]["Price"]["properties"]["cap"]["maximum"];
assert_ne!(
*maximum,
Value::Null,
"maximum: .inf silently nulled — the corruption OAI-12 fixes"
);
panic!("non-finite float must fail loudly, not null");
}
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_nan_value_fails_loudly() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Ratio:
type: object
properties:
value:
type: number
example: .nan
"#
);
match OpenAPISpec::from_yaml(&doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains(".nan") || message.contains("NaN"),
"the error must name the non-finite value: {message}"
);
}
Ok(_) => panic!(".nan must fail loudly, not null"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_merge_key_is_applied_not_advertised() {
let doc = r#"
openapi: 3.0.0
info:
title: T
version: "1"
components:
schemas:
Base: &Base
type: object
required: [id]
properties:
id:
type: string
Widget:
<<: *Base
title: Widget
paths:
/x:
get:
operationId: x
responses:
"200":
content:
application/json:
schema: {}
"#;
let spec = OpenAPISpec::from_yaml(doc).expect("merge keys apply");
let widget = &spec.raw["components"]["schemas"]["Widget"];
assert!(
widget.get("<<").is_none(),
"<< must never survive as a literal property"
);
assert_eq!(widget["type"], "object", "merged from Base");
let required = widget["required"].as_array().expect("required merged");
assert_eq!(required[0], "id");
let props = widget["properties"].as_object().expect("props merged");
assert_eq!(props["id"]["type"], "string", "base properties merged in");
assert_eq!(
widget["title"], "Widget",
"the referencing mapping's own keys are kept"
);
}
#[test]
fn from_yaml_merge_key_local_override_wins_over_base() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Base: &Base
type: object
description: base description
properties:
id:
type: string
Widget:
<<: *Base
description: widget description
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("merge keys apply");
let widget = &spec.raw["components"]["schemas"]["Widget"];
assert_eq!(widget["description"], "widget description");
assert_eq!(
widget["type"], "object",
"keys the referencing mapping does not declare come from the merge"
);
}
#[test]
fn from_yaml_scalar_merge_value_fails_loudly() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Broken:
<<: 5
paths: {{}}
"#
);
match OpenAPISpec::from_yaml(&doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("merge"),
"the error must name the merge-key failure: {message}"
);
}
Ok(_) => panic!("`<<:` with a scalar value must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_quoted_status_keys_survive_unchanged() {
let doc = r#"
openapi: 3.0.0
info:
title: T
version: "1"
paths:
/x:
get:
operationId: x
responses:
"200":
description: ok
content:
application/json:
schema: {}
"404":
description: missing
"#;
let spec = OpenAPISpec::from_yaml(doc).expect("quoted keys unchanged");
let responses = &spec.paths["/x"].operations[0].1.responses;
assert!(responses.contains_key("200"));
assert!(responses.contains_key("404"));
assert_eq!(
&spec.raw["paths"]["/x"]["get"]["responses"]["200"]["description"],
"ok"
);
}
#[test]
fn from_yaml_unquoted_status_keys_match_json_path_shape() {
let doc = format!(
r#"{OAI12_HEADER}
paths:
/x:
get:
operationId: x
responses:
200:
description: ok
404:
description: missing
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("bare numeric keys round-trip");
let responses = &spec.paths["/x"].operations[0].1.responses;
assert!(
responses.contains_key("200") && responses.contains_key("404"),
"unquoted response codes must mean the same thing as quoted/JSON ones"
);
}
#[test]
fn from_yaml_null_and_collection_keys_fail_loudly() {
let doc = "~: 1";
match OpenAPISpec::from_yaml(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("no string form") || message.contains("key"),
"the error must name the unusable key: {message}"
);
}
Ok(_) => panic!("null mapping key must fail loudly, not stringily as \"~\""),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_float_key_preserves_rendering() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Mapped:
type: object
additionalProperties:
type: string
x-key-map:
1.5: one-point-five
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("finite float key stringifies");
let key_map = &spec.raw["components"]["schemas"]["Mapped"]["x-key-map"];
assert_eq!(key_map["1.5"], "one-point-five");
}
#[test]
fn from_yaml_bool_key_stringifies() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Mapped:
type: object
x-flags:
true: enabled
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("bool key stringifies");
assert_eq!(
&spec.raw["components"]["schemas"]["Mapped"]["x-flags"]["true"],
"enabled"
);
}
#[test]
fn from_yaml_plain_scalars_unaffected_by_normalization() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
All:
type: object
properties:
yes:
type: string
default: yes
n:
type: integer
maximum: 9007199254740993
f:
type: number
example: 1.5
s:
type: string
example: "200"
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("normal doc unaffected");
let props = &spec.raw["components"]["schemas"]["All"]["properties"];
assert_eq!(props["yes"]["default"], "yes");
assert_eq!(
props["n"]["maximum"], 9007199254740993i64,
"i64 range survives exactly"
);
assert_eq!(props["f"]["example"], 1.5);
assert_eq!(props["s"]["example"], "200");
}
#[test]
fn from_yaml_untagged_bare_scalars_still_round_trip() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
T:
type: object
properties:
a:
type: string
default: !!str 200
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("tagged scalar passes through");
let props = &spec.raw["components"]["schemas"]["T"]["properties"];
assert_eq!(props["a"]["default"], "200");
}
// --- OAI-13: path-item parameters, response wildcards, webhooks --- // --- OAI-13: path-item parameters, response wildcards, webhooks ---
#[test] #[test]