GraphQL is a spec that describes a declarative query language that your clients can use to ask an API for the exact data they want. This is achieved by creating a strongly typed Schema for your API, ultimate flexibility in how your API can resolve data and client queries validated against your Schema. - Course definition.
The main parts:
Tools and libs:
Example type definitions we are creating:
const gql = require('graphql-tag'); module.exports = gql` directive @log(format: String) on FIELD_DEFINITION directive @formatDate(format: String = "d, MMM, yyyy") on FIELD_DEFINITION enum Theme { DARK LIGHT } enum Role { ADMIN MEMBER GUEST } type User { id: ID! @log(format: "hello") email: String! avatar: String! verified: Boolean! createdAt: String! @formatDate posts: [Post]! role: Role! settings: Settings! } type AuthUser { token: String! user: User! } type Post { id: ID! message: String! author: User! createdAt: String! likes: Int! views: Int! } type Settings { id: ID! user: User! theme: Theme! emailNotifications: Boolean! pushNotifications: Boolean! } type Invite { email: String! from: User! createdAt: String! role: Role! } input NewPostInput { message: String! } input UpdateSettingsInput { theme: Theme emailNotifications: Boolean pushNotifications: Boolean } input UpdateUserInput { email: String avatar: String verified: Boolean } input InviteInput { email: String! role: Role! } input SignupInput { email: String! password: String! role: Role! } input SigninInput { email: String! password: String! } type Query { me: User! posts: [Post]! post(id: ID!): Post! userSettings: Settings! feed: [Post]! } type Mutation { updateSettings(input: UpdateSettingsInput!): Settings! createPost(input: NewPostInput!): Post! updateMe(input: UpdateUserInput!): User invite(input: InviteInput!): Invite! signup(input: SignupInput!): AuthUser! signin(input: SigninInput!): AuthUser! } type Subscription { newPost: Post } `;
For more information on the mutation
and input
types, check the GraphQL docs
We can then declare resolvers to handle the Queries, Mutations etc.
const { AuthenticationError } = require('apollo-server'); const { PubSub } = require('apollo-server'); const { authenticated, authorized } = require('./auth'); const pubsub = new PubSub(); const NEW_POST = 'NEW_POST'; /** * Anything Query / Mutation resolver * using a user for a DB query * requires user authenication */ module.exports = { Query: { me: authenticated((_, __, { user }) => { return user; }), posts: authenticated((_, __, { user, models }) => { return models.Post.findMany({ author: user.id }); }), post: authenticated((_, { id }, { user, models }) => { return models.Post.findOne({ id, author: user.id }); }), userSettings: authenticated((_, __, { user, models }) => { return models.Settings.findOne({ user: user.id }); }), // public resolver feed(_, __, { models }) { return models.Post.findMany(); }, }, Mutation: { updateSettings: authenticated((_, { input }, { user, models }) => { return models.Settings.updateOne({ user: user.id }, input); }), createPost: authenticated((_, { input }, { user, models }) => { const post = models.Post.createOne({ ...input, author: user.id }); pubsub.publish(NEW_POST, { newPost: post }); return post; }), updateMe: authenticated((_, { input }, { user, models }) => { return models.User.updateOne({ id: user.id }, input); }), // admin role invite: authenticated( authorized('ADMIN', (_, { input }, { user }) => { return { from: user.id, role: input.role, createdAt: Date.now(), email: input.email, }; }), ), signup(_, { input }, { models, createToken }) { const existing = models.User.findOne({ email: input.email }); if (existing) { throw new AuthenticationError('nope'); } const user = models.User.createOne({ ...input, verified: false, avatar: 'http', }); const token = createToken(user); models.Settings.createOne({ user: user.id, theme: 'DARK', emailNotifications: true, pushNotifications: true, }); return { token, user }; }, signin(_, { input }, { models, createToken }) { const user = models.User.findOne(input); if (!user) { throw new AuthenticationError('wrong email + password combo'); } const token = createToken(user); return { token, user }; }, }, Subscription: { newPost: { subscribe: () => pubsub.asyncIterator(NEW_POST), }, }, User: { posts(root, _, { user, models }) { if (root.id !== user.id) { throw new AuthenticationError('not your posts'); } return models.Post.findMany({ author: root.id }); }, settings(root, __, { user, models }) { return models.Settings.findOne({ id: root.settings, user: user.id }); }, }, Settings: { user(settings, _, { user, models }) { return models.Settings.findOne({ id: settings.id, user: user.id }); }, }, Post: { author(post, _, { models }) { return models.User.findOne({ id: post.author }); }, }, };
You can read more on Resolvers on the GraphQL docs
Authorization:
Authentication:
How to auth:
The downside is it completely locks down all GraphQL queries and mutations. Extra complexity as well as need to pass auth info to GraphQL.
For the creation of context, that can happen right at the server:
const server = new ApolloServer({ typeDefs, resolvers, // whatever is put here will be returned // as the third argument in a resolver. // This could give us access to Express req etc. context({req}) { const token = req.headers.authorization const user = getUserFromToken(token) return { {...db, user, createToken} } } }) server.listen().then({url} => console.log('Server running')) // then in the resolvers file // code omitted for brevity module.exports = { Mutation: { // 3rd arg is the CONTEXT updateSettings(_, { input }, { user, models }) { return models.Settings.updateOne({ user: user.id }, input); }, }
Read more about context
on the GraphQL Docs
Ties the business logic too close to the authentication logic. Not a good use of separation of concerns.
Here is an example of using authentication:
const { AuthenticationError } = require('apollo-server'); const jwt = require('jsonwebtoken'); const { models } = require('./db'); const secret = 'catpack'; const createToken = ({ id, role }) => jwt.sign({ id, role }, secret); const getUserFromToken = token => { try { const user = jwt.verify(token, secret); return models.User.findOne({ id: user.id }); } catch (e) { return null; } }; const authenticated = next => (root, args, context, info) => { if (!context.user) { throw new AuthenticationError('must authenticate'); } return next(root, args, context, info); }; const authorized = (role, next) => (root, args, context, info) => { if (context.user.role !== role) { throw new AuthenticationError(`you must have ${role} role`); } return next(root, args, context, info); }; module.exports = { getUserFromToken, authenticated, authorized, createToken, };
When using it in our resolvers, we can then run the following.
const { AuthenticationError } = require('apollo-server'); const { PubSub } = require('apollo-server'); const { authenticated, authorized } = require('./auth'); const pubsub = new PubSub(); const NEW_POST = 'NEW_POST'; /** * Anything Query / Mutation resolver * using a user for a DB query * requires user authenication */ module.exports = { Query: { me: authenticated((_, __, { user }) => { return user; }), }, };
There was a question about whether to use directives instead for authentication, with the answer comparing their simularities but the advantage is you won't need to wrap all your resolvers, which gives the benefit of less code.
Another great question was to use the wrappers to operate like Middleware that can also handle things like schema validations that GraphQL's built-in type cannot.
There are two options for real-time with GraphQL:
Nothing stops you from using other real-time solutions, but these two are the ones built into GraphQL.
Subscriptions vs Live Queries:
"Just use subscriptions" - Course runner.
The why subscriptions:
Adding subscription support:
Example subscription from our resolvers.js
file:
const { PubSub } = require('apollo-server'); module.exports = { Mutation: { // publish the event to the pubsub from here createPost: authenticated((_, { input }, { user, models }) => { const post = models.Post.createOne({ ...input, author: user.id }); // must pass the payload to the client pubsub.publish(NEW_POST, { newPost: post }); return post; }), }, Subscription: { newPost: { // `asyncIterator just allows you to process things // one at a time asynchronously subscribe: () => pubsub.asyncIterator(NEW_POST), }, }, };
As for the server we need to pass connection
property and handle the context creation a little differently to pass the connection context.
const server = new ApolloServer({ typeDefs, resolvers, schemaDirectives: { log: LogDirective, formatDate: FormatDateDirective, }, context({ connection }) { // passing connection context if (connection) { return { ...connection.context }; } const token = req.headers.authorization; const user = getUserFromToken(token); return { ...db, user, createToken }; }, subscriptions: { // used to get `connectParams` and set connection for context onConnect(connectionParams) { if (connectionParams.auth) { const user = getUserFromToken(connectionParams.auth); // handled error happened here if (!user) { throw new AuthenticationError('not authenticated'); } return { user }; } throw new AuthenticationError('not authenticated'); }, }, });
When demoing subscriptions in the UI, it is important that you need to run Graphiql in two different tabs. One that subscribes, the other that can make the mutation.
If you throw errors, GraphQL will ensure they are caught. In the context of GraphQL, any error will be caught and passed back in the query.
The query will give back an errors property with an array of errors that contain things including the error name and stack trace etc.
The response can contain errors or data.
If you are in production mode, the stacktrace won't be passed in the response when using Apollo.
Note that Apollo has its own errors exposed from the API that you can extend.
You can also intercept and change errors from the ApolloServer
using the formatError
object method.
Here is a good spot to use something like Sentry etc to help filter what errors to send to Sentry.
Testing resolvers:
Testing schema:
Testing the server:
There is a helper for setting up the server in the tests
directory.
Because the schema is typed, it makes it very easy to turn on mocks! This means you don't have to wait for the end users.
const { ApolloServer } = require('apollo-server'); const { createTestClient } = require('apollo-server-testing'); const typeDefs = require('../src/typedefs'); const resolvers = require('../src/resolvers'); const createTestServer = ctx => { const server = new ApolloServer({ typeDefs, resolvers, // these two are closely tied mockEntireSchema: false, mocks: true, context: () => ctx, }); return createTestClient(server); }; module.exports = createTestServer;
As for the test itself, you can see an example here:
// query.test.js const gql = require('graphql-tag'); const createTestServer = require('./helper'); const FEED = gql` { feed { id message createdAt likes views } } `; describe('queries', () => { test('feed', async () => { // this is where the test server will // take an object const { query } = createTestServer({ user: { id: 1 }, models: { Post: { findMany: jest.fn(() => [ { id: 1, message: 'hello', createdAt: 12345839, likes: 20, views: 300, }, ]), }, }, }); const res = await query({ query: FEED }); expect(res).toMatchSnapshot(); }); });
Allow you to add logic and metadata to your Schemas, Queries or Mutations. Can act like middleware for Schemas, or post processing hooks for your Queries and Mutations.
Why use directives?
TypeDefs
.For example, using the deprecate, formatDate and log directive - one from GraphQL, the other two that we declare:
Directives can go on a property (as seen below) or even on a type ie type User @deprecate { ... }
.
const gql = require('graphql-tag'); module.exports = gql` directive @log(format: String) on FIELD_DEFINITION directive @formatDate(format: String = "d, MMM, yyyy") on FIELD_DEFINITION enum Theme { DARK LIGHT } enum Role { ADMIN MEMBER GUEST } type User { id: ID! @log(format: "hello") email: String! @deprecated avatar: String! verified: Boolean! createdAt: String! @formatDate posts: [Post]! role: Role! settings: Settings! } `;
This then also helps support our GraphQL docs.
Note that @deprected
takes an argument ie @deprecated(reason: "use another field")
.
Front the client query, we can add clientside directives.
query GetMe($yes: Boolean!) { me { # based on query variables error @include(if: $yes) username createdAt } }
There is a library
Can be challenging if you're unfamiliar with how GraphQL works. You will need to work with the AST.
It also requires a definition in your schema.
Finally, you need to create logic for your Directive to use.
// the rest is omitted for brevity. module.exports = gql` directive @log(format: String) on FIELD_DEFINITION directive @formatDate(format: String = "d, MMM, yyyy") on FIELD_DEFINITION `;
The, we can define the logic for our directive:
const { SchemaDirectiveVisitor } = require('apollo-server'); const { defaultFieldResolver, GraphQLString } = require('graphql'); const { formatDate } = require('./utils'); class LogDirective extends SchemaDirectiveVisitor { visitFieldDefinition(field, type) { const { resolve = defaultFieldResolver } = field; field.resolve = async function(root, { format, ...rest }, ctx, info) { console.log(`⚡️ ${type.objectType}.${field.name}`); return resolve.call(this, root, rest, ctx, info); }; } } class FormatDateDirective extends SchemaDirectiveVisitor { visitFieldDefinition(field) { const { resolve = defaultFieldResolver } = field; const { format: defaultFormat } = this.args; field.args.push({ name: 'format', type: GraphQLString, }); field.resolve = async function(root, { format, ...rest }, ctx, info) { const date = await resolve.call(this, root, rest, ctx, info); return formatDate(date, format || defaultFormat); }; } } module.exports = { LogDirective, FormatDateDirective };
For the server, you need to make sure you link the directives for this to all work.
const server = new ApolloServer({ // rest omitted for brevity schemaDirectives: { log: LogDirective, formatDate: FormatDateDirective, }, });
We can use defaultFieldResolver
from graphql
which takes a value, looks at the keys and if the keys match the field, it returns that.
This is used to make sure our definition logs when it is called instead of on startup:
visitFieldDefinition(field, type) { // guves access to old resolver or using the default one const { resolve = defaultFieldResolver } = field; field.resolve = async function(root, { format, ...rest }, ctx, info) { console.log(`⚡️ ${type.objectType}.${field.name}`); return resolve.call(this, root, rest, ctx, info); }; }
Directives can also take a string as an argument. In fact, we can set the directive to take args from the query params:
const { SchemaDirectiveVisitor } = require('apollo-server'); const { defaultFieldResolver, GraphQLString } = require('graphql'); class FormatDateDirective extends SchemaDirectiveVisitor { visitFieldDefinition(field) { const { resolve = defaultFieldResolver } = field; const { format: defaultFormat } = this.args; field.args.push({ name: 'format', type: GraphQLString, }); field.resolve = async function(root, { format, ...rest }, ctx, info) { const date = await resolve.call(this, root, rest, ctx, info); return formatDate(date, format || defaultFormat); }; } }
class AuthenticationDirective extends SchemaDirectiveVisitor { visitFieldDefinition(field) { const resolver = field.resolve || defaultFieldResolver; field.resolve = async (root, args, ctx, info) => { if (!ctx.user) { throw new AuthenticationError('not auth'); } return resolver(root, args, ctx, info); }; } } class AuthorizationDirective extends SchemaDirectiveVisitor { visitFieldDefinition(field) { const resolver = field.resolve || defaultFieldResolver; const { role } = this.args; field.resolve = async (root, args, ctx, info) => { if (ctx.user.role !== role) { throw new AuthenticationError('wrong role'); } return resolver(root, args, ctx, info); }; } }
Then in the GraphQL Tag:
module.exports = gql` directive @authenticated on FIELD_DEFINITION directive @authorized(role: Role!) on FIELD_DEFINITION `;
Finally, we add it onto the server:
const server = new ApolloServer({ // rest omitted for brevity schemaDirectives: { authentication: AuthenticationDirective, authorization: AuthorizationDirective, }, });
You can then use it in a GraphQL theory like so:
module.exports = gql` type Query { me: User! @authenticated @authorized(role: ADMIN) posts: [Post]! post(id: ID!): Post! userSettings: Settings! feed: [Post]! } `
Note that the directives can be added to type definitions as well!
There is:
Application Caching is the preferred way to cache GraphQL right now. Have many options and levels to cache depending on your server.
A bunch of misunderstandings around HTTP caching and GraphQL. This can be complicated if you're not using...
A persisted query is that you build all the queries at runtime and send it to the server. The server prevalidates and stores them on a DB somewhere.
You get that for free with Apollo if you're using the client and the server packages. You can also use edge applications to program your own cache logic. Examples being Lambda Edge, CloudFlare Edge etc.
You can also handle or restrict Mutations over /GET
.