google_ai_schema_derive/lib.rs
1//! Schema derivation framework for Google AI APIs
2//!
3//! Provides procedural macros for generating JSON schemas that comply with
4//! Google's Generative AI API specifications. Enables type-safe API interactions
5//! through compile-time schema validation.
6//!
7//! ## Key Features
8//! - **Schema-GSMA Compliance**: Derive schemas matching Gemini API requirements
9//! - **Serde Integration**: Automatic alignment with deserialization
10//! - **Validation Rules**: Enforce Google-specific schema constraints at compile time
11//! - **Attribute-based Customization**: Fine-tune schema generation through extensive attributes
12//! - **Type Safety**: Compile-time validation of schema constraints
13//!
14//! ## Core Macros
15//! - `#[derive(AsSchema)]`: Main derivation macro for schema generation
16//! - `#[derive(AsSchemaWithSerde)]`: Enhanced version with deeper Serde integration
17//!
18//! ## Attribute Reference
19//! ### Container Attributes (struct/enum level)
20//! - `description`: Overall schema description
21//! - `ignore_serde`: Disable serde integration
22//! - `rename_all`: Naming convention (e.g., "camelCase", "snake_case")
23//! - `rename_all_with`: Custom renaming function
24//! - `crate_path`: Custom crate path specification
25//! - `nullable`: Mark entire structure as nullable
26//!
27//! ### Field/Variant Attributes
28//! - `description`: Field-specific documentation
29//! - `format`: Schema format specification (e.g., "date-time", "email")
30//! - `type`: Specific schema type
31//! - `as_schema`: Custom schema generation function
32//! - `as_schema_generic`: Generic custom schema function
33//! - `required`: Force requirement status
34//! - `min/max_items`: Array size constraints
35//! - `nullable`: Mark item as nullable
36//! - `skip`: Exclude field from schema
37//!
38//! ## Important Notes
39//! - **Recursive Types**: Not supported due to JSON Schema limitations
40//! - **Serde Integration**: Use `AsSchemaWithSerde` for complex serde representations (e.g with Tuple structs)
41//! - **Type-Format Compatibility**: Mismatches like `r#type="String" format="float"` throw compile errors
42//! - `rename_all` and `rename_all_with` are mutually exclusive
43
44mod attr;
45mod schema;
46mod serde_support;
47
48extern crate proc_macro;
49extern crate proc_macro2;
50extern crate quote;
51extern crate syn;
52
53use std::{cell::LazyCell, collections::HashMap};
54
55use attr::{Attr, TopAttr};
56use proc_macro::TokenStream;
57use proc_macro2::Span;
58use quote::ToTokens;
59use schema::{BaseSchema, Format, Schema, SchemaImpl, SchemaImplOwned, Value};
60use syn::{
61 parse_macro_input, parse_quote,
62 punctuated::Punctuated,
63 spanned::Spanned as _,
64 token::{Colon, Comma, Paren},
65 Data, DataEnum, DataStruct, DeriveInput, Error, Field, Fields, FieldsNamed, FieldsUnnamed,
66 Path, PredicateType, TraitBound, Type, TypeParamBound, TypeTuple, Variant, WherePredicate,
67};
68
69/// Derive macro for AsSchema trait.
70///
71/// ## Implementation Notes
72/// ### 1. Description Concatenation
73/// ```rust
74/// # mod google_ai_rs {
75/// # pub trait AsSchema { fn as_schema() -> Schema; }
76/// # pub enum SchemaType { Unspecified = 0, String = 1, Number = 2, Integer = 3, Boolean = 4, Array = 5,Object = 6, }
77/// # #[derive(Default, PartialEq, Eq, Debug)]
78/// # pub struct Schema { pub r#type: i32, pub format: String, pub description: String, pub nullable: bool, pub r#enum: Vec<String>,
79/// # pub items: Option<Box<Schema>>, pub max_items: i64, pub min_items: i64, pub properties: std::collections::HashMap<String, Schema>,
80/// # pub required: Vec<String>, }
81/// # impl AsSchema for String {fn as_schema() -> Schema {Schema {r#type: SchemaType::String as i32, ..Default::default()}}}
82/// # }
83/// # use google_ai_rs::*;
84/// #
85/// # use google_ai_schema_derive::AsSchema;
86/// #[derive(AsSchema)]
87/// # #[schema(crate_path = "google_ai_rs")]
88/// #[schema(description = "Top description ")]
89/// #[schema(description = "continuation")]
90/// struct Type {
91/// #[schema(description = "field description ")]
92/// #[schema(description = "continuation")]
93/// field: String,
94/// }
95///
96/// assert_eq!(
97/// Type::as_schema(),
98/// Schema {
99/// description: "Top description continuation".to_owned(),
100/// r#type: SchemaType::Object as i32,
101/// properties: [(
102/// "field".to_owned(),
103/// Schema {
104/// description: "field description continuation".to_owned(),
105/// ..String::as_schema()
106/// }
107/// )].into(),
108/// required: ["field".to_owned()].into(),
109/// ..Default::default()
110/// }
111/// )
112/// ```
113///
114/// ### 1. Custom Schema Generation
115/// **`as_schema`** - Direct schema override:
116/// ```rust
117/// # mod google_ai_rs {
118/// # pub trait AsSchema { fn as_schema() -> Schema; }
119/// # pub enum SchemaType { Unspecified = 0, String = 1, Number = 2, Integer = 3, Boolean = 4, Array = 5,Object = 6, }
120/// # #[derive(Default)]
121/// # pub struct Schema { pub r#type: i32, pub format: String, pub description: String, pub nullable: bool, pub r#enum: Vec<String>,
122/// # pub items: Option<Box<Schema>>, pub max_items: i64, pub min_items: i64, pub properties: std::collections::HashMap<String, Schema>,
123/// # pub required: Vec<String>, }
124/// # impl AsSchema for String {fn as_schema() -> Schema {Schema {r#type: SchemaType::String as i32, ..Default::default()}}}
125/// # }
126/// # use google_ai_rs::*;
127/// #
128/// # use google_ai_schema_derive::AsSchema;
129/// #[derive(AsSchema)]
130/// # #[schema(crate_path = "google_ai_rs")]
131/// struct Timestamp {
132/// #[schema(as_schema = "datetime_schema")]
133/// millis: i64,
134/// }
135///
136/// /// Simple schema function signature
137/// fn datetime_schema() -> Schema {
138/// # stringify! {
139/// ...
140/// # };
141/// # unimplemented!()
142/// }
143/// ```
144///
145/// **`as_schema_generic`** - Handle generic types:
146/// ```rust
147/// # mod google_ai_rs {
148/// # pub trait AsSchema { fn as_schema() -> Schema; }
149/// # pub enum SchemaType { Unspecified = 0, String = 1, Number = 2, Integer = 3, Boolean = 4, Array = 5,Object = 6, }
150/// # #[derive(Default)]
151/// # pub struct Schema { pub r#type: i32, pub format: String, pub description: String, pub nullable: bool, pub r#enum: Vec<String>,
152/// # pub items: Option<Box<Schema>>, pub max_items: i64, pub min_items: i64, pub properties: std::collections::HashMap<String, Schema>,
153/// # pub required: Vec<String>, }
154/// # impl AsSchema for String {fn as_schema() -> Schema {Schema {r#type: SchemaType::String as i32, ..Default::default()}}}
155/// # }
156/// # use google_ai_rs::*;
157/// #
158/// use std::marker::PhantomData;
159///
160/// struct Wrapper<T> {
161/// inner: T,
162/// }
163///
164/// # use google_ai_schema_derive::AsSchema;
165/// #[derive(AsSchema)]
166/// # #[schema(crate_path = "google_ai_rs")]
167/// struct Data {
168/// #[schema(as_schema_generic = "wrapper_schema")]
169/// field: Wrapper<String>,
170/// }
171///
172/// fn wrapper_schema<T: AsSchema>() -> (Schema, PhantomData<Wrapper<T>>) {
173/// # stringify! {
174/// ...
175/// # };
176/// # unimplemented!()
177/// }
178///
179/// // NOTE: For enums with struct data:
180/// #[derive(AsSchema)]
181/// # #[schema(crate_path = "google_ai_rs")]
182/// enum E {
183/// #[schema(as_schema_generic = "variant1_schema")]
184/// Variant {a: String, b: String},
185/// }
186///
187/// // Notice that the return is a tuple
188/// fn variant1_schema() -> (Schema, PhantomData<(String, String)>) {
189/// # stringify! {
190/// ...
191/// # };
192/// # unimplemented!()
193/// }
194///
195/// // Although it is more ideal to use ordinary as_schema in this example.
196/// ```
197///
198/// ### 2. Name Transformation
199/// **`rename_all`** vs **`rename_all_with`**:
200/// ```rust
201/// # mod google_ai_rs {
202/// # pub trait AsSchema { fn as_schema() -> Schema; }
203/// # pub enum SchemaType { Unspecified = 0, String = 1, Number = 2, Integer = 3, Boolean = 4, Array = 5,Object = 6, }
204/// # #[derive(Default)]
205/// # pub struct Schema { pub r#type: i32, pub format: String, pub description: String, pub nullable: bool, pub r#enum: Vec<String>,
206/// # pub items: Option<Box<Schema>>, pub max_items: i64, pub min_items: i64, pub properties: std::collections::HashMap<String, Schema>,
207/// # pub required: Vec<String>, }
208/// # impl AsSchema for String {fn as_schema() -> Schema {Schema {r#type: SchemaType::String as i32, ..Default::default()}}}
209/// # }
210/// # use google_ai_rs::*;
211/// #
212/// # use google_ai_schema_derive::AsSchema;
213/// #[derive(AsSchema)]
214/// # #[schema(crate_path = "google_ai_rs")]
215/// #[schema(rename_all = "snake_case")] // Built-in conventions
216/// struct SimpleCase { /* ... */ }
217///
218/// #[derive(AsSchema)]
219/// # #[schema(crate_path = "google_ai_rs")]
220/// #[schema(rename_all_with = "reverse_names")] // Custom function
221/// struct CustomCase {
222/// field_one: String, // Becomes "eno_dleif"
223/// }
224///
225/// /// Must be deterministic pure function
226/// fn reverse_names(s: &str) -> String {
227/// s.chars().rev().collect()
228/// }
229/// ```
230///
231/// ### 3. Type Handling Nuances
232/// **Tuples**:
233/// ```rust
234/// struct SimpleTuple(u32); // → Transparent wrapper
235/// struct MixedTuple(u32, String); // → Array with unspecified items
236/// struct UniformTuple(u32, u32); // → Array with item(u32 here) schema
237/// ```
238/// For more control, use AsSchemaWithSerde.
239///
240/// **Enums**:
241/// - **`Data-less enums`** become string enums
242/// ```rust
243/// # mod google_ai_rs {
244/// # pub trait AsSchema { fn as_schema() -> Schema; }
245/// # pub enum SchemaType { Unspecified = 0, String = 1, Number = 2, Integer = 3, Boolean = 4, Array = 5,Object = 6, }
246/// # #[derive(Default, PartialEq, Eq, Debug)]
247/// # pub struct Schema { pub r#type: i32, pub format: String, pub description: String, pub nullable: bool, pub r#enum: Vec<String>,
248/// # pub items: Option<Box<Schema>>, pub max_items: i64, pub min_items: i64, pub properties: std::collections::HashMap<String, Schema>,
249/// # pub required: Vec<String>, }
250/// # impl AsSchema for String {fn as_schema() -> Schema {Schema {r#type: SchemaType::String as i32, ..Default::default()}}}
251/// # }
252/// # use google_ai_rs::*;
253/// #
254/// #
255/// # use google_ai_schema_derive::AsSchema;
256/// #[derive(AsSchema)]
257/// # #[schema(crate_path = "google_ai_rs")]
258/// enum Status {
259/// Active,
260/// Inactive
261/// }
262///
263/// assert_eq!(
264/// Status::as_schema(),
265/// Schema {
266/// r#type: SchemaType::String as i32,
267/// format: "enum".to_owned(),
268/// r#enum: ["Active".to_owned(), "Inactive".to_owned()].into(),
269/// ..Default::default()
270/// }
271/// )
272/// ```
273///
274/// - **`Data-containing enums`** become structural objects with all fields unrequired by default. Return matches serde deserialization.
275///
276/// ```rust
277/// # mod google_ai_rs {
278/// # pub trait AsSchema { fn as_schema() -> Schema; }
279/// # pub enum SchemaType { Unspecified = 0, String = 1, Number = 2, Integer = 3, Boolean = 4, Array = 5,Object = 6, }
280/// # #[derive(Default, PartialEq, Eq, Debug)]
281/// # pub struct Schema { pub r#type: i32, pub format: String, pub description: String, pub nullable: bool, pub r#enum: Vec<String>,
282/// # pub items: Option<Box<Schema>>, pub max_items: i64, pub min_items: i64, pub properties: std::collections::HashMap<String, Schema>,
283/// # pub required: Vec<String>, }
284/// # impl AsSchema for String {fn as_schema() -> Schema {Schema {r#type: SchemaType::String as i32, ..Default::default()}}}
285/// # }
286/// # use google_ai_rs::*;
287/// #
288/// # use google_ai_schema_derive::AsSchema;
289/// #[derive(AsSchema)]
290/// # #[schema(crate_path = "google_ai_rs")]
291/// enum Response {
292/// Success { data: String },
293/// Error(String),
294/// }
295///
296/// assert_eq!(
297/// Response::as_schema(),
298/// Schema {
299/// r#type: SchemaType::Object as i32,
300/// properties: [
301/// (
302/// "Success".to_owned(),
303/// Schema {
304/// r#type: SchemaType::Object as i32,
305/// properties: [("data".to_owned(), String::as_schema())].into(),
306/// required: ["data".to_owned()].into(),
307/// ..Default::default()
308/// }
309/// ),
310/// ("Error".to_owned(), String::as_schema())
311/// ].into(),
312/// required: vec![], // None is required by default
313/// ..Default::default()
314/// }
315/// )
316/// ```
317#[proc_macro_derive(AsSchema, attributes(schema))]
318pub fn derive_schema(input: TokenStream) -> TokenStream {
319 let input = parse_macro_input!(input as DeriveInput);
320 derive_schema_base(input)
321 .map(|si| si.into_token_stream())
322 .unwrap_or_else(|err| err.to_compile_error())
323 .into()
324}
325
326fn derive_schema_base(input: DeriveInput) -> Result<SchemaImplOwned, Error> {
327 let mut ctx = Context::new(input)?;
328 let schema = generate_schema(&mut ctx)?;
329 Ok(SchemaImplOwned { ctx, schema })
330}
331
332/// Hybrid derive macro combining custom schema generation with Serde deserialization
333///
334/// **This is a specialized, opinionated implementation with several constraints**
335///
336/// Only supports tuple structs for now
337///
338/// It represents tuples as objects with positional field names ("0", "1", etc)
339/// and derives the corresponding serde::Deserialize for it.
340///
341/// Adding serde and schema attributes is just as natural as if one added
342/// both `#[derive(AsSchame)]` and `#[derive(serde::Deserialize)]`.
343///
344/// This is to be seen as a minimal-effort hack.
345///
346/// Adding some serde features may cause issues but you'll know at compile time.
347#[proc_macro_derive(AsSchemaWithSerde, attributes(schema, serde))]
348pub fn derive_schema_with_serde(input: TokenStream) -> TokenStream {
349 crate::serde_support::derive_schema_with_serde(input)
350}
351
352struct Context {
353 input: DeriveInput,
354 trait_bound: TraitBound,
355 crate_path: Path,
356 top_attr: TopAttr,
357 // as big brother, let's help serde_support.
358 // It may report false negative because not all type is visited
359 has_static: bool,
360}
361
362impl Context {
363 // Instantiates a new Context fetching the crate_path
364 // from the top attr alongside.
365 fn new(input: DeriveInput) -> Result<Self, Error> {
366 let top_attr = attr::parse_top(&input.attrs)?;
367 let crate_path = top_attr
368 .crate_path
369 .clone()
370 .unwrap_or_else(|| parse_quote!(::google_ai_rs));
371
372 Ok(Self {
373 input,
374 trait_bound: parse_quote!(#crate_path::AsSchema),
375 crate_path,
376 top_attr,
377 has_static: false,
378 })
379 }
380
381 // constrain bounds items type to the #crate::AsSchema
382 // trait. It checks for static borrows along the way
383 // for use in the serde_support module.
384 // It prevents unnecessary double bounds
385 fn constrain(&mut self, ty: &Type) -> bool {
386 // just add all...
387 // we get infinite constrain with recursive types.
388 // "it's not a bug, it's a feature".
389 let predicate = LazyCell::new(|| -> WherePredicate {
390 if !self.has_static {
391 // Let's handle static detection raw...
392
393 // FIXME: no, I won't
394 self.has_static = ty.to_token_stream().to_string().contains("'static");
395 }
396
397 // FIXME: The error is only on "Box" if Q is not AsSchema
398 // struct T {
399 // field: Box<Q>
400 // }
401
402 // Fix span... parse_quote_spanned won't
403 let mut bound = self.trait_bound.clone();
404 bound.path.segments.iter_mut().for_each(|p| {
405 p.ident.set_span(ty.span());
406 });
407 if let Some(ref mut l) = bound.path.leading_colon {
408 l.spans[0] = ty.span();
409 l.spans[1] = ty.span();
410 }
411 WherePredicate::Type(PredicateType {
412 lifetimes: None,
413 bounded_ty: ty.clone(),
414 colon_token: Colon(Span::call_site()),
415 bounds: Punctuated::from_iter([TypeParamBound::Trait(bound)]),
416 })
417 });
418
419 let predicates = &mut self.input.generics.make_where_clause().predicates;
420 if !predicates.iter().any(|p| p.eq(&predicate)) {
421 predicates.push(predicate.to_owned());
422 true
423 } else {
424 false
425 }
426 }
427}
428
429fn generate_schema(ctx: &mut Context) -> Result<Schema, Error> {
430 match ctx.input.data.clone() {
431 Data::Struct(data) => impl_struct(ctx, &data),
432 Data::Enum(data) => impl_enum(ctx, &data),
433 Data::Union(_) => Err(Error::new_spanned(
434 &ctx.input,
435 "Unions are not supported by AsSchema derive",
436 )),
437 }
438}
439
440fn impl_struct(ctx: &mut Context, data: &DataStruct) -> Result<Schema, Error> {
441 dispatch_struct_fields(ctx, &data.fields)
442}
443
444fn dispatch_struct_fields(ctx: &mut Context, fields: &Fields) -> Result<Schema, Error> {
445 match fields {
446 Fields::Named(fields) => named_struct(ctx, fields),
447 Fields::Unnamed(fields) => tuple_struct(ctx, fields),
448 Fields::Unit => unit_struct(ctx),
449 }
450}
451
452fn unit_struct(ctx: &mut Context) -> Result<Schema, Error> {
453 let top_attr = &ctx.top_attr;
454
455 Ok(Schema {
456 r#type: Some(schema::Type::Object),
457 description: top_attr.description.clone(),
458 nullable: top_attr.nullable,
459 ..Default::default()
460 })
461}
462
463// Applies several conditions to decide on the representation of input
464// - If there's only one element, it's represented transparently and
465// transferable top_attrs are applied if the inner doesn't already
466// exist.
467//
468// - If
469// the items in the tuple are literally, typically equal, it is
470// represented as an array of this type.
471// else
472// it is represented as array of unspecified items or it errors if
473// it finds any attribute probably meant for us on any field
474//
475// with the max_min_items as the length of the tuple
476fn tuple_struct(ctx: &mut Context, fields: &FieldsUnnamed) -> Result<Schema, Error> {
477 // We can no longer add transparent so as not to break code
478 // let's add an opposite of that which represents it as an array
479 // This doesn't seem so desired so leave for now
480 if fields.unnamed.len() == 1 {
481 let top_attr = &ctx.top_attr;
482
483 let inner_ty = &fields.unnamed[0].ty;
484 let mut schema_attrs = attr::parse_tuple(
485 &fields.unnamed[0].attrs,
486 top_attr.ignore_serde.unwrap_or(false),
487 )?;
488
489 if schema_attrs.description.is_none() {
490 schema_attrs.description = top_attr.description.clone()
491 }
492
493 if schema_attrs.nullable.is_none() {
494 schema_attrs.nullable = top_attr.nullable
495 }
496
497 Ok(generate_item_schema(ctx, &schema_attrs, inner_ty)?)
498 } else {
499 // Check if they all have the same type. This is trivial.
500 // std::string::String is to the compiler String but not
501 // here. Fighting that is a losing game.
502 let equal_item_ty = fields
503 .unnamed
504 .iter()
505 .try_fold(None, |prev: Option<&Type>, f| {
506 if prev.is_none_or(|ty| *ty == f.ty) {
507 Some(Some(&f.ty))
508 } else {
509 None
510 }
511 });
512
513 let item_schema = if let Some(Some(item_ty)) = equal_item_ty {
514 // We have too many attr options
515 generate_item_schema(ctx, &Attr::default(), item_ty)?
516 } else {
517 // we used to indescriminately treat as an array of unspecified type
518 // we keep that as the default but recommend using AsSchemaWithSerde
519 // if we find attributes for us which might show that AsSchemaWithSerde
520 // is the man for the job.
521
522 // TODO: Maybe provide a way to not make this cause an unrecoverable error
523 fields.unnamed.iter().try_for_each(|f| {
524 f.attrs.iter().try_for_each(|attr| {
525 if attr.path().is_ident("schema") {
526 Err(Error::new_spanned(
527 attr,
528 "Consider deriving with AsSchemaWithSerde for more control. \
529 AsSchema derivation represents tuple structs as \
530 an array of unspecified items which doens't support attributes.",
531 ))
532 } else {
533 Ok(())
534 }
535 })
536 })?;
537
538 Schema {
539 r#type: Some(schema::Type::Unspecified),
540 ..Default::default()
541 }
542 };
543
544 let len = Some(fields.unnamed.len() as i64);
545
546 Ok(Schema {
547 r#type: Some(schema::Type::Array),
548 description: ctx.top_attr.description.clone(),
549 max_items: len,
550 min_items: len,
551 items: Some(item_schema.into()),
552 ..Default::default()
553 })
554 }
555}
556
557trait StructItem {
558 fn name(&self) -> String;
559 fn schema_attrs(&self, top_attr: &TopAttr) -> Result<Attr, Error>;
560 fn schema(&self, ctx: &mut Context, schema_attrs: &Attr) -> Result<Schema, Error>;
561}
562
563impl<I: StructItem> StructItem for &I {
564 fn name(&self) -> String {
565 (*self).name()
566 }
567
568 fn schema_attrs(&self, top_attr: &TopAttr) -> Result<Attr, Error> {
569 (*self).schema_attrs(top_attr)
570 }
571
572 fn schema(&self, ctx: &mut Context, schema_attrs: &Attr) -> Result<Schema, Error> {
573 (*self).schema(ctx, schema_attrs)
574 }
575}
576
577fn named_struct_like<I, T>(ctx: &mut Context, items: I, is_enum: bool) -> Result<Schema, Error>
578where
579 I: IntoIterator<Item = T>,
580 T: StructItem,
581{
582 let rename_all = prepare_rename_all(&ctx.top_attr, is_enum)?;
583 let items = items.into_iter();
584
585 let mut properties = HashMap::with_capacity(items.size_hint().0);
586
587 let mut required = Vec::new();
588
589 for item in items {
590 let schema_attrs = item.schema_attrs(&ctx.top_attr)?;
591 if schema_attrs.skip.unwrap_or_default() {
592 continue;
593 }
594
595 let original_item_name = item.name();
596
597 let field_name = rename_item(rename_all.as_ref(), &original_item_name, &schema_attrs);
598
599 let nullable = schema_attrs.nullable;
600 let required_flag = if nullable.is_some() {
601 schema_attrs.required.unwrap_or(false)
602 } else {
603 schema_attrs.required.unwrap_or(true)
604 };
605
606 if required_flag {
607 required.push(field_name.clone());
608 }
609
610 let field_schema = item.schema(ctx, &schema_attrs)?;
611
612 properties.insert(field_name, field_schema);
613 }
614
615 Ok(Schema {
616 r#type: Some(schema::Type::Object),
617 description: ctx.top_attr.description.clone(),
618 nullable: ctx.top_attr.nullable,
619 properties,
620 required,
621 ..Default::default()
622 })
623}
624
625impl StructItem for Field {
626 fn name(&self) -> String {
627 self.ident
628 .as_ref()
629 .expect("Named field missing ident")
630 .to_string()
631 }
632
633 fn schema_attrs(&self, top_attr: &TopAttr) -> Result<Attr, Error> {
634 attr::parse_field(&self.attrs, top_attr.ignore_serde.unwrap_or(false))
635 }
636
637 fn schema(&self, ctx: &mut Context, schema_attrs: &Attr) -> Result<Schema, Error> {
638 generate_item_schema(ctx, schema_attrs, &self.ty)
639 }
640}
641
642fn named_struct(ctx: &mut Context, fields: &FieldsNamed) -> Result<Schema, Error> {
643 named_struct_like(ctx, &fields.named, !IS_ENUM)
644}
645
646impl StructItem for Variant {
647 fn name(&self) -> String {
648 self.ident.to_string()
649 }
650
651 fn schema_attrs(&self, top_attr: &TopAttr) -> Result<Attr, Error> {
652 // We treat as an object field
653 // Make all fields not required by default
654 let mut attr = attr::parse_field(&self.attrs, top_attr.ignore_serde.unwrap_or(false))?;
655 if attr.required.is_none() {
656 attr.required = Some(false)
657 }
658 Ok(attr)
659 }
660
661 fn schema(&self, ctx: &mut Context, schema_attrs: &Attr) -> Result<Schema, Error> {
662 // Detect generators like r#type, and as_schema*. Here, we don't
663 // dispatch.
664 //
665 // This is done to support as_schema_generic
666 fn data_type(data: &Fields) -> Type {
667 if data.len() == 1 {
668 // don't ask... it's just one clippy.
669 return data.iter().next_back().unwrap().ty.clone();
670 }
671 // rust don't have anonymous structs so we
672 // represent the struct-like data as tuple
673 let elems: Punctuated<Type, Comma> = data.iter().map(|f| f.ty.clone()).collect();
674
675 let paren_token = if !data.is_empty() {
676 match data {
677 Fields::Named(f) => Paren {
678 span: f.brace_token.span,
679 },
680 Fields::Unnamed(f) => f.paren_token,
681 Fields::Unit => Paren::default(),
682 }
683 } else {
684 Paren::default()
685 };
686
687 Type::Tuple(TypeTuple { paren_token, elems })
688 }
689
690 let mut schema = if schema_attrs.r#type.is_some()
691 || schema_attrs.as_schema.is_some()
692 || schema_attrs.as_schema_generic.is_some()
693 {
694 generate_item_schema(ctx, schema_attrs, &data_type(&self.fields))?
695 } else {
696 // these guys will think they're him and use the top attr.
697 // especially tuple_struct.
698 // FIXME: Reconsider context purity.
699
700 let original_description = ctx.top_attr.description.take();
701 let original_nullable = ctx.top_attr.nullable.take();
702 let schema = dispatch_struct_fields(ctx, &self.fields)?;
703 ctx.top_attr.description = original_description;
704 ctx.top_attr.nullable = original_nullable;
705 schema
706 };
707
708 // macro is tired of me by now.. lol
709 macro_rules! transfer_properties {
710 ($($property:ident)*) => {{
711 $(if schema.$property.is_none() {
712 schema.$property = schema_attrs.$property.clone()
713 })*
714 }};
715 }
716 // We add the top attributes values to the schema
717 // if they're not filled
718 transfer_properties! {
719 description nullable max_items min_items
720 }
721
722 Ok(schema)
723 }
724}
725
726// Represents an enum in two ways.
727//
728// - If there's no data in every variant, it is represented using
729// the enum "api" of the schema subset provided by google.
730//
731// - Else, it is represented as a struct with each field as the "name"
732// of the variant. This matches the default tag of serde. All field
733// is not required by default so that not all is provided and so maybe
734// at least one will be.
735fn impl_enum(ctx: &mut Context, data: &DataEnum) -> Result<Schema, Error> {
736 // check if it has data
737 let has_data = data.variants.iter().any(|v| !v.fields.is_empty());
738 if has_data {
739 named_struct_like(ctx, &data.variants, IS_ENUM)
740 } else {
741 let top_attr = &ctx.top_attr;
742 let rename_all = prepare_rename_all(top_attr, IS_ENUM)?;
743
744 let mut variants = Vec::with_capacity(data.variants.len());
745
746 for variant in &data.variants {
747 let schema_attrs =
748 attr::parse_plain_enum(&variant.attrs, top_attr.ignore_serde.unwrap_or(false))?;
749
750 if schema_attrs.skip.unwrap_or_default() {
751 continue;
752 }
753
754 let field_name = rename_item(
755 rename_all.as_ref(),
756 &variant.ident.to_string(),
757 &schema_attrs,
758 );
759
760 variants.push(field_name);
761 }
762
763 Ok(Schema {
764 r#type: Some(schema::Type::String),
765 format: Some(Format::Enum),
766 description: top_attr.description.clone(),
767 r#enum: variants,
768 ..Default::default()
769 })
770 }
771}
772
773// does constrain
774fn generate_item_schema(
775 ctx: &mut Context,
776 schema_attrs: &Attr,
777 item_ty: &Type,
778) -> Result<Schema, Error> {
779 let description = schema_attrs.description.clone();
780 let nullable = schema_attrs.nullable;
781 let min_items = schema_attrs.min_items;
782 let max_items = schema_attrs.max_items;
783
784 if let Some(ty) = schema_attrs.r#type {
785 let format = schema_attrs.format;
786
787 // Validate type and format combination
788 if let Some(format) = format {
789 if !ty.value().is_compatible_with(format.value()) {
790 let mut err = ty.error(format!("`{format}` is not compatible with {ty}"));
791 let err_format = format.error(format!("`{format}` is not compatible with {ty}"));
792
793 err.combine(err_format);
794 return Err(err);
795 }
796 }
797
798 Ok(Schema {
799 r#type: Some(ty.into_inner()),
800 format: format.map(|c| c.into_inner()),
801 description,
802 nullable,
803 max_items,
804 min_items,
805 ..Default::default()
806 })
807 } else {
808 let base = if let Some(as_schema) = &schema_attrs.as_schema {
809 BaseSchema::AsSschema(as_schema.clone())
810 } else if let Some(as_schema_generic) = &schema_attrs.as_schema_generic {
811 BaseSchema::AsSschemaGeneric(as_schema_generic.clone(), item_ty.clone())
812 } else {
813 ctx.constrain(item_ty);
814 BaseSchema::Type(item_ty.clone())
815 };
816
817 Ok(Schema {
818 description,
819 nullable,
820 max_items,
821 min_items,
822 base,
823 ..Default::default()
824 })
825 }
826}
827
828const IS_ENUM: bool = true;
829
830fn prepare_rename_all(top_attr: &TopAttr, is_enum: bool) -> Result<Option<RenameAll>, Error> {
831 if let Some(style) = top_attr.rename_all {
832 if let Some(ref rename_all_with) = top_attr.rename_all_with {
833 return Err(Error::new(
834 rename_all_with.span(), // The whole Attribute should be spanned
835 "Schema attributes rename_all and rename_all_with can't be both set.",
836 ));
837 }
838
839 let rename_all = if is_enum {
840 attr::rename_all_variants(style)
841 } else {
842 attr::rename_all(style)
843 };
844 Ok(Some(RenameAll::RenameAll(rename_all)))
845 } else if let Some(ref rename_all_with) = top_attr.rename_all_with {
846 Ok(Some(RenameAll::RenameWith(rename_all_with.clone())))
847 } else {
848 Ok(None)
849 }
850}
851
852#[derive(Debug)]
853enum RenameAll {
854 RenameAll(fn(&str) -> String),
855 RenameWith(syn::ExprPath),
856}
857
858fn rename_item(rename_all: Option<&RenameAll>, item_name: &str, item_attr: &Attr) -> Value<String> {
859 // Apply the rename attribute on the item or fallback to the cont_attr rename_all or the original name
860 macro_rules! or_rename {
861 ($f:expr) => {
862 if let Some(ref rename) = item_attr.rename {
863 Value::Raw(rename.to_string())
864 } else {
865 $f
866 }
867 };
868 }
869
870 match rename_all {
871 Some(RenameAll::RenameAll(rename_all)) => or_rename!(Value::Raw(rename_all(item_name))),
872 Some(RenameAll::RenameWith(rename_all_with)) => or_rename!(Value::ReCompute(
873 rename_all_with.clone(),
874 item_name.to_string()
875 )),
876 None => or_rename!(Value::Raw(item_name.to_string())),
877 }
878}
879
880#[cfg(test)]
881mod test {
882 use syn::WhereClause;
883
884 use super::*;
885
886 #[test]
887 fn context_init() {
888 struct Test {
889 title: &'static str,
890 input: DeriveInput,
891 crate_path: Path,
892 trait_bound: TraitBound,
893 }
894
895 let tests = [
896 Test {
897 title: "crate specified",
898 input: parse_quote! {
899 #[schema(crate_path = "crate_path")]
900 struct S {
901
902 }
903 },
904 crate_path: parse_quote!(crate_path),
905 trait_bound: parse_quote!(crate_path::AsSchema),
906 },
907 Test {
908 title: "crate unspecified",
909 input: parse_quote! {
910 struct S {
911 }
912 },
913 crate_path: parse_quote!(::google_ai_rs),
914 trait_bound: parse_quote!(::google_ai_rs::AsSchema),
915 },
916 ];
917
918 for test in tests {
919 println!("title: {}", test.title);
920 let ctx = Context::new(test.input).unwrap();
921 assert_eq!(ctx.crate_path, test.crate_path);
922 assert_eq!(ctx.trait_bound, test.trait_bound);
923 assert!(!ctx.has_static);
924 }
925 }
926
927 #[test]
928 fn context_constrain() {
929 struct Test {
930 title: &'static str,
931 input: DeriveInput,
932 where_clause: Option<WhereClause>,
933 // we test that we're properly supporting serde by recognizing 'static
934 has_static: bool,
935 }
936
937 let tests = [
938 Test {
939 title: "plain static",
940 input: parse_quote! {
941 struct S {
942 field: &'static Type
943 }
944 },
945 where_clause: Some(parse_quote! {where &'static Type: ::google_ai_rs::AsSchema}),
946 has_static: true,
947 },
948 Test {
949 title: "no static",
950 input: parse_quote! {
951 struct S {
952 field: staticType
953 }
954 },
955 where_clause: Some(parse_quote! {where staticType: ::google_ai_rs::AsSchema}),
956 has_static: false,
957 },
958 Test {
959 title: "generic lifetime",
960 input: parse_quote! {
961 struct S<'a> {
962 field: &'a Type,
963 }
964 },
965 where_clause: Some(parse_quote! {where &'a Type: ::google_ai_rs::AsSchema}),
966 has_static: false,
967 },
968 Test {
969 title: "inside static",
970 input: parse_quote! {
971 struct S {
972 field: Cow<'static, Type>
973 }
974 },
975 where_clause: Some(
976 parse_quote! {where Cow<'static, Type>: ::google_ai_rs::AsSchema},
977 ),
978 has_static: true,
979 },
980 Test {
981 title: "skipped",
982 input: parse_quote! {
983 struct S {
984 #[schema(skip)]
985 field: Cow<'static, Type>
986 }
987 },
988 where_clause: None,
989 has_static: false,
990 },
991 Test {
992 title: "skipped (1)",
993 input: parse_quote! {
994 struct S {
995 #[schema(r#type = "String")]
996 external: Type
997 }
998 },
999 where_clause: None,
1000 has_static: false,
1001 },
1002 Test {
1003 title: "skipped (2)",
1004 input: parse_quote! {
1005 struct S {
1006 #[schema(as_schema = "type_as_schema")]
1007 external: Type
1008 }
1009 },
1010 where_clause: None,
1011 has_static: false,
1012 },
1013 Test {
1014 title: "skipped (3)",
1015 input: parse_quote! {
1016 struct S {
1017 #[schema(as_schema_generic = "wrapper_as_schema_generic")]
1018 external: Wrapper<Type>
1019 }
1020 },
1021 where_clause: None,
1022 has_static: false,
1023 },
1024 Test {
1025 title: "generated and skipped - static false negative", // this is admissibly a bug that I won't fix yet
1026 input: parse_quote! {
1027 struct S {
1028 #[schema(r#type = "String")]
1029 external: &'static Type
1030 }
1031 },
1032 where_clause: None,
1033 has_static: false,
1034 },
1035 Test {
1036 title: "double bound",
1037 input: parse_quote! {
1038 struct S {
1039 field: Type,
1040 field1: Type
1041 }
1042 },
1043 where_clause: Some(parse_quote! {where Type: ::google_ai_rs::AsSchema}),
1044 has_static: false,
1045 },
1046 Test {
1047 title: "double bound exists",
1048 input: parse_quote! {
1049 struct S<T: ::google_ai_rs::AsSchema> {
1050 field: T,
1051 }
1052 },
1053 where_clause: Some(parse_quote! {where T: ::google_ai_rs::AsSchema}),
1054 has_static: false,
1055 },
1056 ];
1057
1058 for test in tests {
1059 println!("title: {}", test.title);
1060 let mut ctx = Context::new(test.input).unwrap();
1061 _ = generate_schema(&mut ctx);
1062
1063 assert_eq!(ctx.input.generics.where_clause, test.where_clause);
1064 assert_eq!(ctx.has_static, test.has_static);
1065 }
1066 }
1067
1068 #[test]
1069 fn test_derive_schema() {
1070 struct Test {
1071 title: &'static str,
1072 input: DeriveInput,
1073 want: Option<Schema>,
1074 should_fail: bool,
1075 error_like: Option<Vec<&'static str>>,
1076 }
1077
1078 // Test as_schema&_generic
1079 let tests = [
1080 Test {
1081 title: "unit struct",
1082 input: parse_quote! {
1083 #[schema(description = "unit struct")]
1084 #[schema(nullable = "false")]
1085 struct U;
1086 },
1087 want: Some(Schema {
1088 r#type: Some(schema::Type::Object),
1089 description: Some("unit struct".to_owned()),
1090 nullable: Some(false),
1091 ..Default::default()
1092 }),
1093 should_fail: false,
1094 error_like: None,
1095 },
1096 Test {
1097 title: "tuple intended for AsSchemaWithSerde",
1098 input: parse_quote! {
1099 #[schema(description = "Represents a radioactive element")]
1100 struct T(
1101 #[schema(description = "Element name")] String,
1102 #[schema(description = "Half life")] f64,
1103 );
1104 },
1105 want: None,
1106 should_fail: true,
1107 error_like: Some(vec!["AsSchemaWithSerde"]),
1108 },
1109 Test {
1110 title: "tuple struct",
1111 input: parse_quote! {
1112 struct T(String, f64);
1113 },
1114 want: Some(Schema {
1115 r#type: Some(schema::Type::Array),
1116 items: Some(
1117 Schema {
1118 r#type: Some(schema::Type::Unspecified),
1119 ..Default::default()
1120 }
1121 .into(),
1122 ),
1123 max_items: Some(2),
1124 min_items: Some(2),
1125 ..Default::default()
1126 }),
1127 should_fail: false,
1128 error_like: None,
1129 },
1130 Test {
1131 title: "enum",
1132 input: parse_quote! {
1133 enum E {
1134 Variant1,
1135 Variant2,
1136 }
1137 },
1138 want: Some(Schema {
1139 r#type: Some(schema::Type::String),
1140 format: Some(Format::Enum),
1141 r#enum: vec![Value::Raw("Variant1".into()), Value::Raw("Variant2".into())],
1142 ..Default::default()
1143 }),
1144 should_fail: false,
1145 error_like: None,
1146 },
1147 Test {
1148 title: "named struct",
1149 input: parse_quote! {
1150 struct S<'a, T, U> {
1151 field: Vec<T>,
1152 field1: Option<&'a U>,
1153 }
1154 },
1155 want: Some(Schema {
1156 r#type: Some(schema::Type::Object),
1157 properties: [
1158 (
1159 Value::Raw("field".into()),
1160 Schema {
1161 base: BaseSchema::Type(parse_quote!(Vec<T>)),
1162 ..Default::default()
1163 },
1164 ),
1165 (
1166 Value::Raw("field1".into()),
1167 Schema {
1168 base: BaseSchema::Type(parse_quote!(Option<&'a U>)),
1169 ..Default::default()
1170 },
1171 ),
1172 ]
1173 .into(),
1174 required: vec![Value::Raw("field".into()), Value::Raw("field1".into())],
1175 ..Default::default()
1176 }),
1177 should_fail: false,
1178 error_like: None,
1179 },
1180 Test {
1181 title: "rename_all_with",
1182 input: parse_quote! {
1183 #[schema(rename_all_with = "suitcase")]
1184 struct S {
1185 field: (),
1186 field1: (),
1187 }
1188 },
1189 want: Some(Schema {
1190 r#type: Some(schema::Type::Object),
1191 properties: [
1192 (
1193 Value::ReCompute(parse_quote!(suitcase), "field".into()),
1194 Schema {
1195 base: BaseSchema::Type(parse_quote!(())),
1196 ..Default::default()
1197 },
1198 ),
1199 (
1200 Value::ReCompute(parse_quote!(suitcase), "field1".into()),
1201 Schema {
1202 base: BaseSchema::Type(parse_quote!(())),
1203 ..Default::default()
1204 },
1205 ),
1206 ]
1207 .into(),
1208 required: vec![
1209 Value::ReCompute(parse_quote!(suitcase), "field".into()),
1210 Value::ReCompute(parse_quote!(suitcase), "field1".into()),
1211 ],
1212 ..Default::default()
1213 }),
1214 should_fail: false,
1215 error_like: None,
1216 },
1217 Test {
1218 title: "rename_all_with (1)",
1219 input: parse_quote! {
1220 #[schema(rename_all_with = "misc::prettycase")]
1221 enum Enum {
1222 Variant1,
1223 Variant2,
1224 }
1225 },
1226 want: Some(Schema {
1227 r#type: Some(schema::Type::String),
1228 format: Some(Format::Enum),
1229 r#enum: vec![
1230 Value::ReCompute(parse_quote!(misc::prettycase), "Variant1".into()),
1231 Value::ReCompute(parse_quote!(misc::prettycase), "Variant2".into()),
1232 ],
1233 ..Default::default()
1234 }),
1235 should_fail: false,
1236 error_like: None,
1237 },
1238 Test {
1239 title: "as_schema",
1240 input: parse_quote! {
1241 struct S {
1242 #[schema(as_schema = "concrete::as_schema")]
1243 field: Type
1244 }
1245 },
1246 want: Some(Schema {
1247 r#type: Some(schema::Type::Object),
1248 properties: [(
1249 Value::Raw("field".into()),
1250 Schema {
1251 base: BaseSchema::AsSschema(parse_quote!(concrete::as_schema)),
1252 ..Default::default()
1253 },
1254 )]
1255 .into(),
1256 required: vec![Value::Raw("field".into())],
1257 ..Default::default()
1258 }),
1259 should_fail: false,
1260 error_like: None,
1261 },
1262 Test {
1263 title: "as_schema_generic",
1264 input: parse_quote! {
1265 struct S {
1266 #[schema(as_schema_generic = "generic::as_schema_generic")]
1267 field: Wrapper<Type>
1268 }
1269 },
1270 want: Some(Schema {
1271 r#type: Some(schema::Type::Object),
1272 properties: [(
1273 Value::Raw("field".into()),
1274 Schema {
1275 base: BaseSchema::AsSschemaGeneric(
1276 parse_quote!(generic::as_schema_generic),
1277 parse_quote!(Wrapper<Type>),
1278 ),
1279 ..Default::default()
1280 },
1281 )]
1282 .into(),
1283 required: vec![Value::Raw("field".into())],
1284 ..Default::default()
1285 }),
1286 should_fail: false,
1287 error_like: None,
1288 },
1289 ];
1290
1291 for test in tests {
1292 println!("title: {}", test.title);
1293 let derived = derive_schema_base(test.input);
1294
1295 if test.should_fail {
1296 match derived {
1297 Ok(_) => panic!("test did not fail"),
1298 Err(err) => {
1299 if let Some(error_like) = test.error_like {
1300 let mut matches = false;
1301 let err = err.to_string();
1302
1303 for like in error_like {
1304 matches = matches || err.contains(like)
1305 }
1306 println!("{err}");
1307 assert!(matches);
1308 }
1309 }
1310 }
1311 } else {
1312 let derived = derived.unwrap_or_else(|err| panic!("test failed: {err:#?}"));
1313 assert_eq!(derived.schema, test.want.unwrap())
1314 }
1315 }
1316 }
1317}