feat(adapters): path-item params merge, response wildcards, loud webhooks (OAI-13)
- path-item-level `parameters` parse into PathItem and merge into every operation's input schema; operation-level entries override shared name+in duplicates (last-insert wins) - success sweep accepts `2XX` after concrete 2XX keys and before `default` (SSE detection + output schema; concrete outranks wildcard) - `4XX`/`5XX` error keys project to their class representative status (`HTTP_400`/`HTTP_500`) instead of dropping silently; `default` still drops loudly (no implied range) - top-level `webhooks` fails import naming the feature (inbound callbacks are outside the single-endpoint outbound adapter model) - unbound-placeholder error names the parameter-merge state so the diagnosis no longer dead-ends Verification: cargo test (174 lib tests), cargo fmt
This commit is contained in:
+325
-52
@@ -30,7 +30,7 @@ use super::forward::{
|
|||||||
forward, forward_stream, HttpServiceConfig, GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER,
|
forward, forward_stream, HttpServiceConfig, GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER,
|
||||||
HEADER_PARAM_MARKER_VALUE,
|
HEADER_PARAM_MARKER_VALUE,
|
||||||
};
|
};
|
||||||
use super::openapi_spec::{OpenAPISpec, Operation};
|
use super::openapi_spec::{OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS};
|
||||||
use crate::client::SharedHttpClient;
|
use crate::client::SharedHttpClient;
|
||||||
|
|
||||||
fn unbound_placeholders(path_template: &str, input_schema: &Value) -> Vec<String> {
|
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 {
|
fn detect_op_type(method: &str, op: &Operation) -> OperationType {
|
||||||
// OAI-06: the success envelope may be declared under any 2XX key
|
// OAI-06: the success envelope may be declared under any 2XX key
|
||||||
// (204, 206, 226...) or under `default` — a stream declared there
|
// (204, 206, 226...), under the class wildcard `2XX` (review 002
|
||||||
// must still classify as Sub, not fall through to a giant
|
// OAI-13), or under `default` — a stream declared there must
|
||||||
// single-string text body.
|
// still classify as Sub, not fall through to a giant single-
|
||||||
let success = [
|
// string text body. [`SUCCESS_RESPONSE_KEYS`] is in precedence
|
||||||
"200", "201", "202", "203", "204", "205", "206", "226", "default",
|
// order: concrete statuses outrank the wildcard, which outranks
|
||||||
]
|
// `default`.
|
||||||
.iter()
|
let success = SUCCESS_RESPONSE_KEYS
|
||||||
.find_map(|k| op.responses.get(*k));
|
.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;
|
||||||
@@ -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 properties = serde_json::Map::new();
|
||||||
let mut required = Vec::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 {
|
let schema = match ¶m.schema {
|
||||||
Some(s) => self.spec.resolve_refs_recursive(s)?,
|
Some(s) => self.spec.resolve_refs_recursive(s)?,
|
||||||
None => serde_json::json!({"type": "string"}),
|
None => serde_json::json!({"type": "string"}),
|
||||||
@@ -163,9 +175,9 @@ impl FromOpenAPI {
|
|||||||
return Err(AdapterError::SchemaParse {
|
return Err(AdapterError::SchemaParse {
|
||||||
message: format!(
|
message: format!(
|
||||||
"parameter named `{GATEWAY_BODY_KEY}` collides with the gateway's \
|
"parameter named `{GATEWAY_BODY_KEY}` collides with the gateway's \
|
||||||
requestBody placeholder key; the declared parameter would be \
|
requestBody placeholder key; the declared parameter (path-item \
|
||||||
diverted into the request body at call time — rename the \
|
level or operation-level) would be diverted into the request \
|
||||||
parameter (review 001 OAI-07)"
|
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> {
|
fn build_output_schema(&self, op: &Operation) -> Result<Value, AdapterError> {
|
||||||
// Mirrors `detect_op_type`'s success-key sweep (OAI-06): a stream
|
// Mirrors `detect_op_type`'s success-key sweep (OAI-06, OAI-13):
|
||||||
// declared under a non-200/201 2XX key or `default` still governs
|
// a stream declared under a non-200/201 2XX key, the `2XX`
|
||||||
// the output schema shape.
|
// wildcard, or `default` still governs the output schema shape.
|
||||||
let success = [
|
let success = SUCCESS_RESPONSE_KEYS
|
||||||
"200", "201", "202", "203", "204", "205", "206", "226", "default",
|
.iter()
|
||||||
]
|
.find_map(|k| op.responses.get(*k));
|
||||||
.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!({}));
|
||||||
};
|
};
|
||||||
@@ -250,35 +260,46 @@ impl FromOpenAPI {
|
|||||||
if is_2xx {
|
if is_2xx {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// OAI-06: `default`/wildcard response keys have no concrete
|
// OAI-13: class wildcards map to the first legal concrete
|
||||||
// status. An `ErrorDefinition { code: "HTTP_0" }` would be
|
// status in their implied range — `4XX` → HTTP_400, `5XX` →
|
||||||
// advertised by `/search` yet never match a real status, and
|
// HTTP_500 (ADR-023 codes must be a concrete `HTTP_<status>`;
|
||||||
// the runtime mapper synthesizes `HTTP_<actual>` for unmapped
|
// the runtime mapper synthesizes the actual status for any
|
||||||
// statuses anyway — so the entry is dropped, loudly, rather
|
// unmapped one, so the projection is a faithful
|
||||||
// than advertised dead. Callers needing a catch-all declare
|
// representative, not a catch-all lie). `default` has no
|
||||||
// explicit statuses.
|
// implied range at all: an `HTTP_0` entry would be advertised
|
||||||
if status.is_none() {
|
// by `/search` yet never match a real status, so it is
|
||||||
tracing::warn!(
|
// dropped, loudly (review 001 OAI-06). A line recording this
|
||||||
operation = %op.operation_id.as_deref().unwrap_or("?"),
|
// mapping lives in ADR-066.
|
||||||
namespace = %self.config.namespace,
|
let wildcard_status: Option<u16> = match code.as_str() {
|
||||||
response_key = %code,
|
"4XX" => Some(400),
|
||||||
"response key is not a concrete HTTP status; dropping it from the \
|
"5XX" => Some(500),
|
||||||
imported error schemas — unmapped upstream statuses surface as \
|
_ => None,
|
||||||
HTTP_<status> at call time (review 001 OAI-06)"
|
};
|
||||||
);
|
let (status_code, effective_status) = match (status, wildcard_status) {
|
||||||
continue;
|
(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") {
|
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 {
|
||||||
serde_json::json!({})
|
serde_json::json!({})
|
||||||
};
|
};
|
||||||
let status_code = status.unwrap_or(0);
|
|
||||||
out.push(ErrorDefinition {
|
out.push(ErrorDefinition {
|
||||||
code: format!("HTTP_{status_code}"),
|
code: format!("HTTP_{status_code}"),
|
||||||
description: format!("HTTP {status_code} response"),
|
description: format!("HTTP {status_code} response ({code} declared)"),
|
||||||
schema,
|
schema,
|
||||||
http_status: status,
|
http_status: effective_status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -289,11 +310,12 @@ impl FromOpenAPI {
|
|||||||
method: &str,
|
method: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
op: &Operation,
|
op: &Operation,
|
||||||
|
path_parameters: &[Parameter],
|
||||||
) -> Result<HandlerRegistration, AdapterError> {
|
) -> Result<HandlerRegistration, AdapterError> {
|
||||||
let name = Self::normalize_operation_id(op, method, path);
|
let name = Self::normalize_operation_id(op, method, path);
|
||||||
let qualified_name = format!("{}/{name}", self.config.namespace);
|
let qualified_name = format!("{}/{name}", self.config.namespace);
|
||||||
let op_type = Self::detect_op_type(method, op);
|
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 output_schema = self.build_output_schema(op)?;
|
||||||
let error_schemas = self.build_error_schemas(op)?;
|
let error_schemas = self.build_error_schemas(op)?;
|
||||||
|
|
||||||
@@ -321,13 +343,25 @@ impl FromOpenAPI {
|
|||||||
.collect();
|
.collect();
|
||||||
let unbound = unbound_placeholders(&path_template, &input_schema);
|
let unbound = unbound_placeholders(&path_template, &input_schema);
|
||||||
if !unbound.is_empty() {
|
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 {
|
return Err(AdapterError::SchemaParse {
|
||||||
message: format!(
|
message: format!(
|
||||||
"path {method} {path_template} declares placeholder(s) {} with no \
|
"path {method} {path_template} declares placeholder(s) {} with no \
|
||||||
matching parameter in the operation's resolved input schema (an \
|
matching parameter in the operation's resolved input schema. Every \
|
||||||
unresolved parameter$requestBodies $ref, or a parameter missing \
|
`parameters` source was merged (path-item level and operation level, \
|
||||||
`name`/`in`); the placeholder would otherwise render as a literal \
|
review 002 OAI-13); a placeholder left unbound after the merge means \
|
||||||
`{}` path segment",
|
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.join(", "),
|
||||||
unbound[0]
|
unbound[0]
|
||||||
),
|
),
|
||||||
@@ -413,7 +447,7 @@ impl OperationAdapter for FromOpenAPI {
|
|||||||
let mut routes = Vec::new();
|
let mut routes = Vec::new();
|
||||||
for (path, item) in &self.spec.paths {
|
for (path, item) in &self.spec.paths {
|
||||||
for (method, op) in &item.operations {
|
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 name = registration.spec.name.clone();
|
||||||
let qualified_op_id = name
|
let qualified_op_id = name
|
||||||
.rsplit_once('/')
|
.rsplit_once('/')
|
||||||
@@ -836,6 +870,57 @@ mod tests {
|
|||||||
assert_eq!(errors[0].http_status, Some(404));
|
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]
|
#[tokio::test]
|
||||||
async fn default_response_key_is_dropped_not_advertised_as_http_0() {
|
async fn default_response_key_is_dropped_not_advertised_as_http_0() {
|
||||||
let doc = r#"{
|
let doc = r#"{
|
||||||
@@ -843,8 +928,7 @@ mod tests {
|
|||||||
"paths":{"/x":{"get":{"operationId":"x","responses":{
|
"paths":{"/x":{"get":{"operationId":"x","responses":{
|
||||||
"200":{"content":{"application/json":{"schema":{}}}},
|
"200":{"content":{"application/json":{"schema":{}}}},
|
||||||
"404":{"content":{"application/json":{"schema":{}}}},
|
"404":{"content":{"application/json":{"schema":{}}}},
|
||||||
"default":{"content":{"application/json":{"schema":{}}}},
|
"default":{"content":{"application/json":{"schema":{}}}}
|
||||||
"5XX":{"content":{"application/json":{"schema":{}}}}
|
|
||||||
}}}}
|
}}}}
|
||||||
}"#;
|
}"#;
|
||||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||||
@@ -856,7 +940,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
errors.len(),
|
errors.len(),
|
||||||
1,
|
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_eq!(errors[0].code, "HTTP_404");
|
||||||
assert!(
|
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]
|
#[tokio::test]
|
||||||
async fn input_schema_from_params_and_body() {
|
async fn input_schema_from_params_and_body() {
|
||||||
let doc = r#"{
|
let doc = r#"{
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ pub(crate) const MAX_REF_EXPANSION_NODES: usize = 1_000_000;
|
|||||||
pub(crate) const HTTP_METHODS: &[&str] =
|
pub(crate) const HTTP_METHODS: &[&str] =
|
||||||
&["get", "post", "put", "patch", "delete", "head", "options"];
|
&["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.
|
/// The `info` block of an OpenAPI document.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct OpenAPIInfo {
|
pub struct OpenAPIInfo {
|
||||||
@@ -58,6 +68,12 @@ pub struct PathItem {
|
|||||||
/// `(method, operation)` pairs, methods lowercase as declared in the
|
/// `(method, operation)` pairs, methods lowercase as declared in the
|
||||||
/// document (`get`, `post`, ...), in parsed order.
|
/// document (`get`, `post`, ...), in parsed order.
|
||||||
pub operations: Vec<(String, Operation)>,
|
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.
|
/// One OpenAPI operation parsed into the shared model.
|
||||||
@@ -262,6 +278,24 @@ impl OpenAPISpec {
|
|||||||
if raw.get("servers").is_some() {
|
if raw.get("servers").is_some() {
|
||||||
servers_locations.push("document".to_string());
|
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() {
|
if let Some(paths_obj) = paths_raw.as_object() {
|
||||||
for (path, item) in paths_obj {
|
for (path, item) in paths_obj {
|
||||||
if item.as_object().is_some_and(|o| o.contains_key("servers")) {
|
if item.as_object().is_some_and(|o| o.contains_key("servers")) {
|
||||||
@@ -325,7 +359,14 @@ impl OpenAPISpec {
|
|||||||
if operations.is_empty() {
|
if operations.is_empty() {
|
||||||
// OAI-06: `trace` is not in the supported method set;
|
// OAI-06: `trace` is not in the supported method set;
|
||||||
// a path entry carrying only unsupported methods was
|
// 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
|
let skipped: Vec<&str> = item
|
||||||
.as_object()
|
.as_object()
|
||||||
.map(|o| {
|
.map(|o| {
|
||||||
@@ -351,7 +392,21 @@ impl OpenAPISpec {
|
|||||||
}
|
}
|
||||||
continue;
|
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
|
/// A parameter whose `style`/`explode` declaration the adapter cannot
|
||||||
/// serialize faithfully. Carries the parameter identity so the import
|
/// serialize faithfully. Carries the parameter identity so the import
|
||||||
/// error names the feature (OAI-06 cookie-style loudness).
|
/// error names the feature (OAI-06 cookie-style loudness).
|
||||||
@@ -1446,4 +1550,166 @@ mod tests {
|
|||||||
"the supported path still imports alongside the skipped one"
|
"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