Skip to main content

facet_json_schema/
lib.rs

1//! Generate JSON Schema from facet type metadata.
2//!
3//! This crate uses facet's reflection capabilities to generate JSON Schema definitions
4//! from any type that implements `Facet`.
5//!
6//! # Example
7//!
8//! ```
9//! use facet::Facet;
10//! use facet_json_schema::to_schema;
11//!
12//! #[derive(Facet)]
13//! struct User {
14//!     name: String,
15//!     age: u32,
16//!     email: Option<String>,
17//! }
18//!
19//! let schema = to_schema::<User>();
20//! println!("{}", schema);
21//! ```
22
23extern crate alloc;
24
25use alloc::collections::BTreeMap;
26use alloc::string::String;
27use alloc::vec::Vec;
28
29use facet::Facet;
30use facet_core::{Def, Field, Shape, StructKind, Type, UserType};
31
32/// A JSON Schema definition.
33///
34/// This is a simplified representation that covers the most common cases.
35/// It can be serialized to JSON using facet-json.
36#[derive(Debug, Clone, Facet)]
37#[facet(skip_all_unless_truthy)]
38pub struct JsonSchema {
39    /// The JSON Schema dialect
40    #[facet(rename = "$schema")]
41    pub schema: Option<String>,
42
43    /// Reference to another schema definition
44    #[facet(rename = "$ref")]
45    pub ref_: Option<String>,
46
47    /// Schema definitions for reuse
48    #[facet(rename = "$defs")]
49    pub defs: Option<BTreeMap<String, JsonSchema>>,
50
51    /// The type (or list of types) of the schema
52    #[facet(rename = "type")]
53    pub type_: Option<SchemaTypes>,
54
55    /// For objects: the properties
56    pub properties: Option<BTreeMap<String, JsonSchema>>,
57
58    /// For objects: required property names
59    pub required: Option<Vec<String>>,
60
61    /// For objects: additional properties schema or false
62    #[facet(rename = "additionalProperties")]
63    pub additional_properties: Option<AdditionalProperties>,
64
65    /// For arrays: the items schema
66    pub items: Option<Box<JsonSchema>>,
67
68    /// For strings: enumerated values
69    #[facet(rename = "enum")]
70    pub enum_: Option<Vec<String>>,
71
72    /// For numbers: minimum value
73    pub minimum: Option<i64>,
74
75    /// For numbers: maximum value
76    pub maximum: Option<i64>,
77
78    /// For oneOf/anyOf/allOf
79    #[facet(rename = "oneOf")]
80    pub one_of: Option<Vec<JsonSchema>>,
81
82    #[facet(rename = "anyOf")]
83    pub any_of: Option<Vec<JsonSchema>>,
84
85    #[facet(rename = "allOf")]
86    pub all_of: Option<Vec<JsonSchema>>,
87
88    /// Description from doc comments
89    pub description: Option<String>,
90
91    /// Title (type name)
92    pub title: Option<String>,
93
94    /// Constant value
95    #[facet(rename = "const")]
96    pub const_: Option<String>,
97}
98
99/// JSON Schema type
100#[derive(Debug, Clone, Facet)]
101#[facet(rename_all = "lowercase")]
102#[repr(u8)]
103pub enum SchemaType {
104    String,
105    Number,
106    Integer,
107    Boolean,
108    Array,
109    Object,
110    Null,
111}
112
113/// JSON Schema `type` supports either a string or an array of strings.
114#[derive(Debug, Clone, Facet)]
115#[facet(untagged)]
116#[repr(u8)]
117pub enum SchemaTypes {
118    Single(SchemaType),
119    Multiple(Vec<SchemaType>),
120}
121
122impl From<SchemaType> for SchemaTypes {
123    fn from(value: SchemaType) -> Self {
124        Self::Single(value)
125    }
126}
127
128/// Additional properties can be a boolean or a schema
129#[derive(Debug, Clone, Facet)]
130#[facet(untagged)]
131#[repr(u8)]
132pub enum AdditionalProperties {
133    Bool(bool),
134    Schema(Box<JsonSchema>),
135}
136
137impl Default for JsonSchema {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl JsonSchema {
144    /// Create an empty schema
145    pub const fn new() -> Self {
146        Self {
147            schema: None,
148            ref_: None,
149            defs: None,
150            type_: None,
151            properties: None,
152            required: None,
153            additional_properties: None,
154            items: None,
155            enum_: None,
156            minimum: None,
157            maximum: None,
158            one_of: None,
159            any_of: None,
160            all_of: None,
161            description: None,
162            title: None,
163            const_: None,
164        }
165    }
166
167    /// Create a schema with a $schema dialect
168    pub fn with_dialect(dialect: &str) -> Self {
169        Self {
170            schema: Some(dialect.into()),
171            ..Self::new()
172        }
173    }
174
175    /// Create a reference to another schema
176    pub fn reference(ref_path: &str) -> Self {
177        Self {
178            ref_: Some(ref_path.into()),
179            ..Self::new()
180        }
181    }
182}
183
184/// Generate a JSON Schema from a facet type.
185///
186/// This returns a `JsonSchema` struct that can be serialized to JSON.
187pub fn schema_for<T: Facet<'static>>() -> JsonSchema {
188    let mut ctx = SchemaContext::new();
189    let schema = ctx.schema_for_shape(T::SHAPE);
190
191    // If we collected any definitions, add them to the root
192    if ctx.defs.is_empty() {
193        schema
194    } else {
195        JsonSchema {
196            schema: Some("https://json-schema.org/draft/2020-12/schema".into()),
197            defs: Some(ctx.defs),
198            ..schema
199        }
200    }
201}
202
203/// Generate a JSON Schema string from a facet type.
204pub fn to_schema<T: Facet<'static>>() -> String {
205    let schema = schema_for::<T>();
206    facet_json::to_string_pretty(&schema).expect("JSON Schema serialization should not fail")
207}
208
209/// Context for schema generation, tracking definitions to avoid cycles.
210struct SchemaContext {
211    /// Collected schema definitions
212    defs: BTreeMap<String, JsonSchema>,
213    /// Types currently being processed (for cycle detection)
214    in_progress: Vec<&'static str>,
215}
216
217impl SchemaContext {
218    const fn new() -> Self {
219        Self {
220            defs: BTreeMap::new(),
221            in_progress: Vec::new(),
222        }
223    }
224
225    fn schema_for_shape(&mut self, shape: &'static Shape) -> JsonSchema {
226        // Check for cycles - if we're already processing this type, emit a $ref
227        let type_name = shape.type_identifier;
228        if self.in_progress.contains(&type_name) {
229            return JsonSchema::reference(&format!("#/$defs/{}", type_name));
230        }
231
232        // Build description from doc comments
233        let description = if shape.doc.is_empty() {
234            None
235        } else {
236            Some(shape.doc.join("\n").trim().to_string())
237        };
238
239        // Handle the type based on its definition
240        // NOTE: We check Def BEFORE shape.inner because types like Vec<T> set
241        // .inner() for type parameter propagation but should still be treated
242        // as List, not as transparent wrappers.
243        match &shape.def {
244            Def::Scalar => self.schema_for_scalar(shape, description),
245            Def::Option(opt) => {
246                // Option<T> becomes anyOf: [schema(T), {type: "null"}]
247                let inner_schema = self.schema_for_shape(opt.t);
248                JsonSchema {
249                    any_of: Some(vec![
250                        inner_schema,
251                        JsonSchema {
252                            type_: Some(SchemaType::Null.into()),
253                            ..JsonSchema::new()
254                        },
255                    ]),
256                    description,
257                    ..JsonSchema::new()
258                }
259            }
260            Def::List(list) => JsonSchema {
261                type_: Some(SchemaType::Array.into()),
262                items: Some(Box::new(self.schema_for_shape(list.t))),
263                description,
264                ..JsonSchema::new()
265            },
266            Def::Array(arr) => JsonSchema {
267                type_: Some(SchemaType::Array.into()),
268                items: Some(Box::new(self.schema_for_shape(arr.t))),
269                description,
270                ..JsonSchema::new()
271            },
272            Def::Set(set) => JsonSchema {
273                type_: Some(SchemaType::Array.into()),
274                items: Some(Box::new(self.schema_for_shape(set.t))),
275                description,
276                ..JsonSchema::new()
277            },
278            Def::Map(map) => {
279                // Maps become objects with additionalProperties
280                JsonSchema {
281                    type_: Some(SchemaType::Object.into()),
282                    additional_properties: Some(AdditionalProperties::Schema(Box::new(
283                        self.schema_for_shape(map.v),
284                    ))),
285                    description,
286                    ..JsonSchema::new()
287                }
288            }
289            Def::Undefined => {
290                // Check if it's a struct or enum via Type
291                match &shape.ty {
292                    Type::User(UserType::Struct(st)) => {
293                        self.schema_for_struct(shape, st.fields, st.kind, description)
294                    }
295                    Type::User(UserType::Enum(en)) => self.schema_for_enum(shape, en, description),
296                    _ => {
297                        // For other undefined types, check if it's a transparent wrapper
298                        if let Some(inner) = shape.inner {
299                            self.schema_for_shape(inner)
300                        } else {
301                            JsonSchema {
302                                description,
303                                ..JsonSchema::new()
304                            }
305                        }
306                    }
307                }
308            }
309            _ => {
310                // For other defs, check if it's a transparent wrapper
311                if let Some(inner) = shape.inner {
312                    self.schema_for_shape(inner)
313                } else {
314                    JsonSchema {
315                        description,
316                        ..JsonSchema::new()
317                    }
318                }
319            }
320        }
321    }
322
323    fn schema_for_scalar(
324        &mut self,
325        shape: &'static Shape,
326        description: Option<String>,
327    ) -> JsonSchema {
328        let type_name = shape.type_identifier;
329
330        // Map common Rust types to JSON Schema types
331        let (type_, minimum, maximum) = match type_name {
332            // Strings
333            "String" | "str" | "&str" | "Cow" => (Some(SchemaType::String.into()), None, None),
334
335            // Booleans
336            "bool" => (Some(SchemaType::Boolean.into()), None, None),
337
338            // Unsigned integers
339            "u8" | "u16" | "u32" | "u64" | "u128" | "usize" => {
340                (Some(SchemaType::Integer.into()), Some(0), None)
341            }
342
343            // Signed integers
344            "i8" => (Some(SchemaType::Integer.into()), Some(i8::MIN as i64), None),
345            "i16" => (
346                Some(SchemaType::Integer.into()),
347                Some(i16::MIN as i64),
348                None,
349            ),
350            "i32" => (
351                Some(SchemaType::Integer.into()),
352                Some(i32::MIN as i64),
353                None,
354            ),
355            "i64" => (Some(SchemaType::Integer.into()), Some(i64::MIN), None),
356            "i128" => (Some(SchemaType::Integer.into()), Some(i64::MIN), None),
357            "isize" => (Some(SchemaType::Integer.into()), Some(i64::MIN), None),
358
359            // Floats
360            "f32" | "f64" => (Some(SchemaType::Number.into()), None, None),
361
362            // Char as string
363            "char" => (Some(SchemaType::String.into()), None, None),
364
365            // Unknown scalar - no type constraint
366            _ => (None, None, None),
367        };
368
369        JsonSchema {
370            type_,
371            minimum,
372            maximum,
373            description,
374            ..JsonSchema::new()
375        }
376    }
377
378    fn schema_for_struct(
379        &mut self,
380        shape: &'static Shape,
381        fields: &'static [Field],
382        kind: StructKind,
383        description: Option<String>,
384    ) -> JsonSchema {
385        match kind {
386            StructKind::Unit => {
387                // Unit struct serializes as null or empty object
388                JsonSchema {
389                    type_: Some(SchemaType::Null.into()),
390                    description,
391                    ..JsonSchema::new()
392                }
393            }
394            StructKind::TupleStruct if fields.len() == 1 => {
395                // Newtype - serialize as the inner type
396                self.schema_for_shape(fields[0].shape.get())
397            }
398            StructKind::TupleStruct | StructKind::Tuple => {
399                // Tuple struct as array - collect items for prefixItems
400                let _items: Vec<JsonSchema> = fields
401                    .iter()
402                    .map(|f| self.schema_for_shape(f.shape.get()))
403                    .collect();
404
405                // TODO: Use prefixItems for proper tuple schema (JSON Schema 2020-12)
406                JsonSchema {
407                    type_: Some(SchemaType::Array.into()),
408                    description,
409                    ..JsonSchema::new()
410                }
411            }
412            StructKind::Struct => {
413                // Mark as in progress for cycle detection
414                self.in_progress.push(shape.type_identifier);
415
416                let mut properties = BTreeMap::new();
417                let mut required = Vec::new();
418
419                for field in fields {
420                    // Skip fields marked with skip
421                    if field.flags.contains(facet_core::FieldFlags::SKIP) {
422                        continue;
423                    }
424
425                    let field_name = field.effective_name();
426                    let field_schema = self.schema_for_shape(field.shape.get());
427
428                    // Check if field is required (not Option and no default)
429                    let is_option = matches!(field.shape.get().def, Def::Option(_));
430                    let has_default = field.default.is_some();
431
432                    if !is_option && !has_default {
433                        required.push(field_name.to_string());
434                    }
435
436                    properties.insert(field_name.to_string(), field_schema);
437                }
438
439                self.in_progress.pop();
440
441                JsonSchema {
442                    type_: Some(SchemaType::Object.into()),
443                    properties: Some(properties),
444                    required: if required.is_empty() {
445                        None
446                    } else {
447                        Some(required)
448                    },
449                    additional_properties: Some(AdditionalProperties::Bool(false)),
450                    description,
451                    title: Some(shape.type_identifier.to_string()),
452                    ..JsonSchema::new()
453                }
454            }
455        }
456    }
457
458    fn schema_for_enum(
459        &mut self,
460        shape: &'static Shape,
461        enum_type: &facet_core::EnumType,
462        description: Option<String>,
463    ) -> JsonSchema {
464        // Check if all variants are unit variants (simple string enum)
465        let all_unit = enum_type
466            .variants
467            .iter()
468            .all(|v| matches!(v.data.kind, StructKind::Unit));
469
470        if all_unit {
471            // Simple string enum
472            let values: Vec<String> = enum_type
473                .variants
474                .iter()
475                .map(|v| v.effective_name().to_string())
476                .collect();
477
478            JsonSchema {
479                type_: Some(SchemaType::String.into()),
480                enum_: Some(values),
481                description,
482                title: Some(shape.type_identifier.to_string()),
483                ..JsonSchema::new()
484            }
485        } else {
486            // Complex enum - use oneOf with discriminator
487            // This handles internally tagged, externally tagged, adjacently tagged, and untagged
488            let variants: Vec<JsonSchema> = enum_type
489                .variants
490                .iter()
491                .map(|v| {
492                    let variant_name = v.effective_name().to_string();
493                    match v.data.kind {
494                        StructKind::Unit => {
495                            // Unit variant: { "type": "VariantName" } or just "VariantName"
496                            JsonSchema {
497                                const_: Some(variant_name),
498                                ..JsonSchema::new()
499                            }
500                        }
501                        StructKind::TupleStruct if v.data.fields.len() == 1 => {
502                            // Newtype variant: { "VariantName": <inner> }
503                            let mut props = BTreeMap::new();
504                            props.insert(
505                                variant_name.clone(),
506                                self.schema_for_shape(v.data.fields[0].shape.get()),
507                            );
508                            JsonSchema {
509                                type_: Some(SchemaType::Object.into()),
510                                properties: Some(props),
511                                required: Some(vec![variant_name]),
512                                additional_properties: Some(AdditionalProperties::Bool(false)),
513                                ..JsonSchema::new()
514                            }
515                        }
516                        _ => {
517                            // Struct variant: { "VariantName": { ...fields } }
518                            let inner =
519                                self.schema_for_struct(shape, v.data.fields, v.data.kind, None);
520                            let mut props = BTreeMap::new();
521                            props.insert(variant_name.clone(), inner);
522                            JsonSchema {
523                                type_: Some(SchemaType::Object.into()),
524                                properties: Some(props),
525                                required: Some(vec![variant_name]),
526                                additional_properties: Some(AdditionalProperties::Bool(false)),
527                                ..JsonSchema::new()
528                            }
529                        }
530                    }
531                })
532                .collect();
533
534            JsonSchema {
535                one_of: Some(variants),
536                description,
537                title: Some(shape.type_identifier.to_string()),
538                ..JsonSchema::new()
539            }
540        }
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn test_simple_struct() {
550        #[derive(Facet)]
551        struct User {
552            name: String,
553            age: u32,
554        }
555
556        let schema = to_schema::<User>();
557        insta::assert_snapshot!(schema);
558    }
559
560    #[test]
561    fn test_optional_field() {
562        #[derive(Facet)]
563        struct Config {
564            required: String,
565            optional: Option<String>,
566        }
567
568        let schema = to_schema::<Config>();
569        insta::assert_snapshot!(schema);
570    }
571
572    #[test]
573    fn test_simple_enum() {
574        #[derive(Facet)]
575        #[repr(u8)]
576        enum Status {
577            Active,
578            Inactive,
579            Pending,
580        }
581
582        let schema = to_schema::<Status>();
583        insta::assert_snapshot!(schema);
584    }
585
586    #[test]
587    fn test_vec() {
588        #[derive(Facet)]
589        struct Data {
590            items: Vec<String>,
591        }
592
593        let schema = to_schema::<Data>();
594        insta::assert_snapshot!(schema);
595    }
596
597    #[test]
598    fn test_enum_rename_all_snake_case() {
599        #[derive(Facet)]
600        #[facet(rename_all = "snake_case")]
601        #[repr(u8)]
602        enum ValidationErrorCode {
603            CircularDependency,
604            InvalidNaming,
605            UnknownRequirement,
606        }
607
608        let schema = to_schema::<ValidationErrorCode>();
609        insta::assert_snapshot!(schema);
610    }
611
612    #[test]
613    fn test_struct_rename_all_camel_case() {
614        #[derive(Facet)]
615        #[facet(rename_all = "camelCase")]
616        struct ApiResponse {
617            user_name: String,
618            created_at: String,
619            is_active: bool,
620        }
621
622        let schema = to_schema::<ApiResponse>();
623        insta::assert_snapshot!(schema);
624    }
625
626    #[test]
627    fn test_enum_with_data_rename_all() {
628        #[allow(dead_code)]
629        #[derive(Facet)]
630        #[facet(rename_all = "snake_case")]
631        #[repr(C)]
632        enum Message {
633            TextMessage { content: String },
634            ImageUpload { url: String, width: u32 },
635        }
636
637        let schema = to_schema::<Message>();
638        insta::assert_snapshot!(schema);
639    }
640
641    #[test]
642    fn test_deserialize_schema_type_as_string() {
643        let schema: JsonSchema =
644            facet_json::from_str_borrowed(r#"{"type":"integer"}"#).expect("valid schema JSON");
645
646        match schema.type_ {
647            Some(SchemaTypes::Single(SchemaType::Integer)) => {}
648            other => panic!("expected single integer type, got {other:?}"),
649        }
650    }
651
652    #[test]
653    fn test_deserialize_schema_type_as_array() {
654        let schema: JsonSchema =
655            facet_json::from_str_borrowed(r#"{"type":["integer"]}"#).expect("valid schema JSON");
656
657        match schema.type_ {
658            Some(SchemaTypes::Multiple(types)) => {
659                assert!(matches!(types.as_slice(), [SchemaType::Integer]));
660            }
661            other => panic!("expected integer type array, got {other:?}"),
662        }
663    }
664}