Skip to main content

sui_graphql_macros/
lib.rs

1//! Compile-time validated macros for the Sui GraphQL API.
2//!
3//! Two macros, both validated against the embedded Sui GraphQL schema:
4//!
5//! - [`Response`] — derive macro for response types. Generates JSON
6//!   deserialization from declarative field paths, catching unknown fields
7//!   and type mismatches before your code runs.
8//! - [`graphql_query!`] — function-style macro for query/mutation strings.
9//!   Validates the source against the schema, so unknown fields, undefined
10//!   variables, and bad arguments fail to compile.
11//!
12//! For a complete client that uses both, see
13//! [`sui-graphql`](https://docs.rs/sui-graphql).
14//!
15//! # Quick Start
16//!
17//! ```no_run
18//! use sui_graphql_macros::Response;
19//!
20//! #[derive(Response)]
21//! struct ObjectData {
22//!     #[field(path = "object.address")]
23//!     address: String,
24//!     #[field(path = "object.version")]
25//!     version: u64,
26//! }
27//! fn main() {}
28//! ```
29//!
30//! The macro validates that `object.address` and `object.version` exist in the schema
31//! and that their types match at compile time. It then generates a
32//! `from_value(serde_json::Value) -> Result<Self, String>` method and a `Deserialize`
33//! implementation, so the struct can be used directly with
34//! `serde_json::from_value` or as a response type in GraphQL client calls.
35//!
36//! # Path Syntax
37//!
38//! Paths use dot-separated segments with optional suffixes:
39//!
40//! | Syntax | Meaning | Rust Type |
41//! |--------|---------|-----------|
42//! | `field` | Required field | `T` |
43//! | `field?` | Nullable field | `Option<T>` |
44//! | `field[]` | Required list | `Vec<T>` |
45//! | `field?[]` | Nullable list | `Option<Vec<T>>` |
46//! | `field[]?` | List with nullable elements | `Vec<Option<T>>` |
47//! | `field?[]?` | Nullable list, nullable elements | `Option<Vec<Option<T>>>` |
48//!
49//! Multiple `?` markers between `[]` boundaries share one `Option` wrapper.
50//! Each `?` controls null tolerance at that specific segment.
51//!
52//! The macro enforces that path suffixes match the Rust type at compile time.
53//! For example, `field?` requires `Option<T>`, and `field[]` requires `Vec<T>`.
54//! A mismatch (e.g., `field?` with `String` or `field` with `Option<String>`)
55//! produces a compile error.
56//!
57//! ## Null Handling
58//!
59//! ```no_run
60//! use sui_graphql_macros::Response;
61//!
62//! #[derive(Response)]
63//! struct Example {
64//!     // null at `object` → error, null at `address` → error
65//!     #[field(path = "object.address")]
66//!     strict: String,
67//!
68//!     // null at `object` → Ok(None), null at `address` → Ok(None)
69//!     #[field(path = "object?.address?")]
70//!     flexible: Option<String>,
71//!
72//!     // null at `object` → Ok(None), null at `address` → error
73//!     #[field(path = "object?.address")]
74//!     partial: Option<String>,
75//! }
76//! fn main() {}
77//! ```
78//!
79//! ## Lists
80//!
81//! Use `[]` to mark list fields. The macro validates this matches the schema.
82//!
83//! ```no_run
84//! use sui_graphql_macros::Response;
85//!
86//! #[derive(Response)]
87//! struct CheckpointDigests {
88//!     #[field(path = "checkpoints.nodes[].digest")]
89//!     digests: Vec<String>,
90//!
91//!     // Nullable list with nullable elements
92//!     #[field(path = "checkpoints?.nodes?[]?.digest?")]
93//!     maybe_digests: Option<Vec<Option<String>>>,
94//! }
95//! fn main() {}
96//! ```
97//!
98//! ## Aliases
99//!
100//! Use `alias:field` when your GraphQL query uses aliases. The alias (before `:`) is the
101//! JSON key used for extraction, while the field name (after `:`) is validated against
102//! the schema. The alias itself is not schema-validated since it is user-defined in the
103//! query.
104//!
105//! ```no_run
106//! use sui_graphql_macros::Response;
107//!
108//! #[derive(Response)]
109//! struct EpochCheckpoints {
110//!     // GraphQL alias "firstCp" maps to schema field "checkpoints"
111//!     #[field(path = "epoch.firstCp:checkpoints.nodes[].sequenceNumber")]
112//!     first_checkpoints: Vec<u64>,
113//! }
114//! fn main() {}
115//! ```
116//!
117//! ## Enums (GraphQL Unions)
118//!
119//! Use `#[response(root_type = "UnionType")]` on enums with newtype variants:
120//!
121//! ```ignore
122//! #[derive(Response)]
123//! #[response(root_type = "DynamicFieldValue")]
124//! enum FieldValue {
125//!     #[response(on = "MoveValue")]
126//!     Value(MoveValueData),
127//!     MoveObject(MoveObjectData), // `on` defaults to variant name
128//! }
129//! ```
130//!
131//! The macro dispatches on `__typename` in the JSON response.
132//!
133//! ## Attributes
134//!
135//! | Attribute | Level | Description |
136//! |-----------|-------|-------------|
137//! | `#[response(root_type = "Type")]` | struct/enum | Schema type to validate against (default: `"Query"`) |
138//! | `#[response(schema = "path")]` | struct/enum | Custom schema file (relative to `CARGO_MANIFEST_DIR`) |
139//! | `#[field(path = "...")]` | field | Dot-separated path with optional `?`/`[]`/alias |
140//! | `#[field(skip_schema_validation)]` | field | Skip compile-time schema checks for this field |
141//! | `#[response(on = "TypeName")]` | variant | GraphQL `__typename` to match (default: variant name) |
142
143extern crate proc_macro;
144
145mod path;
146mod query;
147mod schema;
148mod validation;
149
150use darling::FromDeriveInput;
151use darling::FromField;
152use darling::FromVariant;
153use darling::util::SpannedValue;
154use proc_macro::TokenStream;
155use proc_macro2::TokenStream as TokenStream2;
156use quote::quote;
157use syn::DeriveInput;
158use syn::parse_macro_input;
159
160// ---------------------------------------------------------------------------
161// Darling input structures — define the "schema" for macro input.
162// Darling generates parsing code automatically, including error messages.
163// ---------------------------------------------------------------------------
164
165#[derive(Debug, FromDeriveInput)]
166#[darling(attributes(response), supports(struct_named, enum_newtype))]
167struct ResponseInput {
168    ident: syn::Ident,
169    generics: syn::Generics,
170    data: darling::ast::Data<ResponseVariant, ResponseField>,
171    #[darling(default)]
172    schema: Option<String>,
173    #[darling(default)]
174    root_type: Option<SpannedValue<String>>,
175}
176
177/// A struct field (requires `#[field(path = "...")]`).
178#[derive(Debug, FromField)]
179#[darling(attributes(field))]
180struct ResponseField {
181    ident: Option<syn::Ident>,
182    ty: syn::Type,
183    path: SpannedValue<String>,
184    #[darling(default)]
185    skip_schema_validation: bool,
186}
187
188/// The inner type of a newtype enum variant.
189#[derive(Debug, FromField)]
190struct VariantInner {
191    ty: syn::Type,
192}
193
194/// An enum variant mapping to a GraphQL union member.
195#[derive(Debug, FromVariant)]
196#[darling(attributes(response))]
197struct ResponseVariant {
198    ident: syn::Ident,
199    fields: darling::ast::Fields<VariantInner>,
200    /// The GraphQL type name this variant maps to (e.g., `#[response(on = "MoveValue")]`).
201    /// Defaults to the variant ident if not specified.
202    #[darling(default)]
203    on: Option<SpannedValue<String>>,
204}
205
206/// Derive macro for GraphQL response types with nested field extraction.
207///
208/// Use `#[field(path = "...")]` to specify the JSON path to extract each field.
209/// Paths are dot-separated (e.g., `"object.address"` extracts `json["object"]["address"]`).
210///
211/// # Root Type
212///
213/// By default, field paths are validated against the `Query` type. Use
214/// `#[response(root_type = "...")]` to validate against a different type instead.
215///
216/// # Generated Code
217///
218/// The macro generates:
219/// - `from_value(serde_json::Value) -> Result<Self, String>` method
220/// - `Deserialize` implementation that uses `from_value`
221///
222/// # Example
223///
224/// ```ignore
225/// // Query response (default)
226/// #[derive(Response)]
227/// struct ChainInfo {
228///     #[field(path = "chainIdentifier")]
229///     chain_id: String,
230///
231///     #[field(path = "epoch.epochId")]
232///     epoch_id: Option<u64>,
233/// }
234///
235/// // Mutation response
236/// #[derive(Response)]
237/// #[response(root_type = "Mutation")]
238/// struct ExecuteResult {
239///     #[field(path = "executeTransaction.effects.effectsBcs")]
240///     effects_bcs: Option<String>,
241/// }
242/// ```
243#[proc_macro_derive(Response, attributes(response, field))]
244pub fn derive_query_response(input: TokenStream) -> TokenStream {
245    let input = parse_macro_input!(input as DeriveInput);
246
247    match derive_query_response_impl(input) {
248        Ok(tokens) => tokens.into(),
249        Err(err) => err.to_compile_error().into(),
250    }
251}
252
253/// Validate a GraphQL query or mutation against the embedded Sui schema at
254/// compile time and return it as a `&'static str`.
255///
256/// On a syntactically or semantically invalid input (unknown field, wrong
257/// argument type, undefined variable, etc.) the macro emits one
258/// `compile_error!` per apollo-compiler diagnostic, so the offending call
259/// site fails to build with the diagnostic text inline.
260#[proc_macro]
261pub fn graphql_query(input: TokenStream) -> TokenStream {
262    query::expand(input)
263}
264
265fn derive_query_response_impl(input: DeriveInput) -> Result<TokenStream2, syn::Error> {
266    let parsed = ResponseInput::from_derive_input(&input)?;
267
268    // Load the GraphQL schema for validation.
269    // If a custom schema path is provided, load it; otherwise use the embedded Sui schema.
270    let loaded_schema = if let Some(path) = &parsed.schema {
271        // Resolve path relative to the crate's directory.
272        // SUI_GRAPHQL_SCHEMA_DIR is used by trybuild tests (which run from a temp directory).
273        let base_dir = std::env::var("SUI_GRAPHQL_SCHEMA_DIR")
274            .or_else(|_| std::env::var("CARGO_MANIFEST_DIR"))
275            .unwrap();
276        let full_path = std::path::Path::new(&base_dir).join(path);
277        let sdl = std::fs::read_to_string(&full_path).map_err(|e| {
278            syn::Error::new(
279                proc_macro2::Span::call_site(),
280                format!(
281                    "Failed to read schema from '{}': {}",
282                    full_path.display(),
283                    e
284                ),
285            )
286        })?;
287        Some(schema::Schema::from_sdl(&sdl)?)
288    } else {
289        None
290    };
291    let schema = if let Some(schema) = &loaded_schema {
292        schema
293    } else {
294        schema::Schema::load()?
295    };
296
297    // Determine root type: use specified root_type or default to "Query"
298    let root_type = parsed
299        .root_type
300        .as_ref()
301        .map(|s| s.as_str())
302        .unwrap_or("Query");
303
304    // Validate that the root type exists in the schema
305    if !schema.has_type(root_type) {
306        use std::fmt::Write;
307
308        let type_names = schema.type_names();
309        let suggestion = validation::find_similar(&type_names, root_type);
310
311        let mut msg = format!("Type '{}' not found in GraphQL schema", root_type);
312        if let Some(suggested) = suggestion {
313            write!(msg, ". Did you mean '{}'?", suggested).unwrap();
314        }
315
316        // We only enter this block if root_type was explicitly specified (and invalid),
317        // since "Query" (the default) always exists in a valid schema.
318        let span = parsed.root_type.as_ref().unwrap().span();
319
320        return Err(syn::Error::new(span, msg));
321    }
322
323    match parsed.data {
324        darling::ast::Data::Struct(ref fields) => {
325            generate_struct_impl(&parsed, &fields.fields, schema, root_type)
326        }
327        darling::ast::Data::Enum(ref variants) => {
328            generate_enum_impl(&parsed, variants, schema, root_type)
329        }
330    }
331}
332
333/// Generate `from_value` and `Deserialize` for a struct.
334fn generate_struct_impl(
335    input: &ResponseInput,
336    fields: &[ResponseField],
337    schema: &schema::Schema,
338    root_type: &str,
339) -> Result<TokenStream2, syn::Error> {
340    let ident = &input.ident;
341    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
342
343    // Generate extraction code for each field
344    let mut field_extractions = Vec::new();
345    let mut field_names = Vec::new();
346
347    for field in fields {
348        let field_ident = field
349            .ident
350            .as_ref()
351            .expect("darling ensures named fields only");
352
353        let spanned_path = &field.path;
354        let parsed_path = path::ParsedPath::parse(spanned_path.as_str())
355            .map_err(|e| syn::Error::new(spanned_path.span(), e.to_string()))?;
356
357        let terminal_type = if !field.skip_schema_validation {
358            Some(validation::validate_path_against_schema(
359                schema,
360                root_type,
361                &parsed_path,
362                spanned_path.span(),
363            )?)
364        } else {
365            None
366        };
367
368        // Skip Vec excess check when schema validation is skipped (user takes full
369        // responsibility) or when the terminal type is an object-like scalar (e.g., JSON)
370        // whose value can be an array.
371        let skip_vec_excess_check = field.skip_schema_validation
372            || terminal_type.is_some_and(validation::is_object_like_scalar);
373        validation::validate_type_matches_path(&parsed_path, &field.ty, skip_vec_excess_check)?;
374
375        // Generate extraction code using the same parsed path
376        let type_structure = validation::analyze_type(&field.ty);
377        let extraction = generate_field_extraction(&parsed_path, &type_structure, field_ident);
378        field_extractions.push(extraction);
379        field_names.push(field_ident);
380    }
381
382    // Generate both `from_value` and `Deserialize` impl:
383    //
384    // - `from_value`: Core extraction logic, parses from serde_json::Value
385    // - `Deserialize`: Allows direct use with serde (e.g., `serde_json::from_str::<MyStruct>(...)`)
386    //   and with the GraphQL client's `query::<T>()` which requires `T: DeserializeOwned`
387    Ok(quote! {
388        impl #impl_generics #ident #ty_generics #where_clause {
389            pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
390                #(#field_extractions)*
391
392                Ok(Self {
393                    #(#field_names),*
394                })
395            }
396        }
397
398        // TODO: Implement efficient deserialization that only extracts the fields we need.
399        impl<'de> serde::Deserialize<'de> for #ident #ty_generics #where_clause {
400            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
401            where
402                D: serde::Deserializer<'de>,
403            {
404                let value = serde_json::Value::deserialize(deserializer)?;
405                Self::from_value(value).map_err(serde::de::Error::custom)
406            }
407        }
408    })
409}
410
411/// Generate `from_value` and `Deserialize` for an enum (GraphQL union).
412///
413/// Each variant wraps a type that implements `from_value`. Dispatches on `__typename`.
414fn generate_enum_impl(
415    input: &ResponseInput,
416    variants: &[ResponseVariant],
417    schema: &schema::Schema,
418    root_type: &str,
419) -> Result<TokenStream2, syn::Error> {
420    let ident = &input.ident;
421    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
422
423    let root_type_span = input
424        .root_type
425        .as_ref()
426        .map(|s| s.span())
427        .unwrap_or_else(|| ident.span());
428
429    if !schema.is_union(root_type) {
430        return Err(syn::Error::new(
431            root_type_span,
432            format!(
433                "'{}' is not a union type. \
434                 Enum Response requires root_type to be a GraphQL union",
435                root_type
436            ),
437        ));
438    }
439
440    let mut match_arms = Vec::new();
441
442    for variant in variants {
443        let variant_ident = &variant.ident;
444
445        // Resolve the GraphQL typename: explicit `on` or variant ident
446        let graphql_typename = variant
447            .on
448            .as_ref()
449            .map(|s| s.as_str().to_string())
450            .unwrap_or_else(|| variant_ident.to_string());
451
452        let span = variant
453            .on
454            .as_ref()
455            .map(|s| s.span())
456            .unwrap_or_else(|| variant_ident.span());
457
458        if let Err(mut err) =
459            validation::validate_union_member(schema, root_type, &graphql_typename, span)
460        {
461            if variant.on.is_none() {
462                err.combine(syn::Error::new(
463                    span,
464                    "hint: use #[response(on = \"...\")] to specify a GraphQL type name different from the variant name",
465                ));
466            }
467            return Err(err);
468        }
469
470        // Newtype variant: delegate to inner type's from_value
471        let inner_ty = &variant.fields.fields[0].ty;
472        match_arms.push(quote! {
473            #graphql_typename => {
474                Ok(Self::#variant_ident(
475                    <#inner_ty>::from_value(value)?
476                ))
477            }
478        });
479    }
480
481    let root_type_str = root_type;
482    let enum_name_str = ident.to_string();
483
484    Ok(quote! {
485        impl #impl_generics #ident #ty_generics #where_clause {
486            pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
487                let typename = value.get("__typename")
488                    .and_then(|v| v.as_str())
489                    .ok_or_else(|| format!(
490                        "union '{}' requires '__typename' in the response to distinguish variants. \
491                         Make sure your query requests '__typename' on this field ({})",
492                        #root_type_str, #enum_name_str
493                    ))?;
494
495                match typename {
496                    #(#match_arms)*
497                    other => Err(format!(
498                        "unknown __typename '{}' for union '{}' ({})",
499                        other, #root_type_str, #enum_name_str
500                    )),
501                }
502            }
503        }
504
505        impl<'de> serde::Deserialize<'de> for #ident #ty_generics #where_clause {
506            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
507            where
508                D: serde::Deserializer<'de>,
509            {
510                let value = serde_json::Value::deserialize(deserializer)?;
511                Self::from_value(value).map_err(serde::de::Error::custom)
512            }
513        }
514    })
515}
516
517/// Generate code to extract a single field from JSON using its path.
518///
519/// Supports multiple path formats:
520/// - Simple: `"object.address"` - navigates to nested field
521/// - Array: `"nodes[].name"` - iterates over array, extracts field from each element
522/// - Nested arrays: `"nodes[].edges[].id"` - nested iteration, returns `Vec<Vec<T>>`
523/// - Aliased: `"alias:field"` - uses alias for JSON extraction, field for validation
524fn generate_field_extraction(
525    path: &path::ParsedPath,
526    type_structure: &validation::TypeStructure,
527    field_ident: &syn::Ident,
528) -> TokenStream2 {
529    let full_path = &path.raw;
530    let inner = generate_from_segments(full_path, &path.segments, type_structure);
531    // The inner expression returns Result<T, String>, so we use ? to unwrap
532    quote! {
533        let #field_ident = {
534            let current = &value;
535            #inner?
536        };
537    }
538}
539
540/// Recursively generate extraction code by traversing path segments.
541///
542/// For JSON extraction, uses the alias if present, otherwise uses the field name.
543/// Returns code that evaluates to `Result<T, String>` (caller adds `?` to unwrap).
544///
545/// ## Example: `"data.nodes[].edges[].id"` with `Option<Vec<Vec<String>>>`
546///
547/// Each `[]` in the path corresponds to one `Vec<_>` wrapper in the type.
548///
549/// For `Option<_>` types, null at the outer level returns `Ok(None)`. This is achieved
550/// by wrapping the extraction in a closure to capture early returns. However, once
551/// inside an array iteration, the element type (`Vec<String>`) is not Optional, so
552/// null values there return errors instead.
553///
554/// ```ignore
555/// (|| {
556///     // "data" (non-list) - missing/null returns None (outer Optional)
557///     let current = current.get("data").unwrap_or(&serde_json::Value::Null);
558///     if current.is_null() { return Ok(None); }
559///
560///     // "nodes[]" (list) - missing/null returns None (outer Optional)
561///     let field_value = current.get("nodes").unwrap_or(&serde_json::Value::Null);
562///     if field_value.is_null() { return Ok(None); }
563///     let array = field_value.as_array().ok_or_else(|| "expected array")?;
564///     array.iter().map(|current| {
565///         // Element type: Vec<String> (not Optional, so null = error)
566///
567///         // "edges[]" (list) - missing/null returns Err
568///         let field_value = current.get("edges").unwrap_or(&serde_json::Value::Null);
569///         if field_value.is_null() { return Err("null at 'edges'"); }
570///         let array = field_value.as_array().ok_or_else(|| "expected array")?;
571///         array.iter().map(|current| {
572///             // Element type: String (not Optional, so null = error)
573///
574///             // "id" (scalar) - missing/null returns Err
575///             let current = current.get("id").unwrap_or(&serde_json::Value::Null);
576///             if current.is_null() { return Err("null at 'id'"); }
577///             serde_json::from_value(current.clone())
578///         }).collect::<Result<Vec<_>, _>>()
579///     }).collect::<Result<Vec<_>, _>>()
580///     .map(Some)  // Wrap in Some for Option
581/// })()
582/// ```
583fn generate_from_segments(
584    full_path: &str,
585    segments: &[path::PathSegment],
586    type_structure: &validation::TypeStructure,
587) -> TokenStream2 {
588    // Step 1: Check if outer type is Optional and unwrap it
589    let (is_optional, inner_type) = match type_structure {
590        validation::TypeStructure::Optional(inner) => (true, inner.as_ref()),
591        other => (false, other),
592    };
593
594    // Step 2: Generate core extraction code
595    let core = generate_from_segments_core(full_path, segments, inner_type);
596
597    // Step 3: Wrap Optional types in a closure so `return Ok(None)` stays local to this field.
598    if is_optional {
599        quote! {
600            (|| {
601                // Handle null elements (from `[]?`) and null top-level values
602                if current.is_null() { return Ok(None) }
603                #core.map(Some)
604            })()
605        }
606    } else {
607        core
608    }
609}
610
611/// Core extraction logic that handles both list and non-list segments.
612///
613/// Each segment determines its own null behavior via `is_nullable`:
614/// - `is_nullable = true` (`?` marker): null → `return Ok(None)`
615/// - `is_nullable = false` (no `?`): null → `return Err(...)`
616fn generate_from_segments_core(
617    full_path: &str,
618    segments: &[path::PathSegment],
619    type_structure: &validation::TypeStructure,
620) -> TokenStream2 {
621    // Base case: no more segments, deserialize the current value
622    let Some((segment, rest)) = segments.split_first() else {
623        return quote! {
624            serde_json::from_value(current.clone())
625                .map_err(|e| format!("failed to deserialize '{}': {}", #full_path, e))
626        };
627    };
628
629    let name = segment.field;
630    // Use alias for JSON extraction if present, otherwise use field name
631    let json_key = segment.json_key();
632
633    // Generate null handling based on this segment's `?` marker
634    let on_null = if segment.is_nullable {
635        quote! { return Ok(None) }
636    } else {
637        quote! {
638            return Err(format!("null value at '{}' in path '{}'", #name, #full_path))
639        }
640    };
641
642    if segment.is_list() {
643        // For list segments, unwrap Vector to get element type
644        let element_type = match type_structure {
645            validation::TypeStructure::Vector(inner) => inner.as_ref(),
646            _ => unreachable!("validated: list segment requires Vec type"),
647        };
648
649        // Each array element is processed independently with its own type structure.
650        // Use generate_from_segments (not _core) to handle element-level Optional.
651        let rest_code = generate_from_segments(full_path, rest, element_type);
652
653        quote! {
654            // Treat missing fields as null (allows Option<T> to deserialize as None)
655            let field_value = current.get(#json_key).unwrap_or(&serde_json::Value::Null);
656            if field_value.is_null() {
657                #on_null
658            }
659            let array = field_value.as_array()
660                .ok_or_else(|| format!("expected array at '{}' in path '{}'", #json_key, #full_path))?;
661            array.iter()
662                .map(|current| { #rest_code })
663                .collect::<Result<Vec<_>, String>>()
664        }
665    } else {
666        // For non-list segments, pass type unchanged to handle nested structures
667        let rest_code = generate_from_segments_core(full_path, rest, type_structure);
668
669        quote! {
670            // Treat missing fields as null (allows Option<T> to deserialize as None)
671            let current = current.get(#json_key).unwrap_or(&serde_json::Value::Null);
672            if current.is_null() {
673                #on_null
674            }
675            #rest_code
676        }
677    }
678}