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#"{
+380 -18
View File
@@ -174,6 +174,45 @@ impl OpenAPISpec {
raw: raw.clone(),
};
// OAI-06: `servers` anywhere in the document (doc root, path, or
// operation level) would be a silent override of where forwarding
// handlers send traffic — the adapter routes everything through
// the single `base_url` configured at assembly time. Reject
// loudly so the import names the feature and the remediation.
let mut servers_locations: Vec<String> = Vec::new();
if raw.get("servers").is_some() {
servers_locations.push("document".to_string());
}
if let Some(paths_obj) = paths_raw.as_object() {
for (path, item) in paths_obj {
if item.as_object().is_some_and(|o| o.contains_key("servers")) {
servers_locations.push(format!("path {path}"));
continue;
}
for method in HTTP_METHODS {
if item
.get(method)
.and_then(|op| op.as_object())
.is_some_and(|op| op.contains_key("servers"))
{
servers_locations.push(format!("{method} {path}"));
}
}
}
}
if !servers_locations.is_empty() {
return Err(AdapterError::SchemaParse {
message: format!(
"the document declares `servers` override(s) at: {}. The HTTP adapter \
routes all operations of a service through the single `base_url` \
configured at assembly time and cannot honor per-location `servers` — \
remove the `servers` entries or split the service into one import per \
base URL (review 001 OAI-06)",
servers_locations.join(", ")
),
});
}
let mut paths = BTreeMap::new();
if let Some(paths_obj) = paths_raw.as_object() {
for (path, item) in paths_obj {
@@ -183,19 +222,54 @@ impl OpenAPISpec {
let mut operations = Vec::new();
for method in HTTP_METHODS {
if let Some(op_raw) = item.get(*method) {
if let Some(op) = parse_operation(op_raw, &provisional) {
operations.push((method.to_string(), op));
} else {
return Err(AdapterError::SchemaParse {
message: format!(
"unresolvable $ref or missing `name`/`in` in parameter of \
{method} {path}"
),
});
match parse_operation(op_raw, &provisional) {
Ok(Some(op)) => operations.push((method.to_string(), op)),
Ok(None) => {
return Err(AdapterError::SchemaParse {
message: format!(
"unresolvable $ref or missing `name`/`in` in parameter of \
{method} {path}"
),
});
}
Err(style_error) => {
return Err(AdapterError::SchemaParse {
message: format!(
"parameter `{}` on {method} {path} {}: (review 001 OAI-06)",
style_error.parameter, style_error.detail
),
});
}
}
}
}
if operations.is_empty() {
// OAI-06: `trace` is not in the supported method set;
// a path entry carrying only unsupported methods was
// previously skipped without a trace. Surface it.
let skipped: Vec<&str> = item
.as_object()
.map(|o| {
o.keys()
.map(|k| k.as_str())
.filter(|k| {
!HTTP_METHODS.contains(k)
&& *k != "parameters"
&& *k != "servers"
&& *k != "summary"
&& *k != "description"
})
.collect()
})
.unwrap_or_default();
if !skipped.is_empty() {
tracing::warn!(
path = %path,
methods = %skipped.join(", "),
"path declares only unsupported HTTP methods; skipping it \
(review 001 OAI-06)"
);
}
continue;
}
paths.insert(path.clone(), PathItem { operations });
@@ -304,9 +378,12 @@ impl OpenAPISpec {
}
}
fn parse_operation(raw: &Value, spec: &OpenAPISpec) -> Option<Operation> {
fn parse_operation(
raw: &Value,
spec: &OpenAPISpec,
) -> Result<Option<Operation>, ParameterStyleError> {
if !raw.is_object() {
return None;
return Ok(None);
}
let operation_id = raw
.get("operationId")
@@ -319,17 +396,28 @@ fn parse_operation(raw: &Value, spec: &OpenAPISpec) -> Option<Operation> {
let p = match p.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => match spec.resolve_ref(reference) {
Ok(resolved) => resolved,
Err(_) => return None,
Err(_) => return Ok(None),
},
None => p.clone(),
};
let name = p.get("name")?.as_str()?.to_string();
let in_ = p.get("in")?.as_str()?.to_string();
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
return Ok(None);
};
let Some(in_) = p.get("in").and_then(|v| v.as_str()) else {
return Ok(None);
};
// OAI-06: non-default `style`/`explode` forms change how arrays
// and objects serialize on the wire (the adapter emits the
// form/simple default — repeated keys for query arrays). A
// parameter declaring a different serialization would silently
// mis-serialize upstream (`"[1,2]"`-style), so it fails import
// with an error naming the parameter and feature.
check_parameter_style(name, in_, &p)?;
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
let schema = p.get("schema").cloned();
parameters.push(Parameter {
name,
in_,
name: name.to_string(),
in_: in_.to_string(),
required,
schema,
});
@@ -365,12 +453,64 @@ fn parse_operation(raw: &Value, spec: &OpenAPISpec) -> Option<Operation> {
}
}
Some(Operation {
Ok(Some(Operation {
operation_id,
parameters,
request_body,
responses,
})
}))
}
/// A parameter whose `style`/`explode` declaration the adapter cannot
/// serialize faithfully. Carries the parameter identity so the import
/// error names the feature (OAI-06 cookie-style loudness).
#[derive(Debug)]
pub(crate) struct ParameterStyleError {
pub parameter: String,
pub detail: String,
}
/// Rejects non-default `style`/`explode` parameter serializations
/// (OAI-06). Supported (wire-equivalent to the adapter's emitter):
/// `form` (the query/path default, explode on) and `simple` (the
/// header/path default, explode off). Everything else — including
/// `spaceDelimited`/`pipeDelimited`/`deepObject` and non-default
/// `explode` flips — would mis-serialize arrays and objects, so it is
/// refused.
fn check_parameter_style(
name: &str,
parameter_in: &str,
p: &Value,
) -> Result<(), ParameterStyleError> {
let _ = parameter_in;
let unsupported = |detail: String| ParameterStyleError {
parameter: name.to_string(),
detail,
};
let style = p.get("style").and_then(|v| v.as_str());
let explode = p.get("explode");
match (style, explode) {
(None, _) => Ok(()),
(Some("form"), None | Some(Value::Bool(true))) => Ok(()),
(Some("simple"), None | Some(Value::Bool(false))) => Ok(()),
(Some("form"), Some(Value::Bool(false))) => Err(unsupported(
"sets `style: form` with `explode: false`, which would comma-glue arrays \
(`?a=1,2`); the adapter emits the exploded default (repeated keys) — remove \
the `explode: false` or move the aggregation into the request body"
.to_string(),
)),
(Some("simple"), Some(Value::Bool(true))) => Err(unsupported(
"sets `style: simple` with `explode: true`; `simple` applies to headers and \
path segments where `explode` has no meaning for the adapter's emitter — \
remove the `explode: true`"
.to_string(),
)),
(Some(other), _) => Err(unsupported(format!(
"uses `style: {other}`, which the HTTP adapter does not serialize; the adapter \
emits query/path parameters in the form default (repeated keys for arrays) — \
drop the `style` declaration or serialize client-side"
))),
}
}
#[cfg(test)]
@@ -669,4 +809,226 @@ mod tests {
Err(e) => panic!("expected circular-$ref SchemaParse, got {e}"),
}
}
// --- OAI-06: loud unsupported-feature handling --------------------------
#[test]
fn non_default_style_parameter_fails_import_naming_the_feature() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/tags": {"get": {
"operationId": "listTags",
"parameters": [{
"name": "ids",
"in": "query",
"style": "spaceDelimited",
"schema": {"type": "array", "items": {"type": "integer"}}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let result = OpenAPISpec::from_json(doc);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("ids"), "message was: {message}");
assert!(
message.contains("spaceDelimited"),
"the error must name the offending style: {message}"
);
assert!(message.contains("OAI-06"), "message was: {message}");
}
Ok(_) => panic!("non-default style must fail import loudly, got a spec"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn deep_object_style_is_rejected_like_the_other_non_default_forms() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/f": {"get": {
"operationId": "f",
"parameters": [{
"name": "filter",
"in": "query",
"style": "deepObject",
"schema": {"type": "object"}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let result = OpenAPISpec::from_json(doc);
assert!(matches!(result, Err(AdapterError::SchemaParse { .. })));
}
#[test]
fn form_style_with_explode_false_is_rejected() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/ids": {"get": {
"operationId": "ids",
"parameters": [{
"name": "ids",
"in": "query",
"style": "form",
"explode": false,
"schema": {"type": "array", "items": {"type": "integer"}}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("explode") && message.contains("form"),
"the error must name the form+explode conflict: {message}"
);
assert!(message.contains("ids"), "message was: {message}");
}
Ok(_) => panic!("form+explode=false mis-serializes arrays; must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn default_style_and_explode_forms_still_import() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {"get": {
"operationId": "x",
"parameters": [
{"name": "q", "in": "query", "style": "form", "explode": true, "schema": {"type": "string"}},
{"name": "X-Trace", "in": "header", "style": "simple", "explode": false, "schema": {"type": "string"}}
],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("default style declarations import");
let op = &spec.paths["/x"].operations[0].1;
assert_eq!(op.parameters.len(), 2);
}
#[test]
fn servers_override_at_document_level_fails_import() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("servers"), "message was: {message}");
assert!(message.contains("base_url"), "message was: {message}");
assert!(message.contains("document"), "message was: {message}");
assert!(message.contains("OAI-06"), "message was: {message}");
}
Ok(_) => panic!("document-level servers must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn servers_override_at_path_and_operation_level_fails_import() {
let path_level = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {
"servers": [{"url": "https://other.example.com"}],
"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}
}
}
}"##;
match OpenAPISpec::from_json(path_level) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("path /x"), "message was: {message}");
}
Ok(_) => panic!("path-level servers must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
let op_level = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/y": {"get": {
"operationId": "y",
"servers": [{"url": "https://other.example.com"}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(op_level) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("get /y"),
"the error must locate the operation-level override: {message}"
);
}
Ok(_) => panic!("operation-level servers must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn servers_absent_baseline_still_imports() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("no servers means no conflict");
assert_eq!(spec.paths.len(), 1);
}
#[test]
fn trace_only_path_is_skipped_and_documented_inert() {
// `trace` is not a supported method (openapi_spec::HTTP_METHODS);
// a path carrying only `trace` is skipped — visibly, via the
// module's warn log — and the rest of the document imports.
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/debug": {"trace": {"operationId": "debug", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}},
"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("trace skip is not an error");
assert!(
!spec.paths.contains_key("/debug"),
"the trace-only path is not imported"
);
assert!(
spec.paths.contains_key("/x"),
"the supported path still imports alongside the skipped one"
);
}
}