From 7ce1ca6fbdd9b431ad1d1e9fe9d5b612a89b4efb Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 08:10:40 +0000 Subject: [PATCH] feat(adapters): loud unsupported-OpenAPI-feature handling (OAI-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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_ 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 --- src/adapters/from_openapi.rs | 99 ++++- src/adapters/openapi_spec.rs | 398 +++++++++++++++++- .../review-001-openapi-loud-degradation.md | 86 +++- 3 files changed, 552 insertions(+), 31 deletions(-) diff --git a/src/adapters/from_openapi.rs b/src/adapters/from_openapi.rs index 4ff56a5..d6e5fc0 100644 --- a/src/adapters/from_openapi.rs +++ b/src/adapters/from_openapi.rs @@ -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 { - 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_` 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_ 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#"{ diff --git a/src/adapters/openapi_spec.rs b/src/adapters/openapi_spec.rs index 1168c78..a1ec262 100644 --- a/src/adapters/openapi_spec.rs +++ b/src/adapters/openapi_spec.rs @@ -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 = 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 { +fn parse_operation( + raw: &Value, + spec: &OpenAPISpec, +) -> Result, 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 { 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 { } } - 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" + ); + } } diff --git a/tasks/adapters/review-001-openapi-loud-degradation.md b/tasks/adapters/review-001-openapi-loud-degradation.md index 89859e4..b398fc4 100644 --- a/tasks/adapters/review-001-openapi-loud-degradation.md +++ b/tasks/adapters/review-001-openapi-loud-degradation.md @@ -1,7 +1,7 @@ --- id: review-001-openapi-loud-degradation name: Loud unsupported-OpenAPI-feature handling (OAI-06) -status: pending +status: completed depends_on: [] scope: narrow risk: low @@ -42,10 +42,10 @@ defensible if logged). ## 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) -- [ ] `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 -- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass +- [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) +- [x] `HTTP_0` no longer emitted (default responses mapped or dropped loudly) — `/search` never advertises a code that can't match +- [x] `style`/`explode` non-default forms do not silently mis-serialize arrays +- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass ## References @@ -54,12 +54,76 @@ defensible if logged). ## Notes -> Agent fills during implementation. Deferred from the original -> decomposition for staleness reasons; unblocked once OAI-02/03/07/09 -> landed (they did). Scope guard: this is import-time fidelity, not -> new feature support — do not implement `servers` overrides or style -> serialization here. +Per-feature decisions (the review's map, applied): + +- **`default`/wildcard responses → dropped loudly (warn, not + reject)**: a catch-all code was considered and rejected — the runtime + mapper (`forward.rs::error_envelope`) already synthesizes + `HTTP_` 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::()` 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, + 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 -> Filled on completion. \ No newline at end of file +- `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`. \ No newline at end of file