482 lines
14 KiB
Rust
482 lines
14 KiB
Rust
|
|
// vapora-shared: Schema registry - Loads and caches Nickel schemas for
|
||
|
|
// validation Compiles Nickel schemas to internal representation for runtime
|
||
|
|
// validation
|
||
|
|
|
||
|
|
use std::collections::HashMap;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::sync::Arc;
|
||
|
|
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
use tokio::sync::RwLock;
|
||
|
|
use tracing::{debug, info, warn};
|
||
|
|
|
||
|
|
use super::nickel_bridge::NickelCli;
|
||
|
|
use crate::error::{Result, VaporaError};
|
||
|
|
|
||
|
|
/// Compiled schema representation (from Nickel)
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct CompiledSchema {
|
||
|
|
/// Schema name (e.g., "tools/kanban_create_task")
|
||
|
|
pub name: String,
|
||
|
|
|
||
|
|
/// Field definitions with types and contracts
|
||
|
|
pub fields: Vec<FieldSchema>,
|
||
|
|
|
||
|
|
/// Custom contract predicates (Nickel functions)
|
||
|
|
pub custom_contracts: Vec<String>,
|
||
|
|
|
||
|
|
/// Source file path
|
||
|
|
pub source_path: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Field schema with type and validation contracts
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
|
|
pub struct FieldSchema {
|
||
|
|
/// Field name (flattened path for nested fields)
|
||
|
|
pub name: String,
|
||
|
|
|
||
|
|
/// Field type (Nickel type)
|
||
|
|
pub field_type: FieldType,
|
||
|
|
|
||
|
|
/// Is this field required?
|
||
|
|
pub required: bool,
|
||
|
|
|
||
|
|
/// Validation contracts (predicates)
|
||
|
|
pub contracts: Vec<Contract>,
|
||
|
|
|
||
|
|
/// Default value (if field is optional)
|
||
|
|
pub default: Option<serde_json::Value>,
|
||
|
|
|
||
|
|
/// Documentation string
|
||
|
|
pub doc: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Nickel field types
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
|
|
pub enum FieldType {
|
||
|
|
/// String type
|
||
|
|
String,
|
||
|
|
|
||
|
|
/// Number type (f64)
|
||
|
|
Number,
|
||
|
|
|
||
|
|
/// Boolean type
|
||
|
|
Bool,
|
||
|
|
|
||
|
|
/// Array type with element type
|
||
|
|
Array(Box<FieldType>),
|
||
|
|
|
||
|
|
/// Object/Record type
|
||
|
|
Object,
|
||
|
|
|
||
|
|
/// Enum with allowed values
|
||
|
|
Enum(Vec<String>),
|
||
|
|
|
||
|
|
/// Union of types (Nickel `|`)
|
||
|
|
Union(Vec<FieldType>),
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Validation contract (Nickel predicate)
|
||
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
|
|
pub enum Contract {
|
||
|
|
/// Non-empty string (std.string.NonEmpty)
|
||
|
|
NonEmpty,
|
||
|
|
|
||
|
|
/// Minimum string length
|
||
|
|
MinLength(usize),
|
||
|
|
|
||
|
|
/// Maximum string length
|
||
|
|
MaxLength(usize),
|
||
|
|
|
||
|
|
/// Regex pattern match
|
||
|
|
Pattern(String),
|
||
|
|
|
||
|
|
/// Numeric range validation
|
||
|
|
Range { min: f64, max: f64 },
|
||
|
|
|
||
|
|
/// Greater than (exclusive)
|
||
|
|
GreaterThan(f64),
|
||
|
|
|
||
|
|
/// Less than (exclusive)
|
||
|
|
LessThan(f64),
|
||
|
|
|
||
|
|
/// Email validation
|
||
|
|
Email,
|
||
|
|
|
||
|
|
/// URL validation
|
||
|
|
Url,
|
||
|
|
|
||
|
|
/// UUID validation
|
||
|
|
Uuid,
|
||
|
|
|
||
|
|
/// Custom Nickel predicate (function source code)
|
||
|
|
Custom(String),
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Schema source (file or inline definition)
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub enum SchemaSource {
|
||
|
|
/// Load from Nickel file
|
||
|
|
File(PathBuf),
|
||
|
|
|
||
|
|
/// Inline Nickel code
|
||
|
|
Inline(String),
|
||
|
|
|
||
|
|
/// Pre-compiled schema (for testing)
|
||
|
|
Compiled(CompiledSchema),
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Registry that loads and caches Nickel schemas
|
||
|
|
pub struct SchemaRegistry {
|
||
|
|
/// Cached compiled schemas
|
||
|
|
schemas: Arc<RwLock<HashMap<String, CompiledSchema>>>,
|
||
|
|
|
||
|
|
/// Base directory for schema files
|
||
|
|
schema_dir: PathBuf,
|
||
|
|
|
||
|
|
/// Nickel CLI bridge
|
||
|
|
nickel_cli: NickelCli,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl SchemaRegistry {
|
||
|
|
/// Create a new schema registry
|
||
|
|
pub fn new(schema_dir: PathBuf) -> Self {
|
||
|
|
Self {
|
||
|
|
schemas: Arc::new(RwLock::new(HashMap::new())),
|
||
|
|
schema_dir,
|
||
|
|
nickel_cli: NickelCli::new(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Create registry with custom Nickel CLI path
|
||
|
|
pub fn with_nickel_cli(schema_dir: PathBuf, nickel_path: PathBuf) -> Self {
|
||
|
|
Self {
|
||
|
|
schemas: Arc::new(RwLock::new(HashMap::new())),
|
||
|
|
schema_dir,
|
||
|
|
nickel_cli: NickelCli::with_path(nickel_path),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Load schema from Nickel file, compile and cache
|
||
|
|
pub async fn load_schema(&self, schema_name: &str) -> Result<CompiledSchema> {
|
||
|
|
// Check cache first
|
||
|
|
{
|
||
|
|
let cache = self.schemas.read().await;
|
||
|
|
if let Some(schema) = cache.get(schema_name) {
|
||
|
|
debug!("Schema {} loaded from cache", schema_name);
|
||
|
|
return Ok(schema.clone());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load from file
|
||
|
|
let schema_path = self.resolve_schema_path(schema_name);
|
||
|
|
let compiled = self.compile_schema(&schema_path, schema_name).await?;
|
||
|
|
|
||
|
|
// Cache it
|
||
|
|
{
|
||
|
|
let mut cache = self.schemas.write().await;
|
||
|
|
cache.insert(schema_name.to_string(), compiled.clone());
|
||
|
|
}
|
||
|
|
|
||
|
|
info!("Schema {} compiled and cached", schema_name);
|
||
|
|
Ok(compiled)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Load schema from source (file, inline, or pre-compiled)
|
||
|
|
pub async fn load_from_source(
|
||
|
|
&self,
|
||
|
|
source: SchemaSource,
|
||
|
|
schema_name: &str,
|
||
|
|
) -> Result<CompiledSchema> {
|
||
|
|
match source {
|
||
|
|
SchemaSource::File(path) => self.compile_schema(&path, schema_name).await,
|
||
|
|
SchemaSource::Inline(code) => self.compile_inline(&code, schema_name).await,
|
||
|
|
SchemaSource::Compiled(schema) => Ok(schema),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Resolve schema name to file path
|
||
|
|
fn resolve_schema_path(&self, schema_name: &str) -> PathBuf {
|
||
|
|
let filename = if schema_name.ends_with(".ncl") {
|
||
|
|
schema_name.to_string()
|
||
|
|
} else {
|
||
|
|
format!("{}.ncl", schema_name)
|
||
|
|
};
|
||
|
|
|
||
|
|
self.schema_dir.join(filename)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Compile Nickel schema from file to internal representation
|
||
|
|
async fn compile_schema(&self, path: &Path, name: &str) -> Result<CompiledSchema> {
|
||
|
|
if !path.exists() {
|
||
|
|
return Err(VaporaError::ValidationError(format!(
|
||
|
|
"Schema file not found: {}",
|
||
|
|
path.display()
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use nickel query to extract schema metadata
|
||
|
|
let metadata_json = self.nickel_cli.query(path, None).await?;
|
||
|
|
|
||
|
|
self.parse_nickel_metadata(&metadata_json, name, path)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Compile inline Nickel code
|
||
|
|
async fn compile_inline(&self, code: &str, name: &str) -> Result<CompiledSchema> {
|
||
|
|
// Write to temp file for Nickel CLI processing
|
||
|
|
let temp_dir = std::env::temp_dir();
|
||
|
|
let temp_path = temp_dir.join(format!("vapora_schema_{}.ncl", name.replace('/', "_")));
|
||
|
|
|
||
|
|
tokio::fs::write(&temp_path, code).await.map_err(|e| {
|
||
|
|
VaporaError::ValidationError(format!("Failed to write temp file: {}", e))
|
||
|
|
})?;
|
||
|
|
|
||
|
|
let result = self.compile_schema(&temp_path, name).await;
|
||
|
|
|
||
|
|
// Clean up temp file
|
||
|
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||
|
|
|
||
|
|
result
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse Nickel metadata JSON into CompiledSchema
|
||
|
|
fn parse_nickel_metadata(
|
||
|
|
&self,
|
||
|
|
json: &serde_json::Value,
|
||
|
|
name: &str,
|
||
|
|
source_path: &Path,
|
||
|
|
) -> Result<CompiledSchema> {
|
||
|
|
// Extract schema name from metadata or use provided name
|
||
|
|
let schema_name = json["tool_name"]
|
||
|
|
.as_str()
|
||
|
|
.or_else(|| json["schema_name"].as_str())
|
||
|
|
.unwrap_or(name)
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// Extract parameters/fields object
|
||
|
|
let params = json["parameters"]
|
||
|
|
.as_object()
|
||
|
|
.or_else(|| json["fields"].as_object());
|
||
|
|
|
||
|
|
let fields = match params {
|
||
|
|
Some(params_obj) => params_obj
|
||
|
|
.iter()
|
||
|
|
.map(|(field_name, field_def)| self.parse_field(field_name, field_def))
|
||
|
|
.collect::<Result<Vec<_>>>()?,
|
||
|
|
None => {
|
||
|
|
warn!("No parameters or fields found in schema {}", name);
|
||
|
|
vec![]
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// Extract custom contracts
|
||
|
|
let custom_contracts = json["contracts"]
|
||
|
|
.as_object()
|
||
|
|
.map(|obj| {
|
||
|
|
obj.iter()
|
||
|
|
.map(|(name, _code)| name.clone())
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
})
|
||
|
|
.unwrap_or_default();
|
||
|
|
|
||
|
|
Ok(CompiledSchema {
|
||
|
|
name: schema_name,
|
||
|
|
fields,
|
||
|
|
custom_contracts,
|
||
|
|
source_path: source_path.to_path_buf(),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse a single field definition from Nickel metadata
|
||
|
|
fn parse_field(&self, name: &str, def: &serde_json::Value) -> Result<FieldSchema> {
|
||
|
|
let field_type = self.parse_type(def)?;
|
||
|
|
let contracts = self.extract_contracts(def);
|
||
|
|
let default = def.get("default").cloned();
|
||
|
|
let required = default.is_none();
|
||
|
|
let doc = def.get("doc").and_then(|v| v.as_str()).map(String::from);
|
||
|
|
|
||
|
|
Ok(FieldSchema {
|
||
|
|
name: name.to_string(),
|
||
|
|
field_type,
|
||
|
|
required,
|
||
|
|
contracts,
|
||
|
|
default,
|
||
|
|
doc,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse Nickel type from metadata
|
||
|
|
fn parse_type(&self, def: &serde_json::Value) -> Result<FieldType> {
|
||
|
|
// Type information comes from Nickel metadata
|
||
|
|
let type_str = def["type"].as_str().unwrap_or("String");
|
||
|
|
|
||
|
|
let field_type = match type_str {
|
||
|
|
"String" => FieldType::String,
|
||
|
|
"Number" => FieldType::Number,
|
||
|
|
"Bool" => FieldType::Bool,
|
||
|
|
"Object" | "Record" => FieldType::Object,
|
||
|
|
t if t.starts_with("Array") => {
|
||
|
|
// Parse array element type
|
||
|
|
FieldType::Array(Box::new(FieldType::String))
|
||
|
|
}
|
||
|
|
t if t.starts_with("Enum") => {
|
||
|
|
// Extract enum variants
|
||
|
|
if let Some(variants) = def["variants"].as_array() {
|
||
|
|
let values = variants
|
||
|
|
.iter()
|
||
|
|
.filter_map(|v| v.as_str().map(String::from))
|
||
|
|
.collect();
|
||
|
|
FieldType::Enum(values)
|
||
|
|
} else {
|
||
|
|
FieldType::String
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => FieldType::String,
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok(field_type)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Extract validation contracts from Nickel metadata
|
||
|
|
fn extract_contracts(&self, def: &serde_json::Value) -> Vec<Contract> {
|
||
|
|
let mut contracts = vec![];
|
||
|
|
|
||
|
|
// Parse contract annotations from Nickel metadata
|
||
|
|
if let Some(annotations) = def["annotations"].as_array() {
|
||
|
|
for ann in annotations {
|
||
|
|
if let Some(contract) = self.parse_contract_annotation(ann) {
|
||
|
|
contracts.push(contract);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
contracts
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse a single contract annotation
|
||
|
|
fn parse_contract_annotation(&self, ann: &serde_json::Value) -> Option<Contract> {
|
||
|
|
let name = ann["name"].as_str()?;
|
||
|
|
|
||
|
|
match name {
|
||
|
|
"std.string.NonEmpty" => Some(Contract::NonEmpty),
|
||
|
|
"std.string.Email" => Some(Contract::Email),
|
||
|
|
"std.string.Url" => Some(Contract::Url),
|
||
|
|
"std.string.Uuid" => Some(Contract::Uuid),
|
||
|
|
n if n.starts_with("std.string.length.min") => {
|
||
|
|
let min = ann["args"][0].as_u64()? as usize;
|
||
|
|
Some(Contract::MinLength(min))
|
||
|
|
}
|
||
|
|
n if n.starts_with("std.string.length.max") => {
|
||
|
|
let max = ann["args"][0].as_u64()? as usize;
|
||
|
|
Some(Contract::MaxLength(max))
|
||
|
|
}
|
||
|
|
n if n.starts_with("std.number.between") => {
|
||
|
|
let min = ann["args"][0].as_f64()?;
|
||
|
|
let max = ann["args"][1].as_f64()?;
|
||
|
|
Some(Contract::Range { min, max })
|
||
|
|
}
|
||
|
|
n if n.starts_with("std.number.greater_than") => {
|
||
|
|
let min = ann["args"][0].as_f64()?;
|
||
|
|
Some(Contract::GreaterThan(min))
|
||
|
|
}
|
||
|
|
n if n.starts_with("std.number.less_than") => {
|
||
|
|
let max = ann["args"][0].as_f64()?;
|
||
|
|
Some(Contract::LessThan(max))
|
||
|
|
}
|
||
|
|
n if n.starts_with("std.string.match") => {
|
||
|
|
let pattern = ann["args"][0].as_str()?.to_string();
|
||
|
|
Some(Contract::Pattern(pattern))
|
||
|
|
}
|
||
|
|
_ => {
|
||
|
|
// Custom contract - store source code
|
||
|
|
ann["source"]
|
||
|
|
.as_str()
|
||
|
|
.map(|source| Contract::Custom(source.to_string()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Invalidate cache for a specific schema (for hot-reload)
|
||
|
|
pub async fn invalidate(&self, schema_name: &str) {
|
||
|
|
let mut cache = self.schemas.write().await;
|
||
|
|
cache.remove(schema_name);
|
||
|
|
debug!("Schema {} invalidated from cache", schema_name);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Invalidate all cached schemas
|
||
|
|
pub async fn invalidate_all(&self) {
|
||
|
|
let mut cache = self.schemas.write().await;
|
||
|
|
let count = cache.len();
|
||
|
|
cache.clear();
|
||
|
|
info!("Invalidated {} schemas from cache", count);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Get all cached schema names
|
||
|
|
pub async fn list_cached(&self) -> Vec<String> {
|
||
|
|
let cache = self.schemas.read().await;
|
||
|
|
cache.keys().cloned().collect()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if schema is cached
|
||
|
|
pub async fn is_cached(&self, schema_name: &str) -> bool {
|
||
|
|
let cache = self.schemas.read().await;
|
||
|
|
cache.contains_key(schema_name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_parse_field_type() {
|
||
|
|
let registry = SchemaRegistry::new(PathBuf::from("/tmp"));
|
||
|
|
|
||
|
|
let string_def = serde_json::json!({"type": "String"});
|
||
|
|
let field_type = registry.parse_type(&string_def).unwrap();
|
||
|
|
assert_eq!(field_type, FieldType::String);
|
||
|
|
|
||
|
|
let number_def = serde_json::json!({"type": "Number"});
|
||
|
|
let field_type = registry.parse_type(&number_def).unwrap();
|
||
|
|
assert_eq!(field_type, FieldType::Number);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_parse_contract_annotation() {
|
||
|
|
let registry = SchemaRegistry::new(PathBuf::from("/tmp"));
|
||
|
|
|
||
|
|
let non_empty = serde_json::json!({"name": "std.string.NonEmpty"});
|
||
|
|
let contract = registry.parse_contract_annotation(&non_empty);
|
||
|
|
assert_eq!(contract, Some(Contract::NonEmpty));
|
||
|
|
|
||
|
|
let min_length = serde_json::json!({
|
||
|
|
"name": "std.string.length.min",
|
||
|
|
"args": [5]
|
||
|
|
});
|
||
|
|
let contract = registry.parse_contract_annotation(&min_length);
|
||
|
|
assert_eq!(contract, Some(Contract::MinLength(5)));
|
||
|
|
|
||
|
|
let range = serde_json::json!({
|
||
|
|
"name": "std.number.between",
|
||
|
|
"args": [0.0, 100.0]
|
||
|
|
});
|
||
|
|
let contract = registry.parse_contract_annotation(&range);
|
||
|
|
assert_eq!(
|
||
|
|
contract,
|
||
|
|
Some(Contract::Range {
|
||
|
|
min: 0.0,
|
||
|
|
max: 100.0
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_schema_source() {
|
||
|
|
let inline_source = SchemaSource::Inline(r#"{ name = "test" }"#.to_string());
|
||
|
|
assert!(matches!(inline_source, SchemaSource::Inline(_)));
|
||
|
|
|
||
|
|
let file_source = SchemaSource::File(PathBuf::from("test.ncl"));
|
||
|
|
assert!(matches!(file_source, SchemaSource::File(_)));
|
||
|
|
}
|
||
|
|
}
|