Skip to main content

apollo_federation/connectors/validation/
mod.rs

1//! Validation of the `@source` and `@connect` directives.
2
3mod connect;
4mod coordinates;
5mod errors;
6mod expression;
7mod graphql;
8mod http;
9mod schema;
10mod source;
11
12use std::ops::Range;
13
14use apollo_compiler::Name;
15use apollo_compiler::Schema;
16use apollo_compiler::parser::LineColumn;
17use apollo_compiler::schema::SchemaBuilder;
18use itertools::Itertools;
19pub(crate) use schema::field_set_is_subset;
20use strum_macros::Display;
21use strum_macros::IntoStaticStr;
22
23use crate::connectors::ConnectSpec;
24use crate::connectors::spec::ConnectLink;
25use crate::connectors::spec::source::SOURCE_DIRECTIVE_NAME_IN_SPEC;
26use crate::connectors::validation::connect::fields_seen_by_all_connects;
27use crate::connectors::validation::graphql::SchemaInfo;
28use crate::connectors::validation::source::SourceDirective;
29
30/// The result of a validation pass on a subgraph
31#[derive(Debug)]
32pub struct ValidationResult {
33    /// All validation errors encountered.
34    pub errors: Vec<Message>,
35
36    /// Whether the validated subgraph contained connector directives
37    pub has_connectors: bool,
38
39    /// The parsed (and potentially invalid) schema of the subgraph
40    pub schema: Schema,
41
42    /// The optionally transformed schema to be used in later steps.
43    pub transformed: String,
44}
45
46/// Validate the connectors-related directives `@source` and `@connect`.
47///
48/// This function attempts to collect as many validation errors as possible, so it does not bail
49/// out as soon as it encounters one.
50pub fn validate(mut source_text: String, file_name: &str) -> ValidationResult {
51    let schema = SchemaBuilder::new()
52        .adopt_orphan_extensions()
53        .parse(&source_text, file_name)
54        .build()
55        .unwrap_or_else(|schema_with_errors| schema_with_errors.partial);
56    let link = match ConnectLink::new(&schema) {
57        None => {
58            return ValidationResult {
59                errors: Vec::new(),
60                has_connectors: false,
61                schema,
62                transformed: source_text,
63            };
64        }
65        Some(Err(err)) => {
66            return ValidationResult {
67                errors: vec![err],
68                has_connectors: true,
69                schema,
70                transformed: source_text,
71            };
72        }
73        Some(Ok(link)) => link,
74    };
75    let schema_info = SchemaInfo::new(&schema, &source_text, link);
76
77    let (source_directives, mut messages) = SourceDirective::find(&schema_info);
78    let all_source_names = source_directives
79        .iter()
80        .map(|directive| directive.name.clone())
81        .collect_vec();
82
83    for source in source_directives {
84        messages.extend(source.type_check());
85    }
86
87    match fields_seen_by_all_connects(&schema_info, &all_source_names) {
88        Ok(fields_seen_by_connectors) => {
89            // Don't run schema-wide checks if any connectors failed to validate
90            messages.extend(schema::validate(
91                &schema_info,
92                file_name,
93                fields_seen_by_connectors,
94            ))
95        }
96        Err(errs) => {
97            messages.extend(errs);
98        }
99    }
100
101    if schema_info.source_directive_name() == DEFAULT_SOURCE_DIRECTIVE_NAME
102        && messages
103            .iter()
104            .any(|error| error.code == Code::NoSourcesDefined)
105    {
106        messages.push(Message {
107            code: Code::NoSourceImport,
108            message: format!("The `@{SOURCE_DIRECTIVE_NAME_IN_SPEC}` directive is not imported. Try adding `@{SOURCE_DIRECTIVE_NAME_IN_SPEC}` to `import` for `{link}`", link=schema_info.connect_link),
109            locations: schema_info.connect_link.directive.line_column_range(&schema.sources)
110                .into_iter()
111                .collect(),
112        });
113    }
114
115    // Auto-upgrade the schema as the _last_ step, so that error messages from earlier don't have
116    // incorrect line/col info if we mess this up
117    if schema_info.connect_link.spec == ConnectSpec::V0_1 {
118        if let Some(version_range) =
119            schema_info
120                .connect_link
121                .directive
122                .location()
123                .and_then(|link_range| {
124                    let version_offset = source_text
125                        .get(link_range.offset()..link_range.end_offset())?
126                        .find(ConnectSpec::V0_1.as_str())?;
127                    let start = link_range.offset() + version_offset;
128                    let end = start + ConnectSpec::V0_1.as_str().len();
129                    Some(start..end)
130                })
131        {
132            source_text.replace_range(version_range, ConnectSpec::V0_2.as_str());
133        } else {
134            messages.push(Message {
135                code: Code::UnknownConnectorsVersion,
136                message: "Failed to auto-upgrade 0.1 to 0.2, you must manually update the version in `@link`".to_string(),
137                locations: schema_info.connect_link.directive.line_column_range(&schema.sources)
138                    .into_iter()
139                    .collect(),
140            });
141            return ValidationResult {
142                errors: messages,
143                has_connectors: true,
144                schema,
145                transformed: source_text,
146            };
147        };
148    }
149
150    ValidationResult {
151        errors: messages,
152        has_connectors: true,
153        schema,
154        transformed: source_text,
155    }
156}
157
158const DEFAULT_SOURCE_DIRECTIVE_NAME: &str = "connect__source";
159
160type DirectiveName = Name;
161
162#[derive(Debug, Clone)]
163pub struct Message {
164    /// A unique, per-error code to allow consuming tools to take specific actions. These codes
165    /// should not change once stabilized.
166    pub code: Code,
167    /// A human-readable message describing the error. These messages are not stable, tools should
168    /// not rely on them remaining the same.
169    ///
170    /// # Formatting messages
171    /// 1. Messages should be complete sentences, starting with capitalization as appropriate and
172    ///    ending with punctuation.
173    /// 2. When referring to elements of the schema, use
174    ///    [schema coordinates](https://github.com/graphql/graphql-wg/blob/main/rfcs/SchemaCoordinates.md)
175    ///    with any additional information added as required for clarity (e.g., the value of an arg).
176    /// 3. When referring to code elements (including schema coordinates), surround them with
177    ///    backticks. This clarifies that `Type.field` is not ending a sentence with its period.
178    pub message: String,
179    pub locations: Vec<Range<LineColumn>>,
180}
181
182/// The error code that will be shown to users when a validation fails during composition.
183///
184/// Note that these codes are global, not scoped to connectors, so they should attempt to be
185/// unique across all pieces of composition, including JavaScript components.
186#[derive(Clone, Copy, Debug, Display, Eq, IntoStaticStr, PartialEq)]
187#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
188pub enum Code {
189    /// A problem with GraphQL syntax or semantics was found. These will usually be caught before
190    /// this validation process.
191    GraphQLError,
192    /// Indicates two connector sources with the same name were created.
193    DuplicateSourceName,
194    /// Indicates two connector IDs with the same name were created.
195    DuplicateIdName,
196    /// The `name` provided for a `@source` was invalid.
197    InvalidSourceName,
198    /// No `name` was provided when creating a connector source with `@source`.
199    EmptySourceName,
200    /// Connector ID name must be `alphanumeric_`.
201    InvalidConnectorIdName,
202    /// A URL provided to `@source` or `@connect` was not valid.
203    InvalidUrl,
204    /// A URL scheme provided to `@source` or `@connect` was not `http` or `https`.
205    InvalidUrlScheme,
206    /// The `source` argument used in a `@connect` directive doesn't match any named connector
207    /// sources created with `@source`.
208    SourceNameMismatch,
209    /// Connectors currently don't support subscription operations.
210    SubscriptionInConnectors,
211    /// The `@connect` is using a `source`, but the URL is absolute. This is not allowed because
212    /// the `@source` URL will be joined with the `@connect` URL, so the `@connect` URL should
213    /// only be a path.
214    AbsoluteConnectUrlWithSource,
215    /// The `@connect` directive is using a relative URL (path only) but does not define a `source`.
216    /// This is a specialization of [`Self::InvalidUrl`].
217    RelativeConnectUrlWithoutSource,
218    /// This is a specialization of [`Self::SourceNameMismatch`] that indicates no sources were defined.
219    NoSourcesDefined,
220    /// The subgraph doesn't import the `@source` directive. This isn't necessarily a problem, but
221    /// is likely a mistake.
222    NoSourceImport,
223    /// The `@connect` directive has multiple HTTP methods when only one is allowed.
224    MultipleHttpMethods,
225    /// The `@connect` directive is missing an HTTP method.
226    MissingHttpMethod,
227    /// The `@connect` directive's `entity` argument should only be used on the root `Query` field.
228    EntityNotOnRootQuery,
229    /// The arguments to the entity reference resolver do not match the entity type.
230    EntityResolverArgumentMismatch,
231    /// The `@connect` directive's `entity` argument should only be used with non-list, nullable, object types.
232    EntityTypeInvalid,
233    /// A `@key` was defined without a corresponding entity connector.
234    MissingEntityConnector,
235    /// The provided selection mapping in a `@connect`s `selection` was not valid.
236    InvalidSelection,
237    /// The `http.body` provided in `@connect` was not valid.
238    InvalidBody,
239    /// The `errors.message` provided in `@connect` or `@source` was not valid.
240    InvalidErrorsMessage,
241    /// The `isSuccess` mapping provided in `@connect` or `@source` was not valid.
242    InvalidIsSuccess,
243    /// A circular reference was detected in a `@connect` directive's `selection` argument.
244    CircularReference,
245    /// A field included in a `@connect` directive's `selection` argument is not defined on the corresponding type.
246    SelectedFieldNotFound,
247    /// A group selection mapping (`a { b }`) was used, but the field is not an object.
248    GroupSelectionIsNotObject,
249    /// The `name` mapping must be unique for all headers.
250    HttpHeaderNameCollision,
251    /// A provided header in `@source` or `@connect` was not valid.
252    InvalidHeader,
253    /// Certain directives are not allowed when using connectors.
254    ConnectorsUnsupportedFederationDirective,
255    /// Abstract types are not allowed when using connectors.
256    ConnectorsUnsupportedAbstractType,
257    /// Fields that return an object type must use a group selection mapping `{}`.
258    GroupSelectionRequiredForObject,
259    /// The schema includes fields that aren't resolved by a connector.
260    ConnectorsUnresolvedField,
261    /// A field resolved by a connector has arguments defined.
262    ConnectorsFieldWithArguments,
263    /// Connector batch key is not reflected in the output selection
264    ConnectorsBatchKeyNotInSelection,
265    /// Connector batch key is derived from a non-root variable such as `$this` or `$context`.
266    ConnectorsNonRootBatchKey,
267    /// A `@key` could not be resolved for the given combination of variables.
268    ConnectorsCannotResolveKey,
269    /// Part of the `@connect` refers to an `$args` which is not defined.
270    UndefinedArgument,
271    /// Part of the `@connect` refers to an `$this` which is not defined.
272    UndefinedField,
273    /// A type used in a variable is not yet supported (i.e., unions).
274    UnsupportedVariableType,
275    /// The version set in the connectors `@link` URL is not recognized.
276    UnknownConnectorsVersion,
277    /// When `@connect` is applied to a type, `entity` can't be set to `false`
278    ConnectOnTypeMustBeEntity,
279    /// `@connect` cannot be applied to a query, mutation, or subscription root type
280    ConnectOnRoot,
281    /// Using both `$batch` and `$this` is not allowed
282    ConnectBatchAndThis,
283    /// Invalid URL property
284    InvalidUrlProperty,
285    /// Any named type not found in a GraphQL schema where expected
286    MissingSchemaType,
287}
288
289impl Code {
290    pub fn severity(&self) -> Severity {
291        match self {
292            Self::NoSourceImport => Severity::Warning,
293            _ => Severity::Error,
294        }
295    }
296}
297
298/// Given the [`Code`] of a [`Message`], how important is that message?
299#[derive(Clone, Copy, Debug, Eq, PartialEq)]
300pub enum Severity {
301    /// This is an error, validation as failed.
302    Error,
303    /// The user probably wants to know about this, but it doesn't halt composition.
304    Warning,
305}
306
307#[cfg(test)]
308mod test_validate_source {
309    use std::fs::read_to_string;
310
311    use insta::assert_debug_snapshot;
312    use insta::assert_snapshot;
313    use insta::glob;
314    use pretty_assertions::assert_str_eq;
315
316    use super::*;
317
318    #[test]
319    fn validation_tests() {
320        insta::with_settings!({prepend_module_to_snapshot => false}, {
321            glob!("test_data", "**/*.graphql", |path| {
322                let schema = read_to_string(path).unwrap();
323                let start_time = std::time::Instant::now();
324                let result = validate(schema.clone(), path.to_str().unwrap());
325                let end_time = std::time::Instant::now();
326                assert_debug_snapshot!(result.errors);
327                if path.parent().is_some_and(|parent| parent.ends_with("transformed")) {
328                    assert_snapshot!(&diff::lines(&schema, &result.transformed).into_iter().filter_map(|res| match res {
329                        diff::Result::Left(line) => Some(format!("- {line}")),
330                        diff::Result::Right(line) => Some(format!("+ {line}")),
331                        diff::Result::Both(_, _) => None,
332                    }).join("\n"));
333                } else {
334                    assert_str_eq!(schema, result.transformed, "Schema should not have been transformed by validations")
335                }
336
337                assert!(end_time - start_time < std::time::Duration::from_millis(100));
338            });
339        });
340    }
341}