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