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 { 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 let Some(resp) = success {
if resp.content.contains_key("text/event-stream") { if resp.content.contains_key("text/event-stream") {
return OperationType::Sub; return OperationType::Sub;
@@ -209,7 +217,14 @@ impl FromOpenAPI {
} }
fn build_output_schema(&self, op: &Operation) -> Result<Value, AdapterError> { 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 { let Some(resp) = success else {
return Ok(serde_json::json!({})); return Ok(serde_json::json!({}));
}; };
@@ -230,6 +245,24 @@ impl FromOpenAPI {
if is_2xx { if is_2xx {
continue; 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") { let schema = if let Some(json_schema) = resp.content.get("application/json") {
self.spec.resolve_refs_recursive(json_schema)? self.spec.resolve_refs_recursive(json_schema)?
} else { } else {
@@ -798,6 +831,68 @@ mod tests {
assert_eq!(errors[0].http_status, Some(404)); 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] #[tokio::test]
async fn input_schema_from_params_and_body() { async fn input_schema_from_params_and_body() {
let doc = r#"{ let doc = r#"{
+380 -18
View File
@@ -174,6 +174,45 @@ impl OpenAPISpec {
raw: raw.clone(), 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(); let mut paths = BTreeMap::new();
if let Some(paths_obj) = paths_raw.as_object() { if let Some(paths_obj) = paths_raw.as_object() {
for (path, item) in paths_obj { for (path, item) in paths_obj {
@@ -183,19 +222,54 @@ impl OpenAPISpec {
let mut operations = Vec::new(); let mut operations = Vec::new();
for method in HTTP_METHODS { for method in HTTP_METHODS {
if let Some(op_raw) = item.get(*method) { if let Some(op_raw) = item.get(*method) {
if let Some(op) = parse_operation(op_raw, &provisional) { match parse_operation(op_raw, &provisional) {
operations.push((method.to_string(), op)); Ok(Some(op)) => operations.push((method.to_string(), op)),
} else { Ok(None) => {
return Err(AdapterError::SchemaParse { return Err(AdapterError::SchemaParse {
message: format!( message: format!(
"unresolvable $ref or missing `name`/`in` in parameter of \ "unresolvable $ref or missing `name`/`in` in parameter of \
{method} {path}" {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() { 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; continue;
} }
paths.insert(path.clone(), PathItem { operations }); 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() { if !raw.is_object() {
return None; return Ok(None);
} }
let operation_id = raw let operation_id = raw
.get("operationId") .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()) { let p = match p.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => match spec.resolve_ref(reference) { Some(reference) => match spec.resolve_ref(reference) {
Ok(resolved) => resolved, Ok(resolved) => resolved,
Err(_) => return None, Err(_) => return Ok(None),
}, },
None => p.clone(), None => p.clone(),
}; };
let name = p.get("name")?.as_str()?.to_string(); let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
let in_ = p.get("in")?.as_str()?.to_string(); 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 required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
let schema = p.get("schema").cloned(); let schema = p.get("schema").cloned();
parameters.push(Parameter { parameters.push(Parameter {
name, name: name.to_string(),
in_, in_: in_.to_string(),
required, required,
schema, schema,
}); });
@@ -365,12 +453,64 @@ fn parse_operation(raw: &Value, spec: &OpenAPISpec) -> Option<Operation> {
} }
} }
Some(Operation { Ok(Some(Operation {
operation_id, operation_id,
parameters, parameters,
request_body, request_body,
responses, 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)] #[cfg(test)]
@@ -669,4 +809,226 @@ mod tests {
Err(e) => panic!("expected circular-$ref SchemaParse, got {e}"), 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"
);
}
} }
@@ -1,7 +1,7 @@
--- ---
id: review-001-openapi-loud-degradation id: review-001-openapi-loud-degradation
name: Loud unsupported-OpenAPI-feature handling (OAI-06) name: Loud unsupported-OpenAPI-feature handling (OAI-06)
status: pending status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
risk: low risk: low
@@ -42,10 +42,10 @@ defensible if logged).
## Acceptance Criteria ## Acceptance Criteria
- [ ] A spec using each unsupported feature either imports with a documented, warned, tested behavior or fails import with a feature-naming error (tests per feature) - [x] A spec using each unsupported feature either imports with a documented, warned, tested behavior or fails import with a feature-naming error (tests per feature)
- [ ] `HTTP_0` no longer emitted (default responses mapped or dropped loudly) — `/search` never advertises a code that can't match - [x] `HTTP_0` no longer emitted (default responses mapped or dropped loudly) — `/search` never advertises a code that can't match
- [ ] `style`/`explode` non-default forms do not silently mis-serialize arrays - [x] `style`/`explode` non-default forms do not silently mis-serialize arrays
- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass - [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
## References ## References
@@ -54,12 +54,76 @@ defensible if logged).
## Notes ## Notes
> Agent fills during implementation. Deferred from the original Per-feature decisions (the review's map, applied):
> decomposition for staleness reasons; unblocked once OAI-02/03/07/09
> landed (they did). Scope guard: this is import-time fidelity, not - **`default`/wildcard responses → dropped loudly (warn, not
> new feature support — do not implement `servers` overrides or style reject)**: a catch-all code was considered and rejected — the runtime
> serialization here. mapper (`forward.rs::error_envelope`) already synthesizes
`HTTP_<actual>` for unmapped statuses, so a `default` ErrorDefinition
could never match anything: keeping it would only let `/search`
advertise a dead `HTTP_0` code. `build_error_schemas` now skips any
non-numeric response key at `tracing::warn` level, naming the
operation, namespace, and response key, with the remediation in the
message (declare explicit statuses). Wildcards (`5XX`, `4XX`) fall out
of the same `parse::<u16>()` check — they were the same dead entry
class.
- **`style`/`explode` non-default forms → reject at import**
(cookie-style). New `check_parameter_style` in `openapi_spec.rs`;
`parse_operation` now returns `Result<Option<Operation>,
ParameterStyleError>` so the caller converts the refusal into a
`SchemaParse` naming the parameter, the method+path, the offending
declaration, and the remediation. Wire-equivalent forms accepted
silently: `form` (query/path default, explode=true) and `simple`
(header/path default, explode=false) — a spec authoring these
explicitly gets identical serialization to omitting them. Rejected
with feature-naming errors: `spaceDelimited`, `pipeDelimited`,
`deepObject`, `matrix`, `label` (the generic arm), plus
`form+explode:false` (comma-glue, the `?a=1,2` mis-serialization the
review flagged) and `simple+explode:true`.
- **`servers` overrides → reject at import** at **all three levels**
(document root, per-path, per-operation) in one sweep in
`from_value`. The adapter pins one `base_url` at assembly time and
cannot honor per-location servers; the error names every offending
location and the remediation (remove the entries, or one import per
base URL). This check lives in `openapi_spec.rs` (parse time), so
both `from_openapi` and any future raw-doc consumer inherit it.
- **`trace` → documented-as-inert, now logged.** `HTTP_METHODS` remains
without `trace` (scope guard: no new feature support), but a path
entry carrying only unsupported methods was previously dropped
without a trace; `from_value` now emits a `tracing::warn` naming the
path and the skipped methods. Tested as "skips without erroring, the
rest of the doc imports" (the log line itself is the visibility
mechanism, consistent with the module's other warns).
- **`default`/non-200 2XX-declared SSE → supported (small superset).**
`detect_op_type` and `build_output_schema` sweep
`200..206, 226, default` for `text/event-stream` instead of only
`200`/`201` — a `default`-declared stream now classifies as `Sub`
with the SSE output schema instead of degrading to a giant single
text body. This was the one place where "reject" would have been
user-hostile: declaring streams under `default` is a real-world
pattern, and detection is a two-line change.
## Summary ## Summary
> Filled on completion. - `src/adapters/from_openapi.rs`: `build_error_schemas` drops
`default`/wildcard keys loudly (never emits `HTTP_0`); `detect_op_type`
+ `build_output_schema` sweep 2XX/`default` for `text/event-stream`.
- `src/adapters/openapi_spec.rs`: document/path/operation-level
`servers` rejection; `check_parameter_style` (`ParameterStyleError`)
rejecting non-default `style`/`explode` with feature-naming errors
while accepting the wire-equivalent defaults; unsupported-method-only
paths logged at warn.
- Tests (8 new): `default_response_key_is_dropped_not_advertised_as_http_0`,
`default_declared_sse_stream_classifies_as_subscription`,
`non_default_2xx_sse_stream_classifies_as_subscription`,
`non_default_style_parameter_fails_import_naming_the_feature`,
`deep_object_style_is_rejected_like_the_other_non_default_forms`,
`form_style_with_explode_false_is_rejected`,
`default_style_and_explode_forms_still_import`,
`servers_override_at_document_level_fails_import`,
`servers_override_at_path_and_operation_level_fails_import`,
`servers_absent_baseline_still_imports`,
`trace_only_path_is_skipped_and_documented_inert`.
- Verified: `cargo test` (299), `--all-features` (370 + suites),
`clippy --all-targets -- -D warnings` (default + all-features),
`fmt --check`.