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 {
|
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
@@ -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`.
|
||||||
Reference in New Issue
Block a user