#[cfg(feature = "client")]
use alkcall::protocol::wire::CallError;
#[cfg(feature = "client")]
use jsonschema::Validator;
#[cfg(feature = "client")]
use serde_json::Value;
pub(crate) const ERROR_LIST_ITEMS: usize = 8;
pub(crate) const ERROR_ITEM_STRING_CAP: usize = 128;
pub(crate) fn bounded_join(items: &[String]) -> String {
let shown: Vec<String> = items
.iter()
.take(ERROR_LIST_ITEMS)
.map(|item| {
if item.chars().count() > ERROR_ITEM_STRING_CAP {
let truncated: String = item.chars().take(ERROR_ITEM_STRING_CAP).collect();
format!("{truncated}…")
} else {
item.clone()
}
})
.collect();
if items.len() > ERROR_LIST_ITEMS {
format!(
"{}, … (+{} more)",
shown.join(", "),
items.len() - ERROR_LIST_ITEMS
)
} else {
shown.join(", ")
}
}
#[cfg(feature = "client")]
#[derive(Clone)]
pub(crate) struct CompiledInputSchema {
validator: std::sync::Arc<Validator>,
}
#[cfg(feature = "client")]
impl CompiledInputSchema {
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),
})
}
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"
)))
}
}
#[cfg(feature = "client")]
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(all(test, feature = "client"))]
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");
}
}
#[cfg(all(test, feature = "client"))]
#[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");
}
}