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}