Skip to content

Commit

Permalink
feat: add new rule no-root-type (#773)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dimitri POSTOLOV committed Nov 13, 2021
1 parent 1914d6a commit 64c302c
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/lazy-pets-accept.md
@@ -0,0 +1,5 @@
---
'@graphql-eslint/eslint-plugin': minor
---

feat: add new rule `no-root-type`
1 change: 1 addition & 0 deletions docs/README.md
Expand Up @@ -35,6 +35,7 @@ Name            &nbs
[no-fragment-cycles](rules/no-fragment-cycles.md)|A GraphQL fragment is only valid when it does not have cycles in fragments usage.|🔮||✅
[no-hashtag-description](rules/no-hashtag-description.md)|Requires to use `"""` or `"` for adding a GraphQL description instead of `#`.|🚀||
[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.|🚀|🔧|✅
[no-root-type](rules/no-root-type.md)|Disallow using root types for `read-only` or `write-only` schemas.|🚀||
[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.|🔮||✅
[no-unreachable-types](rules/no-unreachable-types.md)|Requires all types to be reachable at some level by root level fields.|🚀|🔧|
[no-unused-fields](rules/no-unused-fields.md)|Requires all fields to be used at some level by siblings operations.|🚀|🔧|
Expand Down
4 changes: 2 additions & 2 deletions docs/rules/no-deprecated.md
Expand Up @@ -48,8 +48,8 @@ enum SomeType {
mutation {
changeSomething(
type: OLD # This is deprecated, so you'll get an error
) {
...
) {
...
}
}
```
Expand Down
56 changes: 56 additions & 0 deletions docs/rules/no-root-type.md
@@ -0,0 +1,56 @@
# `no-root-type`

- Category: `Validation`
- Rule name: `@graphql-eslint/no-root-type`
- Requires GraphQL Schema: `true` [ℹ️](../../README.md#extended-linting-rules-with-graphql-schema)
- Requires GraphQL Operations: `false` [ℹ️](../../README.md#extended-linting-rules-with-siblings-operations)

Disallow using root types for `read-only` or `write-only` schemas.

## Usage Examples

### Incorrect (`read-only` schema)

```graphql
# eslint @graphql-eslint/no-root-type: ['error', { disallow: ['mutation', 'subscription'] }]

type Mutation {
createUser(input: CreateUserInput!): User!
}
```

### Incorrect (`write-only` schema)

```graphql
# eslint @graphql-eslint/no-root-type: ['error', { disallow: ['query'] }]

type Query {
users: [User!]!
}
```

### Correct (`read-only` schema)

```graphql
# eslint @graphql-eslint/no-root-type: ['error', { disallow: ['mutation', 'subscription'] }]

type Query {
users: [User!]!
}
```

## Config Schema

The schema defines the following properties:

### `disallow` (array, required)

Additional restrictions:

* Minimum items: `1`
* Unique items: `true`

## Resources

- [Rule source](../../packages/plugin/src/rules/no-root-type.ts)
- [Test source](../../packages/plugin/tests/no-root-type.spec.ts)
1 change: 1 addition & 0 deletions packages/plugin/src/configs/all.ts
Expand Up @@ -26,6 +26,7 @@ export const allConfig = {
'@graphql-eslint/match-document-filename': 'error',
'@graphql-eslint/no-deprecated': 'error',
'@graphql-eslint/no-hashtag-description': 'error',
'@graphql-eslint/no-root-type': ['error', { disallow: ['subscription'] }],
'@graphql-eslint/no-unreachable-types': 'error',
'@graphql-eslint/no-unused-fields': 'error',
'@graphql-eslint/require-deprecation-date': 'error',
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin/src/rules/index.ts
Expand Up @@ -17,6 +17,7 @@ import noCaseInsensitiveEnumValuesDuplicates from './no-case-insensitive-enum-va
import noDeprecated from './no-deprecated';
import noHashtagDescription from './no-hashtag-description';
import noOperationNameSuffix from './no-operation-name-suffix';
import noRootType from './no-root-type';
import noUnreachableTypes from './no-unreachable-types';
import noUnusedFields from './no-unused-fields';
import requireDeprecationDate from './require-deprecation-date';
Expand Down Expand Up @@ -45,6 +46,7 @@ export const rules = {
'no-deprecated': noDeprecated,
'no-hashtag-description': noHashtagDescription,
'no-operation-name-suffix': noOperationNameSuffix,
'no-root-type': noRootType,
'no-unreachable-types': noUnreachableTypes,
'no-unused-fields': noUnusedFields,
'require-deprecation-date': requireDeprecationDate,
Expand Down
104 changes: 104 additions & 0 deletions packages/plugin/src/rules/no-root-type.ts
@@ -0,0 +1,104 @@
import { Kind, NameNode } from 'graphql';
import { getLocation, requireGraphQLSchemaFromContext } from '../utils';
import { GraphQLESLintRule } from '../types';
import { GraphQLESTreeNode } from '../estree-parser';

const ROOT_TYPES: ('query' | 'mutation' | 'subscription')[] = ['query', 'mutation', 'subscription'];

type NoRootTypeConfig = { disallow: typeof ROOT_TYPES };

const rule: GraphQLESLintRule<[NoRootTypeConfig]> = {
meta: {
type: 'suggestion',
docs: {
category: 'Validation',
description: 'Disallow using root types for `read-only` or `write-only` schemas.',
url: 'https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/no-root-type.md',
requiresSchema: true,
examples: [
{
title: 'Incorrect (`read-only` schema)',
usage: [{ disallow: ['mutation', 'subscription'] }],
code: /* GraphQL */ `
type Mutation {
createUser(input: CreateUserInput!): User!
}
`,
},
{
title: 'Incorrect (`write-only` schema)',
usage: [{ disallow: ['query'] }],
code: /* GraphQL */ `
type Query {
users: [User!]!
}
`,
},
{
title: 'Correct (`read-only` schema)',
usage: [{ disallow: ['mutation', 'subscription'] }],
code: /* GraphQL */ `
type Query {
users: [User!]!
}
`,
},
],
optionsForConfig: [{ disallow: ['subscription'] }],
},
schema: {
type: 'array',
minItems: 1,
maxItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: ['disallow'],
properties: {
disallow: {
type: 'array',
uniqueItems: true,
minItems: 1,
items: {
enum: ROOT_TYPES,
},
},
},
},
},
},
create(context) {
const schema = requireGraphQLSchemaFromContext('no-root-type', context);
const disallow = new Set(context.options[0].disallow);

const rootTypeNames = [
disallow.has('query') && schema.getQueryType(),
disallow.has('mutation') && schema.getMutationType(),
disallow.has('subscription') && schema.getSubscriptionType(),
]
.filter(Boolean)
.map(type => type.name);

if (rootTypeNames.length === 0) {
return {};
}

const selector = [
`:matches(${Kind.OBJECT_TYPE_DEFINITION}, ${Kind.OBJECT_TYPE_EXTENSION})`,
'>',
`${Kind.NAME}[value=/^(${rootTypeNames.join('|')})$/]`,
].join(' ');

return {
[selector](node: GraphQLESTreeNode<NameNode>) {
const typeName = node.value;
context.report({
loc: getLocation(node.loc, typeName),
message: `Root type "${typeName}" is forbidden`,
});
},
};
},
};

export default rule;
2 changes: 1 addition & 1 deletion packages/plugin/src/testkit.ts
Expand Up @@ -11,7 +11,7 @@ export type GraphQLESLintRuleListener<WithTypeInfo extends boolean = false> = {
} & Record<string, any>;

export type GraphQLValidTestCase<Options> = Omit<RuleTester.ValidTestCase, 'options' | 'parserOptions'> & {
name: string;
name?: string;
options?: Options;
parserOptions?: ParserOptions;
};
Expand Down
26 changes: 26 additions & 0 deletions packages/plugin/tests/__snapshots__/no-root-type.spec.ts.snap
@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[` 1`] = `
> 1 | type Query
| ^^^^^ Root type "Query" is forbidden
`;

exports[` 2`] = `
> 1 | type Mutation
| ^^^^^^^^ Root type "Mutation" is forbidden
`;

exports[` 3`] = `
> 1 | type Subscription
| ^^^^^^^^^^^^ Root type "Subscription" is forbidden
`;

exports[` 4`] = `
> 1 | extend type Mutation { foo: ID }
| ^^^^^^^^ Root type "Mutation" is forbidden
`;

exports[` 5`] = `
> 1 | type MyMutation
| ^^^^^^^^^^ Root type "MyMutation" is forbidden
`;
56 changes: 56 additions & 0 deletions packages/plugin/tests/no-root-type.spec.ts
@@ -0,0 +1,56 @@
import { GraphQLRuleTester, ParserOptions } from '../src';
import rule from '../src/rules/no-root-type';

const useSchema = (code: string, schema = ''): { code: string; parserOptions: ParserOptions } => ({
code,
parserOptions: {
schema: schema + code,
},
});

const ruleTester = new GraphQLRuleTester();

ruleTester.runGraphQLTests('no-root-type', rule, {
valid: [
{
...useSchema('type Query'),
options: [{ disallow: ['mutation', 'subscription'] }],
},
{
...useSchema('type Mutation'),
options: [{ disallow: ['query'] }],
},
],
invalid: [
{
...useSchema('type Query'),
name: 'disallow query',
options: [{ disallow: ['query'] }],
errors: [{ message: 'Root type "Query" is forbidden' }],
},
{
...useSchema('type Mutation'),
name: 'disallow mutation',
options: [{ disallow: ['mutation'] }],
errors: [{ message: 'Root type "Mutation" is forbidden' }],
},
{
...useSchema('type Subscription'),
name: 'disallow subscription',
options: [{ disallow: ['subscription'] }],
errors: [{ message: 'Root type "Subscription" is forbidden' }],
},
{
...useSchema('extend type Mutation { foo: ID }', 'type Mutation'),
name: 'disallow with extend',
options: [{ disallow: ['mutation'] }],
errors: [{ message: 'Root type "Mutation" is forbidden' }],
},
{
...useSchema('type MyMutation', 'schema { mutation: MyMutation }'),
name: 'disallow when root type name is renamed',
options: [{ disallow: ['mutation'] }],
errors: [{ message: 'Root type "MyMutation" is forbidden' }],
},
],
});

0 comments on commit 64c302c

Please sign in to comment.