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#"{
|
||||
|
||||
+380
-18
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_<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
|
||||
|
||||
> 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`.
|
||||
Reference in New Issue
Block a user