Skip to content

Instantly share code, notes, and snippets.

@aqwert
Last active November 9, 2019 18:22
Show Gist options
  • Save aqwert/6a041827d24ebec3275f75a910018e3f to your computer and use it in GitHub Desktop.
Save aqwert/6a041827d24ebec3275f75a910018e3f to your computer and use it in GitHub Desktop.
GraphQL Schema stiching

NOTE:

Although this is a gist with accompanied files, I will at some stage make a proper project with working parts.

Getting Started with a Graphql Microservice

This is an opinionated view of how to use Prisma Graphql backend in a microservice way. The idea is that each Graphql Micorservice has both a App GraphQL service (the one that creates a public API) and a Prisma GraphQL service (The one that is a ORM to a DB expiosing GraphQL CRUD, filtering and ordering operations). Each of these microservices are then "stitched" together to form a single GraphQL App Service (Like a Backend For Frontend service BFF) that the client consumes.

Creating a GraphQL Microservice

  1. Instructions taken from:
  1. Make changes to the prisma code that got generated from boilerplate

    1. ./database/datamodel.graphql : Add a aggregated root type matching the domain (and delete the old one). i.e.

          type User {
              id: ID! @unique
              name: String!
          }
      
    2. ./database/seed.graphql delete or seed with new entity. i.e.

          mutation {
              admin: createUser(data: {
                  name: "Admin"
              }) {
                  id
              }
          }
      
      • If deleting also delete the seed reference from ./database/prisma.yml
    3. ./.graphqlconfig.yml : Change the name of the app and default endpoint.

      projects:
          user:
              schemaPath: src/schema.graphql
              extensions:
              endpoints:
                  default: http://localhost:4001
          user_db:
              schemaPath: src/generated/prisma.graphql
              extensions:
              prisma: database/prisma.yml
    4. ./database/prisma.yml change the post-deploy to graphql get-schema --project MY_DOMAIN_db where MY_DOMAIN is the name of the new domain.

  2. cd MY_DOMAIN

  3. run prisma deploy --force

  4. If the generated files do not get updated then need to run (This is fixed in prisma-cli 1.9)

    • graphql get-schema --project user_db
    • graphql codegen for Typescript project

    NOTE: Replace user_db with the domain service with _db appended

  5. Make changes to the application server code

    1. ./src/schema.graphql

      • Change the import statement to imort the correct type
      • Change the Query and Mutation to sensible operations. i.e
      type Query {
          users: [User!]!
      }
      
      type Mutation {
          createUser(name: String!): User
          deleteUser(id: ID!): User
      }
      
    2. ./src/index.js or .ts Change the resolvers to match the schema above.

      const resolvers = {
          Query: {
              users(parent, args, ctx, info) {
                  return ctx.db.query.users(info)
              },
          },
          Mutation: {
              createUser(parent, { name }, ctx, info) {
                  return ctx.db.mutation.createUser(
                      {
                      data: {
                          name,
                      },
                      },
                      info,
                  )
              },
              deleteUser(parent, { id }, ctx, info) {
                  return ctx.db.mutation.deleteUser({ where: { id } }, info)
              },
          },
      }
      const resolvers = {
          Query: {
              users(parent, args, context: Context, info) {
                  return context.db.query.users(info)
              },
          },
          Mutation: {
              createUser(parent, { name }, context: Context, info) {
                  return context.db.mutation.createUser(
                      {
                      data: {
                          name,
                      },
                      },
                      info,
                  )
              },
              deleteUser(parent, { id }, context: Context, info) {
                  return context.db.mutation.deleteUser({ where: { id } }, info)
              },
          },
      }
      • Also change the way the app starts by injecting the correct port number. Each microservice locally needs a seprate port number
      const options = {
          port: 4001
      }
      
      server.start(options, ({ port }) => console.log(`Server is running on http://localhost:${port}`))
    3. ../.graphqlconfig.yml Update the root level graphql config file to include the new services (server and DB).

      1. Copy the service section from ./.graphqlconfig.yml into the root config file. i.e:
      user:
          schemaPath: services/user/src/schema.graphql
          extensions:
          endpoints:
              default: http://localhost:4001
      user_db:
          schemaPath: services/user/src/generated/prisma.graphql
          extensions:
          prisma: services/user/database/prisma.yml
      
      1. Make sure you prepend the microservice folder name to the paths.

Run Microservice (Isolated mode)

yarn dev to start the service and the playground webpage for that microservice

Run All Microservices

  1. Open a console / powershell at each microservice folder level

  2. For each microservice:

yarn start
  1. Open a console / powershell at the root ./services and run the playground
graphql playground

You should see all the microservices in one place

Combining all Microservices into one application service

The one service to rule them all

The idea is that each microservice maintains and owns its own domain (bounded context). Although each microservice is not aware of another microservices it is desirable to have a single application that combines the functionality of each microservces together.

Schema Stiching allows this to happen by combining multiple graphql services into a uber graphql service

Creating a BFF GraphQL Service

This is only really done once per application.

Stepes to create a BFF Service are still in a TODO state

Needs to be added to the root .graphqlconfig.yml file

projects:
  app: 
     extensions:
      endpoints:
        default: http://localhost:4000

Updating

With each new microservice they need to be added to this BFF service.

  1. ./app/server.js add the URL from the microservice to the endpoint object
const endpoints = [
   'http://localhost:4001',
   'http://localhost:4002'
];

Run the BFF service

yarn start

Stitching Microservices together in a hierarchy

A single BFF service that just exposes the entities and operations up until now is just a convienent way to avoid service discovery and multiple round trips

However the queries can be a little disconnected with eachother and if the result of one query needs to be fed into another then multiple round trips will be required.

ideally we should be able to write

{
    users {
        id, name
        accounts {
            id, name
        }
    }
}

Which should return all the accounts for a given user. To achieve this we need to stitch the entities together as a relation

Stitching it together

To stitch 2 entities together that are not part of the same service (this could also work on the same service) you need to do 2 things

  • extend the parent type with a new field with the type from the second entity
  • resolve the relationship by joining a parent field to the child field
const { makeRelations, RelationType } = require('./stitch');

const { extendedSchema, resolvers } = makeRelations([
    {
        parent: 'User.id',
        child: 'Account.userId',
        parentField: 'accounts',
        relationType: RelationType.OneToMany,
    }
]);

Serverless (App GraphQL Microservice in AWS Lambda)

Ideally split src/index.js or .ts into two

  • The server running the code
  • queries and mutations that make the service

Note that this method uses the serverless framework to upload the Lambda

Get Ready

The following are steps to get the code ready for serverless

  1. Make the file ./index.js (root folder location of the service)
  2. Change ./package.json and change the script to remove src
    • "start": "node index.js or .ts",
  3. Move the GraphQLServer code from ./src/index.js and copy to ./index.js`
    • Bring in imports also
    • Add new import to ./src/resolvers.js
    • Correct the paths.
  4. Change the filename ./src/index.js or .ts to ./src/resolvers.js or .ts
    • Also export the resolvers exports.resolvers = {...}

Make it Serverless

Prereq: install serverless

npm install -g serverless

Add the necesasary boilerplate code:

  1. Easiest is to copy the example from github

https://github.com/prismagraphql/graphql-yoga/tree/master/examples/lambda

  1. Change ./handler.js to import the resolvers as we did above

  2. Set the typedefs to that of ./index.js

  3. Set the context.

  4. Set the resolvers.

  5. Check the Profile being used !! IMPORTANT IF USING DIFFERENT PROFILES !!

    • aws configure
    • Verify that the correct account / profile is being used.
    • If not set correctly: $env:AWS_PROFILE = "PROFILENAME" which matches .aws/credentials file
      • If there is no user, the administrator needs to create one with the right permissions. See Setting the Permissions below
  6. serverless deploy

Setting the Permissions

The profile that is being used need access to AWS resources. If you are not using the root user (you shouldn't) the user needs to have the correct policies for the serverless framework to work. The following is what I did (may expose a bit too much until I spend more time refining it)

Create a Group

  1. Log into AWS using the root account

  2. In AWS IAM create a group Serverless (name it whatever you want)

  3. In that group set the following AWS premade policies.

    • AWSLambdaFullAccess
    • AmazonAPIGatewayInvokeFullAccess
    • AmazonAPIGatewayAdministrator <- may need to change this
    • AWSCloudFormationReadOnlyAccess
  4. You need to create a custom policy that are not provided by the premade ones from AWS.

    • Create 3 new Policies setting the JSON to... (could be merged intop one) - see below
    • Add them to the group
  5. Add the same User to the group that is being used to deploy the serverless application

Custom Policies

Serverless-CreateRole

{
    "Version": "2012-10-17",
    "Statement": {
        "Effect": "Allow",
        "Action": "iam:CreateRole",
        "Resource": "arn:aws:iam::*:role/*-lambdaRole"
    }
}

Serverless-CloudFormation

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "cloudformation:ListStacks",
                "cloudformation:CreateStack",
                "cloudformation:UpdateStack",
                "cloudformation:DescribeStackResource",
                "cloudformation:CreateChangeSet",
                "cloudformation:DescribeChangeSet",
                "cloudformation:ExecuteChangeSet",
                "cloudformation:ValidateTemplate",
                "cloudformation:DescribeStacks",
                "cloudformation:DeleteStack"
            ],
            "Resource": "*"
        }
    ]
}

Serverless-PutRolePolicy

{
    "Version": "2012-10-17",
    "Statement": {
        "Effect": "Allow",
        "Action": "iam:PutRolePolicy",
        "Resource": "*"
    }
}
const { GraphQLServerLambda } = require('graphql-yoga')
const { Prisma } = require('prisma-binding')
const { resolvers } = require('./src/resolvers')
const lambda = new GraphQLServerLambda({
typeDefs: './src/schema.graphql',
resolvers,
context: req => ({
...req,
db: new Prisma({
typeDefs: './src/generated/prisma.graphql', // the auto-generated GraphQL schema of the Prisma API
endpoint: 'https://.../', // the endpoint of the Prisma API
debug: true, // log all GraphQL queries & mutations sent to the Prisma API
// secret: 'mysecret123', // only needed if specified in `database/prisma.yml`
}),
}),
})
exports.server = lambda.graphqlHandler
exports.playground = lambda.playgroundHandler
const fetch = require('node-fetch');
const { makeRemoteExecutableSchema, introspectSchema } = require('graphql-tools');
const { setContext } = require('apollo-link-context');
const { HttpLink } = require('apollo-link-http');
module.exports = {
getIntrospectSchema: async (url) => {
// Create a link to a GraphQL instance by passing fetch instance and url
const http = new HttpLink({ uri: url, fetch });
const link = setContext((request, previousContext) => {
if (previousContext.graphqlContext) {
if (previousContext.graphqlContext.request) {
if (previousContext.graphqlContext.request.headers) {
return {
headers: {
'Authorization': previousContext.graphqlContext.request.headers['authorization'],
}
}
}
}
}
return {};
}
).concat(http);
const schema = await introspectSchema(link);
return makeRemoteExecutableSchema({
schema,
link,
});
}
};
{
"name": "app",
"version": "1.0.0",
"description": "One GraphQL service to rule them all",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [
"GraphQL",
"Microservices",
"Express",
"NodeJS"
],
"dependencies": {
"apollo-link-http": "^1.3.2",
"apollo-server-express": "^1.3.2",
"body-parser": "^1.18.2",
"express": "^4.16.2",
"graphql": "^0.13.2",
"graphql-tools": "^2.19.0",
"node-fetch": "^1.7.3",
"graphql-yoga": "1.14.6",
"prisma-binding": "2.0.2"
}
}
const { GraphQLServer } = require('graphql-yoga')
const { Prisma } = require('prisma-binding')
const { getIntrospectSchema } = require('./introspection');
const { mergeSchemas } = require('graphql-tools')
const { makeRelations, RelationType } = require('./stitch');
//TODO if there is a stiching of 2 types that have been used before then need to relate correctly
const { extendedSchema, resolvers } = makeRelations([
{
parent: 'User.id',
child: 'Account.userId',
parentField: 'accounts',
relationType: RelationType.OneToMany,
}
]);
const options = {
port: 4000
};
//an option here is point to a service-discovery service
const endpoints = [
'http://localhost:4001',
'http://localhost:4002'
];
(async function () {
try {
//promise.all to grab all remote schemas at the same time, we do not care what order they come back but rather just when they finish
var allSchemas = await Promise.all(endpoints.map(ep => getIntrospectSchema(ep)));
const server = new GraphQLServer({
schema: mergeSchemas({ schemas: [...allSchemas, extendedSchema], resolvers: resolvers }),
context: req => ({
...req,
}),
})
server.start(options, ({ port }) => console.log(`Server is running on http://localhost:${port}`))
} catch (error) {
console.log('ERROR: Failed to grab introspection queries', error);
}
})();
service: example-bankingapp-userservice
# If using Typescript we need to compile to js. Offline also gives ability to run lambda locally
plugins:
- serverless-plugin-typescript
- serverless-offline
package:
exclude:
- environments/**/*
# The `provider` block defines where your service will be deployed
provider:
name: aws
runtime: nodejs8.10
# The `functions` block defines what code to deploy
functions:
graphql:
handler: handler.server
events:
- http:
path: /
method: post
cors: true
playground:
handler: handler.playground
events:
- http:
path: /
method: get
cors: true
//This is pretty awful code.. just quickly done to get it working in a simple case.
function extend(parentEntity, childEntity, parentFieldName) {
return `extend type ${parentEntity} {
${parentFieldName}: ${childEntity}
}`;
}
function extendArray(parentEntity, childEntity, parentFieldName) {
return `extend type ${parentEntity} {
${parentFieldName}: [${childEntity}!]!
}`;
}
exports.RelationType = RelationType = {
OneToOne: 1,
OneToMany: 2,
};
exports.makeRelations = function (relationInfos = []) {
var extendedSchema = '';
relationInfos.forEach(rel => {
var parentName = rel.parent.split('.')[0];
var parentField = rel.parent.split('.')[1];
var childName = rel.child.split('.')[0];
var childField = rel.child.split('.')[1];
var addField = rel.parentField;
extendedSchema += rel.relationType == RelationType.OneToOne ?
extend(parentName, childName, addField) :
extendArray(parentName, childName, addField);
});
var resolvers = mergeInfo => Object.assign(...relationInfos.map(rel => {
var parentName = rel.parent.split('.')[0];
var parentField = rel.parent.split('.')[1];
var childName = rel.child.split('.')[0];
var childField = rel.child.split('.')[1];
var addField = rel.parentField;
var field = {};
field[addField] = {
fragment: `fragment ${childField} on ${parentName} { ${parentField} }`,
resolve(parent, args, context, info) {
var cf = childField;
var pf = parentField;
console.log(`RESOLVING cf:${cf} pf:${pf}`);
return mergeInfo.delegate(
'query',
`${addField}`,
{
[cf]: parent[pf],
},
context,
info,
);
}
}
var result = {};
result[parentName] = field;
return result;
}
));
return {
extendedSchema,
resolvers
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment