Merge branch 'wt/review-002-oai13-path-item-wildcards'

This commit is contained in:
2026-08-30 20:36:33 +00:00
3 changed files with 608 additions and 54 deletions
@@ -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
View File
@@ -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 &param.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#"{
+268 -2
View File
@@ -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);
}
}