Table of Contents
In this article, we will explore how to build a movie search web app using GraphQL, Node.js, and TypeScript. We will create a GraphQL API that allows users to search for movies by title, genre, and release year. We will also implement pagination to handle large result sets efficiently.
To get started, let's set up our project structure and install the necessary dependencies. Create a new directory for your project and initialize it with npm:
mkdir movie-search-app cd movie-search-app npm init -y
Next, let's install the required packages:
npm install express graphql apollo-server-express node-fetch
Now we can start building our GraphQL server. Create an index.ts
file in the root of your project directory and add the following code:
import express from 'express'; import { ApolloServer } from 'apollo-server-express'; import fetch from 'node-fetch'; const app = express(); const typeDefs = ` type Query { movies(title: String!, genre: String, releaseYear: Int): [Movie!]! } type Movie { title: String! genre: String! releaseYear: Int! } `; const resolvers = { Query: { movies: async (_, { title, genre, releaseYear }) => { // Fetch movies from an external API or database based on the provided parameters const response = await fetch(`https://api.example.com/movies?title=${title}&genre=${genre}&releaseYear=${releaseYear}`); const data = await response.json(); return data.movies; }, }, }; const server = new ApolloServer({ typeDefs, resolvers }); server.applyMiddleware({ app }); app.listen(4000, () => { console.log('Server started at http://localhost:4000/graphql'); });
In this code snippet, we import the necessary packages and define our GraphQL schema using the typeDefs
constant. We also define a resolver function for the movies
query, which fetches movies from an external API based on the provided parameters.
To run the server, execute the following command:
node index.ts
You should see a message indicating that the server has started at http://localhost:4000/graphql.
Now you can open your browser and navigate to http://localhost:4000/graphql to access the GraphQL Playground. From here, you can test your movie search API by executing queries like:
query { movies(title: "Avengers", genre: "Action", releaseYear: 2019) { title genre releaseYear } }
This will return a list of movies matching the specified criteria.
Congratulations! You have successfully built a movie search web app using GraphQL, Node.js, and TypeScript. In the next chapter, we will learn how to set up MongoDB for our movie search web app.
Setting Up MongoDB for the Movie Search Web App
In this chapter, we will set up MongoDB as our database for storing movie data in our movie search web app. We will use Mongoose, an Object Data Modeling (ODM) library for MongoDB and Node.js.
To begin, make sure you have MongoDB installed on your machine. You can download it from the official MongoDB website (https://www.mongodb.com/).
Once MongoDB is installed, let's install Mongoose as a dependency in our project:
npm install mongoose
Next, create a new file called db.ts
in your project directory and add the following code:
import mongoose from 'mongoose'; // Connect to the MongoDB database mongoose.connect('mongodb://localhost/movie-search-app', { useNewUrlParser: true, useUnifiedTopology: true, }); const db = mongoose.connection; db.on('error', console.error.bind(console, 'MongoDB connection error:')); db.once('open', () => { console.log('Connected to the MongoDB database'); });
In this code snippet, we import Mongoose and connect to the MongoDB database using the mongoose.connect
method. We also listen for any connection errors or successful connections.
Now, let's modify our index.ts
file to import and execute the db.ts
file:
import express from 'express'; import { ApolloServer } from 'apollo-server-express'; import fetch from 'node-fetch'; import './db'; // Import the db.ts file // Rest of the code...
With these changes in place, our movie search web app is now connected to a MongoDB database. We can now define a Mongoose schema for our movies and update our resolver function to fetch movies from the database instead of an external API.
Related Article: Tutorial: Building a Laravel 9 Real Estate Listing App
Deploying the Movie Search Web App with Docker
In this chapter, we will learn how to deploy our movie search web app using Docker. Docker allows us to package our application and its dependencies into a containerized environment, making it easier to deploy and run consistently across different platforms.
To get started, make sure you have Docker installed on your machine. You can download it from the official Docker website (https://www.docker.com/).
Once Docker is installed, create a new file called Dockerfile
in your project directory and add the following code:
# Use an official Node.js runtime as the base image FROM node:14 # Set the working directory in the container WORKDIR /app # Copy package.json and package-lock.json to the container COPY package*.json ./ # Install dependencies RUN npm install # Copy the rest of the project files to the container COPY . . # Expose port 4000 for the GraphQL server EXPOSE 4000 # Start the GraphQL server when a container is run from this image CMD ["node", "index.ts"]
In this Dockerfile, we start with an official Node.js runtime as our base image. We set the working directory, copy the package.json
and package-lock.json
files, install dependencies, and copy the rest of our project files. We also expose port 4000 for our GraphQL server and specify that it should be started when a container is run from this image.
Next, open a terminal or command prompt in your project directory and build a Docker image using the following command:
docker build -t movie-search-app .
Once the image is built, you can run a Docker container from it:
docker run -p 4000:4000 movie-search-app
Now you can access your movie search web app at http://localhost:4000/graphql, just like before.
Congratulations! You have successfully deployed your movie search web app using Docker. In the next chapter, we will focus on building the front-end of our app using Bootstrap and React.js.
Building the Front-End of the Movie Search Web App with Bootstrap and React.js
In this chapter, we will build the front-end of our movie search web app using Bootstrap for styling and React.js for creating dynamic UI components.
To begin, make sure you have Node.js installed on your machine. You can download it from the official Node.js website (https://nodejs.org/).
Once Node.js is installed, create a new directory called client
in your project directory and navigate to it:
mkdir client cd client
Next, initialize a new React.js project using Create React App:
npx create-react-app .
This will set up a basic React.js project structure with all the necessary dependencies.
Now, let's install Bootstrap as a dependency in our client project:
npm install bootstrap
Once Bootstrap is installed, open the src/index.js
file in your client project and import the Bootstrap CSS file:
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import 'bootstrap/dist/css/bootstrap.css'; // Import the Bootstrap CSS file import App from './App'; ReactDOM.render( , document.getElementById('root') );
With this change, our React app is now ready to use Bootstrap styles.
Next, let's create a new component called MovieSearchForm
that allows users to search for movies. Create a new file called MovieSearchForm.js
in the src
directory and add the following code:
import React, { useState } from 'react'; const MovieSearchForm = ({ onSearch }) => { const [title, setTitle] = useState(''); const [genre, setGenre] = useState(''); const handleTitleChange = (event) => { setTitle(event.target.value); }; const handleGenreChange = (event) => { setGenre(event.target.value); }; const handleSubmit = (event) => { event.preventDefault(); onSearch(title, genre); }; return ( <div> <label>Title</label> </div> <div> <label>Genre</label> </div> <button type="submit">Search</button> ); }; export default MovieSearchForm;
In this code snippet, we define a functional component MovieSearchForm
that renders a form with input fields for the movie title and genre. We use the useState
hook to manage the form state and update it when the input values change. When the form is submitted, we call the onSearch
callback prop with the title and genre values.
Now, let's update our src/App.js
file to use the MovieSearchForm
component:
import React from 'react'; import MovieSearchForm from './MovieSearchForm'; const App = () => { const handleSearch = (title, genre) => { // Implement movie search logic here }; return ( <div> <h1>Movie Search</h1> </div> ); }; export default App;
In this code snippet, we import the MovieSearchForm
component and render it inside our main App
component. We also define a callback function handleSearch
that will be called when the form is submitted.
With these changes in place, our front-end is now ready to accept user input and trigger movie searches. However, we still need to implement the movie search logic in the handleSearch
function.
Implementing Caching with Redis in the Movie Search Web App
In this chapter, we will implement caching with Redis in our movie search web app. Caching can greatly improve performance by storing frequently accessed data in memory, reducing the need for expensive database queries or API calls.
To begin, make sure you have Redis installed on your machine. You can download it from the official Redis website (https://redis.io/).
Once Redis is installed, let's install a Redis client library for Node.js called ioredis
as a dependency in our project:
npm install ioredis
Next, create a new file called cache.ts
in your project directory and add the following code:
import Redis from 'ioredis'; const redis = new Redis(); export const setCache = async (key: string, value: string) => { await redis.set(key, value); }; export const getCache = async (key: string) => { return await redis.get(key); };
In this code snippet, we import ioredis
and create a new instance of Redis
. We then define two functions: setCache
, which sets a key-value pair in the cache, and getCache
, which retrieves a value from the cache based on its key.
Now let's update our resolver function to check if the requested movies are already cached before making an external API call. If they are cached, we can retrieve them directly from Redis instead of fetching them again.
Modify your existing resolver function in index.ts
as follows:
import { getCache, setCache } from './cache'; const resolvers = { Query: { movies: async (_, { title, genre, releaseYear }) => { const cacheKey = `${title}-${genre}-${releaseYear}`; // Check if the movies are already cached const cachedMovies = await getCache(cacheKey); if (cachedMovies) { return JSON.parse(cachedMovies); } // Fetch movies from an external API or database based on the provided parameters const response = await fetch(`https://api.example.com/movies?title=${title}&genre=${genre}&releaseYear=${releaseYear}`); const data = await response.json(); // Cache the fetched movies for future requests await setCache(cacheKey, JSON.stringify(data.movies)); return data.movies; }, }, };
In this updated code snippet, we import the getCache
and setCache
functions from cache.ts
. We first check if the requested movies are already cached using a cache key generated based on the query parameters. If they are cached, we parse and return them directly. Otherwise, we fetch them from an external API and cache them for future requests.
With these changes in place, our movie search web app now benefits from caching with Redis. This can significantly improve performance by reducing the number of external API calls or database queries.
Related Article: How to Resolve the Npm Warnings with Config Global & Local
Use Case 1: Implementing User Authentication and Authorization
In this chapter, we will explore a common use case of implementing user authentication and authorization in our movie search web app. User authentication allows users to securely log in to our app using their credentials, while authorization ensures that only authenticated users can access certain resources or perform specific actions.
To begin, let's install the necessary packages for user authentication and authorization:
npm install bcrypt jsonwebtoken
Next, create a new file called auth.ts
in your project directory and add the following code:
import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; const saltRounds = 10; const secretKey = 'your-secret-key'; // Replace with your own secret key export const hashPassword = async (password: string) => { return await bcrypt.hash(password, saltRounds); }; export const comparePassword = async (password: string, hashedPassword: string) => { return await bcrypt.compare(password, hashedPassword); }; export const generateToken = (userId: string) => { return jwt.sign({ userId }, secretKey); }; export const verifyToken = (token: string) => { try { return jwt.verify(token, secretKey); } catch { throw new Error('Invalid token'); } };
In this code snippet, we import bcrypt
for password hashing and comparison, and jsonwebtoken
for token generation and verification. We define constants for the number of salt rounds used in password hashing and our secret key for signing tokens. We then export functions for hashing passwords, comparing passwords with their hashed counterparts, generating tokens based on user IDs, and verifying tokens.
Now let's update our GraphQL schema to include mutations for user registration, login, and protected resource access. Modify the typeDefs
constant in index.ts
as follows:
const typeDefs = ` type Query { movies(title: String!, genre: String, releaseYear: Int): [Movie!]! protectedResource: String! @authenticated } type Mutation { register(username: String!, password: String!): Boolean! login(username: String!, password: String!): Token! } type Token { token: String! } type Movie { title: String! genre: String! releaseYear: Int! } directive @authenticated on FIELD_DEFINITION `;
In this updated code snippet, we define a new Mutation
type with register
and login
mutations for user registration and login, respectively. The register
mutation takes a username and password as arguments and returns a boolean indicating whether the registration was successful. The login
mutation also takes a username and password as arguments but returns a token instead.
We also define a new custom scalar type called Token
, which represents our authentication token. Finally, we introduce a new directive called @authenticated
, which can be applied to fields in the schema to ensure that only authenticated users can access them.
Now let's update our resolvers to implement the authentication and authorization logic. Modify your existing resolvers in index.ts
as follows:
import { hashPassword, comparePassword, generateToken, verifyToken } from './auth'; const resolvers = { Query: { movies: async (_, { title, genre, releaseYear }) => { // Existing code... }, protectedResource: (_, __, { userId }) => { if (!userId) { throw new Error('Unauthorized'); } return 'This is a protected resource'; }, }, Mutation: { register: async (_, { username, password }) => { // Check if the user already exists const existingUser = await User.findOne({ username }); if (existingUser) { throw new Error('Username already taken'); } // Hash the password const hashedPassword = await hashPassword(password); // Create the user in the database await User.create({ username, password: hashedPassword }); return true; }, login: async (_, { username, password }) => { // Find the user by username const user = await User.findOne({ username }); if (!user) { throw new Error('Invalid username or password'); } // Compare the provided password with the hashed password const isPasswordValid = await comparePassword(password, user.password); if (!isPasswordValid) { throw new Error('Invalid username or password'); } // Generate a token const token = generateToken(user._id.toString()); return { token }; }, }, };
In this updated code snippet, we import the hashPassword
, comparePassword
, generateToken
, and verifyToken
functions from auth.ts
. We modify the resolver for the protectedResource
field to check if a user is authenticated before returning the protected resource.
We also implement resolvers for the register
and login
mutations. The register
resolver checks if a user with the provided username already exists, hashes the provided password, and creates a new user in the database. The login
resolver finds the user by username, compares the provided password with their hashed password, and generates a token.
With these changes in place, our movie search web app now supports user authentication and authorization. Users can register an account, log in to access protected resources like our example "protectedResource" field, and perform movie searches.
Use Case 2: Integrating External APIs for Additional Movie Data
In this chapter, we will explore another use case of integrating external APIs for additional movie data in our movie search web app. By leveraging external APIs that provide additional information about movies such as ratings or reviews, we can enhance the user experience and provide more comprehensive movie details.
To begin, let's choose an external API to integrate. For this example, we will use the OMDB API (http://www.omdbapi.com/), which provides movie data including titles, genres, release years, ratings, and more.
First, sign up for a free API key on the OMDB website. Once you have your API key, create a new file called .env
in your project directory and add the following line:
OMDB_API_KEY=your-api-key
Replace your-api-key
with your actual OMDB API key.
Next, install the necessary package for making HTTP requests:
npm install axios
Now let's update our resolver function to fetch additional movie data from the OMDB API. Modify your existing resolver function in index.ts
as follows:
import axios from 'axios'; const resolvers = { Query: { movies: async (_, { title, genre, releaseYear }) => { // Existing code... }, }, Movie: { // Resolve additional fields for the Movie type ratings: async (movie) => { const response = await axios.get(`http://www.omdbapi.com/?apikey=${process.env.OMDB_API_KEY}&t=${encodeURIComponent(movie.title)}`); const data = response.data; if (data && data.Ratings) { return data.Ratings.map((rating) => ({ source: rating.Source, value: rating.Value, })); } return []; }, }, };
In this updated code snippet, we import axios
for making HTTP requests to the OMDB API. We add a resolver function for the ratings
field of the Movie
type. This resolver fetches additional movie data from the OMDB API based on the movie title and returns an array of ratings, each containing a source and value.
With these changes in place, our movie search web app now integrates an external API for additional movie data. The ratings
field of the Movie
type will be resolved dynamically based on the requested movies.
Best Practice 1: Structuring Your GraphQL Schema for Scalability
In this chapter, we will discuss best practices for structuring your GraphQL schema to ensure scalability and maintainability as your application grows. A well-structured schema can make it easier to add new features, extend existing types, and maintain a clear separation of concerns.
1. Modularize Your Schema: Break down your schema into smaller modules or files based on related functionality or domain concepts. This can improve code organization and make it easier to understand and maintain different parts of your schema.
Example:
# user.graphql type User { id: ID! name: String! email: String! } # movie.graphql type Movie { id: ID! title: String! genre: String! releaseYear: Int! } # index.graphql type Query { user(id: ID!): User movie(id: ID!): Movie }
2. Use Interfaces and Unions: Use interfaces and unions to define common fields or relationships between different types. This can help eliminate duplication in your schema and allow for more flexible queries.
Example:
interface Media { id: ID! title: String! genre: String! releaseYear: Int! } type Movie implements Media { id: ID! title: String! genre: String! releaseYear: Int! director: String! } type TVShow implements Media { id: ID! title: String! genre: String! releaseYear: Int! seasonCount: Int! } type Query { media(id: ID!): Media }
3. Avoid Deep Nesting: Keep your schema shallow and avoid excessive nesting of types. Deeply nested schemas can lead to complex queries and performance issues.
Example:
# Avoid deep nesting like this type User { id: ID! name: String! email: String! moviesWatched(limit: Int!): [Movie!]! }
4. Use Input Types for Mutations: When defining mutations with multiple input arguments, use input types to encapsulate the arguments into a single object. This can make your mutations more readable and easier to extend in the future.
Example:
input CreateUserInput { name: String!, email: String!, password: String!, } type Mutation { createUser(input: CreateUserInput!): User }
5. Version Your Schema: As your application evolves, consider versioning your schema to ensure backward compatibility with existing clients while introducing new features or breaking changes.
Example:
schema { query: QueryV1 mutation: MutationV1 } type QueryV1 { user(id: ID!): UserV1 } type UserV1 { id: ID! nameV1FieldOnly :String! # Existing field in V1 schema # New fields introduced in V2 schema (breaking change) newFieldAddedInV2Schema:String! }
Best Practice 2: Error Handling and Validation in GraphQL Resolvers
In this chapter, we will discuss best practices for error handling and validation in GraphQL resolvers. Proper error handling and validation can improve the reliability, security, and user experience of your movie search web app.
1. Throwing Errors: In your resolvers, throw specific errors to provide meaningful feedback to clients when something goes wrong. Use custom error classes or enums to categorize different types of errors.
Example:
class AuthenticationError extends Error { constructor() { super('Authentication failed'); } } const resolvers = { Query: { movies: async (_, args) => { if (!user.isAuthenticated()) { throw new AuthenticationError(); } // Other resolver logic... }, }, };
2. Handling Errors: Use a global error handler middleware or function to catch and format errors consistently across all your resolvers. This can help centralize error handling logic and ensure a consistent response structure.
Example:
const server = new ApolloServer({ typeDefs, resolvers, formatError: (error) => { // Log the original error for debugging purposes console.error(error.originalError); // Format the error message for clients return { message: 'An unexpected error occurred' }; }, });
3. Input Validation: Validate input arguments in your mutations or queries to ensure they meet certain criteria or constraints. You can use libraries like joi
or yup
for input validation.
Example:
import * as yup from 'yup'; const createUserSchema = yup.object().shape({ name: yup.string().required(), email: yup.string().email().required(), password: yup.string().min(8).required(), }); const resolvers = { Mutation: { createUser: async (_, { input }) => { // Validate the input against the schema await createUserSchema.validate(input); // Create the user in the database // ... }, }, };
4. Custom Directives: Use custom directives to enforce validation rules or apply common logic across multiple fields or types. Directives can be applied at runtime to modify the behavior of resolvers.
Example:
directive @uppercase on FIELD_DEFINITION type Movie { title: String! @uppercase }
5. Error Extensions: Include additional information or metadata in your error responses using error extensions. This can provide clients with more context about errors and help with debugging.
Example:
class AuthenticationError extends Error { constructor() { super('Authentication failed'); this.extensions = { code: 'AUTHENTICATION_ERROR' }; } }`
Related Article: Comparing Kubernetes vs Docker
Performance Consideration 1: Optimizing Query Performance in GraphQL
In this chapter, we will discuss performance considerations for optimizing query performance in GraphQL. By following these best practices, you can ensure that your movie search web app responds quickly and efficiently to client queries.
1. Batching Data Fetches: When resolving fields that require data from external APIs or databases, batch multiple requests together to minimize round trips and reduce latency. Use DataLoader or a similar library to efficiently batch and cache data fetches.
Example:
const resolvers = { Movie: { actors: async (movie, _, { dataLoaders }) => { // Batch multiple requests for movie actors const actorPromises = movie.actorIds.map((actorId) => dataLoaders.actorLoader.load(actorId)); return Promise.all(actorPromises); }, }, };
2. N+1 Problem: Avoid the N+1 problem, where resolving a list of items requires N additional database queries or API calls. Use dataloading techniques or batched resolvers to fetch related data in a single request.
Example:
const resolvers = { Query: { movies: async (_, args, { dataLoaders }) => { const movies = await Movie.find(args); // Batch requests for movie actors const actorPromises = movies.map((movie) => dataLoaders.actorLoader.loadMany(movie.actorIds)); // Resolve the movies and their associated actors in parallel await Promise.all(actorPromises); return movies; }, }, };
3. Caching: Implement caching at various levels, such as database query results, API responses, or computed fields. Caching can greatly improve response times by serving frequently accessed data from memory instead of performing expensive operations.
Example:
import Redis from 'ioredis'; const redis = new Redis(); const resolvers = { Query: { movies: async (_, args) => { // Check if the query result is already cached const cachedResult = await redis.get(`movies:${JSON.stringify(args)}`); if (cachedResult) { return JSON.parse(cachedResult); } // Perform the expensive query const movies = await Movie.find(args); // Cache the query result for future requests await redis.set(`movies:${JSON.stringify(args)}`, JSON.stringify(movies)); return movies; }, }, };
4. Query Complexity Analysis: Analyze the complexity of your GraphQL queries to identify potential performance bottlenecks or inefficient resolver logic. Use tools like graphql-cost-analysis
or graphql-depth-limit
to enforce query complexity limits and prevent overly complex queries.
Example:
import { createComplexityLimitRule } from 'graphql-validation-complexity'; const resolvers = { Query: { movies: async (_, args) => { // Resolver logic... }, }, }; const validationRules = [ createComplexityLimitRule(1000), // Set a complexity limit of 1000 for queries ]; const server = new ApolloServer({ typeDefs, resolvers, validationRules, });
Performance Consideration 2: Caching Strategies for Improved Response Time
In this chapter, we will explore caching strategies that can improve response time in your movie search web app. Caching is an effective technique to reduce latency by storing frequently accessed data closer to the client.
1. Result Caching: Cache the results of expensive or slow database queries, API calls, or resolver functions. Use an in-memory cache like Redis or Memcached to store key-value pairs.
Example:
import Redis from 'ioredis'; const redis = new Redis(); const resolvers = { Query: { movies: async (_, args) => { // Check if the query result is already cached const cachedResult = await redis.get(`movies:${JSON.stringify(args)}`); if (cachedResult) { return JSON.parse(cachedResult); } // Perform the expensive query const movies = await Movie.find(args); // Cache the query result for future requests await redis.set(`movies:${JSON.stringify(args)}`, JSON.stringify(movies)); return movies; }, }, };
2. Edge Caching: Use a content delivery network (CDN) to cache static assets like images, CSS, or JavaScript files at edge locations closer to the client. This reduces the load on your web server and improves response time.
Example:
import express from 'express'; import path from 'path'; const app = express(); app.use('/static', express.static(path.join(__dirname, 'public'))); app.listen(4000, () => { console.log('Server started at http://localhost:4000'); });
3. Client-Side Caching: Leverage client-side caching mechanisms like HTTP caching headers or browser storage to cache responses and reduce round trips to the server. Use appropriate cache-control headers and expiration times for different types of resources.
Example:
app.get('/api/movies', (req, res) => { // Set appropriate cache-control headers res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache response for 1 hour // Send the response with movie data res.json(movies); });
4. Partial Response Caching: Cache partial responses or fragments of data that are frequently accessed together. This can be achieved by storing the results of resolver functions in a cache and retrieving them when needed.
Example:
const resolvers = { Query: { movies: async (_, args) => { const cacheKey = `movies:${JSON.stringify(args)}`; // Check if the partial response is already cached const cachedResponse = await redis.get(cacheKey); if (cachedResponse) { return JSON.parse(cachedResponse); } // Perform the expensive query const movies = await Movie.find(args); // Cache the partial response for future requests await redis.set(cacheKey, JSON.stringify(movies)); return movies; }, }, };
Advanced Technique 1: Real-Time Updates with GraphQL Subscriptions
In this chapter, we will explore an advanced technique for implementing real-time updates in your movie search web app using GraphQL subscriptions. Subscriptions allow clients to receive real-time data updates from the server over a WebSocket connection.
To begin, let's install the necessary packages for GraphQL subscriptions:
npm install graphql-subscriptions apollo-server-express subscriptions-transport-ws
Next, create a new file called subscriptions.ts
in your project directory and add the following code:
import { PubSub } from 'graphql-subscriptions'; export const pubsub = new PubSub();
In this code snippet, we import PubSub
from graphql-subscriptions
to create a publish-subscribe instance. We export it as pubsub
to make it accessible across our application.
Now let's update our server configuration to support subscriptions. Modify your existing server setup in index.ts
as follows:
import { createServer } from 'http'; import { ApolloServer } from 'apollo-server-express'; import { execute, subscribe } from 'graphql'; import { SubscriptionServer } from 'subscriptions-transport-ws'; import { pubsub } from './subscriptions'; const server = new ApolloServer({ typeDefs, resolvers, context: ({ req, connection }) => { if (connection) { // Subscription context return { ...connection.context, pubsub, }; } // Regular HTTP context return { req, pubsub, }; }, }); const app = express(); server.applyMiddleware({ app }); const httpServer = createServer(app); httpServer.listen(4000, () => { console.log(`Server started at http://localhost:4000${server.graphqlPath}`); new SubscriptionServer( { execute, subscribe, schema: server.schema, onConnect: (connectionParams, webSocket) => ({ // Additional subscription context based on the connectionParams or authentication logic userId: connectionParams.userId || null, }), }, { server: httpServer, path: server.graphqlPath, } ); });
In this updated code snippet, we import createServer
from http
, execute
and subscribe
from graphql
, and SubscriptionServer
from subscriptions-transport-ws
. We also import the pubsub
instance we created earlier.
We modify our server configuration to include a new context function that handles both regular HTTP requests and WebSocket connections. For WebSocket connections, we pass the additional subscription context based on the connection parameters or authentication logic.
We create a new instance of SubscriptionServer
and pass it our existing HTTP server, the execute
and subscribe
functions, our schema, and the path for subscriptions.
With these changes in place, our server is now ready to handle GraphQL subscriptions.
Now let's define a new subscription for real-time movie updates. Modify the typeDefs
constant in index.ts
as follows:
const typeDefs = ` type Query { movies(title: String!, genre: String, releaseYear: Int): [Movie!]! } type Subscription { movieUpdated(id: ID!): Movie! } type Movie { id: ID! title: String! genre: String! releaseYear: Int! } `;
In this updated code snippet, we define a new Subscription
type with a single subscription field called movieUpdated
. This subscription takes an ID argument and returns a Movie
object when that movie is updated.
Next, let's update our resolver function to publish updates to the movieUpdated
subscription whenever a movie is updated. Modify your existing resolver function in index.ts
as follows:
const resolvers = { Query: { movies: async (_, { title, genre, releaseYear }) => { // Existing code... }, }, Mutation: { updateMovie: async (_, { id }) => { const updatedMovie = await Movie.findByIdAndUpdate(id, { ... }); // Publish the updated movie to the "movieUpdated" subscription pubsub.publish('movieUpdated', { movieUpdated: updatedMovie }); return updatedMovie; }, }, Subscription: { movieUpdated: { subscribe: () => pubsub.asyncIterator('movieUpdated'), resolve: (payload) => payload.movieUpdated, }, }, };
In this updated code snippet, we add a new mutation called updateMovie
that updates a movie in the database and publishes the updated movie to the movieUpdated
subscription using pubsub.publish
.
We also define a new resolver for the movieUpdated
subscription. The subscribe
function returns an async iterator that listens for updates on the movieUpdated
channel. The resolve
function extracts and returns the updated movie from the payload.
With these changes in place, our movie search web app now supports real-time updates through GraphQL subscriptions. Clients can subscribe to the movieUpdated
subscription and receive real-time updates whenever a movie is updated.
Advanced Technique 2: Implementing Pagination in GraphQL Queries
In this chapter, we will explore an advanced technique for implementing pagination in your movie search web app using GraphQL queries. Pagination allows clients to retrieve large result sets efficiently by fetching data in smaller chunks or pages.
To begin, let's update our schema to include pagination arguments and response types. Modify the typeDefs
constant in index.ts
as follows:
const typeDefs = ` type Query { movies( title: String! genre: String releaseYear: Int first: Int after: String ): MovieConnection! } type MovieConnection { edges: [MovieEdge!]! pageInfo: PageInfo! } type MovieEdge { cursor: String! node: Movie! } type PageInfo { hasNextPage: Boolean! endCursor: String } type Movie { id: ID! title: String! genre: String! releaseYear: Int! } `;
In this updated code snippet, we introduce new types for pagination support. The MovieConnection
type represents a connection to a list of movies and includes an edges
field that contains MovieEdge
objects. Each MovieEdge
object represents a movie in the connection and includes a cursor for pagination and the actual movie object.
The PageInfo
type provides metadata about the pagination state, including whether there is a next page (hasNextPage
) and the cursor of the last item in the current page (endCursor
).
Next, let's update our resolver function to support pagination. Modify your existing resolver function in index.ts
as follows:
const resolvers = { Query: { movies: async (_, { title, genre, releaseYear, first, after }) => { // Perform the query with pagination arguments const query = Movie.find({ title, genre, releaseYear, ...(after ? { _id: { $gt: after } } : {}), }) .sort({ _id: 1 }) .limit(first); const movies = await query.exec(); // Map each movie to a MovieEdge object with a cursor const edges = movies.map((movie) => ({ cursor: movie._id.toString(), node: movie, })); // Determine if there is a next page const hasNextPage = !!(movies.length === first); // Get the end cursor of the current page const endCursor = edges.length > 0 ? edges[edges.length - 1].cursor : null; return { edges, pageInfo: { hasNextPage, endCursor, }, }; }, }, };
In this updated code snippet, we modify our resolver function for the movies
query to support pagination arguments (first
, after
). We use these arguments to perform the database query with appropriate limits and conditions.
We map each returned movie to a MovieEdge
object with a cursor based on the movie's ID. We determine if there is a next page by checking if the number of returned movies matches the first
argument. We also calculate the end cursor of the current page.
Finally, we return a MovieConnection
object that includes the edges and pageInfo.
With these changes in place, our movie search web app now supports pagination in GraphQL queries. Clients can specify pagination arguments (first
, after
) to retrieve movies in smaller chunks or pages.
Congratulations! You have successfully built a movie search web app with GraphQL, Node.js, and TypeScript.