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 mut field_schema = self.schema_for_shape(field.shape.get());
427
428                    // Use field-level doc comments instead of type-level
429                    let field_description = if field.doc.is_empty() {
430                        None
431                    } else {
432                        Some(field.doc.join("\n").trim().to_string())
433                    };
434                    field_schema.description = field_description;
435
436                    // Check if field is required (not Option and no default)
437                    let is_option = matches!(field.shape.get().def, Def::Option(_));
438                    let has_default = field.default.is_some();
439
440                    if !is_option && !has_default {
441                        required.push(field_name.to_string());
442                    }
443
444                    properties.insert(field_name.to_string(), field_schema);
445                }
446
447                self.in_progress.pop();
448
449                JsonSchema {
450                    type_: Some(SchemaType::Object.into()),
451                    properties: Some(properties),
452                    required: if required.is_empty() {
453                        None
454                    } else {
455                        Some(required)
456                    },
457                    additional_properties: Some(AdditionalProperties::Bool(false)),
458                    description,
459                    title: Some(shape.type_identifier.to_string()),
460                    ..JsonSchema::new()
461                }
462            }
463        }
464    }
465
466    fn schema_for_enum(
467        &mut self,
468        shape: &'static Shape,
469        enum_type: &facet_core::EnumType,
470        description: Option<String>,
471    ) -> JsonSchema {
472        // Check if all variants are unit variants (simple string enum)
473        let all_unit = enum_type
474            .variants
475            .iter()
476            .all(|v| matches!(v.data.kind, StructKind::Unit));
477
478        if all_unit {
479            // Simple string enum
480            let values: Vec<String> = enum_type
481                .variants
482                .iter()
483                .map(|v| v.effective_name().to_string())
484                .collect();
485
486            JsonSchema {
487                type_: Some(SchemaType::String.into()),
488                enum_: Some(values),
489                description,
490                title: Some(shape.type_identifier.to_string()),
491                ..JsonSchema::new()
492            }
493        } else {
494            // Complex enum - use oneOf with discriminator
495            // This handles internally tagged, externally tagged, adjacently tagged, and untagged
496            let variants: Vec<JsonSchema> = enum_type
497                .variants
498                .iter()
499                .map(|v| {
500                    let variant_name = v.effective_name().to_string();
501                    match v.data.kind {
502                        StructKind::Unit => {
503                            // Unit variant: { "type": "VariantName" } or just "VariantName"
504                            JsonSchema {
505                                const_: Some(variant_name),
506                                ..JsonSchema::new()
507                            }
508                        }
509                        StructKind::TupleStruct if v.data.fields.len() == 1 => {
510                            // Newtype variant: { "VariantName": <inner> }
511                            let mut props = BTreeMap::new();
512                            props.insert(
513                                variant_name.clone(),
514                                self.schema_for_shape(v.data.fields[0].shape.get()),
515                            );
516                            JsonSchema {
517                                type_: Some(SchemaType::Object.into()),
518                                properties: Some(props),
519                                required: Some(vec![variant_name]),
520                                additional_properties: Some(AdditionalProperties::Bool(false)),
521                                ..JsonSchema::new()
522                            }
523                        }
524                        _ => {
525                            // Struct variant: { "VariantName": { ...fields } }
526                            let inner =
527                                self.schema_for_struct(shape, v.data.fields, v.data.kind, None);
528                            let mut props = BTreeMap::new();
529                            props.insert(variant_name.clone(), inner);
530                            JsonSchema {
531                                type_: Some(SchemaType::Object.into()),
532                                properties: Some(props),
533                                required: Some(vec![variant_name]),
534                                additional_properties: Some(AdditionalProperties::Bool(false)),
535                                ..JsonSchema::new()
536                            }
537                        }
538                    }
539                })
540                .collect();
541
542            JsonSchema {
543                one_of: Some(variants),
544                description,
545                title: Some(shape.type_identifier.to_string()),
546                ..JsonSchema::new()
547            }
548        }
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn test_simple_struct() {
558        #[derive(Facet)]
559        struct User {
560            name: String,
561            age: u32,
562        }
563
564        let schema = to_schema::<User>();
565        insta::assert_snapshot!(schema);
566    }
567
568    #[test]
569    fn test_optional_field() {
570        #[derive(Facet)]
571        struct Config {
572            required: String,
573            optional: Option<String>,
574        }
575
576        let schema = to_schema::<Config>();
577        insta::assert_snapshot!(schema);
578    }
579
580    #[test]
581    fn test_simple_enum() {
582        #[derive(Facet)]
583        #[repr(u8)]
584        enum Status {
585            Active,
586            Inactive,
587            Pending,
588        }
589
590        let schema = to_schema::<Status>();
591        insta::assert_snapshot!(schema);
592    }
593
594    #[test]
595    fn test_vec() {
596        #[derive(Facet)]
597        struct Data {
598            items: Vec<String>,
599        }
600
601        let schema = to_schema::<Data>();
602        insta::assert_snapshot!(schema);
603    }
604
605    #[test]
606    fn test_enum_rename_all_snake_case() {
607        #[derive(Facet)]
608        #[facet(rename_all = "snake_case")]
609        #[repr(u8)]
610        enum ValidationErrorCode {
611            CircularDependency,
612            InvalidNaming,
613            UnknownRequirement,
614        }
615
616        let schema = to_schema::<ValidationErrorCode>();
617        insta::assert_snapshot!(schema);
618    }
619
620    #[test]
621    fn test_struct_rename_all_camel_case() {
622        #[derive(Facet)]
623        #[facet(rename_all = "camelCase")]
624        struct ApiResponse {
625            user_name: String,
626            created_at: String,
627            is_active: bool,
628        }
629
630        let schema = to_schema::<ApiResponse>();
631        insta::assert_snapshot!(schema);
632    }
633
634    #[test]
635    fn test_field_doc_comments_override_type_description() {
636        #[derive(Facet)]
637        /// Shared type-level docs that should not leak into undocumented fields.
638        struct DocumentedInner {
639            value: String,
640        }
641
642        #[derive(Facet)]
643        struct Container {
644            /// Field-level docs win for this property.
645            documented: DocumentedInner,
646            undocumented: DocumentedInner,
647        }
648
649        let schema = schema_for::<Container>();
650        let properties = schema
651            .properties
652            .expect("container should have object properties");
653
654        let documented = properties
655            .get("documented")
656            .expect("documented field schema should exist");
657        assert_eq!(
658            documented.description.as_deref(),
659            Some("Field-level docs win for this property.")
660        );
661
662        let undocumented = properties
663            .get("undocumented")
664            .expect("undocumented field schema should exist");
665        assert_eq!(undocumented.description, None);
666    }
667
668    #[test]
669    fn test_enum_with_data_rename_all() {
670        #[allow(dead_code)]
671        #[derive(Facet)]
672        #[facet(rename_all = "snake_case")]
673        #[repr(C)]
674        enum Message {
675            TextMessage { content: String },
676            ImageUpload { url: String, width: u32 },
677        }
678
679        let schema = to_schema::<Message>();
680        insta::assert_snapshot!(schema);
681    }
682
683    #[test]
684    fn test_deserialize_schema_type_as_string() {
685        let schema: JsonSchema =
686            facet_json::from_str_borrowed(r#"{"type":"integer"}"#).expect("valid schema JSON");
687
688        match schema.type_ {
689            Some(SchemaTypes::Single(SchemaType::Integer)) => {}
690            other => panic!("expected single integer type, got {other:?}"),
691        }
692    }
693
694    #[test]
695    fn test_deserialize_schema_type_as_array() {
696        let schema: JsonSchema =
697            facet_json::from_str_borrowed(r#"{"type":["integer"]}"#).expect("valid schema JSON");
698
699        match schema.type_ {
700            Some(SchemaTypes::Multiple(types)) => {
701                assert!(matches!(types.as_slice(), [SchemaType::Integer]));
702            }
703            other => panic!("expected integer type array, got {other:?}"),
704        }
705    }
706}