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