extern crate alloc;
use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use facet::Facet;
use facet_core::{Def, Field, Shape, StructKind, Type, UserType};
#[derive(Debug, Clone, Facet)]
#[facet(skip_all_unless_truthy)]
pub struct JsonSchema {
#[facet(rename = "$schema")]
pub schema: Option<String>,
#[facet(rename = "$ref")]
pub ref_: Option<String>,
#[facet(rename = "$defs")]
pub defs: Option<BTreeMap<String, JsonSchema>>,
#[facet(rename = "type")]
pub type_: Option<SchemaTypes>,
pub properties: Option<BTreeMap<String, JsonSchema>>,
pub required: Option<Vec<String>>,
#[facet(rename = "additionalProperties")]
pub additional_properties: Option<AdditionalProperties>,
pub items: Option<Box<JsonSchema>>,
#[facet(rename = "enum")]
pub enum_: Option<Vec<String>>,
pub minimum: Option<i64>,
pub maximum: Option<i64>,
#[facet(rename = "oneOf")]
pub one_of: Option<Vec<JsonSchema>>,
#[facet(rename = "anyOf")]
pub any_of: Option<Vec<JsonSchema>>,
#[facet(rename = "allOf")]
pub all_of: Option<Vec<JsonSchema>>,
pub description: Option<String>,
pub title: Option<String>,
#[facet(rename = "const")]
pub const_: Option<String>,
}
#[derive(Debug, Clone, Facet)]
#[facet(rename_all = "lowercase")]
#[repr(u8)]
pub enum SchemaType {
String,
Number,
Integer,
Boolean,
Array,
Object,
Null,
}
#[derive(Debug, Clone, Facet)]
#[facet(untagged)]
#[repr(u8)]
pub enum SchemaTypes {
Single(SchemaType),
Multiple(Vec<SchemaType>),
}
impl From<SchemaType> for SchemaTypes {
fn from(value: SchemaType) -> Self {
Self::Single(value)
}
}
#[derive(Debug, Clone, Facet)]
#[facet(untagged)]
#[repr(u8)]
pub enum AdditionalProperties {
Bool(bool),
Schema(Box<JsonSchema>),
}
impl Default for JsonSchema {
fn default() -> Self {
Self::new()
}
}
impl JsonSchema {
pub const fn new() -> Self {
Self {
schema: None,
ref_: None,
defs: None,
type_: None,
properties: None,
required: None,
additional_properties: None,
items: None,
enum_: None,
minimum: None,
maximum: None,
one_of: None,
any_of: None,
all_of: None,
description: None,
title: None,
const_: None,
}
}
pub fn with_dialect(dialect: &str) -> Self {
Self {
schema: Some(dialect.into()),
..Self::new()
}
}
pub fn reference(ref_path: &str) -> Self {
Self {
ref_: Some(ref_path.into()),
..Self::new()
}
}
}
pub fn schema_for<T: Facet<'static>>() -> JsonSchema {
let mut ctx = SchemaContext::new();
let schema = ctx.schema_for_shape(T::SHAPE);
if ctx.defs.is_empty() {
schema
} else {
JsonSchema {
schema: Some("https://json-schema.org/draft/2020-12/schema".into()),
defs: Some(ctx.defs),
..schema
}
}
}
pub fn to_schema<T: Facet<'static>>() -> String {
let schema = schema_for::<T>();
facet_json::to_string_pretty(&schema).expect("JSON Schema serialization should not fail")
}
struct SchemaContext {
defs: BTreeMap<String, JsonSchema>,
in_progress: Vec<&'static str>,
}
impl SchemaContext {
const fn new() -> Self {
Self {
defs: BTreeMap::new(),
in_progress: Vec::new(),
}
}
fn schema_for_shape(&mut self, shape: &'static Shape) -> JsonSchema {
let type_name = shape.type_identifier;
if self.in_progress.contains(&type_name) {
return JsonSchema::reference(&format!("#/$defs/{}", type_name));
}
let description = if shape.doc.is_empty() {
None
} else {
Some(shape.doc.join("\n").trim().to_string())
};
match &shape.def {
Def::Scalar => self.schema_for_scalar(shape, description),
Def::Option(opt) => {
let inner_schema = self.schema_for_shape(opt.t);
JsonSchema {
any_of: Some(vec![
inner_schema,
JsonSchema {
type_: Some(SchemaType::Null.into()),
..JsonSchema::new()
},
]),
description,
..JsonSchema::new()
}
}
Def::List(list) => JsonSchema {
type_: Some(SchemaType::Array.into()),
items: Some(Box::new(self.schema_for_shape(list.t))),
description,
..JsonSchema::new()
},
Def::Array(arr) => JsonSchema {
type_: Some(SchemaType::Array.into()),
items: Some(Box::new(self.schema_for_shape(arr.t))),
description,
..JsonSchema::new()
},
Def::Set(set) => JsonSchema {
type_: Some(SchemaType::Array.into()),
items: Some(Box::new(self.schema_for_shape(set.t))),
description,
..JsonSchema::new()
},
Def::Map(map) => {
JsonSchema {
type_: Some(SchemaType::Object.into()),
additional_properties: Some(AdditionalProperties::Schema(Box::new(
self.schema_for_shape(map.v),
))),
description,
..JsonSchema::new()
}
}
Def::Undefined => {
match &shape.ty {
Type::User(UserType::Struct(st)) => {
self.schema_for_struct(shape, st.fields, st.kind, description)
}
Type::User(UserType::Enum(en)) => self.schema_for_enum(shape, en, description),
_ => {
if let Some(inner) = shape.inner {
self.schema_for_shape(inner)
} else {
JsonSchema {
description,
..JsonSchema::new()
}
}
}
}
}
_ => {
if let Some(inner) = shape.inner {
self.schema_for_shape(inner)
} else {
JsonSchema {
description,
..JsonSchema::new()
}
}
}
}
}
fn schema_for_scalar(
&mut self,
shape: &'static Shape,
description: Option<String>,
) -> JsonSchema {
let type_name = shape.type_identifier;
let (type_, minimum, maximum) = match type_name {
"String" | "str" | "&str" | "Cow" => (Some(SchemaType::String.into()), None, None),
"bool" => (Some(SchemaType::Boolean.into()), None, None),
"u8" | "u16" | "u32" | "u64" | "u128" | "usize" => {
(Some(SchemaType::Integer.into()), Some(0), None)
}
"i8" => (Some(SchemaType::Integer.into()), Some(i8::MIN as i64), None),
"i16" => (
Some(SchemaType::Integer.into()),
Some(i16::MIN as i64),
None,
),
"i32" => (
Some(SchemaType::Integer.into()),
Some(i32::MIN as i64),
None,
),
"i64" => (Some(SchemaType::Integer.into()), Some(i64::MIN), None),
"i128" => (Some(SchemaType::Integer.into()), Some(i64::MIN), None),
"isize" => (Some(SchemaType::Integer.into()), Some(i64::MIN), None),
"f32" | "f64" => (Some(SchemaType::Number.into()), None, None),
"char" => (Some(SchemaType::String.into()), None, None),
_ => (None, None, None),
};
JsonSchema {
type_,
minimum,
maximum,
description,
..JsonSchema::new()
}
}
fn schema_for_struct(
&mut self,
shape: &'static Shape,
fields: &'static [Field],
kind: StructKind,
description: Option<String>,
) -> JsonSchema {
match kind {
StructKind::Unit => {
JsonSchema {
type_: Some(SchemaType::Null.into()),
description,
..JsonSchema::new()
}
}
StructKind::TupleStruct if fields.len() == 1 => {
self.schema_for_shape(fields[0].shape.get())
}
StructKind::TupleStruct | StructKind::Tuple => {
let _items: Vec<JsonSchema> = fields
.iter()
.map(|f| self.schema_for_shape(f.shape.get()))
.collect();
JsonSchema {
type_: Some(SchemaType::Array.into()),
description,
..JsonSchema::new()
}
}
StructKind::Struct => {
self.in_progress.push(shape.type_identifier);
let mut properties = BTreeMap::new();
let mut required = Vec::new();
for field in fields {
if field.flags.contains(facet_core::FieldFlags::SKIP) {
continue;
}
let field_name = field.effective_name();
let mut field_schema = self.schema_for_shape(field.shape.get());
let field_description = if field.doc.is_empty() {
None
} else {
Some(field.doc.join("\n").trim().to_string())
};
field_schema.description = field_description;
let is_option = matches!(field.shape.get().def, Def::Option(_));
let has_default = field.default.is_some();
if !is_option && !has_default {
required.push(field_name.to_string());
}
properties.insert(field_name.to_string(), field_schema);
}
self.in_progress.pop();
JsonSchema {
type_: Some(SchemaType::Object.into()),
properties: Some(properties),
required: if required.is_empty() {
None
} else {
Some(required)
},
additional_properties: Some(AdditionalProperties::Bool(false)),
description,
title: Some(shape.type_identifier.to_string()),
..JsonSchema::new()
}
}
}
}
fn schema_for_enum(
&mut self,
shape: &'static Shape,
enum_type: &facet_core::EnumType,
description: Option<String>,
) -> JsonSchema {
let all_unit = enum_type
.variants
.iter()
.all(|v| matches!(v.data.kind, StructKind::Unit));
if all_unit {
let values: Vec<String> = enum_type
.variants
.iter()
.map(|v| v.effective_name().to_string())
.collect();
JsonSchema {
type_: Some(SchemaType::String.into()),
enum_: Some(values),
description,
title: Some(shape.type_identifier.to_string()),
..JsonSchema::new()
}
} else {
let variants: Vec<JsonSchema> = enum_type
.variants
.iter()
.map(|v| {
let variant_name = v.effective_name().to_string();
match v.data.kind {
StructKind::Unit => {
JsonSchema {
const_: Some(variant_name),
..JsonSchema::new()
}
}
StructKind::TupleStruct if v.data.fields.len() == 1 => {
let mut props = BTreeMap::new();
props.insert(
variant_name.clone(),
self.schema_for_shape(v.data.fields[0].shape.get()),
);
JsonSchema {
type_: Some(SchemaType::Object.into()),
properties: Some(props),
required: Some(vec![variant_name]),
additional_properties: Some(AdditionalProperties::Bool(false)),
..JsonSchema::new()
}
}
_ => {
let inner =
self.schema_for_struct(shape, v.data.fields, v.data.kind, None);
let mut props = BTreeMap::new();
props.insert(variant_name.clone(), inner);
JsonSchema {
type_: Some(SchemaType::Object.into()),
properties: Some(props),
required: Some(vec![variant_name]),
additional_properties: Some(AdditionalProperties::Bool(false)),
..JsonSchema::new()
}
}
}
})
.collect();
JsonSchema {
one_of: Some(variants),
description,
title: Some(shape.type_identifier.to_string()),
..JsonSchema::new()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_struct() {
#[derive(Facet)]
struct User {
name: String,
age: u32,
}
let schema = to_schema::<User>();
insta::assert_snapshot!(schema);
}
#[test]
fn test_optional_field() {
#[derive(Facet)]
struct Config {
required: String,
optional: Option<String>,
}
let schema = to_schema::<Config>();
insta::assert_snapshot!(schema);
}
#[test]
fn test_simple_enum() {
#[derive(Facet)]
#[repr(u8)]
enum Status {
Active,
Inactive,
Pending,
}
let schema = to_schema::<Status>();
insta::assert_snapshot!(schema);
}
#[test]
fn test_vec() {
#[derive(Facet)]
struct Data {
items: Vec<String>,
}
let schema = to_schema::<Data>();
insta::assert_snapshot!(schema);
}
#[test]
fn test_enum_rename_all_snake_case() {
#[derive(Facet)]
#[facet(rename_all = "snake_case")]
#[repr(u8)]
enum ValidationErrorCode {
CircularDependency,
InvalidNaming,
UnknownRequirement,
}
let schema = to_schema::<ValidationErrorCode>();
insta::assert_snapshot!(schema);
}
#[test]
fn test_struct_rename_all_camel_case() {
#[derive(Facet)]
#[facet(rename_all = "camelCase")]
struct ApiResponse {
user_name: String,
created_at: String,
is_active: bool,
}
let schema = to_schema::<ApiResponse>();
insta::assert_snapshot!(schema);
}
#[test]
fn test_field_doc_comments_override_type_description() {
#[derive(Facet)]
struct DocumentedInner {
value: String,
}
#[derive(Facet)]
struct Container {
documented: DocumentedInner,
undocumented: DocumentedInner,
}
let schema = schema_for::<Container>();
let properties = schema
.properties
.expect("container should have object properties");
let documented = properties
.get("documented")
.expect("documented field schema should exist");
assert_eq!(
documented.description.as_deref(),
Some("Field-level docs win for this property.")
);
let undocumented = properties
.get("undocumented")
.expect("undocumented field schema should exist");
assert_eq!(undocumented.description, None);
}
#[test]
fn test_enum_with_data_rename_all() {
#[allow(dead_code)]
#[derive(Facet)]
#[facet(rename_all = "snake_case")]
#[repr(C)]
enum Message {
TextMessage { content: String },
ImageUpload { url: String, width: u32 },
}
let schema = to_schema::<Message>();
insta::assert_snapshot!(schema);
}
#[test]
fn test_deserialize_schema_type_as_string() {
let schema: JsonSchema =
facet_json::from_str_borrowed(r#"{"type":"integer"}"#).expect("valid schema JSON");
match schema.type_ {
Some(SchemaTypes::Single(SchemaType::Integer)) => {}
other => panic!("expected single integer type, got {other:?}"),
}
}
#[test]
fn test_deserialize_schema_type_as_array() {
let schema: JsonSchema =
facet_json::from_str_borrowed(r#"{"type":["integer"]}"#).expect("valid schema JSON");
match schema.type_ {
Some(SchemaTypes::Multiple(types)) => {
assert!(matches!(types.as_slice(), [SchemaType::Integer]));
}
other => panic!("expected integer type array, got {other:?}"),
}
}
}