1extern 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#[derive(Debug, Clone, Facet)]
37#[facet(skip_all_unless_truthy)]
38pub struct JsonSchema {
39 #[facet(rename = "$schema")]
41 pub schema: Option<String>,
42
43 #[facet(rename = "$ref")]
45 pub ref_: Option<String>,
46
47 #[facet(rename = "$defs")]
49 pub defs: Option<BTreeMap<String, JsonSchema>>,
50
51 #[facet(rename = "type")]
53 pub type_: Option<SchemaTypes>,
54
55 pub properties: Option<BTreeMap<String, JsonSchema>>,
57
58 pub required: Option<Vec<String>>,
60
61 #[facet(rename = "additionalProperties")]
63 pub additional_properties: Option<AdditionalProperties>,
64
65 pub items: Option<Box<JsonSchema>>,
67
68 #[facet(rename = "enum")]
70 pub enum_: Option<Vec<String>>,
71
72 pub minimum: Option<i64>,
74
75 pub maximum: Option<i64>,
77
78 #[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 pub description: Option<String>,
90
91 pub title: Option<String>,
93
94 #[facet(rename = "const")]
96 pub const_: Option<String>,
97}
98
99#[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#[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#[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 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 pub fn with_dialect(dialect: &str) -> Self {
169 Self {
170 schema: Some(dialect.into()),
171 ..Self::new()
172 }
173 }
174
175 pub fn reference(ref_path: &str) -> Self {
177 Self {
178 ref_: Some(ref_path.into()),
179 ..Self::new()
180 }
181 }
182}
183
184pub 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 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
203pub 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
209struct SchemaContext {
211 defs: BTreeMap<String, JsonSchema>,
213 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 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 let description = if shape.doc.is_empty() {
234 None
235 } else {
236 Some(shape.doc.join("\n").trim().to_string())
237 };
238
239 match &shape.def {
244 Def::Scalar => self.schema_for_scalar(shape, description),
245 Def::Option(opt) => {
246 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 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 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 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 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 let (type_, minimum, maximum) = match type_name {
332 "String" | "str" | "&str" | "Cow" => (Some(SchemaType::String.into()), None, None),
334
335 "bool" => (Some(SchemaType::Boolean.into()), None, None),
337
338 "u8" | "u16" | "u32" | "u64" | "u128" | "usize" => {
340 (Some(SchemaType::Integer.into()), Some(0), None)
341 }
342
343 "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 "f32" | "f64" => (Some(SchemaType::Number.into()), None, None),
361
362 "char" => (Some(SchemaType::String.into()), None, None),
364
365 _ => (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 JsonSchema {
389 type_: Some(SchemaType::Null.into()),
390 description,
391 ..JsonSchema::new()
392 }
393 }
394 StructKind::TupleStruct if fields.len() == 1 => {
395 self.schema_for_shape(fields[0].shape.get())
397 }
398 StructKind::TupleStruct | StructKind::Tuple => {
399 let _items: Vec<JsonSchema> = fields
401 .iter()
402 .map(|f| self.schema_for_shape(f.shape.get()))
403 .collect();
404
405 JsonSchema {
407 type_: Some(SchemaType::Array.into()),
408 description,
409 ..JsonSchema::new()
410 }
411 }
412 StructKind::Struct => {
413 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 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 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 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 let all_unit = enum_type
474 .variants
475 .iter()
476 .all(|v| matches!(v.data.kind, StructKind::Unit));
477
478 if all_unit {
479 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 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 JsonSchema {
505 const_: Some(variant_name),
506 ..JsonSchema::new()
507 }
508 }
509 StructKind::TupleStruct if v.data.fields.len() == 1 => {
510 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 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 struct DocumentedInner {
639 value: String,
640 }
641
642 #[derive(Facet)]
643 struct Container {
644 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}