Build a Movie Search App with GraphQL, Node & TypeScript

Avatar

By squashlabs, Last Updated: September 16, 2023

Build a Movie Search App with GraphQL, Node & TypeScript

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: Installing Docker on Ubuntu in No Time: a Step-by-Step Guide

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 Install and Use Docker

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!

  }

Related Article: Docker How-To: Workdir, Run Command, Env Variables

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' };
      }
    }
````   

<h2>Performance Consideration 1: Optimizing Query Performance in GraphQL</h2>

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. <strong>Batching Data Fetches</strong>: 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:
```typescript
    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.

Related Article: How to Secure Docker Containers

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.

You May Also Like

How to Run 100 Queries Simultaneously in Nodejs & PostgreSQL

Learn how to execute 100 queries simultaneously in Node.js with PostgreSQL. Understand how PostgreSQL handles executing multiple queries at once and the impact it can... read more

How to Git Ignore Node Modules Folder Globally

Setting up Git to ignore node_modules folders globally can greatly simplify your development workflow. This article provides a simple guide on how to achieve this,... read more

Tutorial: Managing Docker Secrets

Managing secrets in Docker is essential for maintaining security in your applications. This tutorial provides a comprehensive guide to managing Docker secrets. From... read more

Tutorial: Building a Laravel 9 Real Estate Listing App

Step 1: Building a Laravel 9 Real Estate Listing App will become a breeze with this step-by-step tutorial. Learn how to create a powerful single-app using Laravel 9,... read more