1 | import {
|
2 | specifiedRules,
|
3 | NoUnusedFragmentsRule,
|
4 | GraphQLError,
|
5 | FieldNode,
|
6 | ValidationContext,
|
7 | GraphQLSchema,
|
8 | DocumentNode,
|
9 | OperationDefinitionNode,
|
10 | TypeInfo,
|
11 | FragmentDefinitionNode,
|
12 | visit,
|
13 | visitWithTypeInfo,
|
14 | visitInParallel,
|
15 | getLocation,
|
16 | InlineFragmentNode,
|
17 | Kind,
|
18 | isObjectType,
|
19 | } from "graphql";
|
20 |
|
21 | import { TextEdit } from "vscode-languageserver";
|
22 |
|
23 | import { ToolError, logError } from "./logger";
|
24 | import { ValidationRule } from "graphql/validation/ValidationContext";
|
25 | import { positionFromSourceLocation } from "../utilities/source";
|
26 | import {
|
27 | buildExecutionContext,
|
28 | ExecutionContext,
|
29 | } from "graphql/execution/execute";
|
30 | import { hasClientDirective, simpleCollectFields } from "../utilities/graphql";
|
31 | import { Debug } from "../utilities";
|
32 |
|
33 | export interface CodeActionInfo {
|
34 | message: string;
|
35 | edits: TextEdit[];
|
36 | }
|
37 |
|
38 | const specifiedRulesToBeRemoved = [NoUnusedFragmentsRule];
|
39 |
|
40 | export const defaultValidationRules: ValidationRule[] = [
|
41 | NoAnonymousQueries,
|
42 | NoTypenameAlias,
|
43 | NoMissingClientDirectives,
|
44 | ...specifiedRules.filter((rule) => !specifiedRulesToBeRemoved.includes(rule)),
|
45 | ];
|
46 |
|
47 | export function getValidationErrors(
|
48 | schema: GraphQLSchema,
|
49 | document: DocumentNode,
|
50 | fragments?: { [fragmentName: string]: FragmentDefinitionNode },
|
51 | rules: ValidationRule[] = defaultValidationRules
|
52 | ) {
|
53 | const typeInfo = new TypeInfo(schema);
|
54 |
|
55 |
|
56 |
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 | const errors: GraphQLError[] = [];
|
64 | const onError = (err: GraphQLError) => errors.push(err);
|
65 | const context = new ValidationContext(schema, document, typeInfo, onError);
|
66 |
|
67 | if (fragments) {
|
68 | (context as any)._fragments = fragments;
|
69 | }
|
70 |
|
71 | const visitors = rules.map((rule) => rule(context));
|
72 |
|
73 | visit(document, visitWithTypeInfo(typeInfo, visitInParallel(visitors)));
|
74 |
|
75 |
|
76 |
|
77 | if (typeof context.getErrors === "function") return context.getErrors();
|
78 |
|
79 |
|
80 |
|
81 | return errors;
|
82 | }
|
83 |
|
84 | export function validateQueryDocument(
|
85 | schema: GraphQLSchema,
|
86 | document: DocumentNode
|
87 | ) {
|
88 | try {
|
89 | const validationErrors = getValidationErrors(schema, document);
|
90 | if (validationErrors && validationErrors.length > 0) {
|
91 | for (const error of validationErrors) {
|
92 | logError(error);
|
93 | }
|
94 | return Debug.error("Validation of GraphQL query document failed");
|
95 | }
|
96 | } catch (e) {
|
97 | console.error(e);
|
98 | throw e;
|
99 | }
|
100 | }
|
101 |
|
102 | export function NoAnonymousQueries(context: ValidationContext) {
|
103 | return {
|
104 | OperationDefinition(node: OperationDefinitionNode) {
|
105 | if (!node.name) {
|
106 | context.reportError(
|
107 | new GraphQLError("Apollo does not support anonymous operations", [
|
108 | node,
|
109 | ])
|
110 | );
|
111 | }
|
112 | return false;
|
113 | },
|
114 | };
|
115 | }
|
116 |
|
117 | export function NoTypenameAlias(context: ValidationContext) {
|
118 | return {
|
119 | Field(node: FieldNode) {
|
120 | const aliasName = node.alias && node.alias.value;
|
121 | if (aliasName == "__typename") {
|
122 | context.reportError(
|
123 | new GraphQLError(
|
124 | "Apollo needs to be able to insert __typename when needed, please do not use it as an alias",
|
125 | [node]
|
126 | )
|
127 | );
|
128 | }
|
129 | },
|
130 | };
|
131 | }
|
132 |
|
133 | function hasClientSchema(schema: GraphQLSchema): boolean {
|
134 | const query = schema.getQueryType();
|
135 | const mutation = schema.getMutationType();
|
136 | const subscription = schema.getSubscriptionType();
|
137 |
|
138 | return Boolean(
|
139 | (query && query.clientSchema) ||
|
140 | (mutation && mutation.clientSchema) ||
|
141 | (subscription && subscription.clientSchema)
|
142 | );
|
143 | }
|
144 |
|
145 | export function NoMissingClientDirectives(context: ValidationContext) {
|
146 | const root = context.getDocument();
|
147 | const schema = context.getSchema();
|
148 |
|
149 | if (!hasClientSchema(schema)) return {};
|
150 |
|
151 |
|
152 |
|
153 |
|
154 |
|
155 | const executionContext = buildExecutionContext(
|
156 | schema,
|
157 | root,
|
158 | Object.create(null),
|
159 | Object.create(null),
|
160 | undefined,
|
161 | undefined,
|
162 | undefined
|
163 | );
|
164 | function visitor(
|
165 | node: FieldNode | InlineFragmentNode | FragmentDefinitionNode
|
166 | ) {
|
167 |
|
168 |
|
169 |
|
170 | const parentType =
|
171 | node.kind === Kind.FRAGMENT_DEFINITION
|
172 | ? schema.getType(node.typeCondition.name.value)
|
173 | : context.getParentType();
|
174 |
|
175 | const fieldDef = context.getFieldDef();
|
176 |
|
177 |
|
178 | if (!parentType) return;
|
179 |
|
180 |
|
181 | const clientFields =
|
182 | parentType &&
|
183 | isObjectType(parentType) &&
|
184 | parentType.clientSchema &&
|
185 | parentType.clientSchema.localFields;
|
186 |
|
187 |
|
188 | let clientDirectivePresent = hasClientDirective(node);
|
189 |
|
190 | let message = "@client directive is missing on ";
|
191 | let selectsClientFieldSet = false;
|
192 | switch (node.kind) {
|
193 | case Kind.FIELD:
|
194 |
|
195 |
|
196 | selectsClientFieldSet = Boolean(
|
197 | clientFields && clientFields.includes(fieldDef!.name)
|
198 | );
|
199 | message += `local field "${node.name.value}"`;
|
200 | break;
|
201 | case Kind.INLINE_FRAGMENT:
|
202 | case Kind.FRAGMENT_DEFINITION:
|
203 |
|
204 | if (Array.isArray(executionContext)) break;
|
205 |
|
206 | const fields = simpleCollectFields(
|
207 | executionContext as ExecutionContext,
|
208 | node.selectionSet,
|
209 | Object.create(null),
|
210 | Object.create(null)
|
211 | );
|
212 |
|
213 |
|
214 |
|
215 |
|
216 | const fieldNames = Object.entries(fields).map(([name]) => name);
|
217 | selectsClientFieldSet = fieldNames.every(
|
218 | (field) => clientFields && clientFields.includes(field)
|
219 | );
|
220 | message += `fragment ${
|
221 | "name" in node ? `"${node.name.value}" ` : ""
|
222 | }around local fields "${fieldNames.join(",")}"`;
|
223 | break;
|
224 | }
|
225 |
|
226 | // if the field's parent is part of the client schema and that type
|
227 | // includes a field with the same name as this node, we can see
|
228 | // if it has an @client directive to resolve locally
|
229 | if (selectsClientFieldSet && !clientDirectivePresent) {
|
230 | let extensions: { [key: string]: any } | null = null;
|
231 | const name = "name" in node && node.name;
|
232 | // TODO support code actions for inline fragments, fragment spreads, and fragment definitions
|
233 | if (name && name.loc) {
|
234 | let { source, end: locToInsertDirective } = name.loc;
|
235 | if (
|
236 | "arguments" in node &&
|
237 | node.arguments &&
|
238 | node.arguments.length !== 0
|
239 | ) {
|
240 | // must insert directive after field arguments
|
241 | const endOfArgs = source.body.indexOf(")", locToInsertDirective);
|
242 | locToInsertDirective = endOfArgs + 1;
|
243 | }
|
244 | const codeAction: CodeActionInfo = {
|
245 | message: `Add @client directive to "${name.value}"`,
|
246 | edits: [
|
247 | TextEdit.insert(
|
248 | positionFromSourceLocation(
|
249 | source,
|
250 | getLocation(source, locToInsertDirective)
|
251 | ),
|
252 | " @client"
|
253 | ),
|
254 | ],
|
255 | };
|
256 | extensions = { codeAction };
|
257 | }
|
258 |
|
259 | context.reportError(
|
260 | new GraphQLError(message, [node], null, null, null, null, extensions)
|
261 | );
|
262 | }
|
263 |
|
264 | // if we have selected a client field, no need to continue to recurse
|
265 | if (selectsClientFieldSet) {
|
266 | return false;
|
267 | }
|
268 |
|
269 | return;
|
270 | }
|
271 | return {
|
272 | InlineFragment: visitor,
|
273 | FragmentDefinition: visitor,
|
274 | Field: visitor,
|
275 | // TODO support directives on FragmentSpread
|
276 | };
|
277 | }
|
278 |
|
\ | No newline at end of file |