Skip to main content

apollo_router/
introspection.rs

1use std::num::NonZeroUsize;
2use std::ops::ControlFlow;
3use std::sync::Arc;
4
5use apollo_compiler::executable::Selection;
6use serde_json_bytes::json;
7use sha2::Digest;
8use sha2::Sha256;
9
10use crate::Configuration;
11use crate::cache::storage::CacheStorage;
12use crate::compute_job;
13use crate::compute_job::ComputeBackPressureError;
14use crate::compute_job::ComputeJobType;
15use crate::graphql;
16use crate::json_ext::Object;
17use crate::query_planner::QueryKey;
18use crate::services::layers::query_analysis::ParsedDocument;
19use crate::spec;
20
21const DEFAULT_INTROSPECTION_CACHE_CAPACITY: NonZeroUsize = NonZeroUsize::new(5).unwrap();
22
23#[derive(Clone)]
24pub(crate) struct IntrospectionCache(Mode);
25
26#[derive(Clone)]
27enum Mode {
28    Disabled,
29    Enabled {
30        storage: Arc<CacheStorage<String, graphql::Response>>,
31        max_depth: MaxDepth,
32    },
33}
34
35#[derive(Copy, Clone)]
36enum MaxDepth {
37    Check,
38    Ignore,
39}
40
41impl IntrospectionCache {
42    pub(crate) fn new(configuration: &Configuration) -> Self {
43        if configuration.supergraph.introspection {
44            let storage = Arc::new(CacheStorage::new_in_memory(
45                DEFAULT_INTROSPECTION_CACHE_CAPACITY,
46                "introspection",
47            ));
48            storage.activate();
49            Self(Mode::Enabled {
50                storage,
51                max_depth: if configuration.limits.introspection_max_depth {
52                    MaxDepth::Check
53                } else {
54                    MaxDepth::Ignore
55                },
56            })
57        } else {
58            Self(Mode::Disabled)
59        }
60    }
61
62    pub(crate) fn activate(&self) {
63        match &self.0 {
64            Mode::Disabled => {}
65            Mode::Enabled { storage, .. } => storage.activate(),
66        }
67    }
68
69    /// If `request` is a query with only introspection fields,
70    /// execute it and return a (cached) response
71    pub(crate) async fn maybe_execute(
72        &self,
73        schema: &Arc<spec::Schema>,
74        key: &QueryKey,
75        doc: &ParsedDocument,
76        variables: Object,
77    ) -> ControlFlow<Result<graphql::Response, ComputeBackPressureError>, ()> {
78        Self::maybe_lone_root_typename(schema, doc)?;
79        if doc.operation.is_query() {
80            if doc.has_schema_introspection {
81                if doc.has_explicit_root_fields {
82                    ControlFlow::Break(Ok(Self::mixed_fields_error()))?;
83                } else {
84                    ControlFlow::Break(
85                        self.cached_introspection(schema, key, doc, variables).await,
86                    )?
87                }
88            } else if !doc.has_explicit_root_fields {
89                // Root __typename only
90
91                // No list field so depth is already known to be zero:
92                let max_depth = MaxDepth::Ignore;
93
94                // Probably a small query, execute it without caching:
95                ControlFlow::Break(Ok(Self::execute_introspection(
96                    max_depth, schema, doc, variables,
97                )))?
98            }
99        }
100        ControlFlow::Continue(())
101    }
102
103    /// A `{ __typename }` query is often used as a ping or health check.
104    /// Handle it without touching the cache.
105    ///
106    /// This fast path only applies if no fragment or directive is used,
107    /// so that we don’t have to deal with `@skip` or `@include` here.
108    fn maybe_lone_root_typename(
109        schema: &Arc<spec::Schema>,
110        doc: &ParsedDocument,
111    ) -> ControlFlow<Result<graphql::Response, ComputeBackPressureError>, ()> {
112        if doc.operation.selection_set.selections.len() == 1
113            && let Selection::Field(field) = &doc.operation.selection_set.selections[0]
114            && field.name == "__typename"
115            && field.directives.is_empty()
116        {
117            // `{ alias: __typename }` is much less common so handling it here is not essential
118            // but easier than a conditional to reject it
119            let key = field.response_key().as_str();
120            let object_type_name = schema
121                .api_schema()
122                .root_operation(doc.operation.operation_type)
123                .expect("validation should have caught undefined root operation")
124                .as_str();
125            let data = json!({key: object_type_name});
126            ControlFlow::Break(Ok(graphql::Response::builder().data(data).build()))?
127        }
128        ControlFlow::Continue(())
129    }
130
131    fn mixed_fields_error() -> graphql::Response {
132        let error = graphql::Error::builder()
133            .message(
134                "\
135                Mixed queries with both schema introspection and concrete fields \
136                are not supported yet: https://github.com/apollographql/router/issues/2789\
137            ",
138            )
139            .extension_code("MIXED_INTROSPECTION")
140            .build();
141        graphql::Response::builder().error(error).build()
142    }
143
144    fn introspection_cache_key(query: &str, variables: Object) -> Option<String> {
145        if let Ok(variable_key) = serde_json::to_string(&variables) {
146            let mut hasher = Sha256::new();
147            hasher.update(variable_key);
148            Some(format!("{query}:{:x}", hasher.finalize()))
149        } else {
150            tracing::warn!(
151                "Failed to serialize variables for introspection cache key, skipping cache: {:?}",
152                variables
153            );
154            None
155        }
156    }
157
158    async fn cached_introspection(
159        &self,
160        schema: &Arc<spec::Schema>,
161        key: &QueryKey,
162        doc: &ParsedDocument,
163        variables: Object,
164    ) -> Result<graphql::Response, ComputeBackPressureError> {
165        let (storage, max_depth) = match &self.0 {
166            Mode::Enabled { storage, max_depth } => (storage, *max_depth),
167            Mode::Disabled => {
168                let error = graphql::Error::builder()
169                    .message(String::from("introspection has been disabled"))
170                    .extension_code("INTROSPECTION_DISABLED")
171                    .build();
172                return Ok(graphql::Response::builder().error(error).build());
173            }
174        };
175
176        let cache_key = Self::introspection_cache_key(&key.filtered_query, variables.clone());
177        if let Some(cache_key) = &cache_key
178            && let Some(response) = storage.get(cache_key, |_| unreachable!()).await
179        {
180            return Ok(response);
181        }
182
183        let schema = schema.clone();
184        let doc = doc.clone();
185        let response = compute_job::execute(ComputeJobType::Introspection, move |_| {
186            Self::execute_introspection(max_depth, &schema, &doc, variables)
187        })?
188        // `expect()` propagates any panic that potentially happens in the closure, but:
189        //
190        // * We try to avoid such panics in the first place and consider them bugs
191        // * The panic handler in `apollo-router/src/executable.rs` exits the process
192        //   so this error case should never be reached.
193        .await;
194
195        if let Some(cache_key) = cache_key {
196            storage.insert(cache_key, response.clone()).await;
197        }
198        Ok(response)
199    }
200
201    fn execute_introspection(
202        max_depth: MaxDepth,
203        schema: &spec::Schema,
204        doc: &ParsedDocument,
205        variables: Object,
206    ) -> graphql::Response {
207        let api_schema = schema.api_schema();
208        let operation = &doc.operation;
209        let max_depth_result = match max_depth {
210            MaxDepth::Check => {
211                apollo_compiler::introspection::check_max_depth(&doc.executable, operation)
212            }
213            MaxDepth::Ignore => Ok(()),
214        };
215        let result = max_depth_result
216            .and_then(|()| {
217                apollo_compiler::request::coerce_variable_values(api_schema, operation, &variables)
218            })
219            .and_then(|variable_values| {
220                apollo_compiler::introspection::partial_execute(
221                    api_schema,
222                    &schema.implementers_map,
223                    &doc.executable,
224                    operation,
225                    &variable_values,
226                )
227            });
228        match result {
229            Ok(response) => response.into(),
230            Err(e) => {
231                let error = e.to_graphql_error(&doc.executable.sources);
232                graphql::Response::builder().error(error).build()
233            }
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use serde_json_bytes::json;
241
242    use crate::introspection::IntrospectionCache;
243
244    #[test]
245    fn test_variable_normalization_key() {
246        let variables = json!({
247            "e": true,
248            "a": "John Doe",
249            "b": 30,
250            "f": null,
251            "d": {
252                "b": 1,
253                "a": 2,
254            },
255            "c": [1, "Hello", { "d": "World","a": 3 }],
256        });
257        let key = IntrospectionCache::introspection_cache_key(
258            "query { __typename }",
259            variables.as_object().unwrap().clone(),
260        )
261        .unwrap();
262        assert_eq!(
263            key,
264            "query { __typename }:618d257f0ab1069a2374274c8c6e56f6b6528a839045647cedcfd147bc5dd9cf"
265        );
266    }
267}