From ff97cb231fe89d567bd9f3f382064239b3e41d6a Mon Sep 17 00:00:00 2001 From: zuub-don Date: Thu, 13 Aug 2026 09:02:02 -0700 Subject: [PATCH 1/2] fix(ingest): accept numeric YAML OpenAPI map keys --- crates/kahea-ingest/src/lib.rs | 67 +++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/crates/kahea-ingest/src/lib.rs b/crates/kahea-ingest/src/lib.rs index c817b89..2b5c8e2 100644 --- a/crates/kahea-ingest/src/lib.rs +++ b/crates/kahea-ingest/src/lib.rs @@ -349,11 +349,7 @@ fn yaml_to_json( Yaml::Hash(values) => { let mut object = Map::new(); for (key, value) in values { - let Yaml::String(key) = key else { - return Err(IngestError::Parse(format!( - "non-string mapping key at {location}" - ))); - }; + let key = yaml_mapping_key_to_string(key, location)?; let child_location = format!("{location}/{}", escape_pointer(&key)); object.insert(key, yaml_to_json(value, &child_location, depth + 1, nodes)?); } @@ -365,6 +361,17 @@ fn yaml_to_json( } } +fn yaml_mapping_key_to_string(key: Yaml, location: &str) -> Result { + match key { + Yaml::String(value) => Ok(value), + Yaml::Integer(value) => Ok(value.to_string()), + Yaml::Real(value) if value.parse::().is_ok_and(f64::is_finite) => Ok(value), + _ => Err(IngestError::Parse(format!( + "non-string mapping key at {location}" + ))), + } +} + fn validate_openapi_version(document: &Value) -> Result<(), IngestError> { let Some(version) = document.get("openapi").and_then(Value::as_str) else { return Err(IngestError::UnsupportedVersion); @@ -570,6 +577,56 @@ paths: assert_eq!(page.next.as_deref(), Some("1")); } + #[test] + fn numeric_yaml_response_status_keys_are_accepted() { + let spec = r#" +openapi: 3.1.0 +info: { title: Status keys, version: 1.0.0 } +paths: + /token: + post: + operationId: getToken + responses: + '200': { description: ok } + 401: { description: unauthorized } + 422: { description: invalid } +"#; + let source = load_openapi(Path::new("status-keys.yaml"), spec.as_bytes()).unwrap(); + let responses = source.document["paths"]["/token"]["post"]["responses"] + .as_object() + .unwrap(); + assert!(responses.contains_key("200")); + assert!(responses.contains_key("401")); + assert!(responses.contains_key("422")); + + let index = + inspect_openapi(Path::new("status-keys.yaml"), spec.as_bytes(), None, 50, 0).unwrap(); + assert_eq!(index.operations.len(), 1); + assert_eq!(index.operations[0].3, "getToken"); + } + + #[test] + fn unsupported_yaml_mapping_keys_are_still_rejected() { + let spec = r#" +openapi: 3.1.0 +info: { title: Boolean key, version: 1.0.0 } +paths: + /health: + get: + operationId: getHealth + responses: + true: { description: ok } +"#; + let error = load_openapi(Path::new("boolean-key.yaml"), spec.as_bytes()) + .expect_err("a boolean mapping key was accepted"); + assert!( + error + .to_string() + .contains("non-string mapping key at #/paths/~1health/get/responses"), + "unexpected rejection: {error}" + ); + } + #[test] fn common_request_artifacts_normalize_to_operations() { let curl = load_source( From dd0953a0fb049264b3a029e1157bed1d7528d33f Mon Sep 17 00:00:00 2001 From: zuub-don Date: Thu, 13 Aug 2026 09:13:57 -0700 Subject: [PATCH 2/2] test(ingest): preserve oversized YAML numeric keys --- crates/kahea-ingest/src/lib.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/kahea-ingest/src/lib.rs b/crates/kahea-ingest/src/lib.rs index 2b5c8e2..bc0ceaa 100644 --- a/crates/kahea-ingest/src/lib.rs +++ b/crates/kahea-ingest/src/lib.rs @@ -365,6 +365,7 @@ fn yaml_mapping_key_to_string(key: Yaml, location: &str) -> Result Ok(value), Yaml::Integer(value) => Ok(value.to_string()), + Yaml::Real(value) if !value.contains(['.', 'e', 'E']) => Ok(value), Yaml::Real(value) if value.parse::().is_ok_and(f64::is_finite) => Ok(value), _ => Err(IngestError::Parse(format!( "non-string mapping key at {location}" @@ -605,6 +606,25 @@ paths: assert_eq!(index.operations[0].3, "getToken"); } + #[test] + fn oversized_integer_yaml_mapping_keys_are_preserved_as_strings() { + let spec = r#" +openapi: 3.1.0 +info: { title: Large key, version: 1.0.0 } +paths: + /token: + post: + operationId: getToken + responses: + 1234567890123456789012345678901234567890: { description: impossible but finite } +"#; + let source = load_openapi(Path::new("large-key.yaml"), spec.as_bytes()).unwrap(); + let responses = source.document["paths"]["/token"]["post"]["responses"] + .as_object() + .unwrap(); + assert!(responses.contains_key("1234567890123456789012345678901234567890")); + } + #[test] fn unsupported_yaml_mapping_keys_are_still_rejected() { let spec = r#"