alkhttp 0.5.0

HTTP interface for the alk stack: serves HTTP/1.1 + HTTP/2 on standard ALPNs (with WebSocket upgrade carrying the channels protocol) and hosts the HTTP-backed call-protocol adapters
Documentation
//! 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.

#[cfg(feature = "client")]
use alkcall::protocol::wire::CallError;
#[cfg(feature = "client")]
use jsonschema::Validator;
#[cfg(feature = "client")]
use serde_json::Value;

/// Upper bound on how many list items an adapter error message echoes
/// (review 002 OAI-17), and the per-item string cap. A spec-derived list
/// (servers locations, placeholder names, declared keys) can be
/// arbitrarily large; an error echoing all of it turns a 100k-path
/// document into a multi-megabyte message. The shape is "first N + count
/// of the rest".
pub(crate) const ERROR_LIST_ITEMS: usize = 8;
pub(crate) const ERROR_ITEM_STRING_CAP: usize = 128;

/// Joins list items into an error-message fragment bounded in both item
/// count and item width: at most [`ERROR_LIST_ITEMS`] entries, each
/// truncated to [`ERROR_ITEM_STRING_CAP`] chars with a `…` marker, plus
/// a `, … (+N more)` suffix naming how many were suppressed.
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")]
/// 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>,
}

#[cfg(feature = "client")]
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).
#[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");
    }
}