use async_trait::async_trait;
use serde::Deserialize;
use serde::Serialize;
use crate::auth::AuthContext;
use crate::core::{AppError, ErrorKind};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolContext {
pub subject: String,
pub scopes: Vec<String>,
}
impl ToolContext {
pub fn anonymous() -> Self {
Self {
subject: "anonymous".to_string(),
scopes: Vec::new(),
}
}
pub fn new(subject: impl Into<String>, scopes: Vec<String>) -> Self {
Self {
subject: subject.into(),
scopes,
}
}
pub fn from_auth_context(ctx: &AuthContext) -> Self {
let scopes = ctx
.get_claim("scopes")
.and_then(|value| value.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Self {
subject: ctx.subject.clone(),
scopes,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ToolOutcome {
Output(serde_json::Value),
Enqueued { queue: String, job_id: String },
}
#[async_trait]
pub trait ToolHandler: Send + Sync {
async fn execute(
&self,
ctx: &ToolContext,
input: serde_json::Value,
) -> Result<serde_json::Value, AppError>;
}
pub fn validate_against_schema(
value: &serde_json::Value,
schema: &serde_json::Value,
) -> Result<(), AppError> {
if !schema.is_object() {
return Ok(());
}
if let Some(expected_type) = schema.get("type").and_then(serde_json::Value::as_str) {
let valid = match expected_type {
"object" => value.is_object(),
"string" => value.is_string(),
"number" => value.is_number(),
"integer" => value.as_i64().is_some() || value.as_u64().is_some(),
"boolean" => value.is_boolean(),
"array" => value.is_array(),
"null" => value.is_null(),
_ => {
return Err(AppError::new(
ErrorKind::Validation,
format!("unsupported schema type: {expected_type}"),
));
}
};
if !valid {
return Err(AppError::new(
ErrorKind::Validation,
format!("expected value of type {expected_type}"),
));
}
}
if let Some(enums) = schema.get("enum").and_then(serde_json::Value::as_array)
&& !enums.iter().any(|candidate| candidate == value)
{
return Err(AppError::new(
ErrorKind::Validation,
"value is not one of the allowed enum values",
));
}
if let Some(string) = value.as_str() {
if let Some(minimum) = schema.get("minLength").and_then(serde_json::Value::as_u64)
&& string.chars().count() < minimum as usize
{
return Err(AppError::new(ErrorKind::Validation, "string is too short"));
}
if let Some(maximum) = schema.get("maxLength").and_then(serde_json::Value::as_u64)
&& string.chars().count() > maximum as usize
{
return Err(AppError::new(ErrorKind::Validation, "string is too long"));
}
}
if let Some(number) = value.as_f64() {
if let Some(minimum) = schema.get("minimum").and_then(serde_json::Value::as_f64)
&& number < minimum
{
return Err(AppError::new(
ErrorKind::Validation,
"number is below minimum",
));
}
if let Some(maximum) = schema.get("maximum").and_then(serde_json::Value::as_f64)
&& number > maximum
{
return Err(AppError::new(
ErrorKind::Validation,
"number exceeds maximum",
));
}
}
if let Some(object) = value.as_object() {
if let Some(required) = schema.get("required").and_then(serde_json::Value::as_array) {
for required_field in required {
if let Some(field) = required_field.as_str()
&& !object.contains_key(field)
{
return Err(AppError::new(
ErrorKind::Validation,
format!("missing required field: {field}"),
));
}
}
}
if let Some(properties) = schema
.get("properties")
.and_then(serde_json::Value::as_object)
{
for (field, field_schema) in properties {
if let Some(field_value) = object.get(field) {
validate_against_schema(field_value, field_schema)?;
}
}
if schema.get("additionalProperties") == Some(&serde_json::Value::Bool(false)) {
for field in object.keys() {
if !properties.contains_key(field) {
return Err(AppError::new(
ErrorKind::Validation,
format!("unknown field: {field}"),
));
}
}
}
}
}
if let Some(array) = value.as_array()
&& let Some(items_schema) = schema.get("items")
{
for item in array {
validate_against_schema(item, items_schema)?;
}
if let Some(minimum) = schema.get("minItems").and_then(serde_json::Value::as_u64)
&& array.len() < minimum as usize
{
return Err(AppError::new(
ErrorKind::Validation,
"array has too few items",
));
}
if let Some(maximum) = schema.get("maxItems").and_then(serde_json::Value::as_u64)
&& array.len() > maximum as usize
{
return Err(AppError::new(
ErrorKind::Validation,
"array has too many items",
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_against_schema_accepts_valid_object() {
let schema = serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "string"},
"count": {"type": "integer"}
},
"required": ["id"]
});
let value = serde_json::json!({"id": "abc", "count": 3});
assert!(validate_against_schema(&value, &schema).is_ok());
}
#[test]
fn test_validate_against_schema_rejects_missing_required() {
let schema = serde_json::json!({
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"]
});
let value = serde_json::json!({});
let err = validate_against_schema(&value, &schema).unwrap_err();
assert_eq!(err.kind, ErrorKind::Validation);
}
#[test]
fn test_validate_against_schema_rejects_wrong_type() {
let schema =
serde_json::json!({"type": "object", "properties": {"id": {"type": "string"}}});
let value = serde_json::json!({"id": 42});
let err = validate_against_schema(&value, &schema).unwrap_err();
assert_eq!(err.kind, ErrorKind::Validation);
}
#[test]
fn test_validate_against_schema_enforces_constraints() {
let schema = serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"kind": {"type": "string", "enum": ["movie", "series"]},
"name": {"type": "string", "minLength": 2}
}
});
assert!(
validate_against_schema(&serde_json::json!({"kind": "movie", "name": "ok"}), &schema)
.is_ok()
);
assert!(
validate_against_schema(&serde_json::json!({"kind": "book", "name": "ok"}), &schema)
.is_err()
);
assert!(
validate_against_schema(
&serde_json::json!({"kind": "movie", "name": "ok", "extra": true}),
&schema
)
.is_err()
);
}
#[test]
fn test_validate_against_schema_ignores_non_object_schema() {
assert!(validate_against_schema(&serde_json::json!(1), &serde_json::json!(true)).is_ok());
}
#[test]
fn test_tool_context_from_auth_context_reads_scopes_claim() {
let ctx = AuthContext::new("user-1", "test")
.with_claim("scopes", serde_json::json!(["read:users", "write:users"]));
let tool_ctx = ToolContext::from_auth_context(&ctx);
assert_eq!(tool_ctx.subject, "user-1");
assert_eq!(
tool_ctx.scopes,
vec!["read:users".to_string(), "write:users".to_string()]
);
}
#[test]
fn test_tool_context_anonymous() {
let ctx = ToolContext::anonymous();
assert_eq!(ctx.subject, "anonymous");
assert!(ctx.scopes.is_empty());
}
}