use std::{borrow::Cow, collections::BTreeMap};
use serde::{Deserialize, Serialize};
use crate::{const_string, model::ConstString};
const_string!(ObjectTypeConst = "object");
const_string!(StringTypeConst = "string");
const_string!(NumberTypeConst = "number");
const_string!(IntegerTypeConst = "integer");
const_string!(BooleanTypeConst = "boolean");
const_string!(EnumTypeConst = "string");
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum PrimitiveSchema {
String(StringSchema),
Number(NumberSchema),
Integer(IntegerSchema),
Boolean(BooleanSchema),
Enum(EnumSchema),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum StringFormat {
Email,
Uri,
Date,
DateTime,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct StringSchema {
#[serde(rename = "type")]
pub type_: StringTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_length: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_length: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<StringFormat>,
}
impl Default for StringSchema {
fn default() -> Self {
Self {
type_: StringTypeConst,
title: None,
description: None,
min_length: None,
max_length: None,
format: None,
}
}
}
impl StringSchema {
pub fn new() -> Self {
Self::default()
}
pub fn email() -> Self {
Self {
format: Some(StringFormat::Email),
..Default::default()
}
}
pub fn uri() -> Self {
Self {
format: Some(StringFormat::Uri),
..Default::default()
}
}
pub fn date() -> Self {
Self {
format: Some(StringFormat::Date),
..Default::default()
}
}
pub fn date_time() -> Self {
Self {
format: Some(StringFormat::DateTime),
..Default::default()
}
}
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_length(mut self, min: u32, max: u32) -> Result<Self, &'static str> {
if min > max {
return Err("min_length must be <= max_length");
}
self.min_length = Some(min);
self.max_length = Some(max);
Ok(self)
}
pub fn length(mut self, min: u32, max: u32) -> Self {
assert!(min <= max, "min_length must be <= max_length");
self.min_length = Some(min);
self.max_length = Some(max);
self
}
pub fn min_length(mut self, min: u32) -> Self {
self.min_length = Some(min);
self
}
pub fn max_length(mut self, max: u32) -> Self {
self.max_length = Some(max);
self
}
pub fn format(mut self, format: StringFormat) -> Self {
self.format = Some(format);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct NumberSchema {
#[serde(rename = "type")]
pub type_: NumberTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum: Option<f64>,
}
impl Default for NumberSchema {
fn default() -> Self {
Self {
type_: NumberTypeConst,
title: None,
description: None,
minimum: None,
maximum: None,
}
}
}
impl NumberSchema {
pub fn new() -> Self {
Self::default()
}
pub fn with_range(mut self, min: f64, max: f64) -> Result<Self, &'static str> {
if min > max {
return Err("minimum must be <= maximum");
}
self.minimum = Some(min);
self.maximum = Some(max);
Ok(self)
}
pub fn range(mut self, min: f64, max: f64) -> Self {
assert!(min <= max, "minimum must be <= maximum");
self.minimum = Some(min);
self.maximum = Some(max);
self
}
pub fn minimum(mut self, min: f64) -> Self {
self.minimum = Some(min);
self
}
pub fn maximum(mut self, max: f64) -> Self {
self.maximum = Some(max);
self
}
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct IntegerSchema {
#[serde(rename = "type")]
pub type_: IntegerTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum: Option<i64>,
}
impl Default for IntegerSchema {
fn default() -> Self {
Self {
type_: IntegerTypeConst,
title: None,
description: None,
minimum: None,
maximum: None,
}
}
}
impl IntegerSchema {
pub fn new() -> Self {
Self::default()
}
pub fn with_range(mut self, min: i64, max: i64) -> Result<Self, &'static str> {
if min > max {
return Err("minimum must be <= maximum");
}
self.minimum = Some(min);
self.maximum = Some(max);
Ok(self)
}
pub fn range(mut self, min: i64, max: i64) -> Self {
assert!(min <= max, "minimum must be <= maximum");
self.minimum = Some(min);
self.maximum = Some(max);
self
}
pub fn minimum(mut self, min: i64) -> Self {
self.minimum = Some(min);
self
}
pub fn maximum(mut self, max: i64) -> Self {
self.maximum = Some(max);
self
}
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct BooleanSchema {
#[serde(rename = "type")]
pub type_: BooleanTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<bool>,
}
impl Default for BooleanSchema {
fn default() -> Self {
Self {
type_: BooleanTypeConst,
title: None,
description: None,
default: None,
}
}
}
impl BooleanSchema {
pub fn new() -> Self {
Self::default()
}
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_default(mut self, default: bool) -> Self {
self.default = Some(default);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct EnumSchema {
#[serde(rename = "type")]
pub type_: StringTypeConst,
#[serde(rename = "enum")]
pub enum_values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enum_names: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
}
impl EnumSchema {
pub fn new(values: Vec<String>) -> Self {
Self {
type_: StringTypeConst,
enum_values: values,
enum_names: None,
title: None,
description: None,
}
}
pub fn enum_names(mut self, names: Vec<String>) -> Self {
self.enum_names = Some(names);
self
}
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
pub struct ElicitationSchema {
#[serde(rename = "type")]
pub type_: ObjectTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<Cow<'static, str>>,
pub properties: BTreeMap<String, PrimitiveSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Cow<'static, str>>,
}
impl ElicitationSchema {
pub fn new(properties: BTreeMap<String, PrimitiveSchema>) -> Self {
Self {
type_: ObjectTypeConst,
title: None,
properties,
required: None,
description: None,
}
}
pub fn from_json_schema(schema: crate::model::JsonObject) -> Result<Self, serde_json::Error> {
serde_json::from_value(serde_json::Value::Object(schema))
}
#[cfg(feature = "schemars")]
pub fn from_type<T>() -> Result<Self, serde_json::Error>
where
T: schemars::JsonSchema,
{
use crate::schemars::generate::SchemaSettings;
let mut settings = SchemaSettings::draft07();
settings.transforms = vec![Box::new(schemars::transform::AddNullable::default())];
let generator = settings.into_generator();
let schema = generator.into_root_schema_for::<T>();
let object = serde_json::to_value(schema).expect("failed to serialize schema");
match object {
serde_json::Value::Object(object) => Self::from_json_schema(object),
_ => panic!(
"Schema serialization produced non-object value: expected JSON object but got {:?}",
object
),
}
}
pub fn with_required(mut self, required: Vec<String>) -> Self {
self.required = Some(required);
self
}
pub fn with_title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
pub fn builder() -> ElicitationSchemaBuilder {
ElicitationSchemaBuilder::new()
}
}
#[derive(Debug, Default)]
pub struct ElicitationSchemaBuilder {
pub properties: BTreeMap<String, PrimitiveSchema>,
pub required: Vec<String>,
pub title: Option<Cow<'static, str>>,
pub description: Option<Cow<'static, str>>,
}
impl ElicitationSchemaBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn property(mut self, name: impl Into<String>, schema: PrimitiveSchema) -> Self {
self.properties.insert(name.into(), schema);
self
}
pub fn required_property(mut self, name: impl Into<String>, schema: PrimitiveSchema) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(name_str, schema);
self
}
pub fn string_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.properties
.insert(name.into(), PrimitiveSchema::String(f(StringSchema::new())));
self
}
pub fn required_string_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::String(f(StringSchema::new())));
self
}
pub fn number_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.properties
.insert(name.into(), PrimitiveSchema::Number(f(NumberSchema::new())));
self
}
pub fn required_number_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Number(f(NumberSchema::new())));
self
}
pub fn integer_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchema::Integer(f(IntegerSchema::new())),
);
self
}
pub fn required_integer_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Integer(f(IntegerSchema::new())));
self
}
pub fn bool_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchema::Boolean(f(BooleanSchema::new())),
);
self
}
pub fn required_bool_property(
mut self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Boolean(f(BooleanSchema::new())));
self
}
pub fn required_string(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::String(StringSchema::new()))
}
pub fn optional_string(self, name: impl Into<String>) -> Self {
self.property(name, PrimitiveSchema::String(StringSchema::new()))
}
pub fn required_email(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::String(StringSchema::email()))
}
pub fn optional_email(self, name: impl Into<String>) -> Self {
self.property(name, PrimitiveSchema::String(StringSchema::email()))
}
pub fn required_string_with(
self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::String(f(StringSchema::new())))
}
pub fn optional_string_with(
self,
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.property(name, PrimitiveSchema::String(f(StringSchema::new())))
}
pub fn required_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.required_property(
name,
PrimitiveSchema::Number(NumberSchema::new().range(min, max)),
)
}
pub fn optional_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.property(
name,
PrimitiveSchema::Number(NumberSchema::new().range(min, max)),
)
}
pub fn required_number_with(
self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Number(f(NumberSchema::new())))
}
pub fn optional_number_with(
self,
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.property(name, PrimitiveSchema::Number(f(NumberSchema::new())))
}
pub fn required_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.required_property(
name,
PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)),
)
}
pub fn optional_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.property(
name,
PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)),
)
}
pub fn required_integer_with(
self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Integer(f(IntegerSchema::new())))
}
pub fn optional_integer_with(
self,
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.property(name, PrimitiveSchema::Integer(f(IntegerSchema::new())))
}
pub fn required_bool(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::Boolean(BooleanSchema::new()))
}
pub fn optional_bool(self, name: impl Into<String>, default: bool) -> Self {
self.property(
name,
PrimitiveSchema::Boolean(BooleanSchema::new().with_default(default)),
)
}
pub fn required_bool_with(
self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new())))
}
pub fn optional_bool_with(
self,
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new())))
}
pub fn required_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.required_property(name, PrimitiveSchema::Enum(EnumSchema::new(values)))
}
pub fn optional_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.property(name, PrimitiveSchema::Enum(EnumSchema::new(values)))
}
pub fn mark_required(mut self, name: impl Into<String>) -> Self {
self.required.push(name.into());
self
}
pub fn title(mut self, title: impl Into<Cow<'static, str>>) -> Self {
self.title = Some(title.into());
self
}
pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(description.into());
self
}
pub fn build(self) -> Result<ElicitationSchema, &'static str> {
if !self.required.is_empty() {
for field_name in &self.required {
if !self.properties.contains_key(field_name) {
return Err("Required field does not exist in properties");
}
}
}
Ok(ElicitationSchema {
type_: ObjectTypeConst,
title: self.title,
properties: self.properties,
required: if self.required.is_empty() {
None
} else {
Some(self.required)
},
description: self.description,
})
}
pub fn build_unchecked(self) -> ElicitationSchema {
self.build().expect("Invalid elicitation schema")
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn test_string_schema_serialization() {
let schema = StringSchema::email().description("Email address");
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "string");
assert_eq!(json["format"], "email");
assert_eq!(json["description"], "Email address");
}
#[test]
fn test_number_schema_serialization() {
let schema = NumberSchema::new()
.range(0.0, 100.0)
.description("Percentage");
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "number");
assert_eq!(json["minimum"], 0.0);
assert_eq!(json["maximum"], 100.0);
}
#[test]
fn test_integer_schema_serialization() {
let schema = IntegerSchema::new().range(0, 150);
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "integer");
assert_eq!(json["minimum"], 0);
assert_eq!(json["maximum"], 150);
}
#[test]
fn test_boolean_schema_serialization() {
let schema = BooleanSchema::new().with_default(true);
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "boolean");
assert_eq!(json["default"], true);
}
#[test]
fn test_enum_schema_serialization() {
let schema = EnumSchema::new(vec!["US".to_string(), "UK".to_string()])
.enum_names(vec![
"United States".to_string(),
"United Kingdom".to_string(),
])
.description("Country code");
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "string");
assert_eq!(json["enum"], json!(["US", "UK"]));
assert_eq!(
json["enumNames"],
json!(["United States", "United Kingdom"])
);
assert_eq!(json["description"], "Country code");
}
#[test]
fn test_elicitation_schema_builder_simple() {
let schema = ElicitationSchema::builder()
.required_email("email")
.optional_bool("newsletter", false)
.build()
.unwrap();
assert_eq!(schema.properties.len(), 2);
assert!(schema.properties.contains_key("email"));
assert!(schema.properties.contains_key("newsletter"));
assert_eq!(schema.required, Some(vec!["email".to_string()]));
}
#[test]
fn test_elicitation_schema_builder_complex() {
let schema = ElicitationSchema::builder()
.required_string_with("name", |s| s.length(1, 100))
.required_integer("age", 0, 150)
.optional_bool("newsletter", false)
.required_enum(
"country",
vec!["US".to_string(), "UK".to_string(), "CA".to_string()],
)
.description("User registration")
.build()
.unwrap();
assert_eq!(schema.properties.len(), 4);
assert_eq!(
schema.required,
Some(vec![
"name".to_string(),
"age".to_string(),
"country".to_string()
])
);
assert_eq!(schema.description.as_deref(), Some("User registration"));
}
#[test]
fn test_elicitation_schema_serialization() {
let schema = ElicitationSchema::builder()
.required_string_with("name", |s| s.length(1, 100))
.build()
.unwrap();
let json = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "object");
assert!(json["properties"]["name"].is_object());
assert_eq!(json["required"], json!(["name"]));
}
#[test]
#[should_panic(expected = "minimum must be <= maximum")]
fn test_integer_range_validation() {
IntegerSchema::new().range(10, 5); }
#[test]
#[should_panic(expected = "min_length must be <= max_length")]
fn test_string_length_validation() {
StringSchema::new().length(10, 5); }
#[test]
fn test_integer_range_validation_with_result() {
let result = IntegerSchema::new().with_range(10, 5);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "minimum must be <= maximum");
}
}