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;
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(build_query_field).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(build_mutation_field).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> =
176        schema.subscriptions.iter().map(build_subscription_field).collect();
177
178    IntrospectionType {
179        kind:               TypeKind::Object,
180        name:               Some("Subscription".to_string()),
181        description:        Some("Root subscription type".to_string()),
182        fields:             Some(fields),
183        interfaces:         Some(vec![]),
184        possible_types:     None,
185        enum_values:        None,
186        input_fields:       None,
187        of_type:            None,
188        specified_by_u_r_l: None,
189    }
190}
191
192// =============================================================================
193// Operation field builders
194// =============================================================================
195
196/// Build query field introspection.
197fn build_query_field(query: &QueryDefinition) -> IntrospectionField {
198    // Relay connection queries expose `XxxConnection` as their return type
199    // (always non-null) and add the four standard cursor arguments.
200    if query.relay {
201        return build_relay_query_field(query);
202    }
203
204    let return_type = type_ref(&query.return_type);
205    let return_type = if query.returns_list {
206        IntrospectionType {
207            kind:               TypeKind::List,
208            name:               None,
209            description:        None,
210            fields:             None,
211            interfaces:         None,
212            possible_types:     None,
213            enum_values:        None,
214            input_fields:       None,
215            of_type:            Some(Box::new(return_type)),
216            specified_by_u_r_l: None,
217        }
218    } else {
219        return_type
220    };
221
222    let return_type = if query.nullable {
223        return_type
224    } else {
225        IntrospectionType {
226            kind:               TypeKind::NonNull,
227            name:               None,
228            description:        None,
229            fields:             None,
230            interfaces:         None,
231            possible_types:     None,
232            enum_values:        None,
233            input_fields:       None,
234            of_type:            Some(Box::new(return_type)),
235            specified_by_u_r_l: None,
236        }
237    };
238
239    // Build arguments
240    let args: Vec<IntrospectionInputValue> =
241        query.arguments.iter().map(build_arg_input_value).collect();
242
243    IntrospectionField {
244        name: query.name.clone(),
245        description: query.description.clone(),
246        args,
247        field_type: return_type,
248        is_deprecated: query.is_deprecated(),
249        deprecation_reason: query.deprecation_reason().map(ToString::to_string),
250    }
251}
252
253/// Build introspection for a Relay connection query.
254///
255/// Relay connection queries differ from normal list queries:
256/// - Return type is `XxxConnection!` (non-null), not `[Xxx!]!`
257/// - Arguments are `first: Int, after: String, last: Int, before: String` (instead of
258///   `limit`/`offset`)
259fn build_relay_query_field(query: &QueryDefinition) -> IntrospectionField {
260    let connection_type = format!("{}Connection", query.return_type);
261
262    // Return type: XxxConnection! (always non-null)
263    let return_type = IntrospectionType {
264        kind:               TypeKind::NonNull,
265        name:               None,
266        description:        None,
267        fields:             None,
268        interfaces:         None,
269        possible_types:     None,
270        enum_values:        None,
271        input_fields:       None,
272        of_type:            Some(Box::new(type_ref(&connection_type))),
273        specified_by_u_r_l: None,
274    };
275
276    // Standard Relay cursor arguments.
277    let nullable_int = || IntrospectionType {
278        kind:               TypeKind::Scalar,
279        name:               Some("Int".to_string()),
280        description:        None,
281        fields:             None,
282        interfaces:         None,
283        possible_types:     None,
284        enum_values:        None,
285        input_fields:       None,
286        of_type:            None,
287        specified_by_u_r_l: None,
288    };
289    let nullable_string = || IntrospectionType {
290        kind:               TypeKind::Scalar,
291        name:               Some("String".to_string()),
292        description:        None,
293        fields:             None,
294        interfaces:         None,
295        possible_types:     None,
296        enum_values:        None,
297        input_fields:       None,
298        of_type:            None,
299        specified_by_u_r_l: None,
300    };
301    let relay_args = vec![
302        IntrospectionInputValue {
303            name:               "first".to_string(),
304            description:        Some("Return the first N items.".to_string()),
305            input_type:         nullable_int(),
306            default_value:      None,
307            is_deprecated:      false,
308            deprecation_reason: None,
309            validation_rules:   vec![],
310        },
311        IntrospectionInputValue {
312            name:               "after".to_string(),
313            description:        Some("Cursor: return items after this position.".to_string()),
314            input_type:         nullable_string(),
315            default_value:      None,
316            is_deprecated:      false,
317            deprecation_reason: None,
318            validation_rules:   vec![],
319        },
320        IntrospectionInputValue {
321            name:               "last".to_string(),
322            description:        Some("Return the last N items.".to_string()),
323            input_type:         nullable_int(),
324            default_value:      None,
325            is_deprecated:      false,
326            deprecation_reason: None,
327            validation_rules:   vec![],
328        },
329        IntrospectionInputValue {
330            name:               "before".to_string(),
331            description:        Some("Cursor: return items before this position.".to_string()),
332            input_type:         nullable_string(),
333            default_value:      None,
334            is_deprecated:      false,
335            deprecation_reason: None,
336            validation_rules:   vec![],
337        },
338    ];
339
340    IntrospectionField {
341        name:               query.name.clone(),
342        description:        query.description.clone(),
343        args:               relay_args,
344        field_type:         return_type,
345        is_deprecated:      query.is_deprecated(),
346        deprecation_reason: query.deprecation_reason().map(ToString::to_string),
347    }
348}
349
350/// Build the synthetic `node(id: ID!): Node` field for the Query root type.
351///
352/// Injected automatically when the schema contains Relay types (relay=true).
353fn build_node_query_field() -> IntrospectionField {
354    // Return type: Node (nullable per Relay spec — unknown id returns null).
355    // Kind must be INTERFACE because Node is declared as an interface type,
356    // not an OBJECT. Relay's compiler uses this to dispatch `... on User` fragments.
357    let return_type = IntrospectionType {
358        kind:               TypeKind::Interface,
359        name:               Some("Node".to_string()),
360        description:        None,
361        fields:             None,
362        interfaces:         None,
363        possible_types:     None,
364        enum_values:        None,
365        input_fields:       None,
366        of_type:            None,
367        specified_by_u_r_l: None,
368    };
369
370    // Argument: id: ID! (non-null)
371    let id_arg = IntrospectionInputValue {
372        name:               "id".to_string(),
373        description:        Some("Globally unique opaque identifier.".to_string()),
374        input_type:         IntrospectionType {
375            kind:               TypeKind::NonNull,
376            name:               None,
377            description:        None,
378            fields:             None,
379            interfaces:         None,
380            possible_types:     None,
381            enum_values:        None,
382            input_fields:       None,
383            of_type:            Some(Box::new(type_ref("ID"))),
384            specified_by_u_r_l: None,
385        },
386        default_value:      None,
387        is_deprecated:      false,
388        deprecation_reason: None,
389        validation_rules:   vec![],
390    };
391
392    IntrospectionField {
393        name:               "node".to_string(),
394        description:        Some(
395            "Fetch any object that implements the Node interface by its global ID.".to_string(),
396        ),
397        args:               vec![id_arg],
398        field_type:         return_type,
399        is_deprecated:      false,
400        deprecation_reason: None,
401    }
402}
403
404/// Build mutation field introspection.
405fn build_mutation_field(mutation: &MutationDefinition) -> IntrospectionField {
406    // Mutations always return a single object (not a list)
407    let return_type = type_ref(&mutation.return_type);
408
409    // Build arguments
410    let args: Vec<IntrospectionInputValue> =
411        mutation.arguments.iter().map(build_arg_input_value).collect();
412
413    IntrospectionField {
414        name: mutation.name.clone(),
415        description: mutation.description.clone(),
416        args,
417        field_type: return_type,
418        is_deprecated: mutation.is_deprecated(),
419        deprecation_reason: mutation.deprecation_reason().map(ToString::to_string),
420    }
421}
422
423/// Build subscription field introspection.
424fn build_subscription_field(subscription: &SubscriptionDefinition) -> IntrospectionField {
425    // Subscriptions typically return a single item per event
426    let return_type = type_ref(&subscription.return_type);
427
428    // Build arguments
429    let args: Vec<IntrospectionInputValue> =
430        subscription.arguments.iter().map(build_arg_input_value).collect();
431
432    IntrospectionField {
433        name: subscription.name.clone(),
434        description: subscription.description.clone(),
435        args,
436        field_type: return_type,
437        is_deprecated: subscription.is_deprecated(),
438        deprecation_reason: subscription.deprecation_reason().map(ToString::to_string),
439    }
440}
441
442// =============================================================================
443// IntrospectionResponses
444// =============================================================================
445
446/// Pre-built introspection responses for fast serving.
447#[derive(Debug, Clone)]
448pub struct IntrospectionResponses {
449    /// Full `__schema` response JSON.
450    pub schema_response: String,
451    /// Map of type name -> `__type` response JSON.
452    pub type_responses:  HashMap<String, String>,
453}
454
455impl IntrospectionResponses {
456    /// Build introspection responses from compiled schema.
457    ///
458    /// This is called once at server startup and cached.
459    pub fn build(schema: &CompiledSchema) -> Self {
460        let introspection = IntrospectionBuilder::build(schema);
461        let type_map = IntrospectionBuilder::build_type_map(&introspection);
462
463        // Build __schema response
464        let schema_response = serde_json::json!({
465            "data": {
466                "__schema": introspection
467            }
468        })
469        .to_string();
470
471        // Build __type responses for each type
472        let mut type_responses = HashMap::new();
473        for (name, t) in type_map {
474            let response = serde_json::json!({
475                "data": {
476                    "__type": t
477                }
478            })
479            .to_string();
480            type_responses.insert(name, response);
481        }
482
483        Self {
484            schema_response,
485            type_responses,
486        }
487    }
488
489    /// Get response for `__type(name: "...")` query.
490    #[must_use]
491    pub fn get_type_response(&self, type_name: &str) -> String {
492        self.type_responses.get(type_name).cloned().unwrap_or_else(|| {
493            serde_json::json!({
494                "data": {
495                    "__type": null
496                }
497            })
498            .to_string()
499        })
500    }
501}