fix(adapters): resolve parameter/requestBody refs + reject import collisions (OAI-04, OAI-05)
- index components/parameters + requestBodies in OpenAPISpec; resolve bare $ref parameter and requestBody entries through the cycle-guarded resolver; unresolvable refs abort spec parse loudly - reject path placeholders with no matching input-schema property at registration (no more silently percent-encoded literal placeholders) - reject duplicate operationIds and path+method routes in one import batch instead of silent last-write-wins registration - also reject a parameter named 'body' shadowed by requestBody (OAI-07 adjacency, same code path) Verified: cargo test (243), --all-features (322), clippy -D warnings (default + all-features), fmt --check, doc --no-deps
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
//!
|
||||
//! [ADR-051]: https://docs.rs/alkhttp (docs/architecture/decisions)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::client::{AdapterError, OperationAdapter};
|
||||
@@ -29,6 +30,63 @@ use super::forward::{forward, forward_stream, HttpServiceConfig};
|
||||
use super::openapi_spec::{OpenAPISpec, Operation};
|
||||
use crate::client::SharedHttpClient;
|
||||
|
||||
const GATEWAY_BODY_KEY: &str = "body";
|
||||
|
||||
fn unbound_placeholders(path_template: &str, input_schema: &Value) -> Vec<String> {
|
||||
let mut unbound = Vec::new();
|
||||
let mut rest = path_template;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let Some(end_rel) = rest[start..].find('}') else {
|
||||
break;
|
||||
};
|
||||
let name = &rest[start + 1..start + end_rel];
|
||||
if !name.is_empty() {
|
||||
let properties = input_schema
|
||||
.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.map(|p| p.keys().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
if !properties.iter().any(|k| k == name) {
|
||||
unbound.push(name.to_string());
|
||||
}
|
||||
}
|
||||
rest = &rest[start + end_rel + 1..];
|
||||
}
|
||||
unbound
|
||||
}
|
||||
|
||||
fn collision_message(batch: &str, kind: &str, first_path: &str) -> AdapterError {
|
||||
AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"duplicate {kind} in import batch: `{batch}` (first registered from \
|
||||
{first_path}; the registry would silently replace the earlier \
|
||||
registration — disambiguate the operationIds or paths)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_collisions(
|
||||
op_ids: Vec<String>,
|
||||
paths: Vec<String>,
|
||||
routes: Vec<(String, String)>,
|
||||
) -> Result<(), AdapterError> {
|
||||
assert_eq!(op_ids.len(), routes.len());
|
||||
assert_eq!(op_ids.len(), paths.len());
|
||||
let mut seen_names: HashMap<&str, &str> = HashMap::new();
|
||||
let mut seen_routes: HashMap<&(String, String), &str> = HashMap::new();
|
||||
for ((op_id, path), route) in op_ids.iter().zip(paths.iter()).zip(routes.iter()) {
|
||||
if let Some(first_path) = seen_names.get(op_id.as_str()) {
|
||||
return Err(collision_message(op_id, "operationId", first_path));
|
||||
}
|
||||
if let Some(first_path) = seen_routes.get(route) {
|
||||
return Err(collision_message(op_id, "path+method", first_path));
|
||||
}
|
||||
seen_names.insert(op_id, path);
|
||||
seen_routes.insert(route, path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct FromOpenAPI {
|
||||
spec: OpenAPISpec,
|
||||
config: HttpServiceConfig,
|
||||
@@ -87,6 +145,15 @@ impl FromOpenAPI {
|
||||
Some(s) => self.spec.resolve_refs_recursive(s)?,
|
||||
None => serde_json::json!({"type": "string"}),
|
||||
};
|
||||
if param.name == GATEWAY_BODY_KEY && op.request_body.is_some() {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"parameter named `{GATEWAY_BODY_KEY}` collides with the gateway's \
|
||||
requestBody placeholder key; the parameter would be diverted into \
|
||||
the request body at call time — rename the parameter"
|
||||
),
|
||||
});
|
||||
}
|
||||
properties.insert(param.name.clone(), schema);
|
||||
if param.required {
|
||||
required.push(param.name.clone());
|
||||
@@ -167,13 +234,12 @@ impl FromOpenAPI {
|
||||
qualified_name,
|
||||
op_type,
|
||||
Visibility::Internal,
|
||||
input_schema,
|
||||
input_schema.clone(),
|
||||
output_schema,
|
||||
error_schemas,
|
||||
AccessControl::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
let path_template = path.to_string();
|
||||
let method_upper = method.to_ascii_uppercase();
|
||||
let auth_scheme = self.config.auth.clone();
|
||||
@@ -186,6 +252,20 @@ impl FromOpenAPI {
|
||||
.iter()
|
||||
.map(|e| (e.http_status.unwrap_or(0), e.code.clone()))
|
||||
.collect();
|
||||
let unbound = unbound_placeholders(&path_template, &input_schema);
|
||||
if !unbound.is_empty() {
|
||||
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",
|
||||
unbound.join(", "),
|
||||
unbound[0]
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let handler = if op_type == OperationType::Sub {
|
||||
let stream_handler =
|
||||
@@ -259,12 +339,24 @@ impl FromOpenAPI {
|
||||
impl OperationAdapter for FromOpenAPI {
|
||||
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
||||
let mut bundles = Vec::new();
|
||||
let mut op_ids = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
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 name = registration.spec.name.clone();
|
||||
let qualified_op_id = name
|
||||
.rsplit_once('/')
|
||||
.map(|(_, id)| id.to_string())
|
||||
.unwrap_or_else(|| name.clone());
|
||||
op_ids.push(qualified_op_id);
|
||||
paths.push(path.clone());
|
||||
routes.push((path.clone(), method.to_ascii_uppercase()));
|
||||
bundles.push(registration);
|
||||
}
|
||||
}
|
||||
reject_collisions(op_ids, paths, routes)?;
|
||||
Ok(bundles)
|
||||
}
|
||||
}
|
||||
@@ -395,7 +487,10 @@ mod tests {
|
||||
let doc = r#"{
|
||||
"openapi": "3.0.0",
|
||||
"info": { "title": "T", "version": "1" },
|
||||
"paths": { "/users/{id}/posts": { "get": { "responses": { "200": { "content": { "application/json": { "schema": {} } } } } } } }
|
||||
"paths": { "/users/{id}/posts": { "get": {
|
||||
"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}],
|
||||
"responses": { "200": { "content": { "application/json": { "schema": {} } } } }
|
||||
} } }
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let adapter = adapter(spec, config("svc", "https://api.example.com", None));
|
||||
@@ -403,6 +498,217 @@ mod tests {
|
||||
assert_eq!(bundles[0].spec.name, "svc/get_users_posts");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_operation_ids_rejected_at_import() {
|
||||
let doc = r#"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/a/x": {"get": {"operationId": "dup", "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}},
|
||||
"/b/y": {"get": {"operationId": "dup", "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}}
|
||||
}
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let result = adapter(spec, config("svc", "https://x", None))
|
||||
.import()
|
||||
.await;
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("duplicate operationId"),
|
||||
"message was: {message}"
|
||||
);
|
||||
assert!(message.contains("dup"), "message was: {message}");
|
||||
}
|
||||
Ok(bundles) => panic!(
|
||||
"expected duplicate-operationId rejection, got {} bundles",
|
||||
bundles.len()
|
||||
),
|
||||
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_id_collision_between_template_and_literal_path_rejected() {
|
||||
let doc = r#"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/x/{id}/y": {"get": {"parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}},
|
||||
"/x/y": {"get": {"responses": {"200": {"content": {"application/json": {"schema": {}}}}}}}
|
||||
}
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let result = adapter(spec, config("svc", "https://x", None))
|
||||
.import()
|
||||
.await;
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("duplicate operationId"),
|
||||
"message was: {message}"
|
||||
);
|
||||
assert!(message.contains("get_x_y"), "message was: {message}");
|
||||
}
|
||||
Ok(bundles) => panic!(
|
||||
"expected generated-id collision rejection, got {} bundles",
|
||||
bundles.len()
|
||||
),
|
||||
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_collision_check_rejects_duplicate_path_method() {
|
||||
let result = reject_collisions(
|
||||
vec!["a".to_string(), "b".to_string()],
|
||||
vec!["/shared".to_string(), "/other".to_string()],
|
||||
vec![
|
||||
("/shared".to_string(), "GET".to_string()),
|
||||
("/shared".to_string(), "GET".to_string()),
|
||||
],
|
||||
);
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("duplicate path+method"),
|
||||
"message was: {message}"
|
||||
);
|
||||
}
|
||||
Ok(()) => panic!("expected path+method collision rejection"),
|
||||
other => panic!("expected SchemaParse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unresolved_path_placeholder_fails_import_loudly() {
|
||||
let doc = r#"{
|
||||
"openapi": "3.0.0",
|
||||
"info": { "title": "T", "version": "1" },
|
||||
"paths": { "/users/{id}/posts": { "get": { "responses": { "200": { "content": { "application/json": { "schema": {} } } } } } } }
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let adapter = adapter(spec, config("svc", "https://api.example.com", None));
|
||||
match adapter.import().await {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("placeholder"), "message was: {message}");
|
||||
assert!(message.contains("id"), "message was: {message}");
|
||||
}
|
||||
Ok(bundles) => panic!(
|
||||
"expected unresolved-placeholder import error, got {} bundles",
|
||||
bundles.len()
|
||||
),
|
||||
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refd_parameters_and_request_body_resolve_into_op_schema() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Widget": {"type": "object", "properties": {"name": {"type": "string"}}}
|
||||
},
|
||||
"parameters": {
|
||||
"Id": {
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"requestBodies": {
|
||||
"WidgetInput": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Widget"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/widgets/{id}": {"put": {
|
||||
"operationId": "replaceWidget",
|
||||
"parameters": [{"$ref": "#/components/parameters/Id"}],
|
||||
"requestBody": {"$ref": "#/components/requestBodies/WidgetInput"},
|
||||
"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")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap();
|
||||
assert!(props.contains_key("id"), "ref'd path param present");
|
||||
let body = props.get("body").unwrap();
|
||||
assert_eq!(body["type"], "object");
|
||||
assert!(
|
||||
body.get("properties")
|
||||
.and_then(|p| p.as_object())
|
||||
.unwrap()
|
||||
.contains_key("name"),
|
||||
"recursive $ref (requestBody -> schemas/Widget) fully expanded, not empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_referential_schema_behind_refd_parameter_fails_cleanly_not_abort() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Node": {"type": "object", "properties": {
|
||||
"next": {"$ref": "#/components/schemas/Node"}
|
||||
}}
|
||||
},
|
||||
"requestBodies": {
|
||||
"NodeInput": {
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/requestBodies/NodeInput"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/nodes": {"post": {
|
||||
"operationId": "createNode",
|
||||
"requestBody": {"$ref": "#/components/requestBodies/NodeInput"},
|
||||
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||
}}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let result = adapter(spec, config("svc", "https://x", None))
|
||||
.import()
|
||||
.await;
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("circular $ref"),
|
||||
"clean resolver error, message was: {message}"
|
||||
);
|
||||
}
|
||||
Ok(bundles) => panic!(
|
||||
"expected clean circular-$ref error, got {} bundles",
|
||||
bundles.len()
|
||||
),
|
||||
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn op_type_detection() {
|
||||
let get_doc = r#"{"openapi":"3.0.0","info":{"title":"T","version":"1"},"paths":{"/g":{"get":{"operationId":"g","responses":{"200":{"content":{"application/json":{"schema":{}}}}}}}}}"#;
|
||||
|
||||
+170
-37
@@ -59,6 +59,18 @@ pub struct Response {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Components {
|
||||
pub schemas: HashMap<String, Value>,
|
||||
pub parameters: HashMap<String, Value>,
|
||||
pub request_bodies: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
fn index_component_map(raw: Option<&Value>) -> HashMap<String, Value> {
|
||||
let mut map = HashMap::new();
|
||||
if let Some(obj) = raw.and_then(|m| m.as_object()) {
|
||||
for (k, v) in obj {
|
||||
map.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -147,6 +159,21 @@ impl OpenAPISpec {
|
||||
});
|
||||
}
|
||||
|
||||
let provisional = Self {
|
||||
info: info.clone(),
|
||||
paths: BTreeMap::new(),
|
||||
components: Some(Components {
|
||||
schemas: index_component_map(raw.get("components").and_then(|c| c.get("schemas"))),
|
||||
parameters: index_component_map(
|
||||
raw.get("components").and_then(|c| c.get("parameters")),
|
||||
),
|
||||
request_bodies: index_component_map(
|
||||
raw.get("components").and_then(|c| c.get("requestBodies")),
|
||||
),
|
||||
}),
|
||||
raw: raw.clone(),
|
||||
};
|
||||
|
||||
let mut paths = BTreeMap::new();
|
||||
if let Some(paths_obj) = paths_raw.as_object() {
|
||||
for (path, item) in paths_obj {
|
||||
@@ -156,8 +183,15 @@ impl OpenAPISpec {
|
||||
let mut operations = Vec::new();
|
||||
for method in HTTP_METHODS {
|
||||
if let Some(op_raw) = item.get(*method) {
|
||||
if let Some(op) = parse_operation(op_raw) {
|
||||
if let Some(op) = parse_operation(op_raw, &provisional) {
|
||||
operations.push((method.to_string(), op));
|
||||
} else {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"unresolvable $ref or missing `name`/`in` in parameter of \
|
||||
{method} {path}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,21 +202,11 @@ impl OpenAPISpec {
|
||||
}
|
||||
}
|
||||
|
||||
let components = raw
|
||||
.get("components")
|
||||
.and_then(|c| c.get("schemas"))
|
||||
.and_then(|schemas| {
|
||||
if !schemas.is_object() {
|
||||
return None;
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
if let Some(schemas_obj) = schemas.as_object() {
|
||||
for (k, v) in schemas_obj {
|
||||
map.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
Some(Components { schemas: map })
|
||||
});
|
||||
let components = raw.get("components").map(|c| Components {
|
||||
schemas: index_component_map(c.get("schemas")),
|
||||
parameters: index_component_map(c.get("parameters")),
|
||||
request_bodies: index_component_map(c.get("requestBodies")),
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
@@ -280,7 +304,7 @@ impl OpenAPISpec {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_operation(raw: &Value) -> Option<Operation> {
|
||||
fn parse_operation(raw: &Value, spec: &OpenAPISpec) -> Option<Operation> {
|
||||
if !raw.is_object() {
|
||||
return None;
|
||||
}
|
||||
@@ -289,28 +313,34 @@ fn parse_operation(raw: &Value) -> Option<Operation> {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let parameters = raw
|
||||
.get("parameters")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|p| {
|
||||
let name = p.get("name")?.as_str()?.to_string();
|
||||
let in_ = p.get("in")?.as_str()?.to_string();
|
||||
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let schema = p.get("schema").cloned();
|
||||
Some(Parameter {
|
||||
name,
|
||||
in_,
|
||||
required,
|
||||
schema,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut parameters = Vec::new();
|
||||
if let Some(arr) = raw.get("parameters").and_then(|v| v.as_array()) {
|
||||
for p in arr {
|
||||
let p = match p.get("$ref").and_then(|r| r.as_str()) {
|
||||
Some(reference) => match spec.resolve_ref(reference) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(_) => return None,
|
||||
},
|
||||
None => p.clone(),
|
||||
};
|
||||
let name = p.get("name")?.as_str()?.to_string();
|
||||
let in_ = p.get("in")?.as_str()?.to_string();
|
||||
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let schema = p.get("schema").cloned();
|
||||
parameters.push(Parameter {
|
||||
name,
|
||||
in_,
|
||||
required,
|
||||
schema,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let request_body = raw.get("requestBody").and_then(|rb| {
|
||||
let rb = match rb.get("$ref").and_then(|r| r.as_str()) {
|
||||
Some(reference) => spec.resolve_ref(reference).ok()?,
|
||||
None => rb.clone(),
|
||||
};
|
||||
let content_obj = rb.get("content")?.as_object()?;
|
||||
let mut content = BTreeMap::new();
|
||||
for (k, v) in content_obj {
|
||||
@@ -377,6 +407,109 @@ mod tests {
|
||||
current
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_ref_to_components_resolves_into_operation() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"Id": {
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "string", "pattern": "^[0-9]+$"}
|
||||
},
|
||||
"Q": {"name": "q", "in": "query", "schema": {"type": "boolean"}}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/users/{id}": {"get": {
|
||||
"operationId": "getUser",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/Id"},
|
||||
{"$ref": "#/components/parameters/Q"}
|
||||
],
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let item = spec.paths.get("/users/{id}").expect("path present");
|
||||
let (_, op) = &item.operations[0];
|
||||
assert_eq!(op.operation_id.as_deref(), Some("getUser"));
|
||||
assert_eq!(op.parameters.len(), 2);
|
||||
assert_eq!(op.parameters[0].name, "id");
|
||||
assert_eq!(op.parameters[0].in_, "path");
|
||||
assert!(op.parameters[0].required);
|
||||
let schema = op.parameters[0].schema.as_ref().expect("schema present");
|
||||
assert_eq!(schema["pattern"], "^[0-9]+$");
|
||||
assert_eq!(op.parameters[1].name, "q");
|
||||
assert_eq!(op.parameters[1].in_, "query");
|
||||
assert!(!op.parameters[1].required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_ref_to_components_resolves() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"components": {
|
||||
"requestBodies": {
|
||||
"WidgetInput": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"type": "object", "properties": {"name": {"type": "string"}}}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/widgets": {"post": {
|
||||
"operationId": "createWidget",
|
||||
"requestBody": {"$ref": "#/components/requestBodies/WidgetInput"},
|
||||
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
|
||||
}}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let item = spec.paths.get("/widgets").expect("path present");
|
||||
let (_, op) = &item.operations[0];
|
||||
let rb = op.request_body.as_ref().expect("requestBody resolved");
|
||||
let schema = rb.content.get("application/json").expect("json content");
|
||||
let props = schema.get("properties").expect("schema expanded");
|
||||
assert!(props.get("name").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_ref_to_missing_component_fails_import_loudly() {
|
||||
let doc = r##"{
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "T", "version": "1"},
|
||||
"paths": {
|
||||
"/x": {"get": {
|
||||
"operationId": "x",
|
||||
"parameters": [{"$ref": "#/components/parameters/Missing"}],
|
||||
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
|
||||
}}
|
||||
}
|
||||
}"##;
|
||||
let spec = OpenAPISpec::from_json(doc);
|
||||
match spec {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("unresolvable $ref"),
|
||||
"message was: {message}"
|
||||
);
|
||||
assert!(message.contains("get /x"), "message was: {message}");
|
||||
}
|
||||
Ok(_) => panic!("expected unresolvable-$ref error, spec parsed happily"),
|
||||
other => panic!("expected SchemaParse, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_referential_ref_errors_instead_of_aborting() {
|
||||
let spec = schema_test_spec(json!({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: review-001-openapi-import-integrity
|
||||
name: Resolve parameter/requestBody $refs; detect import collisions (OAI-04, OAI-05)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [review-001-ref-cycle-guard]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
@@ -36,10 +36,10 @@ silently at call time:
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `{"$ref": "#/components/parameters/…"}` params and `requestBody` refs resolve into the op's schema (test)
|
||||
- [ ] Unresolved path placeholders fail loudly (import-time, or call-time with a loud error — tested, not silently `%7Bowner%7D`)
|
||||
- [ ] Duplicate operation IDs in one import batch are detected and rejected (or deterministically disambiguated + warned — tested)
|
||||
- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
|
||||
- [x] `{"$ref": "#/components/parameters/…"}` params and `requestBody` refs resolve into the op's schema (test)
|
||||
- [x] Unresolved path placeholders fail loudly (import-time, or call-time with a loud error — tested, not silently `%7Bowner%7D`)
|
||||
- [x] Duplicate operation IDs in one import batch are detected and rejected (or deterministically disambiguated + warned — tested)
|
||||
- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
|
||||
|
||||
## References
|
||||
|
||||
@@ -51,6 +51,63 @@ silently at call time:
|
||||
> the resolver is already hardened when the new ref kinds are wired
|
||||
> through it.
|
||||
|
||||
Implementation notes. The `Components` struct now indexes
|
||||
`parameters` and `requestBodies` alongside `schemas`
|
||||
(`index_component_map`). `parse_operation` takes the spec and resolves
|
||||
a bare `{"$ref": …}` parameter entry or `requestBody` through
|
||||
`resolve_ref` before field extraction; an unresolvable ref aborts
|
||||
`from_value` with a loud `SchemaParse` naming `"{method} {path}"` —
|
||||
the old silently-skipped entry is gone.
|
||||
|
||||
OAI-04 guard at both layers:
|
||||
|
||||
1. Import-time (`from_value`): a parameter that yields no `name`/`in`
|
||||
(including via a failed ref) fails the parse. Ref resolution for
|
||||
the ref'd *bodies* of parameters (`schema`) still flows through the
|
||||
cycle-guard's `resolve_refs_recursive`, so a recursive schema
|
||||
reached via a components/parameters or requestBodies import fails
|
||||
with the clean `circular $ref` error rather than aborting or
|
||||
emitting an empty schema.
|
||||
2. Call-time-adjacent (`FromOpenAPI::build_registration`):
|
||||
`unbound_placeholders` crosses the path template against the built
|
||||
input schema; any `{placeholder}` with no matching property fails
|
||||
the registration with an error naming the placeholder — the
|
||||
literal-`%7Bowner%7D` request can no longer be constructed.
|
||||
|
||||
OAI-05: `reject_collisions` checks the batch for duplicate
|
||||
unqualified operationIds and duplicate (path, method) routes before
|
||||
returning; both rejection modes are loud `SchemaParse` errors quoting
|
||||
the colliding id and the first path that registered it. The
|
||||
path+method arm is defense-in-depth: a single document's `paths` map
|
||||
cannot hold the same method key twice (JSON object keys are unique,
|
||||
and `HTTP_METHODS` is lowercase-only), but the same check covers any
|
||||
future batch composition path (e.g. multi-spec merges) where a
|
||||
collision could otherwise silently overwrite.
|
||||
|
||||
## Summary
|
||||
|
||||
> Filled on completion.
|
||||
Remediated OAI-04 + OAI-05. OAI-04: `OpenAPISpec::from_value` now
|
||||
indexes `components/parameters` and `components/requestBodies`
|
||||
(previously only `schemas`), and `parse_operation` resolves
|
||||
`{"$ref": …}` parameter and requestBody entries through the existing
|
||||
`resolve_ref`/`resolve_refs_recursive` machinery (depth budget +
|
||||
branch-scoped cycle guard from the OAI-01 work apply unchanged). A
|
||||
parameter ref that cannot resolve, or a resolved parameter lacking
|
||||
`name`/`in`, aborts spec parse with a `SchemaParse` naming the method
|
||||
and path; additionally `build_registration` now rejects any path
|
||||
template placeholder with no matching input-schema property,
|
||||
eliminating the silent literal-`%7Bowner%7D` substitution at call
|
||||
time (verified by a regression test using the review's exact
|
||||
scenario). OAI-05: `import()` routes its batch through
|
||||
`reject_collisions`, which loudly rejects a batch containing two
|
||||
operations with the same unqualified operationId (including the
|
||||
generated-id collision `/x/{id}/y` vs `/x/y` → `get_x_y`, tested) or
|
||||
the same (path, method) route, instead of letting the registry's
|
||||
silent last-write-wins shadow the earlier op. The OAI-07
|
||||
"`body` parameter shadowing" hazard was also closed opportunistically
|
||||
since its fix lives in the same function (`build_input_schema`): a
|
||||
declared parameter named `body` on an operation that also declares a
|
||||
requestBody fails at import. Tests: 9 new (3 openapi_spec parsing,
|
||||
6 from_openapi import/registration); full suite 243 lib +
|
||||
--all-features green, clippy `-D warnings` (default + all-features)
|
||||
clean, fmt clean.
|
||||
Reference in New Issue
Block a user