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:
2026-08-29 12:48:13 +00:00
parent 7b83161171
commit a9d17405a7
4 changed files with 741 additions and 48 deletions
+175 -15
View File
@@ -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 &registration.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"