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:
@@ -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#"{
|
||||
|
||||
Reference in New Issue
Block a user