Repository: mercurius-js/mercurius (js)

Issue:
Is there a way to receive query as AST?
Hello!

Before que used to have apollo-graphql-lambda which supports parsing Graphql AST directly. We've migrated to mercurius but some of our clients sends us graphql AST in the query payload. The thing is we had to create a fastify contentTypeParser to transform that AST back to string, but that is not something we want to keep because it doesn't make much sense as the string is converted back to AST by mercurius, so we are adding unnecessary overhead.

Is there a way of processing graphlq AST directly?


Pull request description:
feat: ast input support added
closes #804 

JSON ast can be now used as input.

The change can be tested with the code of the test added at test/app-decorator.js

Comments are welcomed for both performance and security on the use of `JSON.parse` function

It should be decided whether this feature is desired.

Patch:
diff --git a/index.d.ts b/index.d.ts
index e4f03d1..e2138f5 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -58,7 +58,7 @@ export interface Loader<
 export interface MercuriusLoaders<TContext extends Record<string, any> = MercuriusContext> {
   [root: string]: {
     [field: string]:
-      | Loader
+      | Loader<any, any, TContext>
       | {
           loader: Loader<any, any, TContext>;
           opts?: {
diff --git a/index.js b/index.js
index 14021e2..62a1204 100644
--- a/index.js
+++ b/index.js
@@ -444,9 +444,14 @@ const plugin = fp(async function (app, opts) {
       try {
         document = parse(source)
       } catch (syntaxError) {
-        const err = new MER_ERR_GQL_VALIDATION()
-        err.errors = [syntaxError]
-        throw err
+        try {
+          // Try to parse the source as ast
+          document = JSON.parse(source)
+        } catch {
+          const err = new MER_ERR_GQL_VALIDATION()
+          err.errors = [syntaxError]
+          throw err
+        }
       }
 
       // Trigger preValidation hook


Test patch:
diff --git a/test/app-decorator.js b/test/app-decorator.js
index 138ae95..6a2cabc 100644
--- a/test/app-decorator.js
+++ b/test/app-decorator.js
@@ -1203,3 +1203,237 @@ test('calling extendSchema throws an error if federationMetadata is enabled', as
     t.end()
   }
 })
+
+test('support ast input', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  app.register(GQL, {
+    schema,
+    resolvers
+  })
+
+  // needed so that graphql is defined
+  await app.ready()
+
+  const query = `{
+    "kind": "Document",
+    "definitions": [
+      {
+        "kind": "OperationDefinition",
+        "operation": "query",
+        "variableDefinitions": [],
+        "directives": [],
+        "selectionSet": {
+          "kind": "SelectionSet",
+          "selections": [
+            {
+              "kind": "Field",
+              "name": {
+                "kind": "Name",
+                "value": "add"
+              },
+              "arguments": [
+                {
+                  "kind": "Argument",
+                  "name": {
+                    "kind": "Name",
+                    "value": "x"
+                  },
+                  "value": {
+                    "kind": "IntValue",
+                    "value": "2"
+                  }
+                },
+                {
+                  "kind": "Argument",
+                  "name": {
+                    "kind": "Name",
+                    "value": "y"
+                  },
+                  "value": {
+                    "kind": "IntValue",
+                    "value": "2"
+                  }
+                }
+              ],
+              "directives": []
+            }
+          ]
+        }
+      }
+    ]
+  }`
+  const res = await app.graphql(query)
+
+  t.same(res, {
+    data: {
+      add: 4
+    }
+  })
+})
+
+test('throws on invalid ast input', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  app.register(GQL, {
+    schema,
+    resolvers
+  })
+
+  // needed so that graphql is defined
+  await app.ready()
+
+  // missing "kind": "Document",
+  const query = `{
+    "definitions": [
+      {
+        "kind": "OperationDefinition",
+        "operation": "query",
+        "variableDefinitions": [],
+        "directives": [],
+        "selectionSet": {
+          "kind": "SelectionSet",
+          "selections": [
+            {
+              "kind": "Field",
+              "name": {
+                "kind": "Name",
+                "value": "add"
+              },
+              "arguments": [
+                {
+                  "kind": "Argument",
+                  "name": {
+                    "kind": "Name",
+                    "value": "x"
+                  },
+                  "value": {
+                    "kind": "IntValue",
+                    "value": "2"
+                  }
+                },
+                {
+                  "kind": "Argument",
+                  "name": {
+                    "kind": "Name",
+                    "value": "y"
+                  },
+                  "value": {
+                    "kind": "IntValue",
+                    "value": "2"
+                  }
+                }
+              ],
+              "directives": []
+            }
+          ]
+        }
+      }
+    ]
+  }`
+  try {
+    await app.graphql(query)
+  } catch (err) {
+    t.equal(err.message, 'Invalid AST Node: { definitions: [[Object]] }.')
+    t.equal(err.name, 'Error')
+  }
+})
+
+test('support ast input on external requests', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    add: async ({ x, y }) => x + y
+  }
+
+  app.register(GQL, {
+    schema,
+    resolvers
+  })
+
+  // needed so that graphql is defined
+  await app.ready()
+
+  const query = `{
+    "kind": "Document",
+    "definitions": [
+      {
+        "kind": "OperationDefinition",
+        "operation": "query",
+        "variableDefinitions": [],
+        "directives": [],
+        "selectionSet": {
+          "kind": "SelectionSet",
+          "selections": [
+            {
+              "kind": "Field",
+              "name": {
+                "kind": "Name",
+                "value": "add"
+              },
+              "arguments": [
+                {
+                  "kind": "Argument",
+                  "name": {
+                    "kind": "Name",
+                    "value": "x"
+                  },
+                  "value": {
+                    "kind": "IntValue",
+                    "value": "2"
+                  }
+                },
+                {
+                  "kind": "Argument",
+                  "name": {
+                    "kind": "Name",
+                    "value": "y"
+                  },
+                  "value": {
+                    "kind": "IntValue",
+                    "value": "2"
+                  }
+                }
+              ],
+              "directives": []
+            }
+          ]
+        }
+      }
+    ]
+  }`
+  const res = await app.inject({
+    method: 'POST',
+    url: '/graphql',
+    body: { query }
+  })
+
+  t.same(JSON.parse(res.body), {
+    data: {
+      add: 4
+    }
+  })
+})
diff --git a/test/types/index.ts b/test/types/index.ts
index 8c0dd36..5c47ea1 100644
--- a/test/types/index.ts
+++ b/test/types/index.ts
@@ -6,7 +6,14 @@ import Fastify, { FastifyReply, FastifyRequest, FastifyInstance } from 'fastify'
 // eslint-disable-next-line no-unused-vars
 import { Readable } from 'stream'
 // eslint-disable-next-line no-unused-vars
-import mercurius, { MercuriusOptions, IResolvers, MercuriusContext, MercuriusServiceMetadata, MercuriusPlugin } from '../..'
+import mercurius, {
+  MercuriusOptions,
+  IResolvers,
+  MercuriusContext,
+  MercuriusServiceMetadata,
+  MercuriusPlugin,
+  MercuriusLoaders
+} from '../..'
 // eslint-disable-next-line no-unused-vars
 import { DocumentNode, ExecutionResult, GraphQLSchema, ValidationContext, ValidationRule } from 'graphql'
 import { makeExecutableSchema } from '@graphql-tools/schema'
@@ -921,3 +928,16 @@ expectError(() => {
     }
   })
 })
+
+interface CustomLoaderContext {
+  foo: string
+}
+
+expectAssignable<MercuriusLoaders<CustomLoaderContext>>({
+  Query: {
+    add: async (_queries, context) => {
+      context.foo
+      return []
+    }
+  }
+})