Apollo Server Setup

Building and Configuring GraphQL APIs with Apollo

Introduction

In our previous lecture, we compared REST and GraphQL, exploring their fundamental differences, strengths, and use cases. Today, we'll take a deep dive into Apollo Server, one of the most popular and powerful tools for implementing GraphQL APIs in a Node.js environment.

Apollo Server provides a robust, production-ready framework that makes it easy to build a GraphQL API that works with any data source, schema, or client. By the end of this lecture, you'll understand how to set up Apollo Server, define a schema, implement resolvers, and configure various features to enhance your GraphQL API.

What is Apollo Server?

Apollo Server is an open-source, spec-compliant GraphQL server that's compatible with any GraphQL client, including Apollo Client. It's built on Node.js and provides:

                    graph TD
                    A[Client Applications] -->|GraphQL Queries| B[Apollo Server]
                    B -->|Schema Definition| C[Type Definitions]
                    B -->|Data Fetching| D[Resolvers]
                    D -->|Data Access| E[Database]
                    D -->|Data Access| F[REST APIs]
                    D -->|Data Access| G[Microservices]
                    D -->|Data Access| H[Other Data Sources]
                

Apollo Server vs Other GraphQL Servers

While several GraphQL server implementations exist, Apollo Server is particularly popular because of its:

Comparison of GraphQL Server Implementations

Feature Apollo Server Express-GraphQL graphql-yoga
Developer Experience Excellent Good Excellent
Production Features Comprehensive Basic Good
Customization High Medium High
Framework Integration Multiple (Express, Koa, etc.) Express only Multiple
Active Development Very Active Maintenance Mode Active

Setting Up Apollo Server

Installation

Let's start by setting up a new Apollo Server project:

Setting Up a New Project

# Create a new directory
mkdir apollo-server-demo
cd apollo-server-demo

# Initialize a new Node.js project
npm init -y

# Install dependencies
npm install apollo-server graphql
                

Basic Apollo Server Setup

Here's a minimal Apollo Server implementation:

server.js - Basic Setup

const { ApolloServer, gql } = require('apollo-server');

// Define schema using GraphQL Schema Definition Language (SDL)
const typeDefs = gql`
  type Book {
    id: ID!
    title: String!
    author: String!
    year: Int
  }

  type Query {
    books: [Book]
    book(id: ID!): Book
  }
`;

// Sample data
const books = [
  { id: '1', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', year: 1925 },
  { id: '2', title: 'To Kill a Mockingbird', author: 'Harper Lee', year: 1960 },
  { id: '3', title: '1984', author: 'George Orwell', year: 1949 }
];

// Define resolvers
const resolvers = {
  Query: {
    books: () => books,
    book: (_, { id }) => books.find(book => book.id === id)
  }
};

// Create an Apollo Server instance
const server = new ApolloServer({ typeDefs, resolvers });

// Start the server
server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});
                

Running the Server

To start the server, run:

node server.js

You should see a message that your server is running, typically at http://localhost:4000. Navigate to this URL in your browser to access the Apollo Sandbox, an in-browser IDE for exploring your GraphQL API.

Analogy: Restaurant Menu

Setting up Apollo Server is like creating a restaurant menu and kitchen:

  • Type Definitions (typeDefs) are like the menu that lists all available dishes (types) and their ingredients (fields)
  • Resolvers are like the kitchen staff who know how to prepare each dish when ordered
  • Apollo Server is like the restaurant building itself that houses everything and serves customers
  • GraphQL Playground/Apollo Sandbox is like the ordering system where customers can browse the menu and place orders

Just as a restaurant needs both a menu and chefs to function, a GraphQL API needs both type definitions and resolvers.

Defining Your Schema

The schema is the foundation of any GraphQL API. It defines the types, fields, and relationships that make up your API's capabilities. In Apollo Server, we define the schema using the GraphQL Schema Definition Language (SDL).

Basic Type Definitions

Let's expand our schema to include more types and relationships:

Expanded Schema

const typeDefs = gql`
  type Book {
    id: ID!
    title: String!
    author: Author!
    genre: String
    year: Int
    reviews: [Review]
  }

  type Author {
    id: ID!
    name: String!
    bio: String
    books: [Book!]!
  }

  type Review {
    id: ID!
    rating: Int!
    text: String
    user: String!
    book: Book!
  }

  type Query {
    books: [Book]
    book(id: ID!): Book
    authors: [Author]
    author(id: ID!): Author
  }
`;
                

Schema Types

GraphQL schema supports several types of definitions:

Complex Schema Example

const typeDefs = gql`
  # Enum type for book genres
  enum Genre {
    FICTION
    NON_FICTION
    SCIENCE_FICTION
    FANTASY
    MYSTERY
    BIOGRAPHY
  }

  # Interface for items that can be reviewed
  interface Reviewable {
    id: ID!
    reviews: [Review]
  }

  # Object types
  type Book implements Reviewable {
    id: ID!
    title: String!
    author: Author!
    genre: Genre
    year: Int
    reviews: [Review]
  }

  type Author {
    id: ID!
    name: String!
    bio: String
    books: [Book!]!
  }

  type Review {
    id: ID!
    rating: Int!
    text: String
    user: String!
    item: Reviewable!
  }

  # Input type for creating books
  input BookInput {
    title: String!
    authorId: ID!
    genre: Genre
    year: Int
  }

  type Query {
    books(genre: Genre): [Book]
    book(id: ID!): Book
    authors: [Author]
    author(id: ID!): Author
  }

  type Mutation {
    addBook(book: BookInput!): Book
    addReview(itemId: ID!, rating: Int!, text: String, user: String!): Review
  }
`;
                

Best Practices for Schema Design

Schema With Descriptions

const typeDefs = gql`
  """
  A book in the library
  """
  type Book {
    "Unique identifier for the book"
    id: ID!
    
    "Title of the book"
    title: String!
    
    "Author who wrote the book"
    author: Author!
    
    "Genre of the book"
    genre: String
    
    "Year the book was published"
    year: Int
  }
`;
                

Implementing Resolvers

Resolvers are functions that fetch the data for each field in your schema. They determine how the fields in your schema are populated with actual data.

Resolver Structure

Resolvers match the structure of your schema types and fields:

Basic Resolver Structure

const resolvers = {
  Query: {
    books: (parent, args, context, info) => {
      // Return array of books
    },
    book: (parent, args, context, info) => {
      // Return a single book
    }
  },
  Book: {
    author: (parent, args, context, info) => {
      // Return author for this book
    }
  }
};
                

Resolver Arguments

Each resolver receives four arguments:

Implementing Resolvers for Our Schema

Let's implement resolvers for our expanded schema:

Expanded Resolvers

// Sample data
const books = [
  { id: '1', title: 'The Great Gatsby', authorId: '1', genre: 'FICTION', year: 1925 },
  { id: '2', title: 'To Kill a Mockingbird', authorId: '2', genre: 'FICTION', year: 1960 },
  { id: '3', title: '1984', authorId: '3', genre: 'SCIENCE_FICTION', year: 1949 }
];

const authors = [
  { id: '1', name: 'F. Scott Fitzgerald', bio: 'American novelist...' },
  { id: '2', name: 'Harper Lee', bio: 'American novelist...' },
  { id: '3', name: 'George Orwell', bio: 'English novelist...' }
];

const reviews = [
  { id: '1', bookId: '1', rating: 5, text: 'A masterpiece!', user: 'reader1' },
  { id: '2', bookId: '1', rating: 4, text: 'Beautifully written.', user: 'reader2' },
  { id: '3', bookId: '2', rating: 5, text: 'A classic.', user: 'reader1' }
];

// Resolvers
const resolvers = {
  Query: {
    books: (_, { genre }) => {
      if (genre) {
        return books.filter(book => book.genre === genre);
      }
      return books;
    },
    book: (_, { id }) => books.find(book => book.id === id),
    authors: () => authors,
    author: (_, { id }) => authors.find(author => author.id === id)
  },
  Book: {
    author: (parent) => authors.find(author => author.id === parent.authorId),
    reviews: (parent) => reviews.filter(review => review.bookId === parent.id)
  },
  Author: {
    books: (parent) => books.filter(book => book.authorId === parent.id)
  },
  Review: {
    book: (parent) => books.find(book => book.id === parent.bookId)
  },
  Mutation: {
    addBook: (_, { book }) => {
      const newBook = {
        id: String(books.length + 1),
        title: book.title,
        authorId: book.authorId,
        genre: book.genre,
        year: book.year
      };
      
      books.push(newBook);
      return newBook;
    },
    addReview: (_, { itemId, rating, text, user }) => {
      const newReview = {
        id: String(reviews.length + 1),
        bookId: itemId,
        rating,
        text,
        user
      };
      
      reviews.push(newReview);
      return newReview;
    }
  }
};
                

Resolver Best Practices

Setting up Context

The context is a shared object that's available to all resolvers. It's typically used to pass request-specific data like authentication information, data sources, or utility functions.

Creating a Context Function

Basic Context Setup

const { ApolloServer } = require('apollo-server');

// Create data sources (these could be database connections, API clients, etc.)
const dataSources = {
  books: {
    getAll: () => books,
    getById: (id) => books.find(book => book.id === id),
    create: (book) => {
      const newBook = { id: String(books.length + 1), ...book };
      books.push(newBook);
      return newBook;
    }
  },
  authors: {
    getAll: () => authors,
    getById: (id) => authors.find(author => author.id === id)
  }
};

// Context function
const context = ({ req }) => {
  // Get the user token from the headers
  const token = req.headers.authorization || '';
  
  // Verify the token and get user info
  // This is a simplified example - in a real app, you'd use JWT or similar
  const user = token ? { id: '123', name: 'Test User', isAdmin: true } : null;
  
  // Add the user and data sources to the context
  return {
    user,
    dataSources
  };
};

// Create the Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context
});
                

Using Context in Resolvers

Resolvers with Context

const resolvers = {
  Query: {
    books: (_, args, { dataSources }) => {
      return dataSources.books.getAll();
    },
    book: (_, { id }, { dataSources }) => {
      return dataSources.books.getById(id);
    }
  },
  Mutation: {
    addBook: (_, { book }, { user, dataSources }) => {
      // Check if user is authenticated
      if (!user) {
        throw new Error('You must be logged in to add a book');
      }
      
      return dataSources.books.create({
        ...book,
        createdBy: user.id
      });
    }
  }
};
                

Apollo Data Sources

Apollo provides a dedicated pattern for data sources that helps with caching, deduplication, and error handling:

Using Apollo Data Sources

const { ApolloServer } = require('apollo-server');
const { RESTDataSource } = require('apollo-datasource-rest');

class BooksAPI extends RESTDataSource {
  constructor() {
    super();
    this.baseURL = 'https://api.example.com/';
  }

  async getBooks() {
    return this.get('books');
  }

  async getBook(id) {
    return this.get(`books/${id}`);
  }

  async createBook(book) {
    return this.post('books', book);
  }
}

// Create the Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  dataSources: () => ({
    booksAPI: new BooksAPI()
  })
});
                
                    graph TD
                    A[Client Request] --> B[Apollo Server]
                    B --> C[Context Creation]
                    C --> D[Authentication]
                    C --> E[Data Sources Initialization]
                    C --> F[Request-specific Data]
                    D --> G[Resolver Execution]
                    E --> G
                    F --> G
                    G --> H[Response]
                

Implementing Mutations

Mutations are operations that modify data. In GraphQL, mutations are explicitly named to indicate their purpose.

Defining Mutations in the Schema

Mutation Schema

const typeDefs = gql`
  type Book {
    id: ID!
    title: String!
    authorId: ID!
    genre: String
    year: Int
  }

  input BookInput {
    title: String!
    authorId: ID!
    genre: String
    year: Int
  }

  type Mutation {
    addBook(book: BookInput!): Book
    updateBook(id: ID!, book: BookInput!): Book
    deleteBook(id: ID!): Boolean
  }
`;
                

Implementing Mutation Resolvers

Mutation Resolvers

const resolvers = {
  // ... Query resolvers
  
  Mutation: {
    addBook: (_, { book }, { dataSources, user }) => {
      // Check authentication
      if (!user) throw new Error('You must be logged in');
      
      return dataSources.books.create(book);
    },
    
    updateBook: async (_, { id, book }, { dataSources, user }) => {
      // Check authentication
      if (!user) throw new Error('You must be logged in');
      
      // Check if book exists
      const existingBook = await dataSources.books.getById(id);
      if (!existingBook) throw new Error(`Book with ID ${id} not found`);
      
      // Update book
      return dataSources.books.update(id, book);
    },
    
    deleteBook: async (_, { id }, { dataSources, user }) => {
      // Check authentication and permissions
      if (!user) throw new Error('You must be logged in');
      if (!user.isAdmin) throw new Error('Only admins can delete books');
      
      // Check if book exists
      const existingBook = await dataSources.books.getById(id);
      if (!existingBook) throw new Error(`Book with ID ${id} not found`);
      
      // Delete book
      return dataSources.books.delete(id);
    }
  }
};
                

Mutation Best Practices

                    sequenceDiagram
                    participant Client
                    participant ApolloServer
                    participant InputValidation
                    participant Authentication
                    participant DataSource
                    participant Database
                    
                    Client->>ApolloServer: mutation { addBook(book: {...}) }
                    ApolloServer->>InputValidation: Validate input
                    ApolloServer->>Authentication: Check permissions
                    ApolloServer->>DataSource: Process mutation
                    DataSource->>Database: Insert data
                    Database-->>DataSource: Return result
                    DataSource-->>ApolloServer: Return created object
                    ApolloServer-->>Client: Return response
                

Error Handling in Apollo Server

Proper error handling is crucial for a robust GraphQL API. Apollo Server provides several ways to handle and customize errors.

Basic Error Handling

Throwing Errors in Resolvers

const resolvers = {
  Query: {
    book: (_, { id }) => {
      const book = books.find(book => book.id === id);
      
      if (!book) {
        throw new Error(`Book with ID ${id} not found`);
      }
      
      return book;
    }
  }
};
                

Apollo Error Codes

Apollo Server provides predefined error codes for common scenarios:

Using Apollo Error Codes

const { ApolloServer, gql, UserInputError, AuthenticationError, ForbiddenError } = require('apollo-server');

const resolvers = {
  Mutation: {
    addBook: (_, { book }, { user }) => {
      // Validate input
      if (!book.title) {
        throw new UserInputError('Title is required', {
          invalidArgs: ['title']
        });
      }
      
      // Check authentication
      if (!user) {
        throw new AuthenticationError('You must be logged in');
      }
      
      // Check permissions
      if (!user.canAddBooks) {
        throw new ForbiddenError('You do not have permission to add books');
      }
      
      // Process mutation...
    }
  }
};
                

Custom Error Formatting

You can customize how errors are formatted in the response:

Custom Error Formatting

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (err) => {
    // Don't expose internal server errors to clients
    if (err.message.startsWith('Database error:')) {
      return new Error('Internal server error');
    }
    
    // Add custom error data
    if (err.extensions?.code === 'FORBIDDEN') {
      err.extensions.customData = {
        resourceType: err.path?.[0],
        requiredPermission: 'ADMIN'
      };
    }
    
    // Log error for server-side debugging
    console.error('GraphQL Error:', err);
    
    return err;
  }
});
                

Extending Error Classes

You can create custom error classes for specific error scenarios:

Custom Error Classes

const { ApolloError } = require('apollo-server');

// Custom error class
class ResourceNotFoundError extends ApolloError {
  constructor(resource, id) {
    super(`${resource} with ID ${id} not found`, 'RESOURCE_NOT_FOUND', {
      resource,
      id
    });
  }
}

// In resolver
const resolvers = {
  Query: {
    book: (_, { id }) => {
      const book = books.find(book => book.id === id);
      
      if (!book) {
        throw new ResourceNotFoundError('Book', id);
      }
      
      return book;
    }
  }
};
                

Authentication and Authorization

Securing your GraphQL API is essential. Apollo Server doesn't include built-in authentication mechanisms, but it provides hooks where you can implement your own.

Authentication with JWT

JWT Authentication Example

const { ApolloServer, AuthenticationError } = require('apollo-server');
const jwt = require('jsonwebtoken');

// JWT secret (should be in environment variables in a real app)
const JWT_SECRET = 'your-secret-key';

// Context function for authentication
const context = ({ req }) => {
  // Get the token from the Authorization header
  const token = req.headers.authorization?.split('Bearer ')[1] || '';
  
  if (!token) {
    return { user: null };
  }
  
  try {
    // Verify the token
    const decoded = jwt.verify(token, JWT_SECRET);
    
    // Add the user to the context
    return {
      user: {
        id: decoded.sub,
        email: decoded.email,
        roles: decoded.roles
      }
    };
  } catch (err) {
    // Token verification failed
    return { user: null };
  }
};

// In your schema, add mutations for authentication
const typeDefs = gql`
  type User {
    id: ID!
    email: String!
    roles: [String!]!
  }
  
  type AuthPayload {
    token: String!
    user: User!
  }
  
  type Mutation {
    login(email: String!, password: String!): AuthPayload
  }
`;

// Implement login resolver
const resolvers = {
  Mutation: {
    login: async (_, { email, password }) => {
      // In a real app, you would validate credentials against a database
      const user = await validateCredentials(email, password);
      
      if (!user) {
        throw new AuthenticationError('Invalid email or password');
      }
      
      // Generate JWT token
      const token = jwt.sign(
        {
          sub: user.id,
          email: user.email,
          roles: user.roles
        },
        JWT_SECRET,
        { expiresIn: '1h' }
      );
      
      return {
        token,
        user
      };
    }
  }
};

// Create the Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context
});
                

Field-Level Authorization

You can implement fine-grained authorization at the field level:

Field-Level Authorization

// Context with permissions check helper
const context = ({ req }) => {
  // ... authenticate user from token

  return {
    user,
    // Helper function to check permissions
    checkPermission: (requiredPermission) => {
      if (!user) throw new AuthenticationError('You must be logged in');
      if (!user.permissions.includes(requiredPermission)) {
        throw new ForbiddenError(`Missing permission: ${requiredPermission}`);
      }
      return true;
    }
  };
};

// Using the permission check in resolvers
const resolvers = {
  Query: {
    books: (_, __, { checkPermission }) => {
      checkPermission('read:books');
      return books;
    },
    secretDocuments: (_, __, { checkPermission }) => {
      checkPermission('read:secret');
      return secretDocuments;
    }
  },
  Book: {
    salesData: (parent, _, { checkPermission }) => {
      checkPermission('read:sales');
      return getSalesData(parent.id);
    }
  }
};
                

Using Authentication Directives

You can create schema directives for declarative authorization:

Authentication Directives

const { SchemaDirectiveVisitor } = require('apollo-server');
const { defaultFieldResolver } = require('graphql');

// Define directive in schema
const typeDefs = gql`
  directive @auth(requires: Role = USER) on OBJECT | FIELD_DEFINITION
  
  enum Role {
    USER
    ADMIN
    REVIEWER
  }
  
  type Book @auth(requires: USER) {
    id: ID!
    title: String!
    content: String! @auth(requires: USER)
    salesData: SalesData! @auth(requires: ADMIN)
  }
`;

// Implement directive
class AuthDirective extends SchemaDirectiveVisitor {
  visitObject(object) {
    this.ensureFieldsWrapped(object);
    object._requiredAuthRole = this.args.requires;
  }
  
  visitFieldDefinition(field, details) {
    this.ensureFieldsWrapped(details.objectType);
    field._requiredAuthRole = this.args.requires;
  }
  
  ensureFieldsWrapped(objectType) {
    // This method ensures that all fields are wrapped with auth check
    if (objectType._fieldsWrapped) return;
    objectType._fieldsWrapped = true;
    
    const fields = objectType.getFields();
    
    Object.keys(fields).forEach(fieldName => {
      const field = fields[fieldName];
      const { resolve = defaultFieldResolver } = field;
      
      field.resolve = async function (parent, args, context, info) {
        // Check object-level auth requirements
        const objectRequiredRole = objectType._requiredAuthRole;
        if (objectRequiredRole) {
          const hasRole = context.user && context.user.roles.includes(objectRequiredRole);
          if (!hasRole) {
            throw new ForbiddenError(`Requires role: ${objectRequiredRole}`);
          }
        }
        
        // Check field-level auth requirements
        const fieldRequiredRole = field._requiredAuthRole;
        if (fieldRequiredRole) {
          const hasRole = context.user && context.user.roles.includes(fieldRequiredRole);
          if (!hasRole) {
            throw new ForbiddenError(`Requires role: ${fieldRequiredRole}`);
          }
        }
        
        return resolve.apply(this, [parent, args, context, info]);
      };
    });
  }
}

// Create the Apollo Server with the directive
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context,
  schemaDirectives: {
    auth: AuthDirective
  }
});
                

GraphQL Subscriptions

Subscriptions enable real-time functionality in GraphQL. They allow clients to receive updates when specific events occur on the server.

Setting Up Subscriptions

Basic Subscription Setup

const { ApolloServer, gql, PubSub } = require('apollo-server');

// Create a PubSub instance
const pubsub = new PubSub();

// Event channels
const BOOK_ADDED = 'BOOK_ADDED';
const BOOK_UPDATED = 'BOOK_UPDATED';

// Schema with subscriptions
const typeDefs = gql`
  type Book {
    id: ID!
    title: String!
    author: String!
  }
  
  type Subscription {
    bookAdded: Book
    bookUpdated: Book
  }
  
  type Query {
    books: [Book]
  }
  
  type Mutation {
    addBook(title: String!, author: String!): Book
    updateBook(id: ID!, title: String, author: String): Book
  }
`;

// Sample data
const books = [
  { id: '1', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' }
];

// Resolvers
const resolvers = {
  Query: {
    books: () => books
  },
  Mutation: {
    addBook: (_, { title, author }) => {
      const newBook = { id: String(books.length + 1), title, author };
      books.push(newBook);
      
      // Publish event to subscribers
      pubsub.publish(BOOK_ADDED, { bookAdded: newBook });
      
      return newBook;
    },
    updateBook: (_, { id, title, author }) => {
      const bookIndex = books.findIndex(book => book.id === id);
      
      if (bookIndex === -1) {
        throw new Error(`Book with ID ${id} not found`);
      }
      
      const updatedBook = {
        ...books[bookIndex],
        title: title || books[bookIndex].title,
        author: author || books[bookIndex].author
      };
      
      books[bookIndex] = updatedBook;
      
      // Publish event to subscribers
      pubsub.publish(BOOK_UPDATED, { bookUpdated: updatedBook });
      
      return updatedBook;
    }
  },
  Subscription: {
    bookAdded: {
      subscribe: () => pubsub.asyncIterator([BOOK_ADDED])
    },
    bookUpdated: {
      subscribe: () => pubsub.asyncIterator([BOOK_UPDATED])
    }
  }
};

// Create and start the server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  subscriptions: {
    path: '/subscriptions'
  }
});

server.listen().then(({ url, subscriptionsUrl }) => {
  console.log(`🚀 Server ready at ${url}`);
  console.log(`🔔 Subscriptions ready at ${subscriptionsUrl}`);
});
                

Subscription with Filtering

You can filter subscription events based on criteria:

Filtered Subscriptions

// Schema with filtered subscription
const typeDefs = gql`
  type Subscription {
    bookAdded: Book
    bookUpdated: Book
    bookByAuthorUpdated(author: String!): Book
  }
`;

// Resolver with filtering
const resolvers = {
  Subscription: {
    // ...other subscription resolvers
    
    bookByAuthorUpdated: {
      subscribe: withFilter(
        () => pubsub.asyncIterator([BOOK_UPDATED]),
        (payload, variables) => {
          // Only send event if the book's author matches the subscription variable
          return payload.bookUpdated.author === variables.author;
        }
      )
    }
  }
};
                
                    sequenceDiagram
                    participant Client1 as Client 1
                    participant Client2 as Client 2
                    participant ApolloServer
                    participant PubSub
                    
                    Client1->>ApolloServer: subscription { bookAdded }
                    Client2->>ApolloServer: subscription { bookByAuthorUpdated(author: "Orwell") }
                    ApolloServer->>PubSub: asyncIterator(BOOK_ADDED)
                    ApolloServer->>PubSub: asyncIterator(BOOK_UPDATED)
                    
                    Note over Client1, PubSub: WebSocket connections established
                    
                    ApolloServer->>PubSub: publish(BOOK_ADDED, { newBook })
                    PubSub->>Client1: { bookAdded: newBook }
                    
                    ApolloServer->>PubSub: publish(BOOK_UPDATED, { updatedBook })
                    Note over PubSub, Client2: Filter checks author === "Orwell"
                    PubSub->>Client2: { bookByAuthorUpdated: updatedBook } (if author matches)
                

Using Redis for PubSub

For production environments, it's recommended to use a dedicated PubSub system like Redis:

Redis PubSub

const { RedisPubSub } = require('graphql-redis-subscriptions');
const Redis = require('ioredis');

// Create Redis clients
const options = {
  host: 'localhost',
  port: 6379
};

const pubsub = new RedisPubSub({
  publisher: new Redis(options),
  subscriber: new Redis(options)
});

// Use pubsub as before
                

Advanced Apollo Server Features

Apollo Server Plugins

Plugins allow you to customize Apollo Server's behavior at different stages of the request lifecycle:

Custom Logger Plugin

const loggerPlugin = {
  // Fires when a GraphQL request is received
  requestDidStart(requestContext) {
    console.log('Request started:', requestContext.request.operationName);
    const startTime = Date.now();
    
    return {
      // Fires when parsing was successful
      didResolveOperation(context) {
        console.log('Operation resolved:', context.operationName);
      },
      
      // Fires right before responses are sent
      willSendResponse(context) {
        const duration = Date.now() - startTime;
        console.log(`Request for ${context.operationName} completed in ${duration}ms`);
      },
      
      // Fires when an error occurs
      didEncounterErrors(context) {
        console.error('Errors occurred:', context.errors);
      }
    };
  }
};

// Add the plugin to Apollo Server
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [loggerPlugin]
});
                

Response Caching

Apollo Server includes built-in support for response caching:

Response Caching

const { ApolloServer } = require('apollo-server');
const responseCachePlugin = require('apollo-server-plugin-response-cache');

// Add cache hints to the schema
const typeDefs = gql`
  type Book @cacheControl(maxAge: 3600) {
    id: ID!
    title: String!
    author: String!
    # This field changes frequently, so cache for a shorter time
    rating: Float @cacheControl(maxAge: 60)
  }
  
  type Query {
    books: [Book] @cacheControl(maxAge: 300)
    book(id: ID!): Book
  }
`;

// Create the server with caching
const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [responseCachePlugin()],
  cacheControl: {
    defaultMaxAge: 30, // Default cache time in seconds
    calculateHttpHeaders: true // Add Cache-Control headers to responses
  }
});
                

Apollo Federation

Apollo Federation enables you to split your GraphQL schema across multiple services:

Federation Example

// In your books service
const { ApolloServer } = require('apollo-server');
const { buildFederatedSchema } = require('@apollo/federation');

const typeDefs = gql`
  type Book @key(fields: "id") {
    id: ID!
    title: String!
    authorId: ID!
  }
  
  extend type Query {
    books: [Book]
    book(id: ID!): Book
  }
`;

const resolvers = {
  Query: {
    books: () => books,
    book: (_, { id }) => books.find(book => book.id === id)
  },
  Book: {
    __resolveReference(book) {
      return books.find(b => b.id === book.id);
    }
  }
};

// Create a federated schema
const server = new ApolloServer({
  schema: buildFederatedSchema([{ typeDefs, resolvers }])
});

// In your authors service
const typeDefs = gql`
  type Author @key(fields: "id") {
    id: ID!
    name: String!
    bio: String
  }
  
  extend type Book @key(fields: "id") {
    id: ID! @external
    authorId: ID! @external
    author: Author
  }
  
  extend type Query {
    authors: [Author]
    author(id: ID!): Author
  }
`;

const resolvers = {
  Query: {
    authors: () => authors,
    author: (_, { id }) => authors.find(a => a.id === id)
  },
  Author: {
    __resolveReference(author) {
      return authors.find(a => a.id === author.id);
    }
  },
  Book: {
    author(book) {
      return authors.find(a => a.id === book.authorId);
    }
  }
};

// Create gateway that combines the services
const { ApolloGateway } = require('@apollo/gateway');

const gateway = new ApolloGateway({
  serviceList: [
    { name: 'books', url: 'http://localhost:4001' },
    { name: 'authors', url: 'http://localhost:4002' }
  ]
});

const server = new ApolloServer({
  gateway,
  subscriptions: false // Federation doesn't yet support subscriptions
});
                
                    graph TD
                    A[Client] -->|GraphQL Query| B[Apollo Gateway]
                    B -->|Federation| C[Books Service]
                    B -->|Federation| D[Authors Service]
                    B -->|Federation| E[Reviews Service]
                

Production Deployment Considerations

Environment Configuration

Always use environment variables for sensitive information:

Using Environment Variables

require('dotenv').config();

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => {
    // Use environment variables for sensitive information
    const jwtSecret = process.env.JWT_SECRET;
    // ...
  }
});
                

Security Checklist

Production Security Settings

const { ApolloServer } = require('apollo-server');
const { depthLimit } = require('graphql-depth-limit');
const { createRateLimitRule } = require('graphql-rate-limit');

// Create rate limit rule
const rateLimitRule = createRateLimitRule({
  identifyContext: (context) => context.user?.id || context.req.ip,
  formatError: () => 'Too many requests. Please try again later.',
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP/user to 100 requests per windowMs
});

// Create Apollo Server with production settings
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context,
  validationRules: [
    depthLimit(10), // Limit query depth
    rateLimitRule // Apply rate limiting
  ],
  introspection: process.env.NODE_ENV !== 'production', // Disable in production
  playground: process.env.NODE_ENV !== 'production', // Disable in production
  debug: process.env.NODE_ENV !== 'production', // Disable in production
  formatError: (err) => {
    // Log errors for server monitoring
    console.error('GraphQL Error:', err);
    
    // Don't expose internal server errors in production
    if (process.env.NODE_ENV === 'production') {
      if (err.message.startsWith('Database error:')) {
        return new Error('Internal server error');
      }
    }
    
    return err;
  }
});
                

Monitoring and Observability

For production deployments, implement proper monitoring:

Health Check Endpoint

const express = require('express');
const { ApolloServer } = require('apollo-server-express');

async function startApolloServer() {
  const app = express();
  
  // Health check endpoint
  app.get('/health', (req, res) => {
    res.status(200).send('OK');
  });
  
  // Readiness check endpoint
  app.get('/ready', async (req, res) => {
    try {
      // Check database connection
      await checkDatabaseConnection();
      
      // Check other dependencies
      await checkRedisConnection();
      
      res.status(200).send('Ready');
    } catch (error) {
      console.error('Readiness check failed:', error);
      res.status(503).send('Not Ready');
    }
  });
  
  const server = new ApolloServer({
    typeDefs,
    resolvers
    // ... other options
  });
  
  await server.start();
  server.applyMiddleware({ app });
  
  return { server, app };
}

startApolloServer().then(({ app }) => {
  const PORT = process.env.PORT || 4000;
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
  });
});
                

Practice Activities

Activity 1: Basic Apollo Server

Create a basic Apollo Server with the following features:

Activity 2: Implement Mutations

Extend your Apollo Server to include mutations:

Activity 3: Add Subscriptions

Add real-time features to your Apollo Server:

Activity 4: Production-Ready Server

Optimize your Apollo Server for production:

Summary

In this lecture, we've explored Apollo Server, a powerful framework for building GraphQL APIs:

Apollo Server provides a robust foundation for building GraphQL APIs that are flexible, efficient, and maintainable. By leveraging its features and following best practices, you can create APIs that are a joy for clients to consume and for developers to work with.

Coming Up Next

In our next lecture, we'll explore GraphQL queries and mutations in more depth, looking at advanced querying techniques, complex mutations, and how to optimize performance with DataLoader and other tools.

Additional Resources