feat(adapters): loud unsupported-OpenAPI-feature handling (OAI-06)

- build_error_schemas: default/wildcard response keys dropped with a
  warn instead of emitting a dead HTTP_0 ErrorDefinition — /search
  never advertises a code that can't match (the runtime mapper already
  synthesizes HTTP_<actual> for unmapped statuses)
- check_parameter_style: non-default style/explode parameter forms
  (spaceDelimited, pipeDelimited, deepObject, form+explode:false,
  simple+explode:true) fail import with a feature-naming SchemaParse;
  wire-equivalent defaults (form, simple) import unchanged — no more
  silent "[1,2]" array mis-serialization
- servers overrides rejected at import at all three levels (document,
  path, operation) — the adapter pins one base_url at assembly time
- trace-only paths: skip is now logged (warn naming path + methods),
  documented-as-inert instead of silent
- detect_op_type + build_output_schema sweep 2XX/default keys for
  text/event-stream — a default-declared SSE stream classifies as Sub
  instead of returning one giant text body

Tests: 11 new (error-drop, style rejections + default accept, servers
3-level rejections + baseline, trace skip, SSE default/2XX detection).
Verified: cargo test (299), --all-features (370 + suites), clippy
--all-targets -D warnings (default + all-features), fmt --check.

Tasks: review-001-openapi-loud-degradation
This commit is contained in:
2026-08-30 08:10:40 +00:00
parent edbda6605b
commit 7ce1ca6fbd
3 changed files with 552 additions and 31 deletions
+97 -2
View File
@@ -124,7 +124,15 @@ impl FromOpenAPI {
}
fn detect_op_type(method: &str, op: &Operation) -> OperationType {
let success = op.responses.get("200").or_else(|| op.responses.get("201"));
// OAI-06: the success envelope may be declared under any 2XX key
// (204, 206, 226...) or under `default` — a stream declared there
// must still classify as Sub, not fall through to a giant
// single-string text body.
let success = [
"200", "201", "202", "203", "204", "205", "206", "226", "default",
]
.iter()
.find_map(|k| op.responses.get(*k));
if let Some(resp) = success {
if resp.content.contains_key("text/event-stream") {
return OperationType::Sub;
@@ -209,7 +217,14 @@ impl FromOpenAPI {
}
fn build_output_schema(&self, op: &Operation) -> Result<Value, AdapterError> {
let success = op.responses.get("200").or_else(|| op.responses.get("201"));
// Mirrors `detect_op_type`'s success-key sweep (OAI-06): a stream
// declared under a non-200/201 2XX key or `default` still governs
// the output schema shape.
let success = [
"200", "201", "202", "203", "204", "205", "206", "226", "default",
]
.iter()
.find_map(|k| op.responses.get(*k));
let Some(resp) = success else {
return Ok(serde_json::json!({}));
};
@@ -230,6 +245,24 @@ impl FromOpenAPI {
if is_2xx {
continue;
}
// OAI-06: `default`/wildcard response keys have no concrete
// status. An `ErrorDefinition { code: "HTTP_0" }` would be
// advertised by `/search` yet never match a real status, and
// the runtime mapper synthesizes `HTTP_<actual>` for unmapped
// statuses anyway — so the entry is dropped, loudly, rather
// than advertised dead. Callers needing a catch-all declare
// explicit statuses.
if status.is_none() {
tracing::warn!(
operation = %op.operation_id.as_deref().unwrap_or("?"),
namespace = %self.config.namespace,
response_key = %code,
"response key is not a concrete HTTP status; dropping it from the \
imported error schemas — unmapped upstream statuses surface as \
HTTP_<status> at call time (review 001 OAI-06)"
);
continue;
}
let schema = if let Some(json_schema) = resp.content.get("application/json") {
self.spec.resolve_refs_recursive(json_schema)?
} else {
@@ -798,6 +831,68 @@ mod tests {
assert_eq!(errors[0].http_status, Some(404));
}
#[tokio::test]
async fn default_response_key_is_dropped_not_advertised_as_http_0() {
let doc = r#"{
"openapi":"3.0.0","info":{"title":"T","version":"1"},
"paths":{"/x":{"get":{"operationId":"x","responses":{
"200":{"content":{"application/json":{"schema":{}}}},
"404":{"content":{"application/json":{"schema":{}}}},
"default":{"content":{"application/json":{"schema":{}}}},
"5XX":{"content":{"application/json":{"schema":{}}}}
}}}}
}"#;
let spec = OpenAPISpec::from_json(doc).unwrap();
let bundles = adapter(spec, config("ns", "https://x", None))
.import()
.await
.unwrap();
let errors = &bundles[0].spec.error_schemas;
assert_eq!(
errors.len(),
1,
"only the concrete 404 may be advertised; `default` and `5XX` are dropped"
);
assert_eq!(errors[0].code, "HTTP_404");
assert!(
errors.iter().all(|e| e.code != "HTTP_0"),
"/search must never advertise an HTTP_0 code that cannot match (OAI-06): {errors:?}"
);
assert!(errors.iter().all(|e| e.http_status.is_some()));
}
#[tokio::test]
async fn default_declared_sse_stream_classifies_as_subscription() {
let doc = r##"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/s":{"post":{"operationId":"s","responses":{"default":{"content":{"text/event-stream":{"schema":{}}}}}}}}}"##;
let spec = OpenAPISpec::from_json(doc).unwrap();
assert_eq!(
adapter(spec, config("ns", "https://x", None))
.import()
.await
.unwrap()[0]
.spec
.op_type,
OperationType::Sub,
"a `default`-declared text/event-stream must classify as Sub, not fall \
through to a giant single text body (OAI-06)"
);
}
#[tokio::test]
async fn non_200_2xx_sse_stream_classifies_as_subscription() {
let doc = r##"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/s":{"post":{"operationId":"s","responses":{"206":{"content":{"text/event-stream":{"schema":{}}}}}}}}}"##;
let spec = OpenAPISpec::from_json(doc).unwrap();
assert_eq!(
adapter(spec, config("ns", "https://x", None))
.import()
.await
.unwrap()[0]
.spec
.op_type,
OperationType::Sub
);
}
#[tokio::test]
async fn input_schema_from_params_and_body() {
let doc = r#"{