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}