use serde::{Deserialize, Serialize};
#[cfg(not(feature = "std"))]
use alloc::{
collections::BTreeMap as HashMap,
format,
string::{String, ToString},
vec,
vec::Vec,
};
#[cfg(feature = "std")]
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ElicitationSchema {
#[serde(rename = "type")]
pub schema_type: String,
pub properties: HashMap<String, PrimitiveSchemaDefinition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
pub additional_properties: Option<bool>,
}
fn integral<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<i64>, D::Error> {
let Some(number) = Option::<serde_json::Number>::deserialize(deserializer)? else {
return Ok(None);
};
if let Some(value) = number.as_i64() {
return Ok(Some(value));
}
match number.as_f64() {
Some(value) if value.fract() == 0.0 && value.abs() < 9.2e18 => Ok(Some(value as i64)),
_ => Err(serde::de::Error::custom(format!(
"expected an integer, found {number}"
))),
}
}
impl ElicitationSchema {
pub fn validate_content(&self, content: &serde_json::Value) -> Result<(), String> {
let Some(fields) = content.as_object() else {
return Err("content must be an object".into());
};
for name in self.required.iter().flatten() {
if !fields.contains_key(name) {
return Err(format!("required field '{name}' is missing"));
}
}
for (name, value) in fields {
let Some(property) = self.properties.get(name) else {
if self.additional_properties == Some(false) {
return Err(format!("'{name}' is not a field of the requested schema"));
}
continue;
};
property
.validate_value(value)
.map_err(|reason| format!("field '{name}': {reason}"))?;
}
Ok(())
}
pub fn check_representable_on_2025_06_18(&self) -> Result<(), String> {
for (name, property) in &self.properties {
match property {
PrimitiveSchemaDefinition::Array { .. } => {
return Err(format!(
"field '{name}' is a multi-select, which 2025-06-18 does not have"
));
}
PrimitiveSchemaDefinition::String {
one_of: Some(_), ..
} => {
return Err(format!(
"field '{name}' uses oneOf, which 2025-06-18 does not have"
));
}
_ => {}
}
}
Ok(())
}
#[must_use]
pub fn new() -> Self {
Self {
schema_type: "object".to_string(),
properties: HashMap::new(),
required: Some(Vec::new()),
additional_properties: Some(false),
}
}
#[must_use]
pub fn add_string_property(
mut self,
name: String,
required: bool,
description: Option<String>,
) -> Self {
let property = PrimitiveSchemaDefinition::String {
title: None,
description,
format: None,
min_length: None,
max_length: None,
default: None,
enum_values: None,
enum_names: None,
one_of: None,
};
self.properties.insert(name.clone(), property);
if required && let Some(required_fields) = self.required.as_mut() {
required_fields.push(name);
}
self
}
#[must_use]
pub fn add_enum_property(
mut self,
name: String,
required: bool,
description: Option<String>,
options: Vec<EnumOption>,
) -> Self {
let property = PrimitiveSchemaDefinition::String {
title: None,
description,
format: None,
min_length: None,
max_length: None,
default: None,
enum_values: None,
enum_names: None,
one_of: Some(options),
};
self.properties.insert(name.clone(), property);
if required && let Some(required_fields) = self.required.as_mut() {
required_fields.push(name);
}
self
}
#[must_use]
pub fn add_multi_select_property(
mut self,
name: String,
required: bool,
description: Option<String>,
items: MultiSelectItemsDefinition,
) -> Self {
let property = PrimitiveSchemaDefinition::Array {
title: None,
description,
min_items: None,
max_items: None,
items,
default: None,
};
self.properties.insert(name.clone(), property);
if required && let Some(required_fields) = self.required.as_mut() {
required_fields.push(name);
}
self
}
#[must_use]
pub fn add_number_property(
mut self,
name: String,
required: bool,
description: Option<String>,
minimum: Option<f64>,
maximum: Option<f64>,
) -> Self {
let property = PrimitiveSchemaDefinition::Number {
title: None,
description,
minimum,
maximum,
default: None,
};
self.properties.insert(name.clone(), property);
if required && let Some(required_fields) = self.required.as_mut() {
required_fields.push(name);
}
self
}
#[must_use]
pub fn add_boolean_property(
mut self,
name: String,
required: bool,
description: Option<String>,
default: Option<bool>,
) -> Self {
let property = PrimitiveSchemaDefinition::Boolean {
title: None,
description,
default,
};
self.properties.insert(name.clone(), property);
if required && let Some(required_fields) = self.required.as_mut() {
required_fields.push(name);
}
self
}
}
impl Default for ElicitationSchema {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum PrimitiveSchemaDefinition {
#[serde(rename = "string")]
String {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<String>,
#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
min_length: Option<u32>,
#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
max_length: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<String>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
enum_values: Option<Vec<String>>,
#[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")]
enum_names: Option<Vec<String>>,
#[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
one_of: Option<Vec<EnumOption>>,
},
#[serde(rename = "number")]
Number {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
minimum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
maximum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<f64>,
},
#[serde(rename = "integer")]
Integer {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(
default,
deserialize_with = "integral",
skip_serializing_if = "Option::is_none"
)]
minimum: Option<i64>,
#[serde(
default,
deserialize_with = "integral",
skip_serializing_if = "Option::is_none"
)]
maximum: Option<i64>,
#[serde(
default,
deserialize_with = "integral",
skip_serializing_if = "Option::is_none"
)]
default: Option<i64>,
},
#[serde(rename = "boolean")]
Boolean {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<bool>,
},
#[serde(rename = "array")]
Array {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
min_items: Option<u32>,
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
max_items: Option<u32>,
items: MultiSelectItemsDefinition,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<Vec<String>>,
},
}
impl PrimitiveSchemaDefinition {
fn validate_value(&self, value: &serde_json::Value) -> Result<(), String> {
match self {
Self::String {
enum_values,
one_of,
..
} => {
let Some(text) = value.as_str() else {
return Err("expected a string".into());
};
let allowed: Option<Vec<&str>> = one_of
.as_ref()
.map(|options| options.iter().map(|o| o.const_value.as_str()).collect())
.or_else(|| {
enum_values
.as_ref()
.map(|values| values.iter().map(String::as_str).collect())
});
match allowed {
Some(allowed) if !allowed.contains(&text) => {
Err(format!("'{text}' is not one of the allowed values"))
}
_ => Ok(()),
}
}
Self::Number {
minimum, maximum, ..
} => {
let Some(number) = value.as_f64() else {
return Err("expected a number".into());
};
in_bounds(number, *minimum, *maximum)
}
Self::Integer {
minimum, maximum, ..
} => {
let Some(number) = value.as_i64().or_else(|| {
value
.as_f64()
.filter(|v| v.fract() == 0.0)
.map(|v| v as i64)
}) else {
return Err("expected an integer".into());
};
in_bounds(
number as f64,
minimum.map(|m| m as f64),
maximum.map(|m| m as f64),
)
}
Self::Boolean { .. } => value
.is_boolean()
.then_some(())
.ok_or_else(|| "expected a boolean".into()),
Self::Array {
min_items,
max_items,
items,
..
} => {
let Some(selected) = value.as_array() else {
return Err("expected an array".into());
};
if min_items.is_some_and(|min| selected.len() < min as usize)
|| max_items.is_some_and(|max| selected.len() > max as usize)
{
return Err("wrong number of selections".into());
}
let allowed: Vec<&str> = match items {
MultiSelectItemsDefinition::Titled(titled) => titled
.any_of
.iter()
.map(|o| o.const_value.as_str())
.collect(),
MultiSelectItemsDefinition::Untitled(untitled) => {
untitled.enum_values.iter().map(String::as_str).collect()
}
};
for item in selected {
match item.as_str() {
Some(text) if allowed.contains(&text) => {}
_ => return Err(format!("{item} is not one of the allowed values")),
}
}
Ok(())
}
}
}
}
fn in_bounds(value: f64, minimum: Option<f64>, maximum: Option<f64>) -> Result<(), String> {
if minimum.is_some_and(|min| value < min) || maximum.is_some_and(|max| value > max) {
Err(format!("{value} is out of range"))
} else {
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum MultiSelectItemsDefinition {
Titled(MultiSelectItems),
Untitled(UntitledMultiSelectItems),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnumOption {
#[serde(rename = "const")]
pub const_value: String,
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TitledSingleSelectEnumSchema {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(rename = "oneOf")]
pub one_of: Vec<EnumOption>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UntitledSingleSelectEnumSchema {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(rename = "enum")]
pub enum_values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TitledMultiSelectEnumSchema {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
pub min_items: Option<u32>,
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
pub max_items: Option<u32>,
pub items: MultiSelectItems,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UntitledMultiSelectEnumSchema {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
pub min_items: Option<u32>,
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
pub max_items: Option<u32>,
pub items: UntitledMultiSelectItems,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MultiSelectItems {
#[serde(rename = "anyOf")]
pub any_of: Vec<EnumOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UntitledMultiSelectItems {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(rename = "enum")]
pub enum_values: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LegacyTitledEnumSchema {
#[serde(rename = "type")]
pub schema_type: String,
#[serde(rename = "enum")]
pub enum_values: Vec<String>,
#[serde(rename = "enumNames")]
pub enum_names: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum EnumSchema {
TitledSingleSelect(TitledSingleSelectEnumSchema),
LegacyTitledSingleSelect(LegacyTitledEnumSchema),
UntitledSingleSelect(UntitledSingleSelectEnumSchema),
TitledMultiSelect(TitledMultiSelectEnumSchema),
UntitledMultiSelect(UntitledMultiSelectEnumSchema),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct URLElicitationRequiredError {
#[serde(serialize_with = "serialize_url_elicitations")]
pub elicitations: Vec<crate::protocol::ElicitRequestURLParams>,
}
fn serialize_url_elicitations<S: serde::Serializer>(
elicitations: &[crate::protocol::ElicitRequestURLParams],
serializer: S,
) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(elicitations.len()))?;
for elicitation in elicitations {
seq.serialize_element(&crate::protocol::ElicitRequestParams::Url(
elicitation.clone(),
))?;
}
seq.end()
}
impl URLElicitationRequiredError {
pub const ERROR_CODE: i32 = -32042;
#[must_use]
pub fn single(elicitation: crate::protocol::ElicitRequestURLParams) -> Self {
Self {
elicitations: vec![elicitation],
}
}
#[must_use]
pub fn new(
elicitations: impl IntoIterator<Item = crate::protocol::ElicitRequestURLParams>,
) -> Self {
Self {
elicitations: elicitations.into_iter().collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
#[test]
fn elicitation_schema_builder_round_trip() {
let schema = ElicitationSchema::new()
.add_string_property("name".into(), true, Some("User name".into()))
.add_number_property("age".into(), false, None, Some(0.0), Some(120.0));
let json = serde_json::to_string(&schema).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["type"], "object");
assert!(v["properties"]["name"].is_object());
assert_eq!(v["properties"]["name"]["type"], "string");
assert_eq!(v["properties"]["age"]["type"], "number");
assert_eq!(
v["required"].as_array().unwrap(),
&vec![Value::from("name")]
);
assert_eq!(v["additionalProperties"], false);
}
#[test]
fn primitive_schema_string_serde() {
let s = PrimitiveSchemaDefinition::String {
title: Some("Name".into()),
description: None,
format: Some("email".into()),
min_length: Some(1),
max_length: Some(80),
default: None,
enum_values: None,
enum_names: None,
one_of: None,
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("\"type\":\"string\""));
assert!(json.contains("\"format\":\"email\""));
let back: PrimitiveSchemaDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn titled_single_select_enum_schema_round_trip() {
let schema = TitledSingleSelectEnumSchema {
schema_type: "string".into(),
one_of: vec![
EnumOption {
const_value: "#FF0000".into(),
title: "Red".into(),
},
EnumOption {
const_value: "#00FF00".into(),
title: "Green".into(),
},
],
title: Some("Color".into()),
description: None,
default: Some("#FF0000".into()),
};
let json = serde_json::to_string(&schema).unwrap();
let v: Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["type"], "string");
assert!(v["oneOf"].is_array());
assert_eq!(v["default"], "#FF0000");
let back: TitledSingleSelectEnumSchema = serde_json::from_str(&json).unwrap();
assert_eq!(schema, back);
}
#[test]
fn enum_schema_union_discriminates_correctly() {
let titled = r#"{"type":"string","oneOf":[{"const":"a","title":"A"}]}"#;
match serde_json::from_str::<EnumSchema>(titled).unwrap() {
EnumSchema::TitledSingleSelect(_) => {}
_ => panic!("expected TitledSingleSelect"),
}
let untitled = r#"{"type":"string","enum":["a","b"]}"#;
match serde_json::from_str::<EnumSchema>(untitled).unwrap() {
EnumSchema::UntitledSingleSelect(_) => {}
_ => panic!("expected UntitledSingleSelect"),
}
let multi_titled = r#"{"type":"array","items":{"anyOf":[{"const":"a","title":"A"}]}}"#;
match serde_json::from_str::<EnumSchema>(multi_titled).unwrap() {
EnumSchema::TitledMultiSelect(_) => {}
_ => panic!("expected TitledMultiSelect"),
}
let multi_untitled = r#"{"type":"array","items":{"type":"string","enum":["a","b"]}}"#;
match serde_json::from_str::<EnumSchema>(multi_untitled).unwrap() {
EnumSchema::UntitledMultiSelect(_) => {}
_ => panic!("expected UntitledMultiSelect"),
}
}
fn url_params(id: &str) -> crate::protocol::ElicitRequestURLParams {
crate::protocol::ElicitRequestURLParams {
message: "Please sign in".into(),
url: "https://example.com/oauth".into(),
elicitation_id: id.into(),
task: None,
meta: None,
}
}
#[test]
fn url_elicitation_required_error_round_trip() {
let err = URLElicitationRequiredError::single(url_params("e-123"));
let json = serde_json::to_value(&err).unwrap();
let entries = json["elicitations"]
.as_array()
.expect("`elicitations` must be an array");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["elicitationId"], "e-123");
assert_eq!(entries[0]["url"], "https://example.com/oauth");
assert_eq!(entries[0]["message"], "Please sign in");
assert_eq!(entries[0]["mode"], "url");
let back: URLElicitationRequiredError = serde_json::from_value(json).unwrap();
assert_eq!(err, back);
assert_eq!(URLElicitationRequiredError::ERROR_CODE, -32042);
}
#[test]
fn url_elicitation_required_error_carries_several() {
let err = URLElicitationRequiredError::new([url_params("a"), url_params("b")]);
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["elicitations"].as_array().unwrap().len(), 2);
}
}