Skip to main content

fraiseql_core/schema/introspection/
schema_builder.rs

1//! `__schema` query response construction.
2//!
3//! Builds the Query, Mutation, and Subscription root introspection types, and
4//! the `IntrospectionBuilder` and `IntrospectionResponses` public entry points.
5
6use std::{collections::HashMap, sync::Arc};
7
8use super::{
9    super::{CompiledSchema, MutationDefinition, QueryDefinition, SubscriptionDefinition},
10    directive_builder::{build_custom_directives, builtin_directives},
11    field_resolver::{build_arg_input_value, type_ref},
12    type_resolver::{
13        build_enum_type, build_input_object_type, build_interface_type, build_object_type,
14        build_union_type, builtin_scalars,
15    },
16    types::{
17        IntrospectionField, IntrospectionInputValue, IntrospectionSchema, IntrospectionType,
18        IntrospectionTypeRef, TypeKind,
19    },
20};
21
22// =============================================================================
23// IntrospectionBuilder
24// =============================================================================
25
26/// Builds introspection schema from compiled schema.
27#[must_use = "call .build() to construct the final value"]
28pub struct IntrospectionBuilder;
29
30impl IntrospectionBuilder {
31    /// Build complete introspection schema from compiled schema.
32    #[must_use]
33    pub fn build(schema: &CompiledSchema) -> IntrospectionSchema {
34        let mut types = Vec::new();
35
36        // Add built-in scalar types
37        types.extend(builtin_scalars());
38
39        // Add user-defined types
40        for type_def in &schema.types {
41            types.push(build_object_type(type_def));
42        }
43
44        // Add enum types
45        for enum_def in &schema.enums {
46            types.push(build_enum_type(enum_def));
47        }
48
49        // Add input object types
50        for input_def in &schema.input_types {
51            types.push(build_input_object_type(input_def));
52        }
53
54        // Add interface types
55        for interface_def in &schema.interfaces {
56            types.push(build_interface_type(interface_def, schema));
57        }
58
59        // Add union types
60        for union_def in &schema.unions {
61            types.push(build_union_type(union_def));
62        }
63
64        // Add Query root type
65        types.push(build_query_type(schema));
66
67        // Add Mutation root type if mutations exist
68        if !schema.mutations.is_empty() {
69            types.push(build_mutation_type(schema));
70        }
71
72        // Add Subscription root type if subscriptions exist
73        if !schema.subscriptions.is_empty() {
74            types.push(build_subscription_type(schema));
75        }
76
77        // Build directives: built-in + custom
78        let mut directives = builtin_directives();
79        directives.extend(build_custom_directives(&schema.directives));
80
81        IntrospectionSchema {
82            description: Some("FraiseQL GraphQL Schema".to_string()),
83            types,
84            query_type: IntrospectionTypeRef {
85                name: "Query".to_string(),
86            },
87            mutation_type: if schema.mutations.is_empty() {
88                None
89            } else {
90                Some(IntrospectionTypeRef {
91                    name: "Mutation".to_string(),
92                })
93            },
94            subscription_type: if schema.subscriptions.is_empty() {
95                None
96            } else {
97                Some(IntrospectionTypeRef {
98                    name: "Subscription".to_string(),
99                })
100            },
101            directives,
102        }
103    }
104
105    /// Build a lookup map for `__type(name:)` queries.
106    #[must_use]
107    pub fn build_type_map(schema: &IntrospectionSchema) -> HashMap<String, IntrospectionType> {
108        let mut map = HashMap::new();
109        for t in &schema.types {
110            if let Some(ref name) = t.name {
111                map.insert(name.clone(), t.clone());
112            }
113        }
114        map
115    }
116
117    /// Expose `type_ref` as an associated function for use in tests.
118    #[must_use]
119    pub fn type_ref(name: &str) -> IntrospectionType {
120        type_ref(name)
121    }
122}
123
124// =============================================================================
125// Root type builders
126// =============================================================================
127
128/// Build Query root type.
129fn build_query_type(schema: &CompiledSchema) -> IntrospectionType {
130    let mut fields: Vec<IntrospectionField> =
131        schema.queries.iter().map(|q| build_query_field(q, schema)).collect();
132
133    // Inject synthetic `node(id: ID!): Node` field when relay types exist.
134    let has_relay_types =
135        schema.types.iter().any(|t| t.relay) || schema.interfaces.iter().any(|i| i.name == "Node");
136    if has_relay_types && !fields.iter().any(|f| f.name == "node") {
137        fields.push(build_node_query_field());
138    }
139
140    IntrospectionType {
141        kind:               TypeKind::Object,
142        name:               Some("Query".to_string()),
143        description:        Some("Root query type".to_string()),
144        fields:             Some(fields),
145        interfaces:         Some(vec![]),
146        possible_types:     None,
147        enum_values:        None,
148        input_fields:       None,
149        of_type:            None,
150        specified_by_u_r_l: None,
151    }
152}
153
154/// Build Mutation root type.
155fn build_mutation_type(schema: &CompiledSchema) -> IntrospectionType {
156    let fields: Vec<IntrospectionField> =
157        schema.mutations.iter().map(|m| build_mutation_field(m, schema)).collect();
158
159    IntrospectionType {
160        kind:               TypeKind::Object,
161        name:               Some("Mutation".to_string()),
162        description:        Some("Root mutation type".to_string()),
163        fields:             Some(fields),
164        interfaces:         Some(vec![]),
165        possible_types:     None,
166        enum_values:        None,
167        input_fields:       None,
168        of_type:            None,
169        specified_by_u_r_l: None,
170    }
171}
172
173/// Build Subscription root type.
174fn build_subscription_type(schema: &CompiledSchema) -> IntrospectionType {
175    let fields: Vec<IntrospectionField> = schema
176        .subscriptions
177        .iter()
178        .map(|s| build_subscription_field(s, schema))
179        .collect();
180
181    IntrospectionType {
182        kind:               TypeKind::Object,
183        name:               Some("Subscription".to_string()),
184        description:        Some("Root subscription type".to_string()),
185        fields:             Some(fields),
186        interfaces:         Some(vec![]),
187        possible_types:     None,
188        enum_values:        None,
189        input_fields:       None,
190        of_type:            None,
191        specified_by_u_r_l: None,
192    }
193}
194
195// =============================================================================
196// Operation field builders
197// =============================================================================
198
199/// Build query field introspection.
200fn build_query_field(query: &QueryDefinition, schema: &CompiledSchema) -> IntrospectionField {
201    // Relay connection queries expose `XxxConnection` as their return type
202    // (always non-null) and add the four standard cursor arguments.
203    if query.relay {
204        return build_relay_query_field(query, schema);
205    }
206
207    let return_type = type_ref(&query.return_type);
208    let return_type = if query.returns_list {
209        IntrospectionType {
210            kind:               TypeKind::List,
211            name:               None,
212            description:        None,
213            fields:             None,
214            interfaces:         None,
215            possible_types:     None,
216            enum_values:        None,
217            input_fields:       None,
218            of_type:            Some(Box::new(return_type)),
219            specified_by_u_r_l: None,
220        }
221    } else {
222        return_type
223    };
224
225    let return_type = if query.nullable {
226        return_type
227    } else {
228        IntrospectionType {
229            kind:               TypeKind::NonNull,
230            name:               None,
231            description:        None,
232            fields:             None,
233            interfaces:         None,
234            possible_types:     None,
235            enum_values:        None,
236            input_fields:       None,
237            of_type:            Some(Box::new(return_type)),
238            specified_by_u_r_l: None,
239        }
240    };
241
242    // Build arguments
243    let args: Vec<IntrospectionInputValue> =
244        query.arguments.iter().map(build_arg_input_value).collect();
245
246    IntrospectionField {
247        name: schema.display_name(&query.name),
248        description: query.description.clone(),
249        args,
250        field_type: return_type,
251        is_deprecated: query.is_deprecated(),
252        deprecation_reason: query.deprecation_reason().map(ToString::to_string),
253    }
254}
255
256/// Build introspection for a Relay connection query.
257///
258/// Relay connection queries differ from normal list queries:
259/// - Return type is `XxxConnection!` (non-null), not `[Xxx!]!`
260/// - Arguments are `first: Int, after: String, last: Int, before: String` (instead of
261///   `limit`/`offset`)
262fn build_relay_query_field(query: &QueryDefinition, schema: &CompiledSchema) -> IntrospectionField {
263    let connection_type = format!("{}Connection", query.return_type);
264
265    // Return type: XxxConnection! (always non-null)
266    let return_type = IntrospectionType {
267        kind:               TypeKind::NonNull,
268        name:               None,
269        description:        None,
270        fields:             None,
271        interfaces:         None,
272        possible_types:     None,
273        enum_values:        None,
274        input_fields:       None,
275        of_type:            Some(Box::new(type_ref(&connection_type))),
276        specified_by_u_r_l: None,
277    };
278
279    // Standard Relay cursor arguments.
280    let nullable_int = || IntrospectionType {
281        kind:               TypeKind::Scalar,
282        name:               Some("Int".to_string()),
283        description:        None,
284        fields:             None,
285        interfaces:         None,
286        possible_types:     None,
287        enum_values:        None,
288        input_fields:       None,
289        of_type:            None,
290        specified_by_u_r_l: None,
291    };
292    let nullable_string = || IntrospectionType {
293        kind:               TypeKind::Scalar,
294        name:               Some("String".to_string()),
295        description:        None,
296        fields:             None,
297        interfaces:         None,
298        possible_types:     None,
299        enum_values:        None,
300        input_fields:       None,
301        of_type:            None,
302        specified_by_u_r_l: None,
303    };
304    let relay_args = vec![
305        IntrospectionInputValue {
306            name:               "first".to_string(),
307            description:        Some("Return the first N items.".to_string()),
308            input_type:         nullable_int(),
309            default_value:      None,
310            is_deprecated:      false,
311            deprecation_reason: None,
312            validation_rules:   vec![],
313        },
314        IntrospectionInputValue {
315            name:               "after".to_string(),
316            description:        Some("Cursor: return items after this position.".to_string()),
317            input_type:         nullable_string(),
318            default_value:      None,
319            is_deprecated:      false,
320            deprecation_reason: None,
321            validation_rules:   vec![],
322        },
323        IntrospectionInputValue {
324            name:               "last".to_string(),
325            description:        Some("Return the last N items.".to_string()),
326            input_type:         nullable_int(),
327            default_value:      None,
328            is_deprecated:      false,
329            deprecation_reason: None,
330            validation_rules:   vec![],
331        },
332        IntrospectionInputValue {
333            name:               "before".to_string(),
334            description:        Some("Cursor: return items before this position.".to_string()),
335            input_type:         nullable_string(),
336            default_value:      None,
337            is_deprecated:      false,
338            deprecation_reason: None,
339            validation_rules:   vec![],
340        },
341    ];
342
343    IntrospectionField {
344        name:               schema.display_name(&query.name),
345        description:        query.description.clone(),
346        args:               relay_args,
347        field_type:         return_type,
348        is_deprecated:      query.is_deprecated(),
349        deprecation_reason: query.deprecation_reason().map(ToString::to_string),
350    }
351}
352
353/// Build the synthetic `node(id: ID!): Node` field for the Query root type.
354///
355/// Injected automatically when the schema contains Relay types (relay=true).
356fn build_node_query_field() -> IntrospectionField {
357    // Return type: Node (nullable per Relay spec — unknown id returns null).
358    // Kind must be INTERFACE because Node is declared as an interface type,
359    // not an OBJECT. Relay's compiler uses this to dispatch `... on User` fragments.
360    let return_type = IntrospectionType {
361        kind:               TypeKind::Interface,
362        name:               Some("Node".to_string()),
363        description:        None,
364        fields:             None,
365        interfaces:         None,
366        possible_types:     None,
367        enum_values:        None,
368        input_fields:       None,
369        of_type:            None,
370        specified_by_u_r_l: None,
371    };
372
373    // Argument: id: ID! (non-null)
374    let id_arg = IntrospectionInputValue {
375        name:               "id".to_string(),
376        description:        Some("Globally unique opaque identifier.".to_string()),
377        input_type:         IntrospectionType {
378            kind:               TypeKind::NonNull,
379            name:               None,
380            description:        None,
381            fields:             None,
382            interfaces:         None,
383            possible_types:     None,
384            enum_values:        None,
385            input_fields:       None,
386            of_type:            Some(Box::new(type_ref("ID"))),
387            specified_by_u_r_l: None,
388        },
389        default_value:      None,
390        is_deprecated:      false,
391        deprecation_reason: None,
392        validation_rules:   vec![],
393    };
394
395    IntrospectionField {
396        name:               "node".to_string(),
397        description:        Some(
398            "Fetch any object that implements the Node interface by its global ID.".to_string(),
399        ),
400        args:               vec![id_arg],
401        field_type:         return_type,
402        is_deprecated:      false,
403        deprecation_reason: None,
404    }
405}
406
407/// Build mutation field introspection.
408fn build_mutation_field(
409    mutation: &MutationDefinition,
410    schema: &CompiledSchema,
411) -> IntrospectionField {
412    // Mutations always return a single object (not a list)
413    let return_type = type_ref(&mutation.return_type);
414
415    // Build arguments
416    let args: Vec<IntrospectionInputValue> =
417        mutation.arguments.iter().map(build_arg_input_value).collect();
418
419    IntrospectionField {
420        name: schema.display_name(&mutation.name),
421        description: mutation.description.clone(),
422        args,
423        field_type: return_type,
424        is_deprecated: mutation.is_deprecated(),
425        deprecation_reason: mutation.deprecation_reason().map(ToString::to_string),
426    }
427}
428
429/// Build subscription field introspection.
430fn build_subscription_field(
431    subscription: &SubscriptionDefinition,
432    schema: &CompiledSchema,
433) -> IntrospectionField {
434    // Subscriptions typically return a single item per event
435    let return_type = type_ref(&subscription.return_type);
436
437    // Build arguments
438    let args: Vec<IntrospectionInputValue> =
439        subscription.arguments.iter().map(build_arg_input_value).collect();
440
441    IntrospectionField {
442        name: schema.display_name(&subscription.name),
443        description: subscription.description.clone(),
444        args,
445        field_type: return_type,
446        is_deprecated: subscription.is_deprecated(),
447        deprecation_reason: subscription.deprecation_reason().map(ToString::to_string),
448    }
449}
450
451// =============================================================================
452// IntrospectionResponses
453// =============================================================================
454
455/// Pre-built introspection responses for fast serving.
456///
457/// Responses are stored as `Arc<serde_json::Value>` so cloning is O(1)
458/// (introspection queries are frequent but the schema is immutable).
459#[derive(Debug, Clone)]
460pub struct IntrospectionResponses {
461    /// Full `__schema` response JSON.
462    pub schema_response: Arc<serde_json::Value>,
463    /// Map of type name -> `__type` response JSON.
464    pub type_responses:  HashMap<String, Arc<serde_json::Value>>,
465}
466
467impl IntrospectionResponses {
468    /// Build introspection responses from compiled schema.
469    ///
470    /// This is called once at server startup and cached.
471    pub fn build(schema: &CompiledSchema) -> Self {
472        let introspection = IntrospectionBuilder::build(schema);
473        let type_map = IntrospectionBuilder::build_type_map(&introspection);
474
475        // Build __schema response
476        let schema_response = Arc::new(serde_json::json!({
477            "data": {
478                "__schema": introspection
479            }
480        }));
481
482        // Build __type responses for each type
483        let mut type_responses = HashMap::new();
484        for (name, t) in type_map {
485            let response = Arc::new(serde_json::json!({
486                "data": {
487                    "__type": t
488                }
489            }));
490            type_responses.insert(name, response);
491        }
492
493        Self {
494            schema_response,
495            type_responses,
496        }
497    }
498
499    /// Get response for `__type(name: "...")` query.
500    #[must_use]
501    pub fn get_type_response(&self, type_name: &str) -> serde_json::Value {
502        self.type_responses.get(type_name).map_or_else(
503            || {
504                serde_json::json!({
505                    "data": {
506                        "__type": null
507                    }
508                })
509            },
510            |v| v.as_ref().clone(),
511        )
512    }
513}