| theme | slidev-theme-nearform |
|---|---|
| layout | default |
| highlighter | shiki |
| lineNumbers | false |
- Mercurius is a high-performance GraphQL adapter for the Fastify web framework
- It has many core features and plugins for building world class production-ready applications
- It supports advanced features out of the box such as Apollo Federation specification and Subscriptions
- It's entirely Open Source and made available under the MIT license
- It has a rich and vibrant community contributing to its development
-
This workshop requires an understanding of the Fastify framework.
- We recommend a basic knowledge of the Fastify plugin system which can be acquired by following a workshop similar to this one, focused on Fastify: https://github.com/nearform/the-fastify-workshop
-
A basic grasp on how to write GraphQL queries so you can check your working via curl or graphiql (a browser based GraphQL IDE).
- We recommend the GraphQL queries documentation
- Caching of query parsing and validation
- Automatic loader integration to avoid the N+1 problem
- Just-In-Time compiler via graphql-jit
- Subscriptions
- Federation support
- Federated subscriptions support
- Gateway implementation, including Subscriptions
- Batched query support
- Customizable persisted queries
Clone the repository:
git clone https://github.com/nearform/the-graphql-workshopFollow the instructions in the README file
π‘ Ask if anything doesn't work!
- This workshop is made of multiple, incremental modules
- Each module builds on top of the previous one
- At each step you are asked to add features and solve problems
- You will find the solution to each step in the
src/step-{n}-{name}folder - The π icon indicates bonus features
- The π‘ icon indicates hints
cd src/step-{n}-{name}- Check out the README file in each step
cd src/step-01-basic
npm startCreate a GraphQL server which exposes an add function to compute the sum of two numbers.
- Expose a GraphQL
POST /graphqlroute - Listen on port 3000
- Create a schema including an
addQuery accepting parametersxandy - Implement a resolver for the
addquery - Respond with the JSON object when invoked with
(x:5, y:3)
{
"data": {
"add": 8
}
}// index.js
import Fastify from 'fastify'
import mercurius from 'mercurius'
import { schema, resolvers } from './graphql.js'
export default function buildServer() {
const server = Fastify({
logger: {
transport: {
target: 'pino-pretty'
}
}
})
server.register(mercurius, {
schema,
resolvers,
graphiql: true
})
return server
}// graphql.js
const schema = `
type Query {
add(x: Int!, y: Int!): Int
}
`
const resolvers = {
Query: {
add: async (_, { x, y }) => x + y
}
}
export { schema, resolvers }// server.js
import buildServer from './index.js'
const app = buildServer()
app.listen({ port: 3000 })curl --request POST \
--url http://localhost:3000/graphql \
--header 'Content-Type: application/json' \
--data '{"query":"{ add(x: 5, y:3) }"}'{
"data": {
"add": 8
}
}A loader is an utility to avoid the N+1 query problem of GraphQL. Each defined loader will register a resolver that coalesces each of the request and combines them into a single, bulk query.
Moreover, it can also cache the results, so that other parts of the GraphQL do not have to fetch the same data.
- Create a
Persontype withnameproperty - Create a
Pettype withnameandownerproperty (of typePerson) - Expose a
petsquery which returns all pets and, for each pet, its owner, using GraphQL loaders
- The query should return a JSON object:
{
"data": {
"pets": [
{
"owner": {
"name": "Jennifer"
}
},
{
"owner": {
"name": "Simon"
}
}
]
}
}// index.js
import Fastify from 'fastify'
import mercurius from 'mercurius'
import { schema, resolvers, loaders } from './graphql.js'
export default function buildServer() {
const server = Fastify(...)
server.register(mercurius, {
schema,
resolvers,
loaders,
graphiql: true
})
return server
}// graphql.js
const pets = [
{
name: 'Max'
},
{
name: 'Charlie'
}
]
const owners = {
Max: {
name: 'Jennifer'
},
Charlie: {
name: 'Simon'
}
}const schema = `
type Person {
name: String!
}
type Pet {
name: String!
owner: Person
}
type Query {
pets: [Pet]
}
`
const resolvers = {
Query: {
pets() {
return pets
}
}
}// graphql.js (cont.)
const loaders = {
Pet: {
async owner(queries) {
return queries.map(({ obj: pet }) => owners[pet.name])
}
}
}
export { schema, resolvers, loaders }You can navigate to graphql query editor on http://localhost:3000/graphiql
The @graphql-tools/schema package allows you to create a GraphQL.js GraphQLSchema instance from GraphQL schema language using the function makeExecutableSchema.
Besides common options such as typeDefs and resolvers, it supports more advanced options to customize the creation of the schema.
- Create an executable schema using
typeDefsandresolvers - Use the
@graphql-tools/schemapackage to create the executable schema - Implement the same "add" functionality as in the earlier step
// index.js
import Fastify from 'fastify'
import mercurius from 'mercurius'
import { makeExecutableSchema } from '@graphql-tools/schema'
import { typeDefs, resolvers } from './graphql.js'
export default function buildServer() {
const server = Fastify(...)
server.register(mercurius, {
schema: makeExecutableSchema({ typeDefs, resolvers })
})
return server
}// graphql.js
const typeDefs = `
type Query {
add(x: Int!, y: Int!): Int
}
`
const resolvers = {
Query: {
add: async (_, { x, y }) => x + y
}
}
export { typeDefs, resolvers }- Implement the same Pet and Owners functionality as before
- Allows to query all pets and its owners by using GraphQL loaders
- Stores the pets and owners in postgres database
- Uses a single database query to get owners for all pets
export async function ownersByPetNames(db, petNames) {
const { rows } = await db.query(
SQL`
SELECT owners.*
FROM owners
INNER JOIN pets
ON pets.owner = owners.id
AND pets.name = ANY(${petNames})
ORDER BY
ARRAY_POSITION((${petNames}), pets.name)`
`
)
return rows
}const loaders = {
Pet: {
async owner(queries, context) {
const petNames = queries.map(({ obj }) => obj.name)
return ownersByPetNames(context.app.pg, petNames)
}
}
}π‘ For the full code examples and db setup instructions, see the repository
Context is an object populated at the server level which is made accessible to resolvers.
- Create a
Usertype withnameandlocaleproperties - Create an in-memory array of
Userwith different locales - Create a query called
getUserByLocalereturning the first user withenlocale - Set the property
locale: 'en'in the mercurius context - Respond with JSON object:
{
"data": {
"getUserByLocale": {
"name": "Alice"
}
}
}const server = Fastify(...)
server.register(mercurius, {
schema,
resolvers,
context: () => ({
locale: 'en'
})
})const resolvers = {
Query: {
getUserByLocale(_, __, context) {
return users.find(u => u.locale === context.locale)
}
}
}Hooks are registered with the fastify.graphql.addHook method and allow you to listen to specific events in the GraphQL request/response lifecycle.
By using hooks you can interact directly with the GraphQL lifecycle of Mercurius.
- Create a query called
sumwhich returns the sum of two numbers - Create and print logs for the following hooks:
preParsingpreValidationpreExecutiononResolution
- Enrich the response with an error with the message "foo" in the
preExecutionhook
- The query should return something similar to:
{
"data": {
"add": 10
},
"errors": [
{
"message": "foo"
}
]
}// server.js
app.graphql.addHook('preParsing', async function () {
app.log.info('preParsing called')
})
app.graphql.addHook('preValidation', async function () {
app.log.info('preValidation called')
})
app.graphql.addHook('preExecution', async function (schema, document) {
app.log.info('preExecution called')
return {
document,
errors: [new Error('foo')]
}
})
app.graphql.addHook('onResolution', async function () {
app.log.info('onResolution called')
})In terminal
curl --request POST \
--url http://localhost:3000/graphql \
--header 'Content-Type: application/json' \
--data '{"query":"{ add(x: 5, y:3) }"}'Output
preParsing called
preValidation called
preExecution called
onResolution called{
"data": {
"add": 10
},
"errors": [
{
"message": "foo"
}
]
}- Create
Usertype withnameandidproperty - Create an in-memory array of users of type
User - Create a query
findUserwhich accepts anidand- If the
idmatches then the corresponding user is returned - If the
iddoes not match with any user then an error is thrown with the message"Invalid User ID"
- If the
- Add a property
extensionsto the error object with properties:code: USER_ID_INVALIDid: <query input>
- π‘ Use Mercurius
ErrorWithPropsto create the error
- The query should return something similar to:
{
"data": {
"findUser": null
},
"errors": [
{
"message": "Invalid User ID",
"locations": [{ "line": 2, "column": 3 }],
"path": ["findUser"],
"extensions": {
"code": "USER_ID_INVALID",
"id": "5"
}
}
]
}const resolvers = {
Query: {
findUser: (_, { id }) => {
const user = users[id]
if (user) {
return users[id]
}
throw new ErrorWithProps('Invalid User ID', {
id,
code: 'USER_ID_INVALID'
})
}
}
}A GraphQL server can act as a Gateway that composes the schemas of the underlying services into one federated schema and executes queries across the services. Every underlying service must be a GraphQL server that supports the federation.
- Create a Federated GraphQL gateway which listens on port 4000
- Run and expose to the gateway two GraphQL services on ports 4001 and 4002
- In order to use gateway, you should import the following lib:
import mercuriusGateway from '@mercuriusjs/gateway'
...
gateway.register(mercuriusGateway, {...});- Service 1 has a
Usertype and amequery which returns the user - Service 2 has a
Posttype and extendsUserwith apostsarray which are the posts of that user
- Use the import below for registering the service with federation enabled:
import { mercuriusFederationPlugin } from '@mercuriusjs/federation'
service.register(mercuriusFederationPlugin, {...}- Keep an in-memory array of users of the type
Userand posts of typePost - The query should return something similar to:
{
"data": {
"me": {
"name": "John",
"posts": [
{ "id": "p1", "title": "Post 1", "content": "Content 1" },
{ "id": "p3", "title": "Post 3", "content": "Content 3" }
]
}
}
}// server.js
...
await createService(
4001,
service1.schema,
service1.resolvers
)
await createService(
4002,
service2.schema,
service2.resolvers
)
const gateway = buildGateway()
await gateway.listen({ port: 4000 })
...// index.js
import Fastify from 'fastify'
import mercuriusGateway from '@mercuriusjs/gateway'
export default function buildGateway() {
const gateway = Fastify({
logger: { transport: { target: 'pino-pretty' } }
})
gateway.register(mercuriusGateway, {
graphiql: true,
jit: 1,
gateway: {
services: [
{ name: 'user', url: 'http://localhost:4001/graphql' },
{ name: 'post', url: 'http://localhost:4002/graphql' }
]
}
})
return gateway
}// services/service.js
import Fastify from 'fastify'
import { mercuriusFederationPlugin } from '@mercuriusjs/federation'
const createService = async (port, schema, resolvers) => {
const service = Fastify()
service.register(mercuriusFederationPlugin, {
schema,
resolvers,
graphiql: true,
jit: 1
})
await service.listen({ port })
return service
}
export { createService }π‘ see service1 and service2 implementations in the repo
- Review the
addfunctionality implemented before - The
addfunction is supplied with dynamic parameters($x: Int!, $y: Int!) - The inputs to the
addfunction should be passed as query variables, e.g{ "x": 3, "y": 5 } - The query should return something similar to:
{
"data": {
"add": 8
}
}const schema = `
type Query {
add(x: Int!, y: Int!): Int
}
`
const resolvers = {
add: async ({ x, y }) => x + y
}π‘ The implementation is the same as before
In terminal
curl --request POST \
--url http://localhost:3000/graphql \
--header 'Content-Type: application/json' \
--data '{"query":"query AddQuery ($x: Int!, $y: Int!) { add(x: $x, y: $y) }","variables":{"x":3,"y":5},"operationName":"AddQuery"}'Output
{
"data": {
"add": 8
}
}Create a GraphQL server using mercurius which:
- Has
Usertype withid,name,ageandlevelproperties - Has two Query methods named
getNoviceUsersandgetAdvancedUserswhich return typeUsergetNoviceUsersquery returns users withlevel: novicegetAdvancedUsersquery returns users withlevel: advanced
- Query both methods using a fragment on the type
User
- The query should return something similar to:
{
"data": {
"getNoviceUsers": [
{
"id": 1,
"name": "John Doe",
"age": 32,
"level": "novice"
}
],
"getAdvancedUsers": [
{
"id": 2,
"name": "Jane Doe",
"age": 28,
"level": "advanced"
}
]
}
}const schema = `
type User {
id: Int!
name: String!
age: Int!
level: String!
}
type Query {
getNoviceUsers: [User]
getAdvancedUsers: [User]
}
`
const resolvers = {
Query: {
getNoviceUsers() {
return users.filter(user => user.level === 'novice')
},
getAdvancedUsers() {
return users.filter(user => user.level === 'advanced')
}
}
}curl --request POST \
--url http://localhost:3000/graphql \
--header 'Content-Type: application/json' \
--data '{"query":"{ getNoviceUsers { ...userFields } getAdvancedUsers { ...userFields } } fragment userFields on User { id name age level }"}'{
"data": {
"getNoviceUsers": [
{ "id": 1, "name": "John Doe", "age": 32, "level": "novice" }
],
"getAdvancedUsers": [
{ "id": 2, "name": "Jane Doe", "age": 28, "level": "advanced" }
]
}
}In RESTful APIs, a common use case is to restrict access to certain endpoints unless the user has permission to access them. Likewise in GraphQL, you can restrict access to certain fields unless the user has permission to access them.
In this step, we will carry on from Step 8 (Federation) and modify the me query so that it only returns the current user and their posts, and no one else's.
- In the service containing the
mequery, update the schema to define a new directive - Define a
roleargument for the directive that can be eitherVERIFIEDorADMIN - Annotate the
mequery with the new directive and pass in an argument ofrole: VERIFIED - Annotate the
authorfield with the new directive and pass in an argument ofrole: ADMIN
- Install the
mercurius-authpackage and register it with the gateway - Specify the following properties in the options object for the plugin:
authDirective- the name of the directive you just definedauthContext- logic to extract the user's role. For simplicity's sake, we will extract the user's role from theX-Rolerequest header. In a real-world application, you may pass a JSON Web Token (JWT) via the headers, verify and decode it to extract the user's ID, role(s), and permission(s).applyPolicy- logic that determines, given the user's role, whether the server should allow the client to access this field or object?
// services/service1.js
const service1 = {
schema: `
enum Role {
ADMIN
VERIFIED
}
directive @auth(
role: Role
) on OBJECT | FIELD_DEFINITION
extend type Query {
me: User @auth(role: VERIFIED)
}
type User @key(fields: "id") {
id: ID!
name: String!
}
`,
...
}// services/service2.js
const service2 = {
schema: `
enum Role {
ADMIN
VERIFIED
}
directive @auth(role: Role) on OBJECT | FIELD_DEFINITION
type Post @key(fields: "id") {
id: ID!
title: String
content: String
author: User @auth(role: ADMIN)
}
type User @key(fields: "id") @extends {
id: ID! @external
name: String @external
posts: [Post]
}`,
...
}// index.js
import mercuriusAuth from 'mercurius-auth';
...
gateway.register(mercuriusAuth, {
authContext (context) {
return {
role: context.reply.request.headers['x-role']
}
},
async applyPolicy (authDirectiveAST, parent, args, context, info) {
const directiveRole = authDirectiveAST.arguments
.find(arg => arg.name.value === 'role')
.value.value;
return context.auth.role === directiveRole || context.auth.role === 'ADMIN';
},
authDirective: 'auth'
})curl --request POST \
--url http://localhost:4000/graphql \
--header 'Content-Type: application/json' \
--data '{"query":"{ me { name posts { title author { name }}}}"}'{
"data": { "me": null },
"errors": [{
"message": "Failed auth policy check on me",
"locations": [{
"line": 1,
"column": 3
}],
"path": [ "me" ]
}]
}curl --request POST \
--url http://localhost:4000/graphql \
--header 'Content-Type: application/json' \
--header 'X-Role: VERIFIED' \
--data '{"query":"{ me { name posts { title author { name }}}}"}'{
"data": {
"me": {
"name": "John",
"posts": [{
"title": "Post 1",
"author": null
}, {
"title": "Post 3",
"author": null
}]
}
},
"errors": [{
"message": "Failed auth policy check on author",
"locations": [ ... ],
"path": [ ... ]
},
...
]
}curl --request POST \
--url http://localhost:4000/graphql \
--header 'Content-Type: application/json' \
--header 'X-Role: ADMIN' \
--data '{"query":"{ me { name posts { title author { name }}}}"}'{
"data": {
"me": {
"name": "John",
"posts": [{
"title": "Post 1",
"author": { "name": "John" }
}, {
"title": "Post 3",
"author": { "name": "John" }
}]
}
}
}