@graphql-eslint/eslint-plugin
Version:
GraphQL plugin for ESLint
190 lines (189 loc) • 8.67 kB
JavaScript
import { visit, isObjectType, Kind, isScalarType, } from 'graphql';
import { getDocumentNodeFromSchema } from '@graphql-tools/utils';
import { getTypeName, requireGraphQLSchemaFromContext } from '../utils.js';
const RULE_ID = 'relay-edge-types';
const MESSAGE_MUST_BE_OBJECT_TYPE = 'MESSAGE_MUST_BE_OBJECT_TYPE';
const MESSAGE_MISSING_EDGE_SUFFIX = 'MESSAGE_MISSING_EDGE_SUFFIX';
const MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE = 'MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE';
const MESSAGE_SHOULD_IMPLEMENTS_NODE = 'MESSAGE_SHOULD_IMPLEMENTS_NODE';
let edgeTypesCache;
function getEdgeTypes(schema) {
// We don't want cache edgeTypes on test environment
// Otherwise edgeTypes will be same for all tests
if (process.env.NODE_ENV !== 'test' && edgeTypesCache) {
return edgeTypesCache;
}
const edgeTypes = new Set();
const visitor = {
ObjectTypeDefinition(node) {
var _a;
const typeName = node.name.value;
const hasConnectionSuffix = typeName.endsWith('Connection');
if (!hasConnectionSuffix) {
return;
}
const edges = (_a = node.fields) === null || _a === void 0 ? void 0 : _a.find(field => field.name.value === 'edges');
if (edges) {
const edgesTypeName = getTypeName(edges);
const edgesType = schema.getType(edgesTypeName);
if (isObjectType(edgesType)) {
edgeTypes.add(edgesTypeName);
}
}
},
};
const astNode = getDocumentNodeFromSchema(schema); // Transforms the schema into ASTNode
visit(astNode, visitor);
edgeTypesCache = edgeTypes;
return edgeTypesCache;
}
const schema = {
type: 'array',
maxItems: 1,
items: {
type: 'object',
additionalProperties: false,
minProperties: 1,
properties: {
withEdgeSuffix: {
type: 'boolean',
default: true,
description: 'Edge type name must end in "Edge".',
},
shouldImplementNode: {
type: 'boolean',
default: true,
description: "Edge type's field `node` must implement `Node` interface.",
},
listTypeCanWrapOnlyEdgeType: {
type: 'boolean',
default: true,
description: 'A list type should only wrap an edge type.',
},
},
},
};
export const rule = {
meta: {
type: 'problem',
docs: {
category: 'Schema',
description: [
'Set of rules to follow Relay specification for Edge types.',
'',
"- A type that is returned in list form by a connection type's `edges` field is considered by this spec to be an Edge type",
'- Edge type must be an Object type',
'- Edge type must contain a field `node` that return either Scalar, Enum, Object, Interface, Union, or a non-null wrapper around one of those types. Notably, this field cannot return a list',
'- Edge type must contain a field `cursor` that return either String, Scalar, or a non-null wrapper around one of those types',
'- Edge type name must end in "Edge" _(optional)_',
"- Edge type's field `node` must implement `Node` interface _(optional)_",
'- A list type should only wrap an edge type _(optional)_',
].join('\n'),
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
isDisabledForAllConfig: true,
requiresSchema: true,
examples: [
{
title: 'Correct',
code: /* GraphQL */ `
type UserConnection {
edges: [UserEdge]
pageInfo: PageInfo!
}
`,
},
],
},
messages: {
[MESSAGE_MUST_BE_OBJECT_TYPE]: 'Edge type must be an Object type.',
[MESSAGE_MISSING_EDGE_SUFFIX]: 'Edge type must have "Edge" suffix.',
[MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE]: 'A list type should only wrap an edge type.',
[MESSAGE_SHOULD_IMPLEMENTS_NODE]: "Edge type's field `node` must implement `Node` interface.",
},
schema,
},
create(context) {
const schema = requireGraphQLSchemaFromContext(RULE_ID, context);
const edgeTypes = getEdgeTypes(schema);
const options = {
withEdgeSuffix: true,
shouldImplementNode: true,
listTypeCanWrapOnlyEdgeType: true,
...context.options[0],
};
const isNamedOrNonNullNamed = (node) => node.kind === Kind.NAMED_TYPE ||
(node.kind === Kind.NON_NULL_TYPE && node.gqlType.kind === Kind.NAMED_TYPE);
const checkNodeField = (node) => {
var _a, _b;
const nodeField = (_a = node.fields) === null || _a === void 0 ? void 0 : _a.find(field => field.name.value === 'node');
const message = 'return either a Scalar, Enum, Object, Interface, Union, or a non-null wrapper around one of those types.';
if (!nodeField) {
context.report({
node: node.name,
message: `Edge type must contain a field \`node\` that ${message}`,
});
}
else if (!isNamedOrNonNullNamed(nodeField.gqlType)) {
context.report({ node: nodeField.name, message: `Field \`node\` must ${message}` });
}
else if (options.shouldImplementNode) {
const nodeReturnTypeName = getTypeName(nodeField.gqlType.rawNode());
const type = schema.getType(nodeReturnTypeName);
if (!isObjectType(type)) {
return;
}
const implementsNode = (_b = type.astNode.interfaces) === null || _b === void 0 ? void 0 : _b.some(n => n.name.value === 'Node');
if (!implementsNode) {
context.report({ node: node.name, messageId: MESSAGE_SHOULD_IMPLEMENTS_NODE });
}
}
};
const checkCursorField = (node) => {
var _a;
const cursorField = (_a = node.fields) === null || _a === void 0 ? void 0 : _a.find(field => field.name.value === 'cursor');
const message = 'return either a String, Scalar, or a non-null wrapper wrapper around one of those types.';
if (!cursorField) {
context.report({
node: node.name,
message: `Edge type must contain a field \`cursor\` that ${message}`,
});
return;
}
const typeName = getTypeName(cursorField.rawNode());
if (!isNamedOrNonNullNamed(cursorField.gqlType) ||
(typeName !== 'String' && !isScalarType(schema.getType(typeName)))) {
context.report({ node: cursorField.name, message: `Field \`cursor\` must ${message}` });
}
};
const listeners = {
':matches(ObjectTypeDefinition, ObjectTypeExtension)[name.value=/Connection$/] > FieldDefinition[name.value=edges] > .gqlType Name'(node) {
const type = schema.getType(node.value);
if (!isObjectType(type)) {
context.report({ node, messageId: MESSAGE_MUST_BE_OBJECT_TYPE });
}
},
':matches(ObjectTypeDefinition, ObjectTypeExtension)'(node) {
const typeName = node.name.value;
if (edgeTypes.has(typeName)) {
checkNodeField(node);
checkCursorField(node);
if (options.withEdgeSuffix && !typeName.endsWith('Edge')) {
context.report({ node: node.name, messageId: MESSAGE_MISSING_EDGE_SUFFIX });
}
}
},
};
if (options.listTypeCanWrapOnlyEdgeType) {
listeners['FieldDefinition > .gqlType'] = (node) => {
if (node.kind === Kind.LIST_TYPE ||
(node.kind === Kind.NON_NULL_TYPE && node.gqlType.kind === Kind.LIST_TYPE)) {
const typeName = getTypeName(node.rawNode());
if (!edgeTypes.has(typeName)) {
context.report({ node, messageId: MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE });
}
}
};
}
return listeners;
},
};