feat(http): implement ADR-051 YAML input for from_openapi — from_yaml + JSON-first from_str
Adds OpenAPISpec::from_yaml and OpenAPISpec::from_str alongside the existing from_json. from_str is JSON-first, YAML-fallback (ADR-051 §2): a valid JSON doc parses through serde_json (stricter grammar, immune to YAML-specific coercion); only on JSON failure does it fall back to the YAML parser. yaml_serde 0.10 (resolves to 0.10.4, latest stable) is the maintained official-YAML-org fork of the deprecated serde_yaml; not feature-gated per ADR-051 §3 (YAML OpenAPI is first-class). Note: ADR-051 §2's stated YAML 1.1 boolean-coercion rationale does not hold for yaml_serde 0.10 (YAML 1.2 core schema — yes/no/on/off are strings). The JSON-first rule is retained per the Accepted ADR as a defensive default; tests document the actual 0.10.x behavior with explicit comments. The ADR rationale should be amended separately. 256 tests pass; clippy + fmt clean.
This commit is contained in:
1 parent
193f3a0e24
commit
21c4f7d252
4 files changed
+288
-1
No files matched your search
@@ -29,6 +29,7 @@ reqwest-retry = "0.9"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
yaml_serde = "0.10"
|
||||
async-trait = "0.1"
|
||||
tracing = "0.1"
|
||||
thiserror = "2"
|
||||
|
||||
@@ -109,6 +109,40 @@ impl OpenAPISpec {
|
||||
Self::from_value(raw)
|
||||
}
|
||||
|
||||
/// Parse a YAML OpenAPI document.
|
||||
///
|
||||
/// The caller has declared the format, so this does not attempt JSON
|
||||
/// first — YAML's coercion rules apply (see ADR-051 §2). YAML is parsed
|
||||
/// to a `serde_json::Value` and then fed through the existing
|
||||
/// [`from_value`](Self::from_value) path, so there is one internal
|
||||
/// `OpenAPISpec` representation shared with the JSON path.
|
||||
pub fn from_yaml(doc: &str) -> Result<Self, AdapterError> {
|
||||
let raw: Value = yaml_serde::from_str(doc).map_err(|e| AdapterError::SchemaParse {
|
||||
message: format!("invalid YAML: {e}"),
|
||||
})?;
|
||||
Self::from_value(raw)
|
||||
}
|
||||
|
||||
/// Parse a raw OpenAPI document of unknown format.
|
||||
///
|
||||
/// Detection is **JSON-first, YAML-fallback** (ADR-051 §2). JSON's
|
||||
/// stricter grammar avoids YAML 1.1 boolean coercion (the bare tokens
|
||||
/// `yes`/`no`/`on`/`off` would otherwise be coerced to booleans, silently
|
||||
/// mutating `{"active": "yes"}` into `{"active": true}`). A JSON parse
|
||||
/// succeeds for any valid JSON document; only on JSON failure does this
|
||||
/// fall back to the YAML parser. A YAML-only document (no JSON braces)
|
||||
/// fails JSON parse immediately and goes to the YAML path.
|
||||
#[allow(
|
||||
clippy::should_implement_trait,
|
||||
reason = "ADR-051 §1 names this an inherent constructor `from_str`, not a FromStr impl"
|
||||
)]
|
||||
pub fn from_str(doc: &str) -> Result<Self, AdapterError> {
|
||||
match serde_json::from_str::<Value>(doc) {
|
||||
Ok(raw) => Self::from_value(raw),
|
||||
Err(_) => Self::from_yaml(doc),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(raw: Value) -> Result<Self, AdapterError> {
|
||||
if !raw.is_object() {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
@@ -1782,4 +1816,236 @@ mod tests {
|
||||
other => panic!("expected HTTP_500, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal_spec_yaml() -> &'static str {
|
||||
r#"
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Test
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/widgets:
|
||||
get:
|
||||
operationId: listWidgets
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
"#
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_yaml_parses_minimal_doc_yields_one_registration() {
|
||||
let spec = OpenAPISpec::from_yaml(minimal_spec_yaml()).unwrap();
|
||||
let adapter = adapter(spec, config("widgets", "https://api.example.com", None));
|
||||
let bundles = adapter.import().await.unwrap();
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.name, "widgets/listWidgets");
|
||||
assert_eq!(bundles[0].spec.namespace, "widgets");
|
||||
assert_eq!(bundles[0].spec.op_type, OperationType::Query);
|
||||
assert_eq!(bundles[0].spec.visibility, Visibility::Internal);
|
||||
assert_eq!(bundles[0].provenance, OperationProvenance::FromOpenAPI);
|
||||
assert!(bundles[0].composition_authority.is_none());
|
||||
assert!(bundles[0].scoped_env.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_yaml_resolves_refs_in_input_schema() {
|
||||
let doc = r##"
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: T
|
||||
version: "1"
|
||||
components:
|
||||
schemas:
|
||||
Widget:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
paths:
|
||||
/w:
|
||||
post:
|
||||
operationId: w
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Widget"
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"##;
|
||||
let spec = OpenAPISpec::from_yaml(doc).unwrap();
|
||||
let bundles = adapter(spec, config("ns", "https://x", None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
let props = bundles[0]
|
||||
.spec
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap();
|
||||
let body = props.get("body").unwrap();
|
||||
assert_eq!(body.get("type").unwrap(), "object");
|
||||
assert!(body
|
||||
.get("properties")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_str_json_doc_succeeds_via_json_path() {
|
||||
let spec = OpenAPISpec::from_str(minimal_spec_json()).unwrap();
|
||||
assert_eq!(spec.info.title, "Test");
|
||||
assert_eq!(spec.info.version, "1.0.0");
|
||||
let adapter = adapter(spec, config("widgets", "https://api.example.com", None));
|
||||
let bundles = adapter.import().await.unwrap();
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.name, "widgets/listWidgets");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_str_yaml_doc_succeeds_via_yaml_fallback() {
|
||||
let spec = OpenAPISpec::from_str(minimal_spec_yaml()).unwrap();
|
||||
assert_eq!(spec.info.title, "Test");
|
||||
assert_eq!(spec.info.version, "1.0.0");
|
||||
let adapter = adapter(spec, config("widgets", "https://api.example.com", None));
|
||||
let bundles = adapter.import().await.unwrap();
|
||||
assert_eq!(bundles.len(), 1);
|
||||
assert_eq!(bundles[0].spec.name, "widgets/listWidgets");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_str_json_doc_preserves_yes_string_against_yaml_coercion() {
|
||||
// The JSON-first correctness guard from ADR-051 §2: a JSON doc with a
|
||||
// string field whose value is "yes" must survive `from_str` with the
|
||||
// string intact. JSON parses first; the YAML path (which would apply
|
||||
// any YAML-specific coercion) never runs on a valid JSON doc.
|
||||
//
|
||||
// NOTE: `yaml_serde` 0.10.x implements YAML 1.2, where `yes` is a
|
||||
// string regardless — so this guard is currently defensive rather
|
||||
// than load-bearing. It locks in the JSON-first contract so that a
|
||||
// future YAML-parser swap (to a YAML 1.1 crate) cannot silently
|
||||
// regress JSON input. See the complementary
|
||||
// `from_yaml_preserves_bare_yes_as_string_yaml_1_2_behavior` test
|
||||
// for the explicit dependency-version documentation.
|
||||
let doc_with_default = r#"{
|
||||
"openapi": "3.0.0",
|
||||
"info": { "title": "T", "version": "1" },
|
||||
"paths": {
|
||||
"/x": {
|
||||
"get": {
|
||||
"operationId": "x",
|
||||
"parameters": [
|
||||
{"name": "active", "in": "query", "schema": {"type": "string", "default": "yes"}}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"content": {"application/json": {"schema": {}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_str(doc_with_default).unwrap();
|
||||
let adapter = adapter(spec, config("ns", "https://x", None));
|
||||
let bundles = adapter.import().await.unwrap();
|
||||
let active = &bundles[0].spec.input_schema["properties"]["active"];
|
||||
assert_eq!(active["type"], "string");
|
||||
assert_eq!(active["default"], Value::String("yes".to_string()));
|
||||
assert_ne!(active["default"], Value::Bool(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_yaml_preserves_bare_yes_as_string_yaml_1_2_behavior() {
|
||||
// Documents the actual behavior of `yaml_serde` 0.10.x (the
|
||||
// dependency chosen in ADR-051 §3): it implements the YAML 1.2 core
|
||||
// schema, NOT YAML 1.1. Under YAML 1.2, only `true`/`false` (and
|
||||
// their case variants) are booleans — the bare tokens `yes`/`no`/
|
||||
// `on`/`off`/`y`/`n` are plain strings.
|
||||
//
|
||||
// NOTE: ADR-051 §2's stated correctness rationale for JSON-first
|
||||
// detection (that the maintained YAML serde crates coerce `yes`/`no`
|
||||
// to booleans per YAML 1.1) does not hold for `yaml_serde` 0.10.x.
|
||||
// The JSON-first rule in `from_str` is retained per the Accepted
|
||||
// ADR — it is still a defensible defensive default (JSON's grammar
|
||||
// is a strict subset of YAML and never exhibits any YAML-specific
|
||||
// coercion, present or future), but the silent-mutation hazard the
|
||||
// ADR cites is not present with this dependency version. The ADR's
|
||||
// rationale should be amended separately.
|
||||
let doc = r#"
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: T
|
||||
version: "1"
|
||||
paths:
|
||||
/x:
|
||||
get:
|
||||
operationId: x
|
||||
parameters:
|
||||
- name: active
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
default: yes
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"#;
|
||||
let spec = OpenAPISpec::from_yaml(doc).unwrap();
|
||||
let adapter = adapter(spec, config("ns", "https://x", None));
|
||||
let bundles = adapter.import().await.unwrap();
|
||||
let active = &bundles[0].spec.input_schema["properties"]["active"];
|
||||
// yaml_serde 0.10.x (YAML 1.2 core schema) keeps bare `yes` as a string.
|
||||
assert_eq!(active["default"], Value::String("yes".to_string()));
|
||||
assert_ne!(active["default"], Value::Bool(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_yaml_malformed_returns_schema_parse() {
|
||||
let result = OpenAPISpec::from_yaml("openapi: 3.0.0\ninfo: [unclosed");
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("YAML"), "message was: {message}");
|
||||
}
|
||||
other => panic!("expected SchemaParse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_str_malformed_both_returns_schema_parse() {
|
||||
// Neither valid JSON (unterminated string) nor valid YAML (tab indents
|
||||
// are an error; we use an unterminated flow mapping that both parsers
|
||||
// reject).
|
||||
let malformed = "openapi: 3.0.0\ninfo: {title: \"T\npaths: [";
|
||||
let result = OpenAPISpec::from_str(malformed);
|
||||
assert!(matches!(result, Err(AdapterError::SchemaParse { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_yaml_missing_paths_returns_schema_parse() {
|
||||
let doc = r#"
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: x
|
||||
version: "1"
|
||||
"#;
|
||||
let result = OpenAPISpec::from_yaml(doc);
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => assert!(message.contains("paths")),
|
||||
other => panic!("expected SchemaParse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user