Repository: kamilkisiela/graphql-inspector
Pull request #432 (2019-07-17 19:09:08)

Operation depth limit
Related #431


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf143cfe..28692745 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
 
 ### vNEXT
 
+- **cli**: maximum depth of operations
+- **core**: maximum depth of operations
+
 ### v1.21.0
 
 - **load**: allow to load schema entirely based on `git` [PR #366](https://github.com/kamilkisiela/graphql-inspector/pull/366)
diff --git a/docs/essentials/validate.md b/docs/essentials/validate.md
index 3f430524..ce3fd280 100644
--- a/docs/essentials/validate.md
+++ b/docs/essentials/validate.md
@@ -21,6 +21,7 @@ Run the following command:
 
 - `-d, --deprecated` - Fail on deprecated usage (default: _false_)
 - `--noStrictFragments` - Do not fail on duplicated fragment names (default: _false_)
+- `--maxDepth <n>` - Fail when operation depth exceeds maximum depth (default: _false_)
 - `-r, --require 
` - require a module
 - `-t, --token 
` - an access token
 - `-h, --header 
` - set http header (`--header 'Auth: Basic 123')
diff --git a/example/documents/depth-post.graphql b/example/documents/depth-post.graphql
new file mode 100644
index 00000000..662ef665
--- /dev/null
+++ b/example/documents/depth-post.graphql
@@ -0,0 +1,10 @@
+query depthPost {
+  post {
+    id
+    title
+    author {
+      id
+      name
+    }
+  }
+}
diff --git a/example/package.json b/example/package.json
index b7ea89aa..a229c47c 100644
--- a/example/package.json
+++ b/example/package.json
@@ -10,6 +10,7 @@
     "diff:same:json": "yarn graphql-inspector diff ./schemas/schema.json ./schemas/schema.json",
     "diff:github": "yarn graphql-inspector diff github:kamilkisiela/graphql-inspector#master:example/schemas/schema.json ./schemas/new.graphql  --token PERSONAL_ACCESS_TOKEN",
     "validate": "yarn graphql-inspector validate './documents/*.graphql' ./schemas/schema.graphql",
+    "validate:depth": "yarn graphql-inspector validate './documents/depth-post.graphql' ./schemas/deep.graphql --maxDepth 1",
     "validate:none": "yarn graphql-inspector validate './no-documents/*.graphql' ./schemas/schema.graphql",
     "similar": "yarn graphql-inspector similar ./schemas/similar.graphql",
     "similar:write": "yarn graphql-inspector similar ./schemas/similar.graphql --write ./similar.json",
diff --git a/example/schemas/deep.graphql b/example/schemas/deep.graphql
new file mode 100644
index 00000000..95b1e261
--- /dev/null
+++ b/example/schemas/deep.graphql
@@ -0,0 +1,15 @@
+type Post {
+  id: ID!
+  title: String!
+  createdAt: String!
+  author: User!
+}
+
+type User {
+  id: ID!
+  name: String!
+}
+
+type Query {
+  post: Post!
+}
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
index b18a7cb5..4d5dcd69 100644
--- a/packages/cli/src/commands/validate.ts
+++ b/packages/cli/src/commands/validate.ts
@@ -18,6 +18,7 @@ export async function validate(
     require?: string[];
     deprecated: boolean;
     noStrictFragments: boolean;
+    maxDepth?: number;
     renderer?: Renderer;
     headers?: Record<string, string>;
   },
@@ -32,21 +33,26 @@ export async function validate(
 
     const invalidDocuments = validateDocuments(schema, documents, {
       strictFragments: !options.noStrictFragments,
+      maxDepth: options.maxDepth || undefined,
     });
 
     if (!invalidDocuments.length) {
       renderer.success('All documents are valid');
     } else {
-      const errors = countErrors(invalidDocuments);
+      const errorsCount = countErrors(invalidDocuments);
       const deprecated = countDeprecated(invalidDocuments);
 
-      if (errors) {
+      if (errorsCount) {
         renderer.emit(
-          `\nDetected ${errors} invalid document${errors > 1 ? 's' : ''}:\n`,
+          `\nDetected ${errorsCount} invalid document${
+            errorsCount > 1 ? 's' : ''
+          }:\n`,
         );
 
         invalidDocuments.forEach(doc => {
-          renderer.emit(...renderInvalidDocument(doc));
+          if (doc.errors.length) {
+            renderer.emit(...renderInvalidDocument(doc));
+          }
         });
       } else if (!options.deprecated) {
         renderer.success('All documents are valid');
@@ -60,13 +66,15 @@ export async function validate(
         );
 
         invalidDocuments.forEach(doc => {
-          renderer.emit(
-            ...renderDeprecatedUsageInDocument(doc, options.deprecated),
-          );
+          if (doc.deprecated.length) {
+            renderer.emit(
+              ...renderDeprecatedUsageInDocument(doc, options.deprecated),
+            );
+          }
         });
       }
 
-      if (errors || (deprecated && options.deprecated)) {
+      if (errorsCount || (deprecated && options.deprecated)) {
         process.exit(1);
       }
     }
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index d316dd4d..03f38193 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -60,6 +60,9 @@ commander
     'Do not fail on duplicated fragment names',
     false,
   )
+  .option('--maxDepth <n>', 'Fail on deep operations', (val: string) =>
+    parseInt(val, 10),
+  )
   .description('Validate documents against a schema')
   .action((documents: string, schema: string, cmd: commander.Command) =>
     validate(documents, schema, normalizeOptions(cmd)),
diff --git a/packages/core/src/validate/index.ts b/packages/core/src/validate/index.ts
index 7b926f97..9906ba5c 100644
--- a/packages/core/src/validate/index.ts
+++ b/packages/core/src/validate/index.ts
@@ -12,6 +12,7 @@ import {DepGraph} from 'dependency-graph';
 
 import {readDocument} from '../ast/document';
 import {findDeprecatedUsages} from '../utils/graphql';
+import {validateQueryDepth} from './query-depth';
 
 export interface InvalidDocument {
   source: Source;
@@ -22,6 +23,7 @@ export interface InvalidDocument {
 export interface ValidateOptions {
   strictFragments?: boolean;
   strictDeprecated?: boolean;
+  maxDepth?: number;
 }
 
 export function validate(
@@ -87,7 +89,20 @@ export function validate(
         definitions: [...docWithOperations.definitions, ...extractedFragments],
       };
 
-      const errors = validateDocument(schema, merged) as GraphQLError[];
+      const errors = (validateDocument(schema, merged) as GraphQLError[]) || [];
+
+      if (config.maxDepth) {
+        const depthError = validateQueryDepth({
+          source: doc.source,
+          doc: merged,
+          maxDepth: config.maxDepth,
+          fragmentGraph: graph,
+        });
+
+        if (depthError) {
+          errors.push(depthError);
+        }
+      }
 
       const deprecated = config.strictDeprecated
         ? findDeprecatedUsages(schema, parse(doc.source.body))
diff --git a/packages/core/src/validate/query-depth.ts b/packages/core/src/validate/query-depth.ts
new file mode 100644
index 00000000..af20c266
--- /dev/null
+++ b/packages/core/src/validate/query-depth.ts
@@ -0,0 +1,131 @@
+import {DepGraph} from 'dependency-graph';
+import {
+  DocumentNode,
+  GraphQLError,
+  ASTNode,
+  Kind,
+  FragmentDefinitionNode,
+  Source,
+} from 'graphql';
+
+export function validateQueryDepth({
+  source,
+  doc,
+  maxDepth,
+  fragmentGraph,
+}: {
+  source: Source;
+  doc: DocumentNode;
+  maxDepth: number;
+  fragmentGraph: DepGraph<FragmentDefinitionNode>;
+}): GraphQLError | void {
+  try {
+    calculateDepth({
+      node: doc,
+      currentDepth: 0,
+      maxDepth,
+      getFragment(name) {
+        return fragmentGraph.getNodeData(name);
+      },
+    });
+  } catch (errorOrNode) {
+    if (errorOrNode instanceof Error) {
+      throw errorOrNode;
+    }
+
+    const node: ASTNode = errorOrNode;
+
+    return new GraphQLError(
+      `Query exceeds maximum depth of ${maxDepth}`,
+      node,
+      source,
+      node.loc && node.loc.start ? [node.loc.start] : undefined,
+    );
+  }
+}
+
+export function calculateDepth({
+  node,
+  currentDepth,
+  maxDepth,
+  getFragment,
+}: {
+  node: ASTNode;
+  currentDepth: number;
+  maxDepth?: number;
+  getFragment: (fragmentName: string) => FragmentDefinitionNode;
+}): number | never {
+  if (maxDepth && currentDepth > maxDepth) {
+    throw node;
+  }
+
+  switch (node.kind) {
+    case Kind.FIELD: {
+      if (node.name.value.startsWith('__') || !node.selectionSet) {
+        return 0;
+      }
+
+      const maxInnerDepth = calculateDepth({
+        node: node.selectionSet,
+        currentDepth: currentDepth + 1,
+        maxDepth,
+        getFragment,
+      });
+
+      return 1 + maxInnerDepth;
+    }
+
+    case Kind.SELECTION_SET: {
+      return Math.max(
+        ...node.selections.map(selection => {
+          return calculateDepth({
+            node: selection,
+            currentDepth: currentDepth,
+            maxDepth,
+            getFragment,
+          });
+        }),
+      );
+    }
+
+    case Kind.DOCUMENT: {
+      return Math.max(
+        ...node.definitions.map(def => {
+          return calculateDepth({
+            node: def,
+            currentDepth: currentDepth,
+            maxDepth,
+            getFragment,
+          });
+        }),
+      );
+    }
+
+    case Kind.OPERATION_DEFINITION:
+    case Kind.INLINE_FRAGMENT:
+    case Kind.FRAGMENT_DEFINITION: {
+      return Math.max(
+        ...node.selectionSet.selections.map(selection => {
+          return calculateDepth({
+            node: selection,
+            currentDepth,
+            maxDepth,
+            getFragment,
+          });
+        }),
+      );
+    }
+
+    case Kind.FRAGMENT_SPREAD:
+      return calculateDepth({
+        node: getFragment(node.name.value),
+        currentDepth,
+        maxDepth,
+        getFragment,
+      });
+
+    default: {
+      throw new Error(`Couldn't handle ${node.kind}`);
+    }
+  }
+}


Test patch:
diff --git a/packages/core/__tests__/validate.ts b/packages/core/__tests__/validate.ts
index 539905da..886003d6 100644
--- a/packages/core/__tests__/validate.ts
+++ b/packages/core/__tests__/validate.ts
@@ -186,4 +186,165 @@ describe('validate', () => {
 
     expect(results.length).toEqual(0);
   });
+
+  test('fail when exceeded max query depth (inline fragment)', async () => {
+    const schema = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String
+        createdAt: Int
+        author: User
+      }
+
+      type User {
+        id: ID
+        name: String
+        posts: [Post]
+      }
+
+      type Query {
+        post: Post
+      }
+
+      schema {
+        query: Query
+      }
+    `);
+
+    const docs = [
+      parse(/* GraphQL */ `
+        query getPost {
+          post {
+            ...PostInfo
+          }
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id
+          author {
+            ... on User {
+              id
+            }
+          }
+        }
+      `),
+    ];
+
+    const results = validate(schema, docs.map(doc => new Source(print(doc))), {
+      maxDepth: 1,
+    });
+
+    expect(results.length).toEqual(1);
+  });
+
+  test('fail when exceeded max query depth (spread fragments)', async () => {
+    const schema = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String
+        createdAt: Int
+        author: User
+      }
+
+      type User {
+        id: ID
+        name: String
+        posts: [Post]
+      }
+
+      type Query {
+        post: Post
+      }
+
+      schema {
+        query: Query
+      }
+    `);
+
+    const docs = [
+      parse(/* GraphQL */ `
+        query getPost {
+          post {
+            ...PostInfo
+          }
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id
+          title
+          author {
+            ...UserInfo
+          }
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment UserInfo on User {
+          id
+          name
+        }
+      `),
+    ];
+
+    const results = validate(schema, docs.map(doc => new Source(print(doc))), {
+      maxDepth: 1,
+    });
+
+    expect(results.length).toEqual(1);
+  });
+
+  test('pass when not exceeded max query depth', async () => {
+    const schema = buildSchema(/* GraphQL */ `
+      type Post {
+        id: ID
+        title: String
+        createdAt: Int
+        author: User
+      }
+
+      type User {
+        id: ID
+        name: String
+        posts: [Post]
+      }
+
+      type Query {
+        post: Post
+      }
+
+      schema {
+        query: Query
+      }
+    `);
+
+    const docs = [
+      parse(/* GraphQL */ `
+        query getPost {
+          post {
+            ...PostInfo
+          }
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id
+          author {
+            ...UserInfo
+          }
+        }
+      `),
+      parse(/* GraphQL */ `
+        fragment UserInfo on User {
+          id
+        }
+      `),
+    ];
+
+    const results = validate(schema, docs.map(doc => new Source(print(doc))), {
+      maxDepth: 2,
+    });
+
+    expect(results.length).toEqual(0);
+  });
 });
diff --git a/packages/core/__tests__/validate/query-depth.ts b/packages/core/__tests__/validate/query-depth.ts
new file mode 100644
index 00000000..b995489e
--- /dev/null
+++ b/packages/core/__tests__/validate/query-depth.ts
@@ -0,0 +1,121 @@
+import {parse} from 'graphql';
+
+import {calculateDepth} from '../../src/validate/query-depth';
+
+describe('calculateDepth', () => {
+  test('spread fragments', async () => {
+    const doc = parse(/* GraphQL */ `
+      query getPost {
+        post {
+          ...PostInfo
+        }
+      }
+    `);
+
+    const fragments: Record<string, any> = {
+      PostInfo: parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id # 1
+          title
+          author {
+            ...UserInfo
+          }
+        }
+      `).definitions[0],
+      UserInfo: parse(/* GraphQL */ `
+        fragment UserInfo on User {
+          id # 2
+          name
+        }
+      `).definitions[0],
+    };
+
+    expect(
+      calculateDepth({
+        node: doc,
+        currentDepth: 0,
+        getFragment: name => fragments[name],
+      }),
+    ).toEqual(2);
+  });
+
+  test('inline fragments', async () => {
+    const doc = parse(/* GraphQL */ `
+      query getPost {
+        post {
+          ...PostInfo
+        }
+      }
+    `);
+
+    const fragments: Record<string, any> = {
+      PostInfo: parse(/* GraphQL */ `
+        fragment PostInfo on Post {
+          id #1
+          title
+          author {
+            ... on User {
+              id # 2
+              name
+            }
+          }
+        }
+      `).definitions[0],
+    };
+
+    expect(
+      calculateDepth({
+        node: doc,
+        currentDepth: 0,
+        getFragment: name => fragments[name],
+      }),
+    ).toEqual(2);
+  });
+
+  test('no fragments', async () => {
+    const doc = parse(/* GraphQL */ `
+      query getPost {
+        post {
+          id # 1
+          title
+          author {
+            id # 2
+            name
+          }
+        }
+      }
+    `);
+
+    expect(
+      calculateDepth({
+        node: doc,
+        currentDepth: 0,
+        getFragment: () => ({} as any),
+      }),
+    ).toEqual(2);
+  });
+
+  test('throw ASTNode on maxDepth', async () => {
+    const doc = parse(/* GraphQL */ `
+      query getPost {
+        post {
+          id # 1
+          title
+          author {
+            id # 2
+            name
+          }
+        }
+      }
+    `);
+
+    expect(() => {
+      calculateDepth({
+        node: doc,
+        currentDepth: 0,
+        maxDepth: 1,
+        getFragment: () => ({} as any),
+      });
+    }).toThrow();
+  });
+});