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, including the auto-wired `where`/`orderBy`/`limit`/`offset`
243    // arguments derived from `auto_params` so introspection matches the `_service`
244    // SDL and generated clients.
245    let args: Vec<IntrospectionInputValue> =
246        query.graphql_arguments().iter().map(build_arg_input_value).collect();
247
248    IntrospectionField {
249        name: schema.display_name(&query.name),
250        description: query.description.clone(),
251        args,
252        field_type: return_type,
253        is_deprecated: query.is_deprecated(),
254        deprecation_reason: query.deprecation_reason().map(ToString::to_string),
255    }
256}
257
258/// Build introspection for a Relay connection query.
259///
260/// Relay connection queries differ from normal list queries:
261/// - Return type is `XxxConnection!` (non-null), not `[Xxx!]!`
262/// - Arguments are `first: Int, after: String, last: Int, before: String` (instead of
263///   `limit`/`offset`)
264fn build_relay_query_field(query: &QueryDefinition, schema: &CompiledSchema) -> IntrospectionField {
265    let connection_type = format!("{}Connection", query.return_type);
266
267    // Return type: XxxConnection! (always non-null)
268    let return_type = IntrospectionType {
269        kind:               TypeKind::NonNull,
270        name:               None,
271        description:        None,
272        fields:             None,
273        interfaces:         None,
274        possible_types:     None,
275        enum_values:        None,
276        input_fields:       None,
277        of_type:            Some(Box::new(type_ref(&connection_type))),
278        specified_by_u_r_l: None,
279    };
280
281    // Standard Relay cursor arguments.
282    let nullable_int = || IntrospectionType {
283        kind:               TypeKind::Scalar,
284        name:               Some("Int".to_string()),
285        description:        None,
286        fields:             None,
287        interfaces:         None,
288        possible_types:     None,
289        enum_values:        None,
290        input_fields:       None,
291        of_type:            None,
292        specified_by_u_r_l: None,
293    };
294    let nullable_string = || IntrospectionType {
295        kind:               TypeKind::Scalar,
296        name:               Some("String".to_string()),
297        description:        None,
298        fields:             None,
299        interfaces:         None,
300        possible_types:     None,
301        enum_values:        None,
302        input_fields:       None,
303        of_type:            None,
304        specified_by_u_r_l: None,
305    };
306    let relay_args = vec![
307        IntrospectionInputValue {
308            name:               "first".to_string(),
309            description:        Some("Return the first N items.".to_string()),
310            input_type:         nullable_int(),
311            default_value:      None,
312            is_deprecated:      false,
313            deprecation_reason: None,
314            validation_rules:   vec![],
315        },
316        IntrospectionInputValue {
317            name:               "after".to_string(),
318            description:        Some("Cursor: return items after this position.".to_string()),
319            input_type:         nullable_string(),
320            default_value:      None,
321            is_deprecated:      false,
322            deprecation_reason: None,
323            validation_rules:   vec![],
324        },
325        IntrospectionInputValue {
326            name:               "last".to_string(),
327            description:        Some("Return the last N items.".to_string()),
328            input_type:         nullable_int(),
329            default_value:      None,
330            is_deprecated:      false,
331            deprecation_reason: None,
332            validation_rules:   vec![],
333        },
334        IntrospectionInputValue {
335            name:               "before".to_string(),
336            description:        Some("Cursor: return items before this position.".to_string()),
337            input_type:         nullable_string(),
338            default_value:      None,
339            is_deprecated:      false,
340            deprecation_reason: None,
341            validation_rules:   vec![],
342        },
343    ];
344
345    IntrospectionField {
346        name:               schema.display_name(&query.name),
347        description:        query.description.clone(),
348        args:               relay_args,
349        field_type:         return_type,
350        is_deprecated:      query.is_deprecated(),
351        deprecation_reason: query.deprecation_reason().map(ToString::to_string),
352    }
353}
354
355/// Build the synthetic `node(id: ID!): Node` field for the Query root type.
356///
357/// Injected automatically when the schema contains Relay types (relay=true).
358fn build_node_query_field() -> IntrospectionField {
359    // Return type: Node (nullable per Relay spec — unknown id returns null).
360    // Kind must be INTERFACE because Node is declared as an interface type,
361    // not an OBJECT. Relay's compiler uses this to dispatch `... on User` fragments.
362    let return_type = IntrospectionType {
363        kind:               TypeKind::Interface,
364        name:               Some("Node".to_string()),
365        description:        None,
366        fields:             None,
367        interfaces:         None,
368        possible_types:     None,
369        enum_values:        None,
370        input_fields:       None,
371        of_type:            None,
372        specified_by_u_r_l: None,
373    };
374
375    // Argument: id: ID! (non-null)
376    let id_arg = IntrospectionInputValue {
377        name:               "id".to_string(),
378        description:        Some("Globally unique opaque identifier.".to_string()),
379        input_type:         IntrospectionType {
380            kind:               TypeKind::NonNull,
381            name:               None,
382            description:        None,
383            fields:             None,
384            interfaces:         None,
385            possible_types:     None,
386            enum_values:        None,
387            input_fields:       None,
388            of_type:            Some(Box::new(type_ref("ID"))),
389            specified_by_u_r_l: None,
390        },
391        default_value:      None,
392        is_deprecated:      false,
393        deprecation_reason: None,
394        validation_rules:   vec![],
395    };
396
397    IntrospectionField {
398        name:               "node".to_string(),
399        description:        Some(
400            "Fetch any object that implements the Node interface by its global ID.".to_string(),
401        ),
402        args:               vec![id_arg],
403        field_type:         return_type,
404        is_deprecated:      false,
405        deprecation_reason: None,
406    }
407}
408
409/// Build mutation field introspection.
410fn build_mutation_field(
411    mutation: &MutationDefinition,
412    schema: &CompiledSchema,
413) -> IntrospectionField {
414    // Mutations always return a single object (not a list)
415    let return_type = type_ref(&mutation.return_type);
416
417    // Build arguments
418    let args: Vec<IntrospectionInputValue> =
419        mutation.arguments.iter().map(build_arg_input_value).collect();
420
421    IntrospectionField {
422        name: schema.display_name(&mutation.name),
423        description: mutation.description.clone(),
424        args,
425        field_type: return_type,
426        is_deprecated: mutation.is_deprecated(),
427        deprecation_reason: mutation.deprecation_reason().map(ToString::to_string),
428    }
429}
430
431/// Build subscription field introspection.
432fn build_subscription_field(
433    subscription: &SubscriptionDefinition,
434    schema: &CompiledSchema,
435) -> IntrospectionField {
436    // Subscriptions typically return a single item per event
437    let return_type = type_ref(&subscription.return_type);
438
439    // Build arguments
440    let args: Vec<IntrospectionInputValue> =
441        subscription.arguments.iter().map(build_arg_input_value).collect();
442
443    IntrospectionField {
444        name: schema.display_name(&subscription.name),
445        description: subscription.description.clone(),
446        args,
447        field_type: return_type,
448        is_deprecated: subscription.is_deprecated(),
449        deprecation_reason: subscription.deprecation_reason().map(ToString::to_string),
450    }
451}
452
453// =============================================================================
454// IntrospectionResponses
455// =============================================================================
456
457/// Pre-built introspection responses for fast serving.
458///
459/// Responses are stored as `Arc<serde_json::Value>` so cloning is O(1)
460/// (introspection queries are frequent but the schema is immutable).
461#[derive(Debug, Clone)]
462pub struct IntrospectionResponses {
463    /// Full `__schema` response JSON.
464    pub schema_response: Arc<serde_json::Value>,
465    /// Map of type name -> `__type` response JSON.
466    pub type_responses:  HashMap<String, Arc<serde_json::Value>>,
467}
468
469impl IntrospectionResponses {
470    /// Build introspection responses from compiled schema.
471    ///
472    /// This is called once at server startup and cached.
473    #[must_use]
474    pub fn build(schema: &CompiledSchema) -> Self {
475        let introspection = IntrospectionBuilder::build(schema);
476        let type_map = IntrospectionBuilder::build_type_map(&introspection);
477
478        // Build __schema response
479        let schema_response = Arc::new(serde_json::json!({
480            "data": {
481                "__schema": introspection
482            }
483        }));
484
485        // Build __type responses for each type
486        let mut type_responses = HashMap::new();
487        for (name, t) in type_map {
488            let response = Arc::new(serde_json::json!({
489                "data": {
490                    "__type": t
491                }
492            }));
493            type_responses.insert(name, response);
494        }
495
496        Self {
497            schema_response,
498            type_responses,
499        }
500    }
501
502    /// Filter `@inaccessible` fields from introspection responses.
503    ///
504    /// Removes fields listed as inaccessible from both `__type` and `__schema`
505    /// responses. This is a DX defence-in-depth measure — Apollo Router uses
506    /// `_service { sdl }`, not live introspection, so hiding fields here protects
507    /// tooling that queries the subgraph directly.
508    ///
509    /// Does NOT affect data responses or `_entities` results.
510    pub fn filter_inaccessible(&mut self, inaccessible: &HashMap<String, Vec<String>>) {
511        if inaccessible.is_empty() {
512            return;
513        }
514
515        // Filter __type responses
516        for (type_name, field_names) in inaccessible {
517            if let Some(response) = self.type_responses.get_mut(type_name) {
518                let mut val = response.as_ref().clone();
519                if let Some(fields) =
520                    val.pointer_mut("/data/__type/fields").and_then(|v| v.as_array_mut())
521                {
522                    fields.retain(|f| {
523                        f.get("name")
524                            .and_then(|n| n.as_str())
525                            .is_none_or(|name| !field_names.contains(&name.to_string()))
526                    });
527                }
528                *response = Arc::new(val);
529            }
530        }
531
532        // Filter __schema response (types array contains the same fields)
533        let mut schema_val = self.schema_response.as_ref().clone();
534        if let Some(types) =
535            schema_val.pointer_mut("/data/__schema/types").and_then(|v| v.as_array_mut())
536        {
537            for type_val in types.iter_mut() {
538                let type_name = type_val.get("name").and_then(|n| n.as_str()).unwrap_or("");
539                if let Some(field_names) = inaccessible.get(type_name) {
540                    if let Some(fields) = type_val.get_mut("fields").and_then(|v| v.as_array_mut())
541                    {
542                        fields.retain(|f| {
543                            f.get("name")
544                                .and_then(|n| n.as_str())
545                                .is_none_or(|name| !field_names.contains(&name.to_string()))
546                        });
547                    }
548                }
549            }
550        }
551        self.schema_response = Arc::new(schema_val);
552    }
553
554    /// Get response for `__type(name: "...")` query.
555    #[must_use]
556    pub fn get_type_response(&self, type_name: &str) -> serde_json::Value {
557        self.type_responses.get(type_name).map_or_else(
558            || {
559                serde_json::json!({
560                    "data": {
561                        "__type": null
562                    }
563                })
564            },
565            |v| v.as_ref().clone(),
566        )
567    }
568}