Repository: mercurius-js/mercurius
Pull request #301 (2020-10-04 08:35:43)

Expose buildFederationSchema
Addresses #300 

After digging into it a bit more, I realized that I already need the federated schema before calling `makeExecutableSchema` as described in the issue, due to having federation specific directives (like `@key` and `@external`) in the schema.

To support that use case, I'm exporting `buildFederationSchema` function that will allow me calling it to extend my schema with types, directives and resolvers needed to run in federation mode. I can then extend the federated schema with custom directives.

aldis-ameriks: There were some failing tests on latest master when I opened the PR. Couldn't quite figure out what was causing it. Now it seems that the tests are passing.
Out of curiosity, how were the tests fixed?
mcollina: https://github.com/fastify/fastify-static/releases/tag/v3.2.1 did the trick
PacoDu: Maybe we should add a test with a custom directive using `makeExecutableSchema` from `graphql-tools` like https://github.com/mercurius-js/mercurius/blob/master/test/app-decorator.js#L151 to validate the original issue.

Thoughts for an other PR:
Maybe we should add and expose our own implementation of `makeExecutableSchema` so users don't need to import an additional library for this use case.
aldis-ameriks: > Maybe we should add a test with a custom directive using `makeExecutableSchema` from `graphql-tools` like https://github.com/mercurius-js/mercurius/blob/master/test/app-decorator.js#L151 to validate the original issue.
> 
> Thoughts for an other PR:
> Maybe we should add and expose our own implementation of `makeExecutableSchema` so users don't need to import an additional library for this use case.

Receiving the same fields that `makeExecutableSchema` accepts through api, would definitely make it easier for users. And the api would look much simpler for the use case I have.

I'd imagine the api would look like this.
```
  app.register(mercurius, {
    schema,
    resolvers,
    loaders,
    federationMetadata: true,
    schemaTransforms: [upperDirectiveTransformer]
  })
```


Patch:
diff --git a/index.d.ts b/index.d.ts
index 0ebd9d8..50464b7 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -308,6 +308,11 @@ declare namespace mercurius {
     execution: ExecutionResult,
     context: any
   ) => { statusCode: number, response: ExecutionResult };
+
+  /**
+   * Builds schema with support for federation mode.
+   */
+  const buildFederationSchema: (schema: string) => GraphQLSchema;
 }
 
 export default mercurius;
diff --git a/index.js b/index.js
index 35087df..91217bb 100644
--- a/index.js
+++ b/index.js
@@ -279,8 +279,10 @@ const plugin = fp(async function (app, opts) {
             }
           } else if (prop === '__resolveReference') {
             type.resolveReference = resolver[prop]
-          } else {
+          } else if (fields[prop]) {
             fields[prop].resolve = resolver[prop]
+          } else {
+            throw new Error(`Cannot find field ${prop} of type ${type}`)
           }
         }
       } else if (type instanceof GraphQLScalarType || type instanceof GraphQLEnumType) {
@@ -473,5 +475,6 @@ const plugin = fp(async function (app, opts) {
 plugin.ErrorWithProps = ErrorWithProps
 plugin.defaultErrorFormatter = defaultErrorFormatter
 plugin.persistedQueryDefaults = persistedQueryDefaults
+plugin.buildFederationSchema = buildFederationSchema
 
 module.exports = plugin


Test patch:
diff --git a/test/app-decorator.js b/test/app-decorator.js
index 98b0807..1ef8e37 100644
--- a/test/app-decorator.js
+++ b/test/app-decorator.js
@@ -1088,3 +1088,30 @@ test('Multiple errors in schema', async (t) => {
     t.equal(error.errors[1].name, 'GraphQLError')
   }
 })
+
+test('defineResolvers should throw if field is not defined in schema', async (t) => {
+  const app = Fastify()
+  const schema = `
+    type Query {
+      add(x: Int, y: Int): Int
+    }
+  `
+
+  const resolvers = {
+    Query: {
+      add: async ({ x, y }) => x + y,
+      sub: async ({ x, y }) => x - y
+    }
+  }
+
+  app.register(GQL, { schema: schema })
+
+  app.register(async function (app) {
+    t.throws(function () {
+      app.graphql.defineResolvers(resolvers)
+    }, new Error('Cannot find field sub of type Query'))
+  })
+
+  // needed so that graphql is defined
+  await app.ready()
+})
diff --git a/test/federation.js b/test/federation.js
index a736bd3..5ad0c2d 100644
--- a/test/federation.js
+++ b/test/federation.js
@@ -2,7 +2,8 @@
 
 const { test } = require('tap')
 const Fastify = require('fastify')
-const { printSchema } = require('graphql')
+const { printSchema, defaultFieldResolver } = require('graphql')
+const { MapperKind, mapSchema, getDirectives, makeExecutableSchema, printSchemaWithDirectives, getResolversFromSchema, mergeResolvers } = require('graphql-tools')
 const WebSocket = require('ws')
 const mq = require('mqemitter')
 const GQL = require('..')
@@ -66,6 +67,126 @@ test('basic federation support', async (t) => {
   })
 })
 
+test('federation support using schema from buildFederationSchema', async (t) => {
+  const app = Fastify()
+  const schema = `
+    extend type Query {
+      me: User
+    }
+    
+    extend type Mutation {
+      add(a: Int, b: Int): Int
+    }
+
+    type User @key(fields: "id") {
+      id: ID!
+      name: String
+      username: String
+    }
+  `
+
+  const resolvers = {
+    Query: {
+      me: () => ({
+        id: '1',
+        name: 'John',
+        username: '@john'
+      })
+    },
+    Mutation: {
+      add: (_, { a, b }) => a + b
+    },
+    User: {
+      __resolveReference: (object) => {
+        return {
+          id: object.id,
+          name: 'John',
+          username: '@john'
+        }
+      }
+    }
+  }
+
+  const federationSchema = buildFederationSchema(schema)
+
+  app.register(GQL, {
+    schema: federationSchema,
+    resolvers
+  })
+
+  await app.ready()
+
+  let query = '{ _service { sdl } }'
+  let res = await app.inject({ method: 'GET', url: `/graphql?query=${query}` })
+  t.deepEqual(JSON.parse(res.body), { data: { _service: { sdl: schema } } })
+
+  query = '{ me { id name username } }'
+  res = await app.inject({ method: 'GET', url: `/graphql?query=${query}` })
+  t.deepEqual(JSON.parse(res.body), { data: { me: { id: '1', name: 'John', username: '@john' } } })
+
+  query = 'mutation { add(a: 11 b: 19) }'
+  res = await app.inject({ method: 'POST', url: '/graphql', body: { query } })
+  t.deepEqual(JSON.parse(res.body), { data: { add: 30 } })
+})
+
+test('federation support using schema from buildFederationSchema and custom directives', async (t) => {
+  const app = Fastify()
+  const schema = `
+    directive @upper on FIELD_DEFINITION
+    
+    type Query {
+      foo: String @upper
+    }
+  `
+
+  const resolvers = {
+    Query: {
+      foo: () => 'bar'
+    }
+  }
+
+  function upperDirectiveTransformer (schema) {
+    return mapSchema(schema, {
+      [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
+        const directives = getDirectives(schema, fieldConfig)
+        if (directives.upper) {
+          const { resolve = defaultFieldResolver } = fieldConfig
+          fieldConfig.resolve = async function (source, args, context, info) {
+            const result = await resolve(source, args, context, info)
+            if (typeof result === 'string') {
+              return result.toUpperCase()
+            }
+            return result
+          }
+          return fieldConfig
+        }
+      }
+    })
+  }
+
+  const federationSchema = buildFederationSchema(schema)
+
+  const executableSchema = makeExecutableSchema({
+    typeDefs: printSchemaWithDirectives(federationSchema),
+    resolvers: mergeResolvers([getResolversFromSchema(federationSchema), resolvers]),
+    schemaTransforms: [upperDirectiveTransformer]
+  })
+
+  app.register(GQL, {
+    schema: executableSchema
+  })
+
+  await app.ready()
+
+  let query = '{ _service { sdl } }'
+  let res = await app.inject({ method: 'GET', url: `/graphql?query=${query}` })
+  t.deepEqual(JSON.parse(res.body), { data: { _service: { sdl: schema } } })
+
+  query = 'query { foo }'
+  res = await app.inject({ method: 'POST', url: '/graphql', body: { query } })
+  t.deepEqual(JSON.parse(res.body), { data: { foo: 'BAR' } })
+})
+
 test('a normal schema can be run in federated mode', async (t) => {
   const app = Fastify()
   const schema = `
diff --git a/test/types/index.ts b/test/types/index.ts
index 02a383d..0e56444 100644
--- a/test/types/index.ts
+++ b/test/types/index.ts
@@ -140,6 +140,7 @@ makeGraphqlServer({ schema, resolvers, validationRules: [] })
 makeGraphqlServer({ schema, resolvers, validationRules: [customValidationRule] })
 makeGraphqlServer({ schema, resolvers, validationRules: ({ variables, operationName, source }: { source: string, variables?: Record<string, any>, operationName?: string }) => [customValidationRule] })
 makeGraphqlServer({ schema, errorFormatter: mercurius.defaultErrorFormatter })
+makeGraphqlServer({ schema: mercurius.buildFederationSchema(schema) })
 
 // Gateway mode