fix(adapters): input-schema enforcement + header/cookie params (OAI-02, OAI-03, OAI-07, OAI-09)
- build_request takes the op's input_schema and rejects undeclared input keys (INVALID_INPUT) before any outbound request is built; explicit `additionalProperties: true` opts into catch-all input; non-object inputs rejected (OAI-02) - in: header parameters are stamped `wire: header` in the generated input schema and sent as upstream request headers, not query params; in: cookie fails import with a clear error (OAI-03) - a spec parameter named `body` is rejected at import unconditionally (OAI-07) - FromJsonSchema::new returns Result and validates method/path template/ base_url at construction; registered visibility forced to Internal like from_openapi; module doc corrected (OAI-09) Verified: cargo test, cargo test --all-features, clippy (both feature sets, -D warnings), cargo fmt --check
This commit is contained in:
+175
-15
@@ -26,12 +26,13 @@ use alkcall::registry::spec::{
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::forward::{forward, forward_stream, HttpServiceConfig};
|
||||
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 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;
|
||||
@@ -145,16 +146,44 @@ 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() {
|
||||
if param.name == GATEWAY_BODY_KEY {
|
||||
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"
|
||||
requestBody placeholder key; the declared parameter would be \
|
||||
diverted into the request body at call time — rename the \
|
||||
parameter (review 001 OAI-07)"
|
||||
),
|
||||
});
|
||||
}
|
||||
properties.insert(param.name.clone(), schema);
|
||||
match param.in_.as_str() {
|
||||
"header" => {
|
||||
properties.insert(
|
||||
param.name.clone(),
|
||||
serde_json::json!({
|
||||
HEADER_PARAM_IN_MARKER: HEADER_PARAM_MARKER_VALUE,
|
||||
"schema": schema,
|
||||
}),
|
||||
);
|
||||
}
|
||||
"cookie" => {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: format!(
|
||||
"parameter `{}` on {} {} uses `in: cookie`, which the HTTP \
|
||||
adapter does not support; cookies cannot be declared through \
|
||||
the gateway contract — define a header or query parameter \
|
||||
instead (review 001 OAI-03)",
|
||||
param.name,
|
||||
self.config.namespace,
|
||||
op.operation_id.as_deref().unwrap_or("?")
|
||||
),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if param.in_ != "header" {
|
||||
properties.insert(param.name.clone(), schema);
|
||||
}
|
||||
if param.required {
|
||||
required.push(param.name.clone());
|
||||
}
|
||||
@@ -163,8 +192,8 @@ impl FromOpenAPI {
|
||||
if let Some(body) = &op.request_body {
|
||||
if let Some(json_schema) = body.content.get("application/json") {
|
||||
let resolved = self.spec.resolve_refs_recursive(json_schema)?;
|
||||
properties.insert("body".to_string(), resolved);
|
||||
required.push("body".to_string());
|
||||
properties.insert(GATEWAY_BODY_KEY.to_string(), resolved);
|
||||
required.push(GATEWAY_BODY_KEY.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +307,7 @@ impl FromOpenAPI {
|
||||
let namespace = namespace.clone();
|
||||
let http_client = Arc::clone(&http_client);
|
||||
let error_status_codes = error_status_codes.clone();
|
||||
let input_schema = input_schema.clone();
|
||||
forward_stream(
|
||||
&http_client,
|
||||
&base_url,
|
||||
@@ -286,6 +316,7 @@ impl FromOpenAPI {
|
||||
&auth_scheme,
|
||||
&default_headers,
|
||||
&namespace,
|
||||
&input_schema,
|
||||
&error_status_codes,
|
||||
input,
|
||||
context,
|
||||
@@ -302,6 +333,7 @@ impl FromOpenAPI {
|
||||
let namespace = namespace.clone();
|
||||
let http_client = Arc::clone(&http_client);
|
||||
let error_status_codes = error_status_codes.clone();
|
||||
let input_schema = input_schema.clone();
|
||||
async move {
|
||||
forward(
|
||||
&http_client,
|
||||
@@ -311,6 +343,7 @@ impl FromOpenAPI {
|
||||
&auth_scheme,
|
||||
&default_headers,
|
||||
&namespace,
|
||||
&input_schema,
|
||||
&error_status_codes,
|
||||
input,
|
||||
context,
|
||||
@@ -788,6 +821,131 @@ mod tests {
|
||||
assert!(required.iter().any(|v| v == "id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_parameters_are_marked_and_cookies_rejected() {
|
||||
let doc = r#"{
|
||||
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||
"paths":{"/track/{id}":{"get":{
|
||||
"operationId":"track",
|
||||
"parameters":[
|
||||
{"name":"id","in":"path","required":true,"schema":{"type":"string"}},
|
||||
{"name":"X-Trace-Id","in":"header","schema":{"type":"string"}},
|
||||
{"name":"session","in":"cookie","schema":{"type":"string"}}
|
||||
],
|
||||
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||
}}}
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let result = adapter(spec, config("ns", "https://x", None))
|
||||
.import()
|
||||
.await;
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(message.contains("cookie"), "message was: {message}");
|
||||
assert!(message.contains("session"), "message was: {message}");
|
||||
}
|
||||
Ok(bundles) => panic!(
|
||||
"expected cookie-parameter rejection, got {} bundles",
|
||||
bundles.len()
|
||||
),
|
||||
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||
}
|
||||
|
||||
let doc = r#"{
|
||||
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||
"paths":{"/track/{id}":{"get":{
|
||||
"operationId":"track",
|
||||
"parameters":[
|
||||
{"name":"id","in":"path","required":true,"schema":{"type":"string"}},
|
||||
{"name":"X-Trace-Id","in":"header","schema":{"type":"string"}}
|
||||
],
|
||||
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||
}}}
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(spec, config("ns", "https://x", None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
let props = bundles[0]
|
||||
.spec
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap();
|
||||
let header_prop = props.get("X-Trace-Id").expect("header param declared");
|
||||
assert_eq!(header_prop["wire"], "header");
|
||||
assert!(props.get("id").is_some());
|
||||
let q_prop = props
|
||||
.get("q")
|
||||
.map(|v| v.get("wire").is_none())
|
||||
.unwrap_or(true);
|
||||
assert!(q_prop, "query params carry no wire marker");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_parameter_flows_as_upstream_request_header_not_query() {
|
||||
let doc = r#"{
|
||||
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||
"paths":{"/track":{"get":{
|
||||
"operationId":"track",
|
||||
"parameters":[{"name":"X-Trace-Id","in":"header","schema":{"type":"string"}}],
|
||||
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||
}}}}"#;
|
||||
let (base, rx) = spawn_capturing_server().await;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(spec, config("svc", &base, None))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
let ctx = noop_context("req-hdr", Capabilities::new());
|
||||
let response = match &bundles[0].handler {
|
||||
HandlerKind::Once(h) => h(serde_json::json!({"X-Trace-Id": "t-9"}), ctx).await,
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
assert!(response.result.is_ok(), "{:?}", response.result);
|
||||
let captured = rx.await.unwrap();
|
||||
assert!(
|
||||
!captured.query.contains("X-Trace-Id"),
|
||||
"header param must not land in the query string: {}",
|
||||
captured.query
|
||||
);
|
||||
assert_eq!(
|
||||
captured.headers.get("x-trace-id").map(String::as_str),
|
||||
Some("t-9")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parameter_named_body_is_rejected_at_import_even_without_request_body() {
|
||||
let doc = r#"{
|
||||
"openapi":"3.0.0","info":{"title":"T","version":"1"},
|
||||
"paths":{"/x":{"post":{
|
||||
"operationId":"x",
|
||||
"parameters":[{"name":"body","in":"query","schema":{"type":"string"}}],
|
||||
"responses":{"200":{"content":{"application/json":{"schema":{}}}}}
|
||||
}}}}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let result = adapter(spec, config("ns", "https://x", None))
|
||||
.import()
|
||||
.await;
|
||||
match result {
|
||||
Err(AdapterError::SchemaParse { message }) => {
|
||||
assert!(
|
||||
message.contains("collides with the gateway"),
|
||||
"message was: {message}"
|
||||
);
|
||||
assert!(message.contains("OAI-07"), "message was: {message}");
|
||||
}
|
||||
Ok(bundles) => panic!(
|
||||
"expected body-key collision rejection, got {} bundles",
|
||||
bundles.len()
|
||||
),
|
||||
Err(e) => panic!("expected SchemaParse, got {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ref_resolution_in_input_schema() {
|
||||
let doc = r##"{
|
||||
@@ -832,6 +990,7 @@ mod tests {
|
||||
&Some(HttpAuthScheme::Bearer),
|
||||
&HashMap::new(),
|
||||
"github",
|
||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||
&serde_json::json!({"owner":"a","repo":"b"}),
|
||||
&ctx,
|
||||
)
|
||||
@@ -857,6 +1016,7 @@ mod tests {
|
||||
}),
|
||||
&HashMap::new(),
|
||||
"vastai",
|
||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||
&serde_json::json!({}),
|
||||
&ctx,
|
||||
)
|
||||
@@ -877,6 +1037,7 @@ mod tests {
|
||||
&None,
|
||||
&HashMap::new(),
|
||||
"svc",
|
||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||
&serde_json::json!({"id":42,"filter":"active"}),
|
||||
&ctx,
|
||||
)
|
||||
@@ -1092,6 +1253,7 @@ mod tests {
|
||||
&Some(HttpAuthScheme::Bearer),
|
||||
&HashMap::new(),
|
||||
"openai",
|
||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||
&serde_json::json!({"body":{"prompt":"hi"}}),
|
||||
&ctx,
|
||||
)
|
||||
@@ -1290,6 +1452,7 @@ mod tests {
|
||||
&Some(HttpAuthScheme::Basic),
|
||||
&HashMap::new(),
|
||||
"svc",
|
||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||
&serde_json::json!({}),
|
||||
&ctx,
|
||||
)
|
||||
@@ -1328,6 +1491,7 @@ mod tests {
|
||||
&None,
|
||||
&defaults,
|
||||
"svc",
|
||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||
&serde_json::json!({}),
|
||||
&ctx,
|
||||
)
|
||||
@@ -1408,11 +1572,7 @@ mod tests {
|
||||
let ctx = noop_context("req-16", Capabilities::new());
|
||||
let response = match ®istration.handler {
|
||||
HandlerKind::Once(h) => {
|
||||
h(
|
||||
serde_json::json!({"id":"42","filter":"new","body":{"name":"widget"}}),
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
h(serde_json::json!({"id":"42","body":{"name":"widget"}}), ctx).await
|
||||
}
|
||||
_ => panic!("expected Once handler"),
|
||||
};
|
||||
@@ -1424,7 +1584,7 @@ mod tests {
|
||||
let captured = rx.await.unwrap();
|
||||
assert_eq!(captured.method, "POST");
|
||||
assert_eq!(captured.path, "/items/42");
|
||||
assert_eq!(captured.query, "filter=new");
|
||||
assert_eq!(captured.query, "");
|
||||
assert_eq!(
|
||||
captured.headers.get("content-type").unwrap(),
|
||||
"application/json"
|
||||
|
||||
Reference in New Issue
Block a user