feat(adapters): enforce advertised input schemas at call time (OAI-18, option a)
Decision: advertise == enforce. The key allowlist (OAI-02) stays as the first gate with its established unknown-key message; a compiled leaf validator now runs second, so required/type/enum/pattern/bounds violations surface as INVALID_INPUT 422 naming the keyword — not as upstream round-trips. - new src/adapters/input_validation.rs: CompiledInputSchema compiles an op's input_schema once at import with the jsonschema crate (same 2020-12 dialect publish_schema uses) and validates peer input at call time; the compile-time copy is hardened closed-by-default (additionalProperties: false injected when absent) so the validator reproduces the allowlist's unknown-key semantics; explicit additionalProperties:true catch-all and schema values are preserved; the original spec value is never mutated - from_openapi/from_jsonschema import(): compile per registration, capture the validator in the handler closure (re-import recompiles — the closure capture is the invalidation story); a non-compilable input schema fails import loudly (AdapterError::SchemaParse naming the operation), matching the publish_schema fail-closed precedent - from_openapi generated input schemas now carry additionalProperties:false explicitly, so the /schema advert states the enforced rule and external schema-driven validators reach the same verdicts - forward/forward_stream/build_request gain an Option<&CompiledInputSchema> parameter; enforcement runs after the allowlist - round-trip test (review 002 Test-gap 10): the /schema-exported input_schema is compiled with the same validator and driven against build_request over a 10-input violation matrix — accept-sets exactly equal in both directions; the chain-test that lets advertise/enforce drift surface as a CI failure - ADR-066: new decision section (advertise==enforce) with the trust- boundary reasoning and the rejected option (b) rationale - module + enforce_input_schema docs updated to the two-gate shape cargo test --all-features 596 pass; clippy --all-features/-D warnings, fmt, doc gates clean. docs(tasks): mark review-002-fu-oai18-decision done
This commit is contained in:
@@ -149,6 +149,73 @@ error projection and the success sweep (SSE detection +
|
|||||||
output-schema selection), where the precedence order is: concrete 2XX
|
output-schema selection), where the precedence order is: concrete 2XX
|
||||||
statuses, then `2XX`, then `default`.
|
statuses, then `2XX`, then `default`.
|
||||||
|
|
||||||
|
### Input-schema enforcement: advertise == enforce (review 002 OAI-18)
|
||||||
|
|
||||||
|
**Decided: enforce the full input schema at call time.** Until this
|
||||||
|
decision, `build_request` enforced a **key allowlist** only (review 001
|
||||||
|
OAI-02: unknown keys rejected), while `/schema` advertised the full
|
||||||
|
JSON Schema — `required`, `enum`, `pattern`, value types, bounds. The
|
||||||
|
advertised contract was broader than the defended one: a
|
||||||
|
`required: [id]` operation accepted `{}` and let the upstream surface
|
||||||
|
the violation as a remote 422; a `{"type": "string"}` property sent as
|
||||||
|
an object serialized as JSON text into the query string;
|
||||||
|
`enum`/`pattern`/`minimum` were never consulted. This is the same
|
||||||
|
advertise/enforce drift class review 002 filed as
|
||||||
|
[minor→major-class], and OAI-02's own rationale decides the direction:
|
||||||
|
peer **input** is never trusted, and the advertised schema is exactly
|
||||||
|
what a peer reads before crafting input. ADR-066's trust boundary
|
||||||
|
(specs are trusted *configuration*) answers whether the assembly's
|
||||||
|
schema must be defended against; it does not make peer input trusted,
|
||||||
|
so "the spec is configuration" does not justify enforcement-by-allowlist.
|
||||||
|
Scoping the advert instead (the rejected alternative) would have to
|
||||||
|
strip `required`/`enum`/`pattern` from **two** projection surfaces
|
||||||
|
(`to_openapi` and `to_mcp`) and degrades the contract for well-behaved
|
||||||
|
consumers.
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
|
||||||
|
- Each adapter's `import()` compiles the op's `input_schema` **once**
|
||||||
|
with the `jsonschema` crate (the same compiler and 2020-12 dialect
|
||||||
|
`PublishSchemaCache` already uses for `publish_schema`) and captures
|
||||||
|
the validator in the handler closure — compile once per registration,
|
||||||
|
no runtime cache-invalidation scheme needed, mirroring the
|
||||||
|
value-keyed cache only in spirit (the closure capture *is* the
|
||||||
|
invalidation story: re-import recompiles).
|
||||||
|
- The compile step **hardens** the schema copy it compiles: when
|
||||||
|
`additionalProperties` is absent, `additionalProperties: false` is
|
||||||
|
injected into the compiled copy, so the validator reproduces the
|
||||||
|
OAI-02 closed-by-default semantics. The spec value itself is never
|
||||||
|
mutated.
|
||||||
|
- `from_openapi`'s *generated* input schemas now state the rule in the
|
||||||
|
advert itself: every generated schema carries
|
||||||
|
`additionalProperties: false` explicitly, so a schema-driven external
|
||||||
|
consumer reaches the same verdicts the gateway enforces (pinned by
|
||||||
|
the advertised-vs-enforced round-trip test).
|
||||||
|
- The allowlist check runs **first** and is unchanged (its message
|
||||||
|
names the undeclared key and the declared set — better diagnosis
|
||||||
|
than the validator's generic additionalProperties error); the
|
||||||
|
compiled validator runs second. Unknown-key rejection therefore
|
||||||
|
keeps the established message, and leaf violations surface as
|
||||||
|
`INVALID_INPUT` 422 naming the violated keyword and input location
|
||||||
|
(ADR-023 shape) — the gateway rejects, never an upstream round-trip.
|
||||||
|
- The explicit `"additionalProperties": true` opt-in catch-all
|
||||||
|
(documented catch-all for open-shaped endpoints) is preserved
|
||||||
|
verbatim by the compiler; declared keys' constraints still bind
|
||||||
|
under it.
|
||||||
|
- An input schema that fails to compile fails **import** loudly
|
||||||
|
(`AdapterError::SchemaParse` naming the operation), matching the
|
||||||
|
`publish_schema` fail-closed precedent: an un-validatable input
|
||||||
|
contract must never register as an operation whose enforcement
|
||||||
|
silently degrades.
|
||||||
|
|
||||||
|
Interaction with the gateway marker extensions: the
|
||||||
|
`HEADER_PARAM_IN_MARKER`-decorated properties and the `body` property
|
||||||
|
are peer-visible schema extensions that ride inside `properties`, so
|
||||||
|
the compiled validator treats them as ordinary properties (validated
|
||||||
|
against their embedded schema; the marker key itself is not a JSON
|
||||||
|
Schema keyword and is ignored by the compiler). The catch-all
|
||||||
|
semantics above keep open-shaped endpoints working.
|
||||||
|
|
||||||
### Forwarding contract decisions (review 002 FWD-17/18/19)
|
### Forwarding contract decisions (review 002 FWD-17/18/19)
|
||||||
|
|
||||||
The shared forwarding core (`src/adapters/forward.rs`) used by both
|
The shared forwarding core (`src/adapters/forward.rs`) used by both
|
||||||
|
|||||||
+197
-19
@@ -77,13 +77,19 @@
|
|||||||
//! missing key rather than sent unauthenticated to produce corrupted
|
//! missing key rather than sent unauthenticated to produce corrupted
|
||||||
//! upstream 401s with no local diagnostic.
|
//! upstream 401s with no local diagnostic.
|
||||||
//!
|
//!
|
||||||
//! Input-schema enforcement (review 001 OAI-02): every input key must be
|
//! Input-schema enforcement (review 001 OAI-02, review 002 OAI-18):
|
||||||
//! declared by the operation's `input_schema` or consumed by a path
|
//! two call-time gates. The key allowlist rejects undeclared keys with
|
||||||
//! template placeholder; undeclared keys are rejected with `INVALID_INPUT`
|
//! `INVALID_INPUT` — every input key must be declared by the operation's
|
||||||
//! at call time. There is no pass-through knob — a request the forwarder
|
//! `input_schema` or consumed by a path template placeholder, so
|
||||||
//! sends must match what `/schema` advertises, so peer-supplied input
|
//! peer-supplied input cannot add upstream query parameters, headers, or
|
||||||
//! cannot add upstream query parameters, headers, or craft a request body
|
//! craft a request body the contract does not declare (no pass-through
|
||||||
//! the contract does not declare. Declared keys route by designation: a
|
//! knob). The compiled leaf validator (captured per registration at
|
||||||
|
//! `import()`, `CompiledInputSchema`) then enforces the schema the
|
||||||
|
//! gateway advertises — `required`, value types, `enum`, `pattern`,
|
||||||
|
//! bounds — as `INVALID_INPUT` naming the keyword, so advertise ==
|
||||||
|
//! enforce and violations fail here instead of as upstream 422s;
|
||||||
|
//! non-compilable input schemas fail import loudly. Declared keys route
|
||||||
|
//! by designation: a
|
||||||
//! property marked with the `HEADER_PARAM_IN_MARKER` marker key set to "header" is sent as a
|
//! property marked with the `HEADER_PARAM_IN_MARKER` marker key set to "header" is sent as a
|
||||||
//! request header, the declared `GATEWAY_BODY_KEY` property becomes the
|
//! request header, the declared `GATEWAY_BODY_KEY` property becomes the
|
||||||
//! request body, and every other declared key becomes an upstream query
|
//! request body, and every other declared key becomes an upstream query
|
||||||
@@ -134,6 +140,7 @@ use reqwest::Method;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::adapters::input_validation::CompiledInputSchema;
|
||||||
use crate::client::SharedHttpClient;
|
use crate::client::SharedHttpClient;
|
||||||
|
|
||||||
/// Maximum size, in bytes, of a buffered upstream response body on any
|
/// Maximum size, in bytes, of a buffered upstream response body on any
|
||||||
@@ -289,6 +296,7 @@ pub(crate) fn build_request(
|
|||||||
default_headers: &HashMap<String, String>,
|
default_headers: &HashMap<String, String>,
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
input_schema: &Value,
|
input_schema: &Value,
|
||||||
|
input_validator: Option<&CompiledInputSchema>,
|
||||||
input: &Value,
|
input: &Value,
|
||||||
context: &OperationContext,
|
context: &OperationContext,
|
||||||
) -> Result<(Method, Url, Option<Value>, HeaderMap), CallError> {
|
) -> Result<(Method, Url, Option<Value>, HeaderMap), CallError> {
|
||||||
@@ -300,6 +308,9 @@ pub(crate) fn build_request(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
enforce_input_schema(input_schema, inputs)?;
|
enforce_input_schema(input_schema, inputs)?;
|
||||||
|
if let Some(validator) = input_validator {
|
||||||
|
validator.validate(input)?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut query_params: Vec<(String, String)> = Vec::new();
|
let mut query_params: Vec<(String, String)> = Vec::new();
|
||||||
let mut header_params: Vec<(String, String)> = Vec::new();
|
let mut header_params: Vec<(String, String)> = Vec::new();
|
||||||
@@ -452,19 +463,35 @@ fn type_name_of(value: &Value) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input-schema enforcement at call time (review 001 OAI-02).
|
/// Input-schema enforcement at call time (review 001 OAI-02, review
|
||||||
|
/// 002 OAI-18).
|
||||||
///
|
///
|
||||||
/// Every input key must be declared by the input schema's `properties` —
|
/// Two gates run in order:
|
||||||
/// including the gateway body property, which `from_openapi` declares as
|
///
|
||||||
/// `body` whenever the operation has a requestBody, and including the
|
/// 1. The **key allowlist** (this function): every input key must be
|
||||||
/// path-consumed placeholders, which `from_openapi` also declares. An
|
/// declared by the input schema's `properties` — including the
|
||||||
/// explicit `"additionalProperties": true` opts the operation into
|
/// gateway body property, which `from_openapi` declares as `body`
|
||||||
/// catch-all input (JSON Schema semantics: the schema advertises that
|
/// whenever the operation has a requestBody, and including the
|
||||||
/// extra properties are valid), which `from_jsonschema` callers can use
|
/// path-consumed placeholders, which `from_openapi` also declares.
|
||||||
/// for open-shaped endpoints. Anything else undeclared is a rejected
|
/// An explicit `"additionalProperties": true` opts the operation
|
||||||
/// `INVALID_INPUT` rather than a silently-added upstream query parameter,
|
/// into catch-all input (JSON Schema semantics: the schema
|
||||||
/// so peer input like `{debug: true}` or `{impersonate_id: …}` cannot
|
/// advertises that extra properties are valid), which
|
||||||
/// decorate an upstream request the contract does not advertise.
|
/// `from_jsonschema` callers can use for open-shaped endpoints.
|
||||||
|
/// Anything else undeclared is a rejected `INVALID_INPUT` naming the
|
||||||
|
/// key and the declared set.
|
||||||
|
/// 2. The **compiled leaf validator** (`CompiledInputSchema`, OAI-18):
|
||||||
|
/// the input value is validated against the `input_schema` the
|
||||||
|
/// adapter compiled at import (hardened closed-by-default), so
|
||||||
|
/// `required`, value types, `enum`, `pattern`, and bounds are
|
||||||
|
/// enforced exactly as `/schema` advertises — violations are
|
||||||
|
/// `INVALID_INPUT` naming the keyword, never an upstream round-trip.
|
||||||
|
/// Adapters capture the validator at `import()`; a schema that
|
||||||
|
/// cannot compile fails import loudly (fail-closed).
|
||||||
|
///
|
||||||
|
/// The allowlist-first ordering keeps the OAI-02 unknown-key message
|
||||||
|
/// (better diagnosis than the validator's generic
|
||||||
|
/// additionalProperties error); leaf enforcement then closes the
|
||||||
|
/// advertise/enforce drift OAI-18 filed.
|
||||||
fn enforce_input_schema(
|
fn enforce_input_schema(
|
||||||
input_schema: &Value,
|
input_schema: &Value,
|
||||||
inputs: &serde_json::Map<String, Value>,
|
inputs: &serde_json::Map<String, Value>,
|
||||||
@@ -931,6 +958,7 @@ pub(crate) async fn forward(
|
|||||||
namespace: &str,
|
namespace: &str,
|
||||||
input_schema: &Value,
|
input_schema: &Value,
|
||||||
error_status_codes: &[(u16, String)],
|
error_status_codes: &[(u16, String)],
|
||||||
|
input_validator: Option<&CompiledInputSchema>,
|
||||||
input: Value,
|
input: Value,
|
||||||
context: OperationContext,
|
context: OperationContext,
|
||||||
) -> ResponseEnvelope {
|
) -> ResponseEnvelope {
|
||||||
@@ -944,6 +972,7 @@ pub(crate) async fn forward(
|
|||||||
default_headers,
|
default_headers,
|
||||||
namespace,
|
namespace,
|
||||||
input_schema,
|
input_schema,
|
||||||
|
input_validator,
|
||||||
&input,
|
&input,
|
||||||
&context,
|
&context,
|
||||||
) {
|
) {
|
||||||
@@ -1067,6 +1096,7 @@ pub(crate) fn forward_stream(
|
|||||||
namespace: &str,
|
namespace: &str,
|
||||||
input_schema: &Value,
|
input_schema: &Value,
|
||||||
error_status_codes: &[(u16, String)],
|
error_status_codes: &[(u16, String)],
|
||||||
|
input_validator: Option<&CompiledInputSchema>,
|
||||||
input: Value,
|
input: Value,
|
||||||
context: OperationContext,
|
context: OperationContext,
|
||||||
) -> ResponseStream {
|
) -> ResponseStream {
|
||||||
@@ -1080,6 +1110,7 @@ pub(crate) fn forward_stream(
|
|||||||
default_headers,
|
default_headers,
|
||||||
namespace,
|
namespace,
|
||||||
input_schema,
|
input_schema,
|
||||||
|
input_validator,
|
||||||
&input,
|
&input,
|
||||||
&context,
|
&context,
|
||||||
) {
|
) {
|
||||||
@@ -1484,6 +1515,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&input,
|
&input,
|
||||||
&ctx,
|
&ctx,
|
||||||
)?;
|
)?;
|
||||||
@@ -1647,6 +1679,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&schema,
|
&schema,
|
||||||
|
None,
|
||||||
&input,
|
&input,
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -1656,6 +1689,127 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OAI-18 (enforce leg): leaf constraints the compiled validator
|
||||||
|
/// enforces — `required`, value types, `enum`, `minimum` — reject
|
||||||
|
/// with `INVALID_INPUT` naming the violated keyword, so the gateway
|
||||||
|
/// rejects before an upstream round-trip would surface the
|
||||||
|
/// violation as a remote 422/400.
|
||||||
|
#[test]
|
||||||
|
fn compiled_input_schema_enforces_leaf_constraints_as_invalid_input() {
|
||||||
|
let ctx = noop_context();
|
||||||
|
let schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"level": {"enum": ["low", "high"]},
|
||||||
|
"n": {"minimum": 1},
|
||||||
|
"tag": {"pattern": "^[a-z]+$"},
|
||||||
|
},
|
||||||
|
"required": ["id"],
|
||||||
|
});
|
||||||
|
let validator = Some(CompiledInputSchema::compile(&schema).expect("schema compiles"));
|
||||||
|
for (input, keyword) in [
|
||||||
|
(json!({}), "required"),
|
||||||
|
(json!({"id": 7}), "type"),
|
||||||
|
(json!({"id": "x", "level": "medium"}), "enum"),
|
||||||
|
(json!({"id": "x", "n": 0}), "minimum"),
|
||||||
|
(json!({"id": "x", "tag": "UPPER"}), "pattern"),
|
||||||
|
] {
|
||||||
|
let err = build_request(
|
||||||
|
"https://api.example.com",
|
||||||
|
"/x",
|
||||||
|
"GET",
|
||||||
|
&None,
|
||||||
|
&TestHashMap::new(),
|
||||||
|
"svc",
|
||||||
|
&schema,
|
||||||
|
validator.as_ref(),
|
||||||
|
&input,
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect_err("violated leaf constraint must be rejected");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT", "input was: {input}");
|
||||||
|
assert!(
|
||||||
|
err.message.contains(&format!("[keyword: {keyword}")),
|
||||||
|
"input {input} must name the violated keyword `{keyword}`: {}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OAI-18: absent the compiled validator (`None`, the shape direct
|
||||||
|
/// `build_request` callers may still use), enforcement is exactly
|
||||||
|
/// the key allowlist — the schema's leaf constraints are not
|
||||||
|
/// consulted. This pins the split so a future refactor cannot
|
||||||
|
/// silently make leaf enforcement depend on the parameter.
|
||||||
|
#[test]
|
||||||
|
fn without_a_compiled_validator_enforcement_stays_allowlist_only() {
|
||||||
|
let ctx = noop_context();
|
||||||
|
let schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
"required": ["id"],
|
||||||
|
});
|
||||||
|
let (_, url, _, _) = build_request(
|
||||||
|
"https://api.example.com",
|
||||||
|
"/x",
|
||||||
|
"GET",
|
||||||
|
&None,
|
||||||
|
&TestHashMap::new(),
|
||||||
|
"svc",
|
||||||
|
&schema,
|
||||||
|
None,
|
||||||
|
&json!({}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect("allowlist-only enforcement accepts the empty input");
|
||||||
|
assert_eq!(url.path(), "/x");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OAI-18: the explicit `additionalProperties: true` catch-all is
|
||||||
|
/// preserved by the compiled validator (the documented opt-in), and
|
||||||
|
/// the validator's schema for declared keys still binds: a
|
||||||
|
/// `{"type": "string"}` property sent as an object is rejected even
|
||||||
|
/// under the catch-all.
|
||||||
|
#[test]
|
||||||
|
fn catch_all_opt_in_keeps_working_under_the_compiled_validator() {
|
||||||
|
let ctx = noop_context();
|
||||||
|
let schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"q": {"type": "string"}},
|
||||||
|
"additionalProperties": true,
|
||||||
|
});
|
||||||
|
let validator = Some(CompiledInputSchema::compile(&schema).expect("schema compiles"));
|
||||||
|
build_request(
|
||||||
|
"https://api.example.com",
|
||||||
|
"/search",
|
||||||
|
"GET",
|
||||||
|
&None,
|
||||||
|
&TestHashMap::new(),
|
||||||
|
"svc",
|
||||||
|
&schema,
|
||||||
|
validator.as_ref(),
|
||||||
|
&json!({"debug": true}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect("the catch-all still accepts undeclared keys");
|
||||||
|
let err = build_request(
|
||||||
|
"https://api.example.com",
|
||||||
|
"/search",
|
||||||
|
"GET",
|
||||||
|
&None,
|
||||||
|
&TestHashMap::new(),
|
||||||
|
"svc",
|
||||||
|
&schema,
|
||||||
|
validator.as_ref(),
|
||||||
|
&json!({"q": {"object": "value"}}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.expect_err("a declared property's type still binds under the catch-all");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
|
assert!(err.message.contains("[keyword: type"), "{}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn declared_body_and_header_params_route_off_the_query_string() {
|
fn declared_body_and_header_params_route_off_the_query_string() {
|
||||||
let ctx = noop_context();
|
let ctx = noop_context();
|
||||||
@@ -1675,6 +1829,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&schema,
|
&schema,
|
||||||
|
None,
|
||||||
&json!({"q": "rust", "X-Trace-Id": "t-1", "body": {"page": 2}}),
|
&json!({"q": "rust", "X-Trace-Id": "t-1", "body": {"page": 2}}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -1704,6 +1859,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&schema,
|
&schema,
|
||||||
|
None,
|
||||||
&input,
|
&input,
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -1728,6 +1884,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&schema,
|
&schema,
|
||||||
|
None,
|
||||||
&json!({"q": "rust", "debug": "true"}),
|
&json!({"q": "rust", "debug": "true"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -1771,6 +1928,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&schema,
|
&schema,
|
||||||
|
None,
|
||||||
&json!({"id": "x", "undeclared": "v"}),
|
&json!({"id": "x", "undeclared": "v"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -2191,6 +2349,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
@@ -2274,6 +2433,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -2313,6 +2473,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -2347,6 +2508,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -2434,6 +2596,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -2516,6 +2679,7 @@ mod tests {
|
|||||||
&defaults,
|
&defaults,
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
|
None,
|
||||||
&json!({}),
|
&json!({}),
|
||||||
&noop_context(),
|
&noop_context(),
|
||||||
)
|
)
|
||||||
@@ -2537,6 +2701,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
|
None,
|
||||||
&json!({}),
|
&json!({}),
|
||||||
&ctx,
|
&ctx,
|
||||||
);
|
);
|
||||||
@@ -2570,6 +2735,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
|
None,
|
||||||
&json!({}),
|
&json!({}),
|
||||||
&noop_context(),
|
&noop_context(),
|
||||||
)
|
)
|
||||||
@@ -2607,6 +2773,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
|
None,
|
||||||
&json!({}),
|
&json!({}),
|
||||||
&noop_context(),
|
&noop_context(),
|
||||||
)
|
)
|
||||||
@@ -2664,6 +2831,7 @@ mod tests {
|
|||||||
&defaults,
|
&defaults,
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
|
None,
|
||||||
&json!({}),
|
&json!({}),
|
||||||
&noop_context(),
|
&noop_context(),
|
||||||
)
|
)
|
||||||
@@ -2811,6 +2979,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -2869,6 +3038,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -3009,6 +3179,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -3061,6 +3232,7 @@ mod tests {
|
|||||||
"properties": {"id": {"type": "string"}}
|
"properties": {"id": {"type": "string"}}
|
||||||
}),
|
}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
input,
|
input,
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -3104,6 +3276,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -3162,6 +3335,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -3189,6 +3363,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
)
|
)
|
||||||
@@ -3221,6 +3396,7 @@ mod tests {
|
|||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object"}),
|
&serde_json::json!({"type": "object"}),
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
json!({}),
|
json!({}),
|
||||||
noop_context(),
|
noop_context(),
|
||||||
);
|
);
|
||||||
@@ -3327,6 +3503,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&schema,
|
&schema,
|
||||||
|
None,
|
||||||
&json!({"X-Trace": "bad\u{0000}value"}),
|
&json!({"X-Trace": "bad\u{0000}value"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -3356,6 +3533,7 @@ mod tests {
|
|||||||
&TestHashMap::new(),
|
&TestHashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&schema,
|
&schema,
|
||||||
|
None,
|
||||||
&json!({"bad header": "v"}),
|
&json!({"bad header": "v"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ use alkcall::registry::registration::{
|
|||||||
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||||
};
|
};
|
||||||
use alkcall::registry::spec::{OperationSpec, OperationType, Visibility};
|
use alkcall::registry::spec::{OperationSpec, OperationType, Visibility};
|
||||||
|
|
||||||
|
use crate::adapters::input_validation::CompiledInputSchema;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -163,6 +165,16 @@ fn spec_name_references_undeclared(spec: &OperationSpec, path_template: &str) ->
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OperationAdapter for FromJsonSchema {
|
impl OperationAdapter for FromJsonSchema {
|
||||||
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
||||||
|
let input_validator =
|
||||||
|
CompiledInputSchema::compile(&self.spec.input_schema).map_err(|message| {
|
||||||
|
AdapterError::SchemaParse {
|
||||||
|
message: format!(
|
||||||
|
"operation `{}`: {message} (review 002 OAI-18: an un-validatable input \
|
||||||
|
contract must fail import, not register unenforced)",
|
||||||
|
self.spec.name
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
let path_template = self.path_template.clone();
|
let path_template = self.path_template.clone();
|
||||||
let method_upper = self.method.to_ascii_uppercase();
|
let method_upper = self.method.to_ascii_uppercase();
|
||||||
let auth_scheme = self.config.auth.clone();
|
let auth_scheme = self.config.auth.clone();
|
||||||
@@ -192,6 +204,7 @@ impl OperationAdapter for FromJsonSchema {
|
|||||||
let http_client = Arc::clone(&http_client);
|
let http_client = Arc::clone(&http_client);
|
||||||
let error_status_codes = error_status_codes.clone();
|
let error_status_codes = error_status_codes.clone();
|
||||||
let input_schema = input_schema.clone();
|
let input_schema = input_schema.clone();
|
||||||
|
let input_validator = input_validator.clone();
|
||||||
forward_stream(
|
forward_stream(
|
||||||
&http_client,
|
&http_client,
|
||||||
&base_url,
|
&base_url,
|
||||||
@@ -202,6 +215,7 @@ impl OperationAdapter for FromJsonSchema {
|
|||||||
&namespace,
|
&namespace,
|
||||||
&input_schema,
|
&input_schema,
|
||||||
&error_status_codes,
|
&error_status_codes,
|
||||||
|
Some(&input_validator),
|
||||||
input,
|
input,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
@@ -218,6 +232,7 @@ impl OperationAdapter for FromJsonSchema {
|
|||||||
let http_client = Arc::clone(&http_client);
|
let http_client = Arc::clone(&http_client);
|
||||||
let error_status_codes = error_status_codes.clone();
|
let error_status_codes = error_status_codes.clone();
|
||||||
let input_schema = input_schema.clone();
|
let input_schema = input_schema.clone();
|
||||||
|
let input_validator = input_validator.clone();
|
||||||
async move {
|
async move {
|
||||||
forward(
|
forward(
|
||||||
&http_client,
|
&http_client,
|
||||||
@@ -229,6 +244,7 @@ impl OperationAdapter for FromJsonSchema {
|
|||||||
&namespace,
|
&namespace,
|
||||||
&input_schema,
|
&input_schema,
|
||||||
&error_status_codes,
|
&error_status_codes,
|
||||||
|
Some(&input_validator),
|
||||||
input,
|
input,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
@@ -417,6 +433,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"github",
|
"github",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({"owner":"a","repo":"b"}),
|
&serde_json::json!({"owner":"a","repo":"b"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -439,6 +456,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({"id":42,"filter":"active"}),
|
&serde_json::json!({"id":42,"filter":"active"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -608,7 +626,7 @@ mod tests {
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
serde_json::json!({"id": {"nested": "object"}}),
|
serde_json::json!({"id": {"nested": "object"}}),
|
||||||
"must be a scalar",
|
"[keyword: type",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
let base = spawn_echo_server(200, "data: {\"n\":1}\n\n", "text/event-stream").await;
|
let base = spawn_echo_server(200, "data: {\"n\":1}\n\n", "text/event-stream").await;
|
||||||
@@ -660,6 +678,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"openai",
|
"openai",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({"body":{"prompt":"hi"}}),
|
&serde_json::json!({"body":{"prompt":"hi"}}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -678,6 +697,35 @@ mod tests {
|
|||||||
std::env::remove_var("OPENAI_API_KEY");
|
std::env::remove_var("OPENAI_API_KEY");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OAI-18 fail-closed precedent: an input schema that cannot
|
||||||
|
/// compile fails *import* loudly — never a registration whose
|
||||||
|
/// call-time enforcement silently degrades.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn uncompilable_input_schema_fails_import_loudly() {
|
||||||
|
let mut spec = test_spec("svc/broken", OperationType::Query);
|
||||||
|
spec.input_schema = serde_json::json!({"type": "object", "required": "n"});
|
||||||
|
let adapter = FromJsonSchema::new(
|
||||||
|
spec,
|
||||||
|
test_config("svc", "https://api.example.com"),
|
||||||
|
"/x".to_string(),
|
||||||
|
"GET".to_string(),
|
||||||
|
test_http_client(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = adapter.import().await;
|
||||||
|
match result {
|
||||||
|
Err(AdapterError::SchemaParse { message }) => {
|
||||||
|
assert!(
|
||||||
|
message.contains("failed to compile"),
|
||||||
|
"message was: {message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("svc/broken"), "message was: {message}");
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("an un-validatable input contract must fail import (OAI-18)"),
|
||||||
|
Err(e) => panic!("expected SchemaParse, got {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_method_path_template_and_base_url_fail_at_construction() {
|
fn malformed_method_path_template_and_base_url_fail_at_construction() {
|
||||||
for (method, path_template, base_url, expected_fragment) in [
|
for (method, path_template, base_url, expected_fragment) in [
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use super::forward::{
|
|||||||
use super::openapi_spec::{
|
use super::openapi_spec::{
|
||||||
collect_ignored_schema_keys, OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS,
|
collect_ignored_schema_keys, OpenAPISpec, Operation, Parameter, SUCCESS_RESPONSE_KEYS,
|
||||||
};
|
};
|
||||||
|
use crate::adapters::input_validation::CompiledInputSchema;
|
||||||
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> {
|
||||||
@@ -239,13 +240,21 @@ impl FromOpenAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if properties.is_empty() {
|
if properties.is_empty() {
|
||||||
return Ok(serde_json::json!({"type": "object"}));
|
return Ok(serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAI-18: the generated input schema advertises closed-by-default
|
||||||
|
// object shape explicitly (`additionalProperties: false`), so the
|
||||||
|
// /schema advert states the rule call time enforces and a
|
||||||
|
// schema-driven external validator reaches the same verdicts.
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": properties,
|
"properties": properties,
|
||||||
"required": required,
|
"required": required,
|
||||||
|
"additionalProperties": false,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +422,15 @@ impl FromOpenAPI {
|
|||||||
AccessControl::default(),
|
AccessControl::default(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
|
let input_validator = CompiledInputSchema::compile(&input_schema).map_err(|message| {
|
||||||
|
AdapterError::SchemaParse {
|
||||||
|
message: format!(
|
||||||
|
"operation `{}`: {message} (review 002 OAI-18: an un-validatable \
|
||||||
|
input contract must fail import, not register unenforced)",
|
||||||
|
spec.name
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
let path_template = path.to_string();
|
let path_template = path.to_string();
|
||||||
let method_upper = method.to_ascii_uppercase();
|
let method_upper = method.to_ascii_uppercase();
|
||||||
let auth_scheme = self.config.auth.clone();
|
let auth_scheme = self.config.auth.clone();
|
||||||
@@ -464,6 +482,7 @@ impl FromOpenAPI {
|
|||||||
let http_client = Arc::clone(&http_client);
|
let http_client = Arc::clone(&http_client);
|
||||||
let error_status_codes = error_status_codes.clone();
|
let error_status_codes = error_status_codes.clone();
|
||||||
let input_schema = input_schema.clone();
|
let input_schema = input_schema.clone();
|
||||||
|
let input_validator = input_validator.clone();
|
||||||
forward_stream(
|
forward_stream(
|
||||||
&http_client,
|
&http_client,
|
||||||
&base_url,
|
&base_url,
|
||||||
@@ -474,6 +493,7 @@ impl FromOpenAPI {
|
|||||||
&namespace,
|
&namespace,
|
||||||
&input_schema,
|
&input_schema,
|
||||||
&error_status_codes,
|
&error_status_codes,
|
||||||
|
Some(&input_validator),
|
||||||
input,
|
input,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
@@ -490,6 +510,7 @@ impl FromOpenAPI {
|
|||||||
let http_client = Arc::clone(&http_client);
|
let http_client = Arc::clone(&http_client);
|
||||||
let error_status_codes = error_status_codes.clone();
|
let error_status_codes = error_status_codes.clone();
|
||||||
let input_schema = input_schema.clone();
|
let input_schema = input_schema.clone();
|
||||||
|
let input_validator = input_validator.clone();
|
||||||
async move {
|
async move {
|
||||||
forward(
|
forward(
|
||||||
&http_client,
|
&http_client,
|
||||||
@@ -501,6 +522,7 @@ impl FromOpenAPI {
|
|||||||
&namespace,
|
&namespace,
|
||||||
&input_schema,
|
&input_schema,
|
||||||
&error_status_codes,
|
&error_status_codes,
|
||||||
|
Some(&input_validator),
|
||||||
input,
|
input,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
@@ -553,13 +575,16 @@ impl OperationAdapter for FromOpenAPI {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::adapters::forward::{build_request, HttpAuthScheme};
|
use crate::adapters::forward::{build_request, HttpAuthScheme};
|
||||||
|
use crate::adapters::input_validation::CompiledInputSchema;
|
||||||
use crate::client::HttpClientConfig;
|
use crate::client::HttpClientConfig;
|
||||||
|
use alkcall::core::types::Capabilities;
|
||||||
use alkcall::protocol::wire::ResponseEnvelope;
|
use alkcall::protocol::wire::ResponseEnvelope;
|
||||||
use alkcall::registry::context::AbortPolicy;
|
use alkcall::registry::context::AbortPolicy;
|
||||||
use alkcall::registry::env::OperationEnv;
|
use alkcall::registry::env::OperationEnv;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use reqwest::header::AUTHORIZATION;
|
use reqwest::header::AUTHORIZATION;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
@@ -640,6 +665,94 @@ mod tests {
|
|||||||
}"#
|
}"#
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OAI-18 advertised-vs-enforced round-trip (review 002 Test-gap 10):
|
||||||
|
/// the chain-test that lets advertise/enforce drift surface as a
|
||||||
|
/// CI failure instead of surviving reviews. A doc is imported; the
|
||||||
|
/// `/schema`-exported `input_schema` (the exact value
|
||||||
|
/// `OperationSpec::input_schema` carries, projected verbatim by
|
||||||
|
/// `spec_to_json`) is compiled with the same runtime validator and
|
||||||
|
/// driven over a matrix of violating inputs; every input the
|
||||||
|
/// exported schema accepts must be accepted by `build_request`, and
|
||||||
|
/// every input it rejects must be rejected as `INVALID_INPUT` —
|
||||||
|
/// accept-sets exactly equal, both directions.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn advertised_input_schema_accepts_exactly_what_build_request_accepts() {
|
||||||
|
let doc = r#"{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": { "title": "T", "version": "1" },
|
||||||
|
"paths": {
|
||||||
|
"/items": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listItems",
|
||||||
|
"parameters": [
|
||||||
|
{"name": "q", "in": "query", "schema": {"type": "string"}},
|
||||||
|
{"name": "n", "in": "query", "schema": {"type": "integer", "minimum": 1},
|
||||||
|
"required": true},
|
||||||
|
{"name": "level", "in": "query",
|
||||||
|
"schema": {"type": "string", "enum": ["low", "high"]}}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {"content": {"application/json": {"schema": {}}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||||
|
let imported = adapter(spec, config("items", "https://api.example.com", None));
|
||||||
|
let bundles = imported.import().await.unwrap();
|
||||||
|
assert_eq!(bundles.len(), 1);
|
||||||
|
let advertised_schema = bundles[0].spec.input_schema.clone();
|
||||||
|
|
||||||
|
for (input, expect_accepted) in [
|
||||||
|
(json!({"n": 1}), true),
|
||||||
|
(json!({"n": 1, "q": "rust"}), true),
|
||||||
|
(json!({"n": 1, "level": "high"}), true),
|
||||||
|
(json!({}), false),
|
||||||
|
(json!({"q": "missing-n"}), false),
|
||||||
|
(json!({"n": 0}), false),
|
||||||
|
(json!({"n": 1, "level": "medium"}), false),
|
||||||
|
(json!({"n": 1, "debug": true}), false),
|
||||||
|
(json!({"n": 1, "q": {"object": 1}}), false),
|
||||||
|
(json!({"n": 1, "n": 2}), true),
|
||||||
|
] {
|
||||||
|
let advertised_verdict = jsonschema::options()
|
||||||
|
.build(&advertised_schema)
|
||||||
|
.unwrap()
|
||||||
|
.is_valid(&input);
|
||||||
|
assert_eq!(
|
||||||
|
advertised_verdict, expect_accepted,
|
||||||
|
"advertised schema verdict mismatch for {input}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = build_request(
|
||||||
|
"https://api.example.com",
|
||||||
|
"/items",
|
||||||
|
"GET",
|
||||||
|
&None,
|
||||||
|
&HashMap::new(),
|
||||||
|
"items",
|
||||||
|
&advertised_schema,
|
||||||
|
Some(&CompiledInputSchema::compile(&advertised_schema).unwrap()),
|
||||||
|
&input,
|
||||||
|
&noop_context("rt-1", Capabilities::new()),
|
||||||
|
);
|
||||||
|
match (result, expect_accepted) {
|
||||||
|
(Ok(_), true) => {}
|
||||||
|
(Err(err), false) => {
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT", "input was: {input}");
|
||||||
|
}
|
||||||
|
(Ok(_), false) => {
|
||||||
|
panic!("enforcement accepted what the advertised schema rejects: {input}")
|
||||||
|
}
|
||||||
|
(Err(err), true) => panic!(
|
||||||
|
"enforcement rejected what the advertised schema accepts: {input} — {}",
|
||||||
|
err.message
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn import_minimal_doc_yields_one_registration() {
|
async fn import_minimal_doc_yields_one_registration() {
|
||||||
let spec = OpenAPISpec::from_json(minimal_spec_json()).unwrap();
|
let spec = OpenAPISpec::from_json(minimal_spec_json()).unwrap();
|
||||||
@@ -1585,6 +1698,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"github",
|
"github",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({"owner":"a","repo":"b"}),
|
&serde_json::json!({"owner":"a","repo":"b"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -1611,6 +1725,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"vastai",
|
"vastai",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -1632,6 +1747,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({"id":42,"filter":"active"}),
|
&serde_json::json!({"id":42,"filter":"active"}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -1848,6 +1964,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"openai",
|
"openai",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({"body":{"prompt":"hi"}}),
|
&serde_json::json!({"body":{"prompt":"hi"}}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -2054,6 +2171,7 @@ mod tests {
|
|||||||
&HashMap::new(),
|
&HashMap::new(),
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
@@ -2093,6 +2211,7 @@ mod tests {
|
|||||||
&defaults,
|
&defaults,
|
||||||
"svc",
|
"svc",
|
||||||
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
&serde_json::json!({"type": "object", "additionalProperties": true}),
|
||||||
|
None,
|
||||||
&serde_json::json!({}),
|
&serde_json::json!({}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
//! Compile-once input-schema validators for the HTTP forwarding
|
||||||
|
//! adapters (review 002 OAI-18 — the enforce leg of the
|
||||||
|
//! advertise-vs-enforce decision).
|
||||||
|
//!
|
||||||
|
//! `/schema` advertises a full JSON Schema for every adapter-imported
|
||||||
|
//! operation (`required`, `enum`, `pattern`, value types, bounds), but
|
||||||
|
//! call-time enforcement was a key allowlist only (review 001 OAI-02):
|
||||||
|
//! a `required: [id]` operation accepted `{}`, a `{"type": "string"}`
|
||||||
|
//! property sent as an object serialized as JSON text into the query
|
||||||
|
//! string, and `enum`/`pattern`/`minimum` were never consulted. This
|
||||||
|
//! module closes the drift: the op's `input_schema` compiles **once at
|
||||||
|
//! import**, and peer input is validated against the compiled schema at
|
||||||
|
//! call time — advertise == enforce.
|
||||||
|
//!
|
||||||
|
//! # Closed-by-default hardening
|
||||||
|
//!
|
||||||
|
//! The adapters generate input schemas without
|
||||||
|
//! `additionalProperties`, relying on the allowlist for the
|
||||||
|
//! closed-by-default unknown-key rejection (OAI-02). Raw JSON Schema
|
||||||
|
//! semantics are permissive by default, so compiling the spec verbatim
|
||||||
|
//! would *weaken* enforcement. The compile step therefore injects
|
||||||
|
//! `"additionalProperties": false` into its copy of the schema when the
|
||||||
|
//! key is absent; an explicit `true` (the documented opt-in catch-all)
|
||||||
|
//! or an explicit schema value is preserved as written. The original
|
||||||
|
//! spec value is never mutated — the hardened copy exists only inside
|
||||||
|
//! the compiled validator.
|
||||||
|
//!
|
||||||
|
//! # Failure discipline
|
||||||
|
//!
|
||||||
|
//! A schema that fails to compile fails **import** loudly
|
||||||
|
//! (`AdapterError::SchemaParse`), matching the `publish_schema`
|
||||||
|
//! fail-closed precedent (review-001 follow-up): an un-validatable
|
||||||
|
//! input contract must never register as an operation that would then
|
||||||
|
//! either skip validation or fail closed per call. Compilation happens
|
||||||
|
//! once per registration; no runtime cache invalidation problem exists
|
||||||
|
//! because the validator is captured in the same closure as the
|
||||||
|
//! schema it was compiled from.
|
||||||
|
|
||||||
|
use alkcall::protocol::wire::CallError;
|
||||||
|
use jsonschema::Validator;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// A call-time input validator compiled from an operation's
|
||||||
|
/// `input_schema`. `None`-free by construction: use
|
||||||
|
/// [`CompiledInputSchema::for_schema`] so operations without
|
||||||
|
/// properties-shaped input still validate against their declared
|
||||||
|
/// (possibly empty-object) schema.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct CompiledInputSchema {
|
||||||
|
validator: std::sync::Arc<Validator>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompiledInputSchema {
|
||||||
|
/// Compile `input_schema` for call-time enforcement. Fails with a
|
||||||
|
/// schema-diagnostics string when the schema is not compilable —
|
||||||
|
/// the caller (each adapter's `import`) turns that into a loud
|
||||||
|
/// `AdapterError::SchemaParse` naming the operation.
|
||||||
|
pub(crate) fn compile(input_schema: &Value) -> Result<Self, String> {
|
||||||
|
let hardened = harden_closed_by_default(input_schema);
|
||||||
|
let validator = jsonschema::options()
|
||||||
|
.build(&hardened)
|
||||||
|
.map_err(|error| format!("input schema failed to compile: {error}"))?;
|
||||||
|
Ok(Self {
|
||||||
|
validator: std::sync::Arc::new(validator),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate an input object against the compiled schema. `Ok(())`
|
||||||
|
/// means the input satisfies every leaf constraint the schema
|
||||||
|
/// advertises; `Err` carries an `INVALID_INPUT` `CallError` naming
|
||||||
|
/// the violated keyword and the input location.
|
||||||
|
pub(crate) fn validate(&self, input: &Value) -> Result<(), CallError> {
|
||||||
|
let error = match self.validator.validate(input) {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let kind = error.kind();
|
||||||
|
let keyword = kind.keyword().to_string();
|
||||||
|
let instance_path = error.instance_path().to_string();
|
||||||
|
let detail = error.to_string();
|
||||||
|
Err(CallError::invalid_input(format!(
|
||||||
|
"input violates the operation's input schema: {detail} [keyword: {keyword}, \
|
||||||
|
at: {instance_path}]; the gateway enforces the schema that /schema advertises"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a hardened copy of the input schema for compilation:
|
||||||
|
/// `additionalProperties: false` is injected when the key is absent, so
|
||||||
|
/// the compiled validator keeps the OAI-02 closed-by-default semantics
|
||||||
|
/// the allowlist enforces today. `true` and explicit schema values pass
|
||||||
|
/// through unchanged (the documented catch-all opt-in).
|
||||||
|
fn harden_closed_by_default(input_schema: &Value) -> Value {
|
||||||
|
if input_schema.get("additionalProperties").is_some() {
|
||||||
|
return input_schema.clone();
|
||||||
|
}
|
||||||
|
let mut hardened = input_schema.clone();
|
||||||
|
if let Some(map) = hardened.as_object_mut() {
|
||||||
|
map.insert("additionalProperties".to_string(), Value::Bool(false));
|
||||||
|
}
|
||||||
|
hardened
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_additional_properties_is_hardened_to_false() {
|
||||||
|
let hardened = harden_closed_by_default(&json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
hardened.get("additionalProperties"),
|
||||||
|
Some(&Value::Bool(false)),
|
||||||
|
"the compiled copy is closed by default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_additional_properties_is_preserved() {
|
||||||
|
for value in [json!(true), json!({"type": "string"}), json!(false)] {
|
||||||
|
let schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
"additionalProperties": value,
|
||||||
|
});
|
||||||
|
let hardened = harden_closed_by_default(&schema);
|
||||||
|
assert_eq!(hardened.get("additionalProperties"), Some(&value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_original_schema_is_never_mutated() {
|
||||||
|
let original = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
});
|
||||||
|
let snapshot = original.clone();
|
||||||
|
let _ = CompiledInputSchema::compile(&original).expect("compiles");
|
||||||
|
assert_eq!(original, snapshot, "compile must not mutate the spec value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_is_enforced_at_call_time() {
|
||||||
|
let compiled = CompiledInputSchema::compile(&json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
"required": ["id"],
|
||||||
|
}))
|
||||||
|
.expect("compiles");
|
||||||
|
let err = compiled
|
||||||
|
.validate(&json!({}))
|
||||||
|
.expect_err("the missing required key must be rejected");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
|
assert!(err.message.contains("required"), "message: {}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_type_mismatch_is_rejected() {
|
||||||
|
let compiled = CompiledInputSchema::compile(&json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"q": {"type": "string"}},
|
||||||
|
}))
|
||||||
|
.expect("compiles");
|
||||||
|
let err = compiled
|
||||||
|
.validate(&json!({"q": {"deep": 1}}))
|
||||||
|
.expect_err("an object under a string property must be rejected");
|
||||||
|
assert!(err.message.contains("type"), "message: {}", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_key_is_rejected_by_the_hardened_schema() {
|
||||||
|
let compiled = CompiledInputSchema::compile(&json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"q": {"type": "string"}},
|
||||||
|
}))
|
||||||
|
.expect("compiles");
|
||||||
|
let err = compiled
|
||||||
|
.validate(&json!({"debug": true}))
|
||||||
|
.expect_err("an undeclared key must be rejected");
|
||||||
|
assert!(
|
||||||
|
err.message.contains("Additional properties")
|
||||||
|
|| err.message.contains("additionalProperties"),
|
||||||
|
"message: {}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_catch_all_keeps_accepting_unknown_keys() {
|
||||||
|
let compiled = CompiledInputSchema::compile(&json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"q": {"type": "string"}},
|
||||||
|
"additionalProperties": true,
|
||||||
|
}))
|
||||||
|
.expect("compiles");
|
||||||
|
compiled
|
||||||
|
.validate(&json!({"debug": true}))
|
||||||
|
.expect("the documented catch-all opt-in must keep working");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enum_and_minimum_are_enforced() {
|
||||||
|
let compiled = CompiledInputSchema::compile(&json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"level": {"enum": ["low", "high"]}, "n": {"minimum": 1}},
|
||||||
|
}))
|
||||||
|
.expect("compiles");
|
||||||
|
for bad in [json!({"level": "medium"}), json!({"n": 0})] {
|
||||||
|
let err = compiled
|
||||||
|
.validate(&bad)
|
||||||
|
.expect_err("violated leaf constraints must be rejected");
|
||||||
|
assert_eq!(err.code, "INVALID_INPUT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marked_and_body_properties_are_ordinary_properties() {
|
||||||
|
use crate::adapters::forward::{GATEWAY_BODY_KEY, HEADER_PARAM_IN_MARKER};
|
||||||
|
let compiled = CompiledInputSchema::compile(&json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"region": {HEADER_PARAM_IN_MARKER: "header", "type": "string"},
|
||||||
|
GATEWAY_BODY_KEY: {"type": "object"},
|
||||||
|
},
|
||||||
|
"required": [GATEWAY_BODY_KEY],
|
||||||
|
}))
|
||||||
|
.expect("compiles");
|
||||||
|
let input = json!({"region": "eu", GATEWAY_BODY_KEY: {"prompt": "hi"}});
|
||||||
|
compiled
|
||||||
|
.validate(&input)
|
||||||
|
.expect("marker-decorated and body properties validate as ordinary properties");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uncompilable_schema_is_an_error_not_a_panicking_path() {
|
||||||
|
let result = CompiledInputSchema::compile(&json!({"required": "n"}));
|
||||||
|
assert!(result.is_err(), "a malformed schema fails compilation");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod from_jsonschema;
|
pub mod from_jsonschema;
|
||||||
pub mod from_openapi;
|
pub mod from_openapi;
|
||||||
|
pub mod input_validation;
|
||||||
pub mod openapi_spec;
|
pub mod openapi_spec;
|
||||||
pub mod to_openapi;
|
pub mod to_openapi;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: review-002-fu-oai18-decision
|
id: review-002-fu-oai18-decision
|
||||||
name: OAI-18 advertise-vs-enforce decision — enforce leaf constraints or scope the advert (OAI-18)
|
name: OAI-18 advertise-vs-enforce decision — enforce leaf constraints or scope the advert (OAI-18)
|
||||||
status: pending
|
status: done
|
||||||
depends_on: []
|
depends_on: []
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: medium
|
risk: medium
|
||||||
|
|||||||
Reference in New Issue
Block a user