apollo-language-server
Version:
A language server for Apollo GraphQL projects
278 lines (248 loc) • 9.04 kB
text/typescript
import {
specifiedRules,
NoUnusedFragmentsRule,
GraphQLError,
FieldNode,
ValidationContext,
GraphQLSchema,
DocumentNode,
OperationDefinitionNode,
TypeInfo,
FragmentDefinitionNode,
visit,
visitWithTypeInfo,
visitInParallel,
getLocation,
InlineFragmentNode,
Kind,
isObjectType,
} from "graphql";
import { TextEdit } from "vscode-languageserver";
import { ToolError, logError } from "./logger";
import { ValidationRule } from "graphql/validation/ValidationContext";
import { positionFromSourceLocation } from "../utilities/source";
import {
buildExecutionContext,
ExecutionContext,
} from "graphql/execution/execute";
import { hasClientDirective, simpleCollectFields } from "../utilities/graphql";
import { Debug } from "../utilities";
export interface CodeActionInfo {
message: string;
edits: TextEdit[];
}
const specifiedRulesToBeRemoved = [NoUnusedFragmentsRule];
export const defaultValidationRules: ValidationRule[] = [
NoAnonymousQueries,
NoTypenameAlias,
NoMissingClientDirectives,
...specifiedRules.filter((rule) => !specifiedRulesToBeRemoved.includes(rule)),
];
export function getValidationErrors(
schema: GraphQLSchema,
document: DocumentNode,
fragments?: { [fragmentName: string]: FragmentDefinitionNode },
rules: ValidationRule[] = defaultValidationRules
) {
const typeInfo = new TypeInfo(schema);
// The 4th argument to `ValidationContext` is an `onError` callback. This was
// introduced by https://github.com/graphql/graphql-js/pull/2074 and first
// published in graphql@14.5.0. It is meant to replace the `getErrors` method
// which was previously used. Since we support versions of graphql older than
// that, it's possible that this callback will not be invoked and we'll need
// to resort to using `getErrors`. Therefore, although we'll collect errors
// via this callback, if `getErrors` is present on the context we create,
// we'll go ahead and use that instead.
const errors: GraphQLError[] = [];
const onError = (err: GraphQLError) => errors.push(err);
const context = new ValidationContext(schema, document, typeInfo, onError);
if (fragments) {
(context as any)._fragments = fragments;
}
const visitors = rules.map((rule) => rule(context));
// Visit the whole document with each instance of all provided rules.
visit(document, visitWithTypeInfo(typeInfo, visitInParallel(visitors)));
// @ts-ignore
// `getErrors` is gone in `graphql@15`, but we still support older versions.
if (typeof context.getErrors === "function") return context.getErrors();
// If `getErrors` doesn't exist, we must be on a `graphql@15` or higher,
// so we'll use the errors we collected via the `onError` callback.
return errors;
}
export function validateQueryDocument(
schema: GraphQLSchema,
document: DocumentNode
) {
try {
const validationErrors = getValidationErrors(schema, document);
if (validationErrors && validationErrors.length > 0) {
for (const error of validationErrors) {
logError(error);
}
return Debug.error("Validation of GraphQL query document failed");
}
} catch (e) {
console.error(e);
throw e;
}
}
export function NoAnonymousQueries(context: ValidationContext) {
return {
OperationDefinition(node: OperationDefinitionNode) {
if (!node.name) {
context.reportError(
new GraphQLError("Apollo does not support anonymous operations", [
node,
])
);
}
return false;
},
};
}
export function NoTypenameAlias(context: ValidationContext) {
return {
Field(node: FieldNode) {
const aliasName = node.alias && node.alias.value;
if (aliasName == "__typename") {
context.reportError(
new GraphQLError(
"Apollo needs to be able to insert __typename when needed, please do not use it as an alias",
[node]
)
);
}
},
};
}
function hasClientSchema(schema: GraphQLSchema): boolean {
const query = schema.getQueryType();
const mutation = schema.getMutationType();
const subscription = schema.getSubscriptionType();
return Boolean(
(query && query.clientSchema) ||
(mutation && mutation.clientSchema) ||
(subscription && subscription.clientSchema)
);
}
export function NoMissingClientDirectives(context: ValidationContext) {
const root = context.getDocument();
const schema = context.getSchema();
// early return if we don't have any client fields on the schema
if (!hasClientSchema(schema)) return {};
// this isn't really execution context, but it does group the fragments and operations
// together correctly
// XXX we have a simplified version of this in @apollo/gateway that we could probably use
// intead of this
const executionContext = buildExecutionContext(
schema,
root,
Object.create(null),
Object.create(null),
undefined,
undefined,
undefined
);
function visitor(
node: FieldNode | InlineFragmentNode | FragmentDefinitionNode
) {
// In cases where we are looking at a FragmentDefinition, there is no parent type
// but instead, the FragmentDefinition contains the type that we can read from the
// schema
const parentType =
node.kind === Kind.FRAGMENT_DEFINITION
? schema.getType(node.typeCondition.name.value)
: context.getParentType();
const fieldDef = context.getFieldDef();
// if we don't have a type to check then we can early return
if (!parentType) return;
// here we collect all of the fields on a type that are marked "local"
const clientFields =
parentType &&
isObjectType(parentType) &&
parentType.clientSchema &&
parentType.clientSchema.localFields;
// XXXX in the case of a fragment spread, the directive could be on the fragment definition
let clientDirectivePresent = hasClientDirective(node);
let message = "@client directive is missing on ";
let selectsClientFieldSet = false;
switch (node.kind) {
case Kind.FIELD:
// fields are simple because we can just see if the name exists in the local fields
// array on the parent type
selectsClientFieldSet = Boolean(
clientFields && clientFields.includes(fieldDef!.name)
);
message += `local field "${node.name.value}"`;
break;
case Kind.INLINE_FRAGMENT:
case Kind.FRAGMENT_DEFINITION:
// XXX why isn't this type checking below?
if (Array.isArray(executionContext)) break;
const fields = simpleCollectFields(
executionContext as ExecutionContext,
node.selectionSet,
Object.create(null),
Object.create(null)
);
// once we have a list of fields on the fragment, we can compare them
// to the list of types. The fields within a fragment need to be a
// subset of the overall local fields types
const fieldNames = Object.entries(fields).map(([name]) => name);
selectsClientFieldSet = fieldNames.every(
(field) => clientFields && clientFields.includes(field)
);
message += `fragment ${
"name" in node ? `"${node.name.value}" ` : ""
}around local fields "${fieldNames.join(",")}"`;
break;
}
// if the field's parent is part of the client schema and that type
// includes a field with the same name as this node, we can see
// if it has an @client directive to resolve locally
if (selectsClientFieldSet && !clientDirectivePresent) {
let extensions: { [key: string]: any } | null = null;
const name = "name" in node && node.name;
// TODO support code actions for inline fragments, fragment spreads, and fragment definitions
if (name && name.loc) {
let { source, end: locToInsertDirective } = name.loc;
if (
"arguments" in node &&
node.arguments &&
node.arguments.length !== 0
) {
// must insert directive after field arguments
const endOfArgs = source.body.indexOf(")", locToInsertDirective);
locToInsertDirective = endOfArgs + 1;
}
const codeAction: CodeActionInfo = {
message: `Add @client directive to "${name.value}"`,
edits: [
TextEdit.insert(
positionFromSourceLocation(
source,
getLocation(source, locToInsertDirective)
),
" @client"
),
],
};
extensions = { codeAction };
}
context.reportError(
new GraphQLError(message, [node], null, null, null, null, extensions)
);
}
// if we have selected a client field, no need to continue to recurse
if (selectsClientFieldSet) {
return false;
}
return;
}
return {
InlineFragment: visitor,
FragmentDefinition: visitor,
Field: visitor,
// TODO support directives on FragmentSpread
};
}