Skip to content

Commit 64c302c

Browse files
author
Dimitri POSTOLOV
authored
feat: add new rule no-root-type (#773)
1 parent 1914d6a commit 64c302c

File tree

10 files changed

+254
-3
lines changed

10 files changed

+254
-3
lines changed

.changeset/lazy-pets-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-eslint/eslint-plugin': minor
3+
---
4+
5+
feat: add new rule `no-root-type`

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Name            &nbs
3535
[no-fragment-cycles](rules/no-fragment-cycles.md)|A GraphQL fragment is only valid when it does not have cycles in fragments usage.|🔮||✅
3636
[no-hashtag-description](rules/no-hashtag-description.md)|Requires to use `"""` or `"` for adding a GraphQL description instead of `#`.|🚀||
3737
[no-operation-name-suffix](rules/no-operation-name-suffix.md)|Makes sure you are not adding the operation type to the name of the operation.|🚀|🔧|✅
38+
[no-root-type](rules/no-root-type.md)|Disallow using root types for `read-only` or `write-only` schemas.|🚀||
3839
[no-undefined-variables](rules/no-undefined-variables.md)|A GraphQL operation is only valid if all variables encountered, both directly and via fragment spreads, are defined by that operation.|🔮||✅
3940
[no-unreachable-types](rules/no-unreachable-types.md)|Requires all types to be reachable at some level by root level fields.|🚀|🔧|
4041
[no-unused-fields](rules/no-unused-fields.md)|Requires all fields to be used at some level by siblings operations.|🚀|🔧|

docs/rules/no-deprecated.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ enum SomeType {
4848
mutation {
4949
changeSomething(
5050
type: OLD # This is deprecated, so you'll get an error
51-
) {
52-
...
51+
) {
52+
...
5353
}
5454
}
5555
```

docs/rules/no-root-type.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# `no-root-type`
2+
3+
- Category: `Validation`
4+
- Rule name: `@graphql-eslint/no-root-type`
5+
- Requires GraphQL Schema: `true` [ℹ️](../../README.md#extended-linting-rules-with-graphql-schema)
6+
- Requires GraphQL Operations: `false` [ℹ️](../../README.md#extended-linting-rules-with-siblings-operations)
7+
8+
Disallow using root types for `read-only` or `write-only` schemas.
9+
10+
## Usage Examples
11+
12+
### Incorrect (`read-only` schema)
13+
14+
```graphql
15+
# eslint @graphql-eslint/no-root-type: ['error', { disallow: ['mutation', 'subscription'] }]
16+
17+
type Mutation {
18+
createUser(input: CreateUserInput!): User!
19+
}
20+
```
21+
22+
### Incorrect (`write-only` schema)
23+
24+
```graphql
25+
# eslint @graphql-eslint/no-root-type: ['error', { disallow: ['query'] }]
26+
27+
type Query {
28+
users: [User!]!
29+
}
30+
```
31+
32+
### Correct (`read-only` schema)
33+
34+
```graphql
35+
# eslint @graphql-eslint/no-root-type: ['error', { disallow: ['mutation', 'subscription'] }]
36+
37+
type Query {
38+
users: [User!]!
39+
}
40+
```
41+
42+
## Config Schema
43+
44+
The schema defines the following properties:
45+
46+
### `disallow` (array, required)
47+
48+
Additional restrictions:
49+
50+
* Minimum items: `1`
51+
* Unique items: `true`
52+
53+
## Resources
54+
55+
- [Rule source](../../packages/plugin/src/rules/no-root-type.ts)
56+
- [Test source](../../packages/plugin/tests/no-root-type.spec.ts)

packages/plugin/src/configs/all.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const allConfig = {
2626
'@graphql-eslint/match-document-filename': 'error',
2727
'@graphql-eslint/no-deprecated': 'error',
2828
'@graphql-eslint/no-hashtag-description': 'error',
29+
'@graphql-eslint/no-root-type': ['error', { disallow: ['subscription'] }],
2930
'@graphql-eslint/no-unreachable-types': 'error',
3031
'@graphql-eslint/no-unused-fields': 'error',
3132
'@graphql-eslint/require-deprecation-date': 'error',

packages/plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import noCaseInsensitiveEnumValuesDuplicates from './no-case-insensitive-enum-va
1717
import noDeprecated from './no-deprecated';
1818
import noHashtagDescription from './no-hashtag-description';
1919
import noOperationNameSuffix from './no-operation-name-suffix';
20+
import noRootType from './no-root-type';
2021
import noUnreachableTypes from './no-unreachable-types';
2122
import noUnusedFields from './no-unused-fields';
2223
import requireDeprecationDate from './require-deprecation-date';
@@ -45,6 +46,7 @@ export const rules = {
4546
'no-deprecated': noDeprecated,
4647
'no-hashtag-description': noHashtagDescription,
4748
'no-operation-name-suffix': noOperationNameSuffix,
49+
'no-root-type': noRootType,
4850
'no-unreachable-types': noUnreachableTypes,
4951
'no-unused-fields': noUnusedFields,
5052
'require-deprecation-date': requireDeprecationDate,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Kind, NameNode } from 'graphql';
2+
import { getLocation, requireGraphQLSchemaFromContext } from '../utils';
3+
import { GraphQLESLintRule } from '../types';
4+
import { GraphQLESTreeNode } from '../estree-parser';
5+
6+
const ROOT_TYPES: ('query' | 'mutation' | 'subscription')[] = ['query', 'mutation', 'subscription'];
7+
8+
type NoRootTypeConfig = { disallow: typeof ROOT_TYPES };
9+
10+
const rule: GraphQLESLintRule<[NoRootTypeConfig]> = {
11+
meta: {
12+
type: 'suggestion',
13+
docs: {
14+
category: 'Validation',
15+
description: 'Disallow using root types for `read-only` or `write-only` schemas.',
16+
url: 'https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/no-root-type.md',
17+
requiresSchema: true,
18+
examples: [
19+
{
20+
title: 'Incorrect (`read-only` schema)',
21+
usage: [{ disallow: ['mutation', 'subscription'] }],
22+
code: /* GraphQL */ `
23+
type Mutation {
24+
createUser(input: CreateUserInput!): User!
25+
}
26+
`,
27+
},
28+
{
29+
title: 'Incorrect (`write-only` schema)',
30+
usage: [{ disallow: ['query'] }],
31+
code: /* GraphQL */ `
32+
type Query {
33+
users: [User!]!
34+
}
35+
`,
36+
},
37+
{
38+
title: 'Correct (`read-only` schema)',
39+
usage: [{ disallow: ['mutation', 'subscription'] }],
40+
code: /* GraphQL */ `
41+
type Query {
42+
users: [User!]!
43+
}
44+
`,
45+
},
46+
],
47+
optionsForConfig: [{ disallow: ['subscription'] }],
48+
},
49+
schema: {
50+
type: 'array',
51+
minItems: 1,
52+
maxItems: 1,
53+
items: {
54+
type: 'object',
55+
additionalProperties: false,
56+
required: ['disallow'],
57+
properties: {
58+
disallow: {
59+
type: 'array',
60+
uniqueItems: true,
61+
minItems: 1,
62+
items: {
63+
enum: ROOT_TYPES,
64+
},
65+
},
66+
},
67+
},
68+
},
69+
},
70+
create(context) {
71+
const schema = requireGraphQLSchemaFromContext('no-root-type', context);
72+
const disallow = new Set(context.options[0].disallow);
73+
74+
const rootTypeNames = [
75+
disallow.has('query') && schema.getQueryType(),
76+
disallow.has('mutation') && schema.getMutationType(),
77+
disallow.has('subscription') && schema.getSubscriptionType(),
78+
]
79+
.filter(Boolean)
80+
.map(type => type.name);
81+
82+
if (rootTypeNames.length === 0) {
83+
return {};
84+
}
85+
86+
const selector = [
87+
`:matches(${Kind.OBJECT_TYPE_DEFINITION}, ${Kind.OBJECT_TYPE_EXTENSION})`,
88+
'>',
89+
`${Kind.NAME}[value=/^(${rootTypeNames.join('|')})$/]`,
90+
].join(' ');
91+
92+
return {
93+
[selector](node: GraphQLESTreeNode<NameNode>) {
94+
const typeName = node.value;
95+
context.report({
96+
loc: getLocation(node.loc, typeName),
97+
message: `Root type "${typeName}" is forbidden`,
98+
});
99+
},
100+
};
101+
},
102+
};
103+
104+
export default rule;

packages/plugin/src/testkit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export type GraphQLESLintRuleListener<WithTypeInfo extends boolean = false> = {
1111
} & Record<string, any>;
1212

1313
export type GraphQLValidTestCase<Options> = Omit<RuleTester.ValidTestCase, 'options' | 'parserOptions'> & {
14-
name: string;
14+
name?: string;
1515
options?: Options;
1616
parserOptions?: ParserOptions;
1717
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[` 1`] = `
4+
> 1 | type Query
5+
| ^^^^^ Root type "Query" is forbidden
6+
`;
7+
8+
exports[` 2`] = `
9+
> 1 | type Mutation
10+
| ^^^^^^^^ Root type "Mutation" is forbidden
11+
`;
12+
13+
exports[` 3`] = `
14+
> 1 | type Subscription
15+
| ^^^^^^^^^^^^ Root type "Subscription" is forbidden
16+
`;
17+
18+
exports[` 4`] = `
19+
> 1 | extend type Mutation { foo: ID }
20+
| ^^^^^^^^ Root type "Mutation" is forbidden
21+
`;
22+
23+
exports[` 5`] = `
24+
> 1 | type MyMutation
25+
| ^^^^^^^^^^ Root type "MyMutation" is forbidden
26+
`;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { GraphQLRuleTester, ParserOptions } from '../src';
2+
import rule from '../src/rules/no-root-type';
3+
4+
const useSchema = (code: string, schema = ''): { code: string; parserOptions: ParserOptions } => ({
5+
code,
6+
parserOptions: {
7+
schema: schema + code,
8+
},
9+
});
10+
11+
const ruleTester = new GraphQLRuleTester();
12+
13+
ruleTester.runGraphQLTests('no-root-type', rule, {
14+
valid: [
15+
{
16+
...useSchema('type Query'),
17+
options: [{ disallow: ['mutation', 'subscription'] }],
18+
},
19+
{
20+
...useSchema('type Mutation'),
21+
options: [{ disallow: ['query'] }],
22+
},
23+
],
24+
invalid: [
25+
{
26+
...useSchema('type Query'),
27+
name: 'disallow query',
28+
options: [{ disallow: ['query'] }],
29+
errors: [{ message: 'Root type "Query" is forbidden' }],
30+
},
31+
{
32+
...useSchema('type Mutation'),
33+
name: 'disallow mutation',
34+
options: [{ disallow: ['mutation'] }],
35+
errors: [{ message: 'Root type "Mutation" is forbidden' }],
36+
},
37+
{
38+
...useSchema('type Subscription'),
39+
name: 'disallow subscription',
40+
options: [{ disallow: ['subscription'] }],
41+
errors: [{ message: 'Root type "Subscription" is forbidden' }],
42+
},
43+
{
44+
...useSchema('extend type Mutation { foo: ID }', 'type Mutation'),
45+
name: 'disallow with extend',
46+
options: [{ disallow: ['mutation'] }],
47+
errors: [{ message: 'Root type "Mutation" is forbidden' }],
48+
},
49+
{
50+
...useSchema('type MyMutation', 'schema { mutation: MyMutation }'),
51+
name: 'disallow when root type name is renamed',
52+
options: [{ disallow: ['mutation'] }],
53+
errors: [{ message: 'Root type "MyMutation" is forbidden' }],
54+
},
55+
],
56+
});

0 commit comments

Comments
 (0)