Skip to main content

apollo_federation/connectors/spec/
source.rs

1use apollo_compiler::Name;
2use apollo_compiler::Node;
3use apollo_compiler::Schema;
4use apollo_compiler::ast::Value;
5use apollo_compiler::name;
6use apollo_compiler::parser::SourceMap;
7use apollo_compiler::schema::Component;
8use apollo_compiler::schema::Directive;
9use itertools::Itertools;
10
11use super::errors::ERRORS_ARGUMENT_NAME;
12use super::errors::ErrorsArguments;
13use crate::connectors::ConnectSpec;
14use crate::connectors::Header;
15use crate::connectors::JSONSelection;
16use crate::connectors::OriginatingDirective;
17use crate::connectors::SourceName;
18use crate::connectors::StringTemplate;
19use crate::connectors::spec::connect::DEFAULT_CONNECT_SPEC;
20use crate::connectors::spec::connect::IS_SUCCESS_ARGUMENT_NAME;
21use crate::connectors::spec::connect_spec_from_schema;
22use crate::connectors::spec::http::HTTP_ARGUMENT_NAME;
23use crate::connectors::spec::http::PATH_ARGUMENT_NAME;
24use crate::connectors::spec::http::QUERY_PARAMS_ARGUMENT_NAME;
25use crate::connectors::string_template;
26use crate::connectors::validation::Code;
27use crate::connectors::validation::Message;
28use crate::error::FederationError;
29
30pub(crate) const SOURCE_DIRECTIVE_NAME_IN_SPEC: Name = name!("source");
31pub(crate) const SOURCE_NAME_ARGUMENT_NAME: Name = name!("name");
32pub(crate) const SOURCE_HTTP_NAME_IN_SPEC: Name = name!("SourceHTTP");
33
34pub(crate) fn extract_source_directive_arguments(
35    schema: &Schema,
36    name: &Name,
37) -> Result<Vec<SourceDirectiveArguments>, FederationError> {
38    let connect_spec = connect_spec_from_schema(schema).unwrap_or(DEFAULT_CONNECT_SPEC);
39    schema
40        .schema_definition
41        .directives
42        .iter()
43        .filter(|directive| directive.name == *name)
44        .map(|directive| {
45            SourceDirectiveArguments::from_directive(directive, &schema.sources, connect_spec)
46        })
47        .collect()
48}
49
50/// Arguments to the `@source` directive
51#[cfg_attr(test, derive(Debug))]
52pub(crate) struct SourceDirectiveArguments {
53    /// The friendly name of this source for use in `@connect` directives
54    pub(crate) name: SourceName,
55
56    /// Common HTTP options
57    pub(crate) http: SourceHTTPArguments,
58
59    /// Configure the error mapping functionality for this source
60    pub(crate) errors: Option<ErrorsArguments>,
61
62    /// Conditional statement to override the default success criteria for responses
63    pub(crate) is_success: Option<JSONSelection>,
64}
65
66impl SourceDirectiveArguments {
67    fn from_directive(
68        value: &Component<Directive>,
69        sources: &SourceMap,
70        spec: ConnectSpec,
71    ) -> Result<Self, FederationError> {
72        let args = &value.arguments;
73        let directive_name = &value.name;
74
75        // We'll have to iterate over the arg list and keep the properties by their name
76        let name = SourceName::from_directive_permissive(value, sources).map_err(|message| {
77            crate::error::SingleFederationError::InvalidGraphQL {
78                message: message.message,
79            }
80        })?;
81        let mut http = None;
82        let mut errors = None;
83        let mut is_success = None;
84        for arg in args {
85            let arg_name = arg.name.as_str();
86
87            if arg_name == HTTP_ARGUMENT_NAME.as_str() {
88                let http_value = arg.value.as_object().ok_or_else(|| {
89                    FederationError::internal(format!(
90                        "`http` field in `@{directive_name}` directive is not an object"
91                    ))
92                })?;
93                let http_value =
94                    SourceHTTPArguments::from_directive(http_value, directive_name, sources, spec)?;
95
96                http = Some(http_value);
97            } else if arg_name == ERRORS_ARGUMENT_NAME.as_str() {
98                let http_value = arg.value.as_object().ok_or_else(|| {
99                    FederationError::internal(format!(
100                        "`errors` field in `@{directive_name}` directive is not an object"
101                    ))
102                })?;
103                let errors_value = ErrorsArguments::try_from((http_value, directive_name, spec))?;
104
105                errors = Some(errors_value);
106            } else if arg_name == IS_SUCCESS_ARGUMENT_NAME.as_str() {
107                let selection_value = arg.value.as_str().ok_or_else(|| {
108                    FederationError::internal(format!(
109                        "`is_success` field in `@{directive_name}` directive is not a string"
110                    ))
111                })?;
112                is_success = Some(
113                    JSONSelection::parse_with_spec(selection_value, spec)
114                        .map_err(|e| FederationError::internal(e.message))?,
115                );
116            }
117        }
118
119        Ok(Self {
120            name,
121            http: http.ok_or_else(|| {
122                FederationError::internal(format!(
123                    "missing `http` field in `@{directive_name}` directive"
124                ))
125            })?,
126            errors,
127            is_success,
128        })
129    }
130}
131
132/// Parsed `@source(http:)`
133#[cfg_attr(test, derive(Debug))]
134pub struct SourceHTTPArguments {
135    /// The base URL containing all sub API endpoints
136    pub(crate) base_url: BaseUrl,
137
138    /// HTTP headers used when requesting resources from the upstream source.
139    /// Can be overridden by name with headers in a @connect directive.
140    pub(crate) headers: Vec<Header>,
141    pub(crate) path: Option<JSONSelection>,
142    pub(crate) query_params: Option<JSONSelection>,
143}
144
145impl SourceHTTPArguments {
146    pub fn from_directive(
147        values: &[(Name, Node<Value>)],
148        directive_name: &Name,
149        sources: &SourceMap,
150        spec: ConnectSpec,
151    ) -> Result<Self, FederationError> {
152        let base_url = BaseUrl::parse(values, directive_name, sources, spec)
153            .map_err(|err| FederationError::internal(err.message))?;
154        let headers: Vec<Header> =
155            Header::from_http_arg(values, OriginatingDirective::Source, spec)
156                .into_iter()
157                .try_collect()
158                .map_err(|err| FederationError::internal(err.to_string()))?;
159        let mut path = None;
160        let mut query = None;
161        for (name, value) in values {
162            let name = name.as_str();
163
164            if name == PATH_ARGUMENT_NAME.as_str() {
165                let value = value.as_str().ok_or_else(|| {
166                    FederationError::internal(format!(
167                        "`{PATH_ARGUMENT_NAME}` field in `@{directive_name}` directive's `http.path` field is not a string"
168                    ))
169                })?;
170                path = Some(
171                    JSONSelection::parse_with_spec(value, spec)
172                        .map_err(|e| FederationError::internal(e.message))?,
173                );
174            } else if name == QUERY_PARAMS_ARGUMENT_NAME.as_str() {
175                let value = value.as_str().ok_or_else(|| FederationError::internal(format!(
176                    "`{QUERY_PARAMS_ARGUMENT_NAME}` field in `@{directive_name}` directive's `http.queryParams` field is not a string"
177                )))?;
178                query = Some(
179                    JSONSelection::parse_with_spec(value, spec)
180                        .map_err(|e| FederationError::internal(e.message))?,
181                );
182            }
183        }
184
185        Ok(Self {
186            base_url,
187            headers,
188            path,
189            query_params: query,
190        })
191    }
192}
193
194/// The `baseURL` argument to the `@source` directive
195#[derive(Debug, Clone)]
196pub(crate) struct BaseUrl {
197    pub(crate) template: StringTemplate,
198    pub(crate) node: Node<Value>,
199}
200
201impl BaseUrl {
202    pub(crate) const ARGUMENT: Name = name!("baseURL");
203
204    pub(crate) fn parse(
205        values: &[(Name, Node<Value>)],
206        directive_name: &Name,
207        sources: &SourceMap,
208        spec: ConnectSpec,
209    ) -> Result<Self, Message> {
210        const BASE_URL: Name = BaseUrl::ARGUMENT;
211
212        let value = values
213            .iter()
214            .find_map(|(key, value)| (key == &Self::ARGUMENT).then_some(value))
215            .ok_or_else(|| Message {
216                code: Code::GraphQLError,
217                message: format!("`@{directive_name}` must have a `baseURL` argument."),
218                locations: directive_name
219                    .line_column_range(sources)
220                    .into_iter()
221                    .collect(),
222            })?;
223        let str_value = value.as_str().ok_or_else(|| Message {
224            code: Code::GraphQLError,
225            message: format!("`@{directive_name}({BASE_URL}:)` must be a string."),
226            locations: value.line_column_range(sources).into_iter().collect(),
227        })?;
228        let template: StringTemplate = StringTemplate::parse_with_spec(
229            str_value,
230            spec,
231        ).map_err(|inner: string_template::Error| {
232            Message {
233                code: Code::InvalidUrl,
234                message: format!(
235                    "`@{directive_name}({BASE_URL})` value {str_value} is not a valid URL Template: {inner}."
236                ),
237                locations: value.line_column_range(sources).into_iter().collect(),
238            }
239        })?;
240
241        Ok(Self {
242            template,
243            node: value.clone(),
244        })
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use apollo_compiler::Schema;
251    use http::Uri;
252
253    use super::*;
254    use crate::ValidFederationSubgraphs;
255    use crate::connectors::Namespace;
256    use crate::schema::FederationSchema;
257    use crate::supergraph::extract_subgraphs_from_supergraph;
258
259    static SIMPLE_SUPERGRAPH: &str = include_str!("../tests/schemas/simple.graphql");
260    static TEMPLATED_SOURCE_SUPERGRAPH: &str =
261        include_str!("../tests/schemas/source-template.graphql");
262    static IS_SUCCESS_SOURCE_SUPERGRAPH: &str =
263        include_str!("../tests/schemas/is-success-source.graphql");
264
265    fn get_subgraphs(supergraph_sdl: &str) -> ValidFederationSubgraphs {
266        let schema = Schema::parse(supergraph_sdl, "supergraph.graphql").unwrap();
267        let supergraph_schema = FederationSchema::new(schema).unwrap();
268        extract_subgraphs_from_supergraph(&supergraph_schema, Some(true)).unwrap()
269    }
270
271    #[test]
272    fn it_parses_at_source() {
273        let subgraphs = get_subgraphs(SIMPLE_SUPERGRAPH);
274        let subgraph = subgraphs.get("connectors").unwrap();
275
276        let actual_definition = subgraph
277            .schema
278            .get_directive_definition(&SOURCE_DIRECTIVE_NAME_IN_SPEC)
279            .unwrap()
280            .get(subgraph.schema.schema())
281            .unwrap();
282
283        insta::assert_snapshot!(actual_definition.to_string(), @"directive @source(name: String!, http: connect__SourceHTTP, errors: connect__ConnectorErrors, isSuccess: connect__JSONSelection) repeatable on SCHEMA");
284
285        insta::assert_debug_snapshot!(
286            subgraph.schema
287                .referencers()
288                .get_directive(SOURCE_DIRECTIVE_NAME_IN_SPEC.as_str()),
289            @r###"
290                DirectiveReferencers {
291                    schema: Some(
292                        SchemaDefinitionPosition,
293                    ),
294                    scalar_types: {},
295                    object_types: {},
296                    object_fields: {},
297                    object_field_arguments: {},
298                    interface_types: {},
299                    interface_fields: {},
300                    interface_field_arguments: {},
301                    union_types: {},
302                    enum_types: {},
303                    enum_values: {},
304                    input_object_types: {},
305                    input_object_fields: {},
306                    directive_arguments: {},
307                }
308            "###
309        );
310    }
311
312    #[test]
313    fn it_extracts_at_source() {
314        let sources = extract_source_directive_args(SIMPLE_SUPERGRAPH);
315
316        let source = sources.first().unwrap();
317        assert_eq!(source.name, SourceName::cast("json"));
318        assert_eq!(
319            source
320                .http
321                .base_url
322                .template
323                .interpolate_uri(&Default::default())
324                .unwrap()
325                .0,
326            Uri::from_static("https://jsonplaceholder.typicode.com/")
327        );
328        assert_eq!(source.http.path, None);
329        assert_eq!(source.http.query_params, None);
330
331        insta::assert_debug_snapshot!(
332            source.http.headers,
333            @r#"
334        [
335            Header {
336                name: "authtoken",
337                source: From(
338                    "x-auth-token",
339                ),
340            },
341            Header {
342                name: "user-agent",
343                source: Value(
344                    HeaderValue(
345                        StringTemplate {
346                            parts: [
347                                Constant(
348                                    Constant {
349                                        value: "Firefox",
350                                        location: 0..7,
351                                    },
352                                ),
353                            ],
354                        },
355                    ),
356                ),
357            },
358        ]
359        "#
360        );
361    }
362
363    #[test]
364    fn it_parses_as_template_at_source() {
365        let directive_args = extract_source_directive_args(TEMPLATED_SOURCE_SUPERGRAPH);
366
367        // Extract the matching templated URL from the matching source or panic if no match
368        let templated_base_url = directive_args
369            .iter()
370            .find(|arg| arg.name == SourceName::cast("json"))
371            .map(|arg| arg.http.base_url.clone())
372            .unwrap()
373            .template;
374        assert_eq!(
375            templated_base_url.to_string(),
376            "https://${$config.subdomain}.typicode.com/"
377        );
378
379        // Ensure config variable exists as expected.
380        templated_base_url
381            .expressions()
382            .flat_map(|exp| exp.expression.variable_references())
383            .find(|var_ref| var_ref.namespace.namespace == Namespace::Config)
384            .unwrap();
385    }
386
387    #[test]
388    fn it_supports_is_success_in_source() {
389        let spec_from_success_source_subgraph = ConnectSpec::V0_1;
390        let sources = extract_source_directive_args(IS_SUCCESS_SOURCE_SUPERGRAPH);
391        let source = sources.first().unwrap();
392        assert_eq!(source.name, SourceName::cast("json"));
393        assert!(source.is_success.is_some());
394        let expected =
395            JSONSelection::parse_with_spec("$status->eq(202)", spec_from_success_source_subgraph)
396                .unwrap();
397        assert_eq!(source.is_success.as_ref().unwrap(), &expected);
398    }
399
400    fn extract_source_directive_args(graph: &str) -> Vec<SourceDirectiveArguments> {
401        let subgraphs = get_subgraphs(graph);
402        let subgraph = subgraphs.get("connectors").unwrap();
403        let schema = &subgraph.schema;
404
405        // Extract the sources from the schema definition and map them to their `Source` equivalent
406        let sources = schema
407            .referencers()
408            .get_directive(&SOURCE_DIRECTIVE_NAME_IN_SPEC);
409
410        let schema_directive_refs = sources.schema.as_ref().unwrap();
411        let sources: Result<Vec<_>, _> = schema_directive_refs
412            .get(schema.schema())
413            .directives
414            .iter()
415            .filter(|directive| directive.name == SOURCE_DIRECTIVE_NAME_IN_SPEC)
416            .map(|directive| {
417                let connect_spec =
418                    connect_spec_from_schema(schema.schema()).unwrap_or(DEFAULT_CONNECT_SPEC);
419                SourceDirectiveArguments::from_directive(
420                    directive,
421                    &schema.schema().sources,
422                    connect_spec,
423                )
424            })
425            .collect();
426        sources.unwrap()
427    }
428}