Merge branch 'wt/review-002-oai13-path-item-wildcards'
This commit is contained in:
@@ -134,6 +134,21 @@ The
|
||||
difference is purely the input shape: a full document vs. a single
|
||||
endpoint.
|
||||
|
||||
### Response-key wildcards (review 002 OAI-13)
|
||||
|
||||
`from_openapi` projects OpenAPI response keys onto `HTTP_<status>` error
|
||||
codes, which require a concrete status. Class wildcards map to the first
|
||||
legal concrete status in their implied range: `4XX` → `HTTP_400`, `5XX`
|
||||
→ `HTTP_500`. The declared payload schema of the wildcard response is
|
||||
carried by the projected entry. `default` has no implied status range,
|
||||
so it is not projected (it would advertise an `HTTP_0` code that can
|
||||
never match a callback status) — unmapped upstream statuses surface as
|
||||
the synthesized `HTTP_<actual>` at call time regardless. A concrete
|
||||
status key always outranks a wildcard covering the same range, in both
|
||||
error projection and the success sweep (SSE detection +
|
||||
output-schema selection), where the precedence order is: concrete 2XX
|
||||
statuses, then `2XX`, then `default`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**:
|
||||
|
||||
+325
-52
@@ -30,7 +30,7 @@ use super::forward::{
|
||||
forward, forward_stream, HttpServiceConfig, GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER,
|
||||
HEADER_PARAM_MARKER_VALUE,
|
||||
};
|
||||
use super::openapi_spec::{OpenAPISpec, Operation};
|
||||
use super::openapi_spec::{OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS};
|
||||
use crate::client::SharedHttpClient;
|
||||
|
||||
fn unbound_placeholders(path_template: &str, input_schema: &Value) -> Vec<String> {
|
||||
@@ -130,14 +130,15 @@ impl FromOpenAPI {
|
||||
|
||||
fn detect_op_type(method: &str, op: &Operation) -> OperationType {
|
||||
// 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));
|
||||
// (204, 206, 226...), under the class wildcard `2XX` (review 002
|
||||
// OAI-13), or under `default` — a stream declared there must
|
||||
// still classify as Sub, not fall through to a giant single-
|
||||
// string text body. [`SUCCESS_RESPONSE_KEYS`] is in precedence
|
||||
// order: concrete statuses outrank the wildcard, which outranks
|
||||
// `default`.
|
||||
let success = SUCCESS_RESPONSE_KEYS
|
||||
.iter()
|
||||
.find_map(|k| op.responses.get(*k));
|
||||
if let Some(resp) = success {
|
||||
if resp.content.contains_key("text/event-stream") {
|
||||
return OperationType::Sub;
|
||||
@@ -150,11 +151,22 @@ impl FromOpenAPI {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_schema(&self, op: &Operation) -> Result<Value, AdapterError> {
|
||||
fn build_input_schema(
|
||||
&self,
|
||||
op: &Operation,
|
||||
path_parameters: &[Parameter],
|
||||
) -> Result<Value, AdapterError> {
|
||||
let mut properties = serde_json::Map::new();
|
||||
let mut required = Vec::new();
|
||||
|
||||
for param in &op.parameters {
|
||||
// OAI-13: path-item-level parameters merge into every operation
|
||||
// under the path. The operation-level entries come second, so a
|
||||
// same-`name`-same-`in` override wins by last-insert-wins in the
|
||||
// loop below (OpenAPI override semantics), and the overridden
|
||||
// path-item entry's `required` cannot leak: the operation entry
|
||||
// is the authoritative declaration pushed last.
|
||||
let merged: Vec<&Parameter> = path_parameters.iter().chain(op.parameters.iter()).collect();
|
||||
for param in merged {
|
||||
let schema = match ¶m.schema {
|
||||
Some(s) => self.spec.resolve_refs_recursive(s)?,
|
||||
None => serde_json::json!({"type": "string"}),
|
||||
@@ -163,9 +175,9 @@ impl FromOpenAPI {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"parameter named `{GATEWAY_BODY_KEY}` collides with the gateway's \
|
||||
requestBody placeholder key; the declared parameter would be \
|
||||
diverted into the request body at call time — rename the \
|
||||
parameter (review 001 OAI-07)"
|
||||
requestBody placeholder key; the declared parameter (path-item \
|
||||
level or operation-level) would be diverted into the request \
|
||||
body at call time — rename the parameter (review 001 OAI-07)"
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -222,14 +234,12 @@ impl FromOpenAPI {
|
||||
}
|
||||
|
||||
fn build_output_schema(&self, op: &Operation) -> Result<Value, AdapterError> {
|
||||
// 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));
|
||||
// Mirrors `detect_op_type`'s success-key sweep (OAI-06, OAI-13):
|
||||
// a stream declared under a non-200/201 2XX key, the `2XX`
|
||||
// wildcard, or `default` still governs the output schema shape.
|
||||
let success = SUCCESS_RESPONSE_KEYS
|
||||
.iter()
|
||||
.find_map(|k| op.responses.get(*k));
|
||||
let Some(resp) = success else {
|
||||
return Ok(serde_json::json!({}));
|
||||
};
|
||||
@@ -250,35 +260,46 @@ 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;
|
||||
}
|
||||
// OAI-13: class wildcards map to the first legal concrete
|
||||
// status in their implied range — `4XX` → HTTP_400, `5XX` →
|
||||
// HTTP_500 (ADR-023 codes must be a concrete `HTTP_<status>`;
|
||||
// the runtime mapper synthesizes the actual status for any
|
||||
// unmapped one, so the projection is a faithful
|
||||
// representative, not a catch-all lie). `default` has no
|
||||
// implied range at all: an `HTTP_0` entry would be advertised
|
||||
// by `/search` yet never match a real status, so it is
|
||||
// dropped, loudly (review 001 OAI-06). A line recording this
|
||||
// mapping lives in ADR-066.
|
||||
let wildcard_status: Option<u16> = match code.as_str() {
|
||||
"4XX" => Some(400),
|
||||
"5XX" => Some(500),
|
||||
_ => None,
|
||||
};
|
||||
let (status_code, effective_status) = match (status, wildcard_status) {
|
||||
(Some(s), _) => (s, Some(s)),
|
||||
(None, Some(s)) => (s, Some(s)),
|
||||
(None, 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 {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let status_code = status.unwrap_or(0);
|
||||
out.push(ErrorDefinition {
|
||||
code: format!("HTTP_{status_code}"),
|
||||
description: format!("HTTP {status_code} response"),
|
||||
description: format!("HTTP {status_code} response ({code} declared)"),
|
||||
schema,
|
||||
http_status: status,
|
||||
http_status: effective_status,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -289,11 +310,12 @@ impl FromOpenAPI {
|
||||
method: &str,
|
||||
path: &str,
|
||||
op: &Operation,
|
||||
path_parameters: &[Parameter],
|
||||
) -> Result<HandlerRegistration, AdapterError> {
|
||||
let name = Self::normalize_operation_id(op, method, path);
|
||||
let qualified_name = format!("{}/{name}", self.config.namespace);
|
||||
let op_type = Self::detect_op_type(method, op);
|
||||
let input_schema = self.build_input_schema(op)?;
|
||||
let input_schema = self.build_input_schema(op, path_parameters)?;
|
||||
let output_schema = self.build_output_schema(op)?;
|
||||
let error_schemas = self.build_error_schemas(op)?;
|
||||
|
||||
@@ -321,13 +343,25 @@ impl FromOpenAPI {
|
||||
.collect();
|
||||
let unbound = unbound_placeholders(&path_template, &input_schema);
|
||||
if !unbound.is_empty() {
|
||||
// OAI-13 name-the-cause: after the path-item merge the only
|
||||
// remaining sources of an unbound placeholder are refs the
|
||||
// parser could not resolve or a parameter missing `name`/`in`
|
||||
// — both fail at parse time — so a live placeholder almost
|
||||
// always means the spec relies on a feature the adapter does
|
||||
// not model (e.g. path-item `parameters` under an older
|
||||
// build, or a `parameters` $ref the parse path skipped).
|
||||
// Name the placeholder and the parameter path so the
|
||||
// diagnosis is not a dead end.
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"path {method} {path_template} declares placeholder(s) {} with no \
|
||||
matching parameter in the operation's resolved input schema (an \
|
||||
unresolved parameter$requestBodies $ref, or a parameter missing \
|
||||
`name`/`in`); the placeholder would otherwise render as a literal \
|
||||
`{}` path segment",
|
||||
matching parameter in the operation's resolved input schema. Every \
|
||||
`parameters` source was merged (path-item level and operation level, \
|
||||
review 002 OAI-13); a placeholder left unbound after the merge means \
|
||||
a declared parameter was dropped — check that each entry under \
|
||||
`paths.{path_template}.parameters` (and the path-item's shared \
|
||||
list) has `name` and `in`, and that its $ref, if any, resolves. \
|
||||
The placeholder would otherwise render as a literal `{}` path segment",
|
||||
unbound.join(", "),
|
||||
unbound[0]
|
||||
),
|
||||
@@ -413,7 +447,7 @@ impl OperationAdapter for FromOpenAPI {
|
||||
let mut routes = Vec::new();
|
||||
for (path, item) in &self.spec.paths {
|
||||
for (method, op) in &item.operations {
|
||||
let registration = self.build_registration(method, path, op)?;
|
||||
let registration = self.build_registration(method, path, op, &item.parameters)?;
|
||||
let name = registration.spec.name.clone();
|
||||
let qualified_op_id = name
|
||||
.rsplit_once('/')
|
||||
@@ -836,6 +870,57 @@ mod tests {
|
||||
assert_eq!(errors[0].http_status, Some(404));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wildcard_error_keys_project_to_class_representative_status() {
|
||||
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":{}}}},
|
||||
"4XX":{"content":{"application/json":{"schema":{"type":"object","properties":{"err":{"type":"string"}}}}}},
|
||||
"5XX":{"content":{"application/json":{"schema":{"type":"object"}}}},
|
||||
"default":{"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;
|
||||
let codes: Vec<&str> = errors.iter().map(|e| e.code.as_str()).collect();
|
||||
assert!(
|
||||
codes.contains(&"HTTP_400"),
|
||||
"`4XX` projects to the class representative HTTP_400 (OAI-13): {codes:?}"
|
||||
);
|
||||
assert!(
|
||||
codes.contains(&"HTTP_500"),
|
||||
"`5XX` projects to the class representative HTTP_500 (OAI-13): {codes:?}"
|
||||
);
|
||||
assert!(
|
||||
codes.contains(&"HTTP_404"),
|
||||
"the concrete 404 is retained alongside the wildcard: {codes:?}"
|
||||
);
|
||||
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()));
|
||||
let wildcard_4xx = errors
|
||||
.iter()
|
||||
.find(|e| e.code == "HTTP_400")
|
||||
.expect("4XX representative present");
|
||||
assert_eq!(wildcard_4xx.http_status, Some(400));
|
||||
assert!(
|
||||
wildcard_4xx
|
||||
.schema
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.is_some_and(|p| p.contains_key("err")),
|
||||
"the wildcard's declared payload schema is carried by the representative"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_response_key_is_dropped_not_advertised_as_http_0() {
|
||||
let doc = r#"{
|
||||
@@ -843,8 +928,7 @@ mod tests {
|
||||
"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":{}}}}
|
||||
"default":{"content":{"application/json":{"schema":{}}}}
|
||||
}}}}
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
@@ -856,7 +940,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"only the concrete 404 may be advertised; `default` and `5XX` are dropped"
|
||||
"only the concrete 404 may be advertised; `default` has no implied status range"
|
||||
);
|
||||
assert_eq!(errors[0].code, "HTTP_404");
|
||||
assert!(
|
||||
@@ -898,6 +982,195 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wildcard_2xx_sse_stream_classifies_as_subscription() {
|
||||
let doc = r##"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/s":{"post":{"operationId":"s","responses":{"2XX":{"content":{"text/event-stream":{"schema":{}}}}}}}}}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(spec, config("ns", "https://x", None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bundles[0].spec.op_type,
|
||||
OperationType::Sub,
|
||||
"a `2XX`-declared text/event-stream must classify as Sub, not fall through \
|
||||
to a giant single text body (OAI-13)"
|
||||
);
|
||||
assert!(
|
||||
matches!(bundles[0].handler, HandlerKind::Stream(_)),
|
||||
"the Sub classification registers a streaming handler"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concrete_success_key_outranks_2xx_wildcard() {
|
||||
let doc = r##"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/s":{"post":{"operationId":"s","responses":{
|
||||
"2XX":{"content":{"text/event-stream":{"schema":{}}}},
|
||||
"200":{"content":{"application/json":{"schema":{}}}}
|
||||
}}}}}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(spec, config("ns", "https://x", None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bundles[0].spec.op_type,
|
||||
OperationType::Mutation,
|
||||
"the concrete 200 JSON response governs; the 2XX SSE entry is not consulted"
|
||||
);
|
||||
let output = &bundles[0].spec.output_schema;
|
||||
assert_ne!(
|
||||
output.get("type").and_then(|t| t.as_str()),
|
||||
Some("string"),
|
||||
"output schema follows the concrete 200 JSON response, not the SSE wildcard"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wildcard_2xx_json_output_schema_resolves() {
|
||||
let doc = r##"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/j":{"get":{"operationId":"j","responses":{
|
||||
"2XX":{"content":{"application/json":{"schema":{"type":"object","properties":{"v":{"type":"integer"}}}}}}
|
||||
}}}}}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(spec, config("ns", "https://x", None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
let output = &bundles[0].spec.output_schema;
|
||||
assert_eq!(
|
||||
output["properties"]["v"]["type"],
|
||||
"string".replace("string", "integer"),
|
||||
"the 2XX-declared JSON schema governs the output schema (OAI-13)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_path_item_parameters_import_and_operation_overrides_win() {
|
||||
let doc = r##"{
|
||||
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||
"paths":{
|
||||
"/users/{id}/posts": {
|
||||
"parameters":[
|
||||
{"name":"id","in":"path","required":true,"schema":{"type":"string","pattern":"^u-"}},
|
||||
{"name":"verbose","in":"query","schema":{"type":"boolean"}}
|
||||
],
|
||||
"get":{
|
||||
"operationId":"listPosts",
|
||||
"parameters":[
|
||||
{"name":"verbose","in":"query","required":true,"schema":{"type":"string","maxLength":2}}
|
||||
],
|
||||
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||
},
|
||||
"delete":{
|
||||
"operationId":"deletePost",
|
||||
"responses":{"204":{"content":{"application/json":{"schema":{}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(spec, config("svc", "https://x", None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bundles.len(),
|
||||
2,
|
||||
"both operations import, not a placeholder failure"
|
||||
);
|
||||
|
||||
let get = bundles
|
||||
.iter()
|
||||
.find(|b| b.spec.name == "svc/listPosts")
|
||||
.expect("get op registered");
|
||||
let props = get
|
||||
.spec
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.expect("input schema has properties");
|
||||
assert!(
|
||||
props.contains_key("id"),
|
||||
"the shared id placeholder is bound by the path-item parameter"
|
||||
);
|
||||
let verbose = props
|
||||
.get("verbose")
|
||||
.expect("verbose declared at both levels");
|
||||
assert_eq!(
|
||||
verbose["maxLength"], 2,
|
||||
"the operation-level entry override wins on shared name+in"
|
||||
);
|
||||
let get_required = get
|
||||
.spec
|
||||
.input_schema
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.expect("required list present");
|
||||
assert!(
|
||||
get_required.iter().any(|v| v == "id"),
|
||||
"path-item required id flows into the merged requirements"
|
||||
);
|
||||
assert!(
|
||||
get_required.iter().any(|v| v == "verbose"),
|
||||
"the override's own required:true is honored"
|
||||
);
|
||||
|
||||
let delete = bundles
|
||||
.iter()
|
||||
.find(|b| b.spec.name == "svc/deletePost")
|
||||
.expect("delete op registered");
|
||||
let delete_props = delete
|
||||
.spec
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.expect("input schema has properties");
|
||||
assert!(delete_props.contains_key("id"));
|
||||
let verbose_schema = delete_props
|
||||
.get("verbose")
|
||||
.expect("inherited from path item");
|
||||
assert_eq!(
|
||||
verbose_schema["type"], "boolean",
|
||||
"no operation-level entry means the path-item declaration applies verbatim"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_path_item_parameters_defeat_the_misleading_placeholder_failure() {
|
||||
// The OAI-13 headline case: a Petstore-with-shared-params shape
|
||||
// previously failed the whole import with "declares placeholder(s)
|
||||
// id with no matching parameter", pointing nowhere near the cause.
|
||||
let doc = r##"{
|
||||
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||
"components":{"parameters":{
|
||||
"Id":{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}
|
||||
}},
|
||||
"paths":{
|
||||
"/pets/{id}": {
|
||||
"parameters":[{"$ref":"#/components/parameters/Id"}],
|
||||
"get":{
|
||||
"operationId":"showPetById",
|
||||
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(spec, config("svc", "https://x", None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bundles.len(), 1);
|
||||
let props = bundles[0]
|
||||
.spec
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.expect("input schema has properties");
|
||||
assert!(props.contains_key("id"));
|
||||
assert_eq!(props["id"]["type"], "integer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn input_schema_from_params_and_body() {
|
||||
let doc = r#"{
|
||||
|
||||
@@ -42,6 +42,16 @@ pub(crate) const MAX_REF_EXPANSION_NODES: usize = 1_000_000;
|
||||
pub(crate) const HTTP_METHODS: &[&str] =
|
||||
&["get", "post", "put", "patch", "delete", "head", "options"];
|
||||
|
||||
/// Response keys, in precedence order, under which a success envelope
|
||||
/// may be declared: concrete 2XX statuses first, then the class
|
||||
/// wildcard `2XX`, then `default` (OAI-06, review 002 OAI-13). The
|
||||
/// first key present governs SSE detection and the output schema, so a
|
||||
/// concrete status outranks the wildcard and the wildcard outranks
|
||||
/// `default`.
|
||||
pub(crate) const SUCCESS_RESPONSE_KEYS: &[&str] = &[
|
||||
"200", "201", "202", "203", "204", "205", "206", "226", "2XX", "default",
|
||||
];
|
||||
|
||||
/// The `info` block of an OpenAPI document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenAPIInfo {
|
||||
@@ -58,6 +68,12 @@ pub struct PathItem {
|
||||
/// `(method, operation)` pairs, methods lowercase as declared in the
|
||||
/// document (`get`, `post`, ...), in parsed order.
|
||||
pub operations: Vec<(String, Operation)>,
|
||||
/// Path-item-level `parameters` shared by every operation under the
|
||||
/// path (review 002 OAI-13). Operation-level entries override these
|
||||
/// per OpenAPI spec semantics (`name`+`in` identity); at parse time
|
||||
/// the two lists are concatenated and the override is resolved when
|
||||
/// the input schema is built from the merged sequence.
|
||||
pub parameters: Vec<Parameter>,
|
||||
}
|
||||
|
||||
/// One OpenAPI operation parsed into the shared model.
|
||||
@@ -262,6 +278,24 @@ impl OpenAPISpec {
|
||||
if raw.get("servers").is_some() {
|
||||
servers_locations.push("document".to_string());
|
||||
}
|
||||
if raw.get("webhooks").is_some() {
|
||||
// OAI-13: `webhooks` are consumer-registered, server-initiated
|
||||
// callbacks — the inbound direction the single-endpoint HTTP
|
||||
// adapter does not model (no inbound route table). Silently
|
||||
// vanishing them (the pre-OAI-13 behavior) hides half the
|
||||
// document's declared surface; failing the mixed document
|
||||
// names the feature. Same philosophy for `callbacks` would
|
||||
// be a separate review item.
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: "the document declares top-level `webhooks`, which the HTTP \
|
||||
adapter cannot import: webhooks are server-initiated \
|
||||
callbacks (inbound), while the adapter registers outbound \
|
||||
forwarding operations only — split the webhooks into their \
|
||||
own service definition or remove the `webhooks` key \
|
||||
(review 002 OAI-13)"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
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")) {
|
||||
@@ -325,7 +359,14 @@ impl OpenAPISpec {
|
||||
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.
|
||||
// previously skipped without a trace. Surface it. The
|
||||
// fixed path-item keys (`parameters`, `servers`,
|
||||
// `summary`, `description`) are inert at this level —
|
||||
// either mirrored into each operation (`parameters`
|
||||
// merged below, OAI-13) or deliberately unsupported
|
||||
// (`servers` rejected earlier, OAI-06) — so they do
|
||||
// not count as skipped features when no method is
|
||||
// present to receive them.
|
||||
let skipped: Vec<&str> = item
|
||||
.as_object()
|
||||
.map(|o| {
|
||||
@@ -351,7 +392,21 @@ impl OpenAPISpec {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
paths.insert(path.clone(), PathItem { operations });
|
||||
let merged = ItemParameters::parse(item, &provisional).map_err(|style_error| {
|
||||
AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"parameter `{}` on path item {path} {}: (review 001 OAI-06)",
|
||||
style_error.parameter, style_error.detail
|
||||
),
|
||||
}
|
||||
})?;
|
||||
paths.insert(
|
||||
path.clone(),
|
||||
PathItem {
|
||||
operations,
|
||||
parameters: merged,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +665,55 @@ fn parse_operation(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parses the path-item-level `parameters` array (review 002 OAI-13).
|
||||
///
|
||||
/// `$ref`ed parameters (`#/components/parameters/...`) resolve the same
|
||||
/// way operation-level ones do; an unresolvable ref or a parameter
|
||||
/// missing `name`/`in` fails import with the path named. `style`/
|
||||
/// `explode` gates (OAI-06) apply at this level too — a shared
|
||||
/// `deepObject` parameter would mis-serialize for every operation under
|
||||
/// the path, so it is refused once, here.
|
||||
struct ItemParameters;
|
||||
|
||||
impl ItemParameters {
|
||||
fn parse(item: &Value, spec: &OpenAPISpec) -> Result<Vec<Parameter>, ParameterStyleError> {
|
||||
let path_style_error = |detail: String| ParameterStyleError {
|
||||
parameter: "path-item parameters".to_string(),
|
||||
detail,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let Some(arr) = item.get("parameters").and_then(|v| v.as_array()) else {
|
||||
return Ok(out);
|
||||
};
|
||||
for p in arr {
|
||||
let p = match p.get("$ref").and_then(|r| r.as_str()) {
|
||||
Some(reference) => spec
|
||||
.resolve_ref(reference)
|
||||
.map_err(|_| path_style_error(format!("unresolvable $ref: {reference}")))?,
|
||||
None => p.clone(),
|
||||
};
|
||||
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
|
||||
return Err(path_style_error(
|
||||
"path-item parameter is missing `name`".to_string(),
|
||||
));
|
||||
};
|
||||
let Some(in_) = p.get("in").and_then(|v| v.as_str()) else {
|
||||
return Err(path_style_error(format!(
|
||||
"path-item parameter `{name}` is missing `in`"
|
||||
)));
|
||||
};
|
||||
check_parameter_style(name, in_, &p)?;
|
||||
out.push(Parameter {
|
||||
name: name.to_string(),
|
||||
in_: in_.to_string(),
|
||||
required: p.get("required").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
schema: p.get("schema").cloned(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -1446,4 +1550,166 @@ mod tests {
|
||||
"the supported path still imports alongside the skipped one"
|
||||
);
|
||||
}
|
||||
|
||||
// --- OAI-13: path-item parameters, response wildcards, webhooks ---
|
||||
|
||||
#[test]
|
||||
fn path_item_parameters_merge_into_operations() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/users/{id}/posts": {
|
||||
"parameters": [
|
||||
{"name": "id", "in": "path", "required": true,
|
||||
"schema": {"type": "string", "pattern": "^u-"}},
|
||||
{"name": "verbose", "in": "query", "schema": {"type": "boolean"}}
|
||||
],
|
||||
"get": {
|
||||
"operationId": "listPosts",
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPost",
|
||||
"requestBody": {"content": {"application/json": {"schema": {}}}},
|
||||
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).expect("shared path-item params import");
|
||||
let item = spec.paths.get("/users/{id}/posts").expect("path present");
|
||||
assert_eq!(item.parameters.len(), 2, "path-item params retained");
|
||||
assert_eq!(item.parameters[0].name, "id");
|
||||
assert_eq!(item.parameters[0].in_, "path");
|
||||
assert!(item.parameters[0].required);
|
||||
assert_eq!(item.operations.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_item_parameter_refs_to_components_resolve() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"Id": {"name": "id", "in": "path", "required": true,
|
||||
"schema": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"parameters": [{"$ref": "#/components/parameters/Id"}],
|
||||
"get": {
|
||||
"operationId": "getUser",
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).expect("ref'd path-item params import");
|
||||
let item = spec.paths.get("/users/{id}").expect("path present");
|
||||
assert_eq!(item.parameters.len(), 1);
|
||||
assert_eq!(item.parameters[0].name, "id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_item_parameter_missing_in_fails_import_naming_the_cause() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"parameters": [{"name": "id", "schema": {"type": "string"}}],
|
||||
"get": {
|
||||
"operationId": "getUser",
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
match OpenAPISpec::from_json(doc) {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("path-item parameter"),
|
||||
"the error must name the path-item parameter level: {message}"
|
||||
);
|
||||
assert!(message.contains("id"), "message was: {message}");
|
||||
}
|
||||
Ok(_) => panic!("path-item parameter missing `in` must fail loudly"),
|
||||
other => panic!("expected SchemaParse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_item_parameter_style_gate_applies() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/tags": {
|
||||
"parameters": [{
|
||||
"name": "ids", "in": "query", "style": "deepObject",
|
||||
"schema": {"type": "object"}
|
||||
}],
|
||||
"get": {
|
||||
"operationId": "listTags",
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
match OpenAPISpec::from_json(doc) {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("deepObject"), "message was: {message}");
|
||||
assert!(message.contains("OAI-06"), "message was: {message}");
|
||||
}
|
||||
Ok(_) => panic!("path-item non-default style must fail loudly"),
|
||||
other => panic!("expected SchemaParse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhooks_mixed_document_fails_import_naming_the_feature() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"webhooks": {
|
||||
"newPet": {"post": {
|
||||
"operationId": "newPet",
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}}
|
||||
},
|
||||
"paths": {
|
||||
"/pets": {"get": {
|
||||
"operationId": "listPets",
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}}
|
||||
}
|
||||
}"##;
|
||||
match OpenAPISpec::from_json(doc) {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("webhooks"), "message was: {message}");
|
||||
assert!(message.contains("OAI-13"), "message was: {message}");
|
||||
}
|
||||
Ok(_) => panic!("mixed webhooks+paths doc must fail loudly (OAI-13)"),
|
||||
other => panic!("expected SchemaParse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhooks_missing_document_still_imports() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/pets": {"get": {
|
||||
"operationId": "listPets",
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).expect("no webhooks key is the normal case");
|
||||
assert_eq!(spec.paths.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user