GraphQL
Introduction & some common problems at scale
Overview
- What is GraphQL?
- Application structure
- Authentication and authorization
- Design your resolvers
- Client state management
What is it?
GraphQL is a query language for APIs and a runtime for fulfilling those queries
- schema: A GraphQL schema is at the center of any GraphQL server implementation and describes the functionality available to the clients which connect to it
- resolvers: In order to respond to queries, a schema needs to have resolve functions for all fields. This collection of functions is called the “resolver map”
// Schema: describe your data
type User {
email: String
name: String
}
type Query {
user: User
}
// Resolver: handle returned data
const resolver = {
Query: {
user: () => ({
email: "dthanh1291@gmail.com",
name: "Thanh Nguyen"
})
}
}
// Query
query {
user {
email
name
}
}
// Response
{
"user": {
"email": "dinhthanh@gmail.com",
"name": "Thanh Nguyen"
}
}
Query execution
- Parsing the query: the server parses the query string and turns it into an abstract syntax tree
- Validation: makes sure the query is syntactically correct and makes sense
- Executing: execution begins at the root of the query, then calls the resolve function of the fields from the top level to bottom
type Author {
id: String
name: String
posts: [Post]
}
type Post {
id: String
title: String
text: String
author: Author
}
query {
getAuthor(id: 5) {
name
posts {
title
author {
name
}
}
}
}
Why GraphQL?
- Solving Over-fetching
- Avoiding multiple roundtrips
- Simplifying client integration
- Self-documentation and enforcing Schema & Type System
- Allowing multiple data sources from a single endpoint
Application structure
Simple implementation
- When we add more and more features, the schema and resolvers become too large and complex
request
to /graphql
GraphQL Server
Resolvers
Data Sources
/src
/resolvers
users.js
posts.js
/services
userService.js
postService.js
schema.graphql
server.js
Http Server
Feature based structure
GraphQL server
What's inside a module?
- Schema
- Resolvers
- Providers
const UserModule = new GraphQLModule({
typeDefs: gql`
type Query {
user: User
}
type User {
email: String!
name: String
}
`,
resolvers: {
Query: {
user: (root, args, context)
=> context.injector.get(USER_DATA_LOADER).load(args.id),
},
},
providers: [{
provide: USER_DATA_LOADER,
useValue: new DataLoader(keys => fetchUsersByIds(keys));
}]
);
Authentication & authorization
Authentication
request
HTTP Server
GraphQL Server
Resolvers
business logic
1
2
3
1 - At HTTP Server
const api = express();
const server = http.Server(api);
api.post('/graphql', (req, res, next) => auth(req, res, next));
- We can separate authentication logic from business logic
- GraphQL layer is unaware of current user
3 - At resolvers
resolvers: {
Query: {
user: (_, args, context) {
const user = authenticate(context.authToken);
if (!user) {
throw new Error('Invalid token!');
}
}
}
}
- Duplication
- Business logic is tightly coupled with authentication
2 - At GraphQL server
const server = new ApolloServer({
context: async ({ req }) => {
const currentUser = await authenticate(req.headers.auth);
if (!currentUser) {
throw new Error('Invalid token');
}
return {
currentUser
};
}
});
- The resolvers can access to user session via currentUser, e.g we can use it for authorization
- In resolvers
- Via midlewares
- Via directives
Directives provide a way to describe alternate runtime execution and type validation behavior in a GraphQL document.
// Middleware
const validateRole = role
=> next
=> (root, args, context, info) => {
if (context.currentUser.role !== role) {
throw new Error('Permission denied!');
}
return next(root, args, context, info);
};
const UserModule = new GraphQLModule({
// Directives
typeDefs: gql`
type Query {
user: User @auth(role: ADMIN)
}
`,
resolvers: {
Query: {
user: (root, args, context) => {
if (context.currentUser.role !== 'ADMIN') {
throw new Error('Permission denied!');
}
// return user
},
user: validateRole('ADMIN')((...args) => {
// return user
})
},
},
);
Authorization
Resolvers
Data loading
- Can cause N + 1 problem
const UserModule = new GraphQLModule({
typeDefs: gql`
type Query {
users: [User]
}
type User {
email: String!
name: String
referrer: User
}
`,
resolvers: {
Query: {
users: (root, args, context) => fetchUsers(),
},
User: {
referrer: parentNode
=> fetchUserById(parentNode.referrerId),
}
},
);
// e.g we fetch the first 10 users
// fetch 10 users
// fetch 1st user's referrer
// fetch 2nd user's referrer
...
// fetch 10th user's referrer
DataLoader
-
DataLoader is a generic utility to be used as part of your application's data fetching layer to provide a simplified and consistent API over various remote data sources such as databases or web services via batching and caching
-
Each DataLoader contains a batch loading function which accepts an Array of keys, and returns a Promise which resolves to an Array of values
const userLoader = new DataLoader(userIds => {
return batchFetchingUsersByIds(userIds);
// e.g
// SELECT * FROM users WHERE id in (...userIds)
});
const UserModule = new GraphQLModule({
resolvers: {
User: {
referrer: (parentNode, _, context)
=> context.userLoader
.load(parentNode.referrerId),
}
},
);
// fetch 10 users
// load user id 1 -> push it to a queue
// load user id 2 -> push it to the queue
...
// load user id n -> push it to the queue
// fetch referrers by ids
Client side
- Querying data
- Handling mutation
- Handling client state and caching
Query
- Query is used by the client to request the data it needs from the server
const GET_USER_QUERY = gql`
query userById($id: ID!) {
user(id: $id) {
email
name
}
}
`;
const withUserQuery
= graphql(GET_USER_QUERY)(Component);
function Component({ data: { user } }) {
return (
<div>
<div>{user.email}</div>
<div>{user.name}</div>
</div>
);
}
function Component () {
return (
<Query query={GET_USER_QUERY}>
{({ data: { user } }) => {
return (
<div>
<div>{user.email}</div>
<div>{user.name}</div>
</div>
);
}}
</Query>
)
};
Mutation
- Mutations are used to create, update and delete data
const ADD_USER = gql`
mutation ($email: String!, name: String!) {
addUser($email: String!, name: String!) {
id
email
name
}
}
`;
const AddUser = () => (
<Mutation mutation={ADD_TODO}>
{(addUser) => (
<AddUserForm addUser={addUser} />
)}
</Mutation>
);
const withAddUserMutation = graphql(ADD_USER, {
props: ({ mutate }) => ({
addUser: variables => mutate({ variables }),
}),
});
Optimistic UI
- Optimistic UI is a pattern that you can use to simulate the results of a mutation and update the UI even before receiving a response from the server
- Improving perceived performance
const UPDATE_USER = gql`
mutation ($id: String!, name: String!) {
updateUser($id: String!, name: String!) {
id
name
}
}
`;
const UpdateUserForm = () => (
<Mutation mutation={UPDATE_USER}>
{mutate => {
<UpdateUser
updateUser={({ id, name }) =>
mutate({
variables: { id, name },
optimisticResponse: {
__typename: "Mutation",
updateUser: {
__typename: "User",
id,
name,
}
}
})
}
/>;
}}
</Mutation>
);
Client state management
- It's not recommended to introduce another client store like Redux, MobX..
- Apollo cache should be the single source of truth
Local state
Apollo Client allows to store your local data inside the Apollo cache alongside your remote data
const client = new ApolloClient({
uri: 'https://localhost:3000/graphql',
clientState: {
typeDefs: `
type Query {
theme: String
}
`,
defaults: {
theme: 'light',
},
resolvers: {
Mutation: {
selectTheme: async (_, { theme }, { cache }) => {
await cache.writeData({ data: { theme } });
return null;
}
}
}
}
});
Thank you for listening,
deck
By Thanh Nguyễn
deck
- 340