Build a GraphQL API with NodeJS and TypeScript || A Comprehensive Guide
Abeinemukama Vicent
Posted on January 12, 2024
GraphQL is an open-source data query and manipulation language for APIs and a query runtime engine. GraphQL enables declarative data fetching where a client can specify exactly what data it needs from an API.
GraphQL offers several benefits, including reducing over-fetching or under-fetching of data, enabling precise queries, and providing a more efficient and flexible API. It has gained widespread adoption in various domains due to its developer-friendly approach and ability to handle complex data requirements.
In this article, we will build a real world GraphQL movie directory API using NodeJS, TypeScript, MongoDB and Express.
GrapqL VS REST
Rest stands for Representational State Transfer and is the most commonly used architecure while developing and interacting with APIs (Application Programming Interfaces). In the ever-evolving realm of software development, choosing the right API architecture is a pivotal decision. Two prominent contenders, GraphQL and REST, have emerged as the go-to options, each with its own set of principles and methodologies. As developers navigate the intricate terrain of data retrieval and manipulation, understanding the distinctions between GraphQL and REST becomes imperative.
The two differ in their architectural principles and how they handle data retrieval.
REST (Representational State Transfer):
Endpoint-Centric:
In REST, each resource (e.g., user, post) is represented by a specific URL (endpoint).
Different endpoints are used for different operations (GET for retrieval, POST for creation, PUT/PATCH for updates, DELETE for deletion).
Fixed Structure:
REST APIs typically have a fixed structure, and the data returned from an endpoint is predetermined by the server.
Over-fetching (receiving more data than needed) or under-fetching (not getting enough data) can be common issues.
Stateless:
REST is stateless, meaning each request from a client contains all the information needed for the server to fulfill the request.
Multiple Endpoints:
Clients may need to make multiple requests to different endpoints to fetch all the required data.
Versioning:
Versioning is often used to handle changes in the API.
GraphQL:
Query Language:
GraphQL uses a query language to request only the data needed. Clients specify the shape and structure of the response.
Single Endpoint:
GraphQL typically uses a single endpoint, and clients can request exactly the data they need, avoiding over-fetching or under-fetching.
Real-time Data:
GraphQL supports real-time data with subscriptions, allowing clients to receive updates when data changes.
Introspection:
Clients can introspect the schema to discover what data can be queried, reducing the need for extensive documentation.
Flexibility:
Clients have more flexibility in defining the shape and structure of the data they want to receive.
GraphQL Concepts/Terminologies
Schema
The foundation of a GraphQL API is its schema, which defines the types and relationships within the data.
- Types: GraphQL allows you to define custom types, such as Object, Scalar, Enum, Interface, Union, and Input.
- Fields: Each type can have fields, and each field can return another type.
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
The exclamation mark (!) at the end of a type definition denotes that a particular field is non-nullable. It means that the field must always have a value and cannot be null.
Query
- Clients request the specific data they need using queries.
- The query structure mirrors the shape of the response.
query {
user(id: "123") {
name
email
posts {
title
}
}
}
Mutation
- Mutations are used for modifying or creating data on the server. ```javascript
mutation {
createUser(name: "John Doe", email: "john@example.com") {
id
name
}
}
###Subscription
GraphQL supports real-time data with subscriptions, allowing clients to receive updates when specific events occur without relying on external libraries or additional technologies like socket.io.
The subscription is defined in the GraphQL schema, similar to queries and mutations.
Under the hood, GraphQL subscriptions typically use WebSocket connections to establish a persistent connection between the client and server.
When an event occurs (e.g., new data is available), the server can push updates to all subscribed clients over the WebSocket connection.
```javascript
subscription {
newPost {
title
content
}
}
Resolver
- Resolvers are functions that define how to retrieve or manipulate data for each field in the schema.
- Resolvers are specific to each field and type.
Introspection
GraphQL provides introspection, allowing clients to query the schema itself to discover available types, fields, and their types.
query {
__schema {
types {
name
}
}
}
Project Setup
In this article,we will be using the following folder structure:
This structure is minimal but will help us understand the main concepts while using GraphQL to develop APIs in a NodeJS environment, after which you can come up with your own folder structure. A future article may concentrate on folder structure for a production grade GraphQL API, which may also include unit testing, user authentication and authorisation say using json web token and much more separation of concerns. For now, the above will work for us.
Install Dependencies
Create a new folder in your desired location and name it qraphql_api_guide.
With the folder open in your favourite code editor, open terminal and run the following command to initialise a NodeJS project:
npm init -y
After initialising the project, install the following dependencies with the following command:
npm install express cors helmet bcrypt dotenv mongoose express-graphql graphql graphql-http morgan
and
npm install -D @types/cors @types/express @types/bcrypt @types/morgan nodemon ts-node typescript
to install development dependencies.
In Node.js applications, we enhance functionality and security by incorporating several essential npm packages. Begin by installing express
, a versatile web framework, along with cors
for managing cross-origin resource sharing and helmet
to fortify the app's security with HTTP header configuration. Ensure password security using bcrypt
for hashing, and manage environment variables effortlessly with dotenv
. For seamless interaction with MongoDB, we will use mongoose
, an Object Data Modeling (ODM) library. To integrate GraphQL into our app, use express-graphql
middleware, while the graphql
package enables efficient query processing. Lastly, we will facilitate HTTP request logging for debugging purposes using morgan
.
Before we start writing code, lets first complete setup of our development environment:
In the home directory, create a new file: nodemon.json and place the following code:
{
"watch": ["src"],
"ext": ".ts,.js",
"exec": "ts-node ./src/index"
}
, tsconfig.json and place the following:
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2020",
"baseUrl": "src",
"noImplicitAny": true,
"sourceMap": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
and update package.jsons scripts section to add scripts for starting our server for both development and production mode:
"scripts": {
"build": "tsc",
"start": "npm run build && node dist/src/index.js",
"dev": "nodemon"
},
The build command helps us transpile TypeScript to JavaScript in production where npm start
command is used to start or restart our server. The dist folder is the destination for our resultant JavaScript code as specified in tsconfig.json. In development, nodemon restarts our server automatically without a need for transpilation first.
Also, add the line:
"module": "module",
to your package.json to specify that we are using EsModules not NodeJS's default CommonJS pattern.
All set, lets create our main file: src/index.ts and place the following:
import express from "express";
const app = express();
import cors from "cors";
import helmet from "helmet";
import dotenv from "dotenv";
import mongoose from "mongoose";
import morgan from "morgan";
import { graphqlHTTP } from "express-graphql";
import schema from "./schema";
dotenv.config();
app.use(morgan("common"));
// USE HELMET AND CORS MIDDLEWARES
app.use(
cors({
origin: ["*"], // Comma separated list of your urls to access your api. * means allow everything
credentials: true, // Allow cookies to be sent with requests
})
);
app.use(
helmet({
contentSecurityPolicy:
process.env.NODE_ENV === "production" ? undefined : false,
})
);
app.use(express.json());
// DB CONNECTION
if (!process.env.MONGODB_URL) {
throw new Error("MONGODB_URL environment variable is not defined");
}
mongoose
.connect(process.env.MONGODB_URL)
.then(() => {
console.log("MongoDB connected to the backend successfully");
})
.catch((err: Error) => console.log(err));
app.use(
"/graphql",
graphqlHTTP({
schema,
graphiql: true,
})
);
// Start backend server
const PORT = process.env.PORT || 8500;
app.listen(PORT, () => {
console.log(`Backend server is running at port ${PORT}`);
});
export default app;
we have initialized an Express server, configured with middleware to enhance security and functionality. We use the "cors" package for handling cross-origin resource sharing, allowing specified origins to access the API, and "helmet" to set secure HTTP headers, with conditional content security policy based on the environment. Environment variables are managed using "dotenv", and request logging is facilitated by the "morgan" middleware. We also have established a connection to a MongoDB database using the "mongoose" library. The GraphQL schema is defined in a separate file and integrated with the Express server using the "express-graphql" middleware. The server is set to run on a specified port, with console logs indicating successful connections and server initiation.
Lets start with completing database connection by creating a new file in home directory: .env
and place the following code:
MONGODB_URL="Your MongoDB URL"
Head over to MongoDB and create an account or login to grab your connection string.
Writing Database Models
In our API, we will have only 2 models, User and Movie.
Create a new file: src/models/User.ts
and place the following code:
// Import necessary modules
import mongoose, { Schema, Document, Types } from "mongoose";
// Define the interface for User document
export interface IUser extends Document {
email: string;
username: string;
password: string;
isAdmin: boolean;
createdAt: Date;
updatedAt: Date;
}
// Create a schema for the User model
const userSchema: Schema<IUser> = new Schema(
{
email: { type: String, required: true, unique: true },
username: { type: String, required: true, unique: true },
password: { type: String, required: true },
isAdmin: { type: Boolean, default: false },
},
{ timestamps: true }
);
// Create and export the User model
export default mongoose.model<IUser>("User", userSchema);
andsrc/models/Movie.ts
and place the following code:
// Import necessary modules
import mongoose, { Schema, Document } from "mongoose";
// Define the interface for User document
export interface IMovie extends Document {
title: string;
genre: string;
rating: number;
duration: string; // e.g 2 hours
}
// Create a schema for the User model
const movieSchema: Schema<IMovie> = new Schema(
{
title: { type: String, required: true, unique: true },
genre: { type: String, required: true },
rating: { type: Number, required: true },
duration: { type: String, required: true },
},
{ timestamps: true }
);
// Create and export the User model
export default mongoose.model<IMovie>("Movie", movieSchema);
The User
model is represented by an interface IUser
, extending the Mongoose Document interface, specifying the structure of a user document. The user schema is created with Mongoose's Schema class, specifying fields such as email, username, password, and isAdmin (indicating whether the user has admin privileges). The schema includes additional options, such as setting timestamps for createdAt and updatedAt. These timestamps automatically update whenever a document is created or modified. The user model is then exported using mongoose.model, making it available for use throughout the application.
The Movie
model is defined through an interface named IMovie
, extending the Mongoose Document interface to outline the structure of a movie document. The schema is created using Mongoose's Schema class, specifying fields such as title, genre, rating, and duration. The schema also includes validations for required fields, and uniqueness is enforced for the movie titles. Additionally, timestamps for createdAt and updatedAt are automatically managed by Mongoose. The movie model is exported using mongoose.model, allowing it to be utilized throughout the application.
GraphQL Schema Design
All good, lets move to designing graphql schema.
Create a new file: src/schema/User.ts
and place the following code:
import {
GraphQLObjectType,
GraphQLID,
GraphQLString,
GraphQLBoolean,
} from "graphql";
const UserType = new GraphQLObjectType({
name: "User",
fields: () => ({
id: { type: GraphQLID },
email: { type: GraphQLString },
username: { type: GraphQLString },
password: { type: GraphQLString },
isAdmin: { type: GraphQLBoolean },
createdAt: { type: GraphQLString },
updatedAt: { type: GraphQLString },
}),
});
export default UserType;
and src/schema/Movie.ts
and place the following code:
import {
GraphQLObjectType,
GraphQLID,
GraphQLString,
GraphQLInt,
} from "graphql";
const MovieType = new GraphQLObjectType({
name: "Movie",
fields: () => ({
id: { type: GraphQLID },
title: { type: GraphQLString },
genre: { type: GraphQLString },
rating: { type: GraphQLInt },
duration: { type: GraphQLString },
}),
});
export default MovieType;
In the Movie
schema, we define a GraphQL object type called MovieType using the GraphQLObjectType from the "graphql" library. This type is designed to represent movie-related data in a GraphQL schema. The MovieType has fields such as id, title, genre, rating, and duration, each specifying the type of data it holds (e.g., GraphQLID, GraphQLString, and GraphQLInt). The fields property is a function that returns an object containing the field definitions. This setup enables us to structure and standardize how movie information is queried and returned in GraphQL operations. We export MovieType for use throughout the application, facilitating a clear and consistent representation of movie entities in the GraphQL schema.
In the User
schema, we create a GraphQL object type called UserType
using the GraphQLObjectType from the "graphql" library. This type serves to define the structure of user-related data within a GraphQL schema. The UserType is composed of fields such as id, email, username, password, isAdmin, createdAt, and updatedAt, each specifying the type of data it represents (e.g., GraphQLID, GraphQLString, GraphQLBoolean). These fields collectively represent essential information about a user, including identifiers, authentication credentials, role permissions, and timestamps for creation and updates.
The use of UserType facilitates a standardized and coherent representation of user entities in GraphQL operations, promoting clarity and consistency throughout the application. By exporting UserType, we can easily integrate it into various parts of our GraphQL schema to define and interact with user-related data in a uniform manner.
Lets now create our GraphQL index file:src/schema/index.ts
and put the following code:
import {
GraphQLSchema,
GraphQLObjectType,
GraphQLList,
GraphQLNonNull,
GraphQLString,
GraphQLInt,
GraphQLBoolean,
} from "graphql";
import UserType from "./User";
import MovieType from "./Movie";
import Movie from "../models/Movie";
import User from "../models/User";
import { hashPassword } from "../utils/passwordUtils";
// Queries
const RootQuery = new GraphQLObjectType({
name: "RootQueryType",
fields: {
// Query to get all users
users: {
type: GraphQLList(UserType),
resolve: async () => {
try {
const users = await User.find();
return users.map((user) => ({
...user.toObject(),
id: user._id,
createdAt: user.createdAt.toISOString(), // Format createdAt as ISO 8601
updatedAt: user.updatedAt.toISOString(), // Format createdAt as ISO 8601
}));
} catch (error) {
throw new Error(error.message);
}
},
},
// Query to get a user by ID
user: {
type: UserType,
args: { id: { type: GraphQLNonNull(GraphQLString) } },
resolve: async (_, args) => {
try {
const user = await User.findById(args.id);
return {
...user.toObject(),
id: user._id,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
} catch (error) {
throw new Error(error.message);
}
},
},
// Query to get all movies
movies: {
type: GraphQLList(MovieType),
resolve: async () => {
try {
return await Movie.find();
} catch (error) {
throw new Error(error.message);
}
},
},
// Query to get a movie by ID
movie: {
type: MovieType,
args: { id: { type: GraphQLNonNull(GraphQLString) } },
resolve: async (_, args) => {
try {
return await Movie.findById(args.id);
} catch (error) {
throw new Error(error.message);
}
},
},
},
});
// Mutations
const Mutation = new GraphQLObjectType({
name: "Mutation",
fields: {
// Mutation to add a new user
addUser: {
type: UserType,
args: {
email: { type: GraphQLNonNull(GraphQLString) },
username: { type: GraphQLNonNull(GraphQLString) },
password: { type: GraphQLNonNull(GraphQLString) },
isAdmin: { type: GraphQLNonNull(GraphQLBoolean) },
},
resolve: async (_, args) => {
try {
// Destructure password
const { password, ...others } = args;
// Send a hashed password
const hashedPassword = await hashPassword(password);
console.log(hashPassword);
const user = new User({
password: hashedPassword,
...others,
});
return await user.save();
} catch (error) {
throw new Error(error.message);
}
},
},
// Mutation to update a user by ID
updateUser: {
type: UserType,
args: {
id: { type: GraphQLNonNull(GraphQLString) },
email: { type: GraphQLNonNull(GraphQLString) },
username: { type: GraphQLNonNull(GraphQLString) },
password: { type: GraphQLNonNull(GraphQLString) },
isAdmin: { type: GraphQLNonNull(GraphQLString) },
},
resolve: async (_, args) => {
try {
return await User.findByIdAndUpdate(args.id, args, { new: true });
} catch (error) {
throw new Error(error.message);
}
},
},
// Mutation to delete a user by ID
deleteUser: {
type: UserType,
args: { id: { type: GraphQLNonNull(GraphQLString) } },
resolve: async (_, args) => {
try {
return await User.findByIdAndDelete(args.id);
} catch (error) {
throw new Error(error.message);
}
},
},
// Mutation to add a new movie
addMovie: {
type: MovieType,
args: {
title: { type: GraphQLNonNull(GraphQLString) },
genre: { type: GraphQLNonNull(GraphQLString) },
rating: { type: GraphQLNonNull(GraphQLInt) },
duration: { type: GraphQLNonNull(GraphQLString) },
},
resolve: async (_, args) => {
try {
const movie = new Movie(args);
return await movie.save();
} catch (error) {
throw new Error(error.message);
}
},
},
// Mutation to update a movie by ID
updateMovie: {
type: MovieType,
args: {
id: { type: GraphQLNonNull(GraphQLString) },
title: { type: GraphQLNonNull(GraphQLString) },
genre: { type: GraphQLNonNull(GraphQLString) },
rating: { type: GraphQLNonNull(GraphQLInt) },
duration: { type: GraphQLNonNull(GraphQLString) },
},
resolve: async (_, args) => {
try {
return await Movie.findByIdAndUpdate(args.id, args, { new: true });
} catch (error) {
throw new Error(error.message);
}
},
},
// Mutation to delete a movie by ID
deleteMovie: {
type: MovieType,
args: { id: { type: GraphQLNonNull(GraphQLString) } },
resolve: async (_, args) => {
try {
return await Movie.findByIdAndDelete(args.id);
} catch (error) {
throw new Error(error.message);
}
},
},
},
});
export default new GraphQLSchema({
query: RootQuery,
mutation: Mutation,
});
In our index file, we create a RootQuery and Mutation using the GraphQLObjectType from the "graphql" library to handle queries and mutations, respectively. The schema includes two main types: UserType for representing user-related data and MovieType for movie-related data. These types define the structure of the data that can be queried or mutated in the GraphQL API.
The RootQuery includes queries to retrieve users (users and user) and movies (movies and movie). For example, the users query retrieves a list of users, while the user query fetches a specific user by their ID. Similarly, the movies query retrieves all movies, and the movie query fetches a specific movie by its ID.
The Mutation type defines operations to modify data. For users, there are mutations to add a new user (addUser), update a user by ID (updateUser), and delete a user by ID (deleteUser). For movies, there are mutations to add a new movie (addMovie), update a movie by ID (updateMovie), and delete a movie by ID (deleteMovie). The mutations handle necessary logic, such as hashing passwords before saving a new user.
Finally, the GraphQLSchema is created, incorporating the defined RootQuery and Mutation. This schema serves as the entry point for the GraphQL API, providing a structured and standardized way to interact with user and movie data. We can now use the exported schema main index file: src/index.ts
as previously illustrated.
Lets finalise with our password hashing function in src/utils/passwordUtils.ts
:
// Import necessary modules
import bcrypt from "bcrypt";
// Hash a password
export const hashPassword = async (password: string): Promise<string> => {
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(password, saltRounds);
return hashedPassword;
};
At this point, we can open terminal and run:
npm run dev
and should see the following output in the console:
In your browser, visit localhost:8500/graphql
and you should see the following interface:
This is the graphql playground allows developers to interact with a GraphQL API, test queries and mutations, and explore the available schema. It provides a convenient and visual way to understand the structure of the API, test different queries, and experiment with the data.
Lets make a sample mutation request for creating a new user.
In the left pane of the playground,place:
// Mutation to add a new user
mutation {
addUser(email: "example@email.com", username: "exampleUser", password: "password123", isAdmin: false) {
id
username
email
isAdmin
}
}
and hit cmd+enter
or control+enter
or the play button in the navigation panel.
The following output should be seen on the right hand side of the playground:
As you can observe, only requested data is returned from the server and nothing less, nothing more, a case in point, createdAt and updatedAt were not requested because we probably didnt need them, which is why they were not returned. This architecture is way more convenient because of this.
You can test all the crud operations on both user and movie using the same pattern. For all get requests, we use query not mutation. Mutation is for put, post and delete.
Conclusion
In conclusion, building a GraphQL API with Node.js and TypeScript offers a powerful and flexible solution for designing efficient, scalable, and strongly typed APIs. Leveraging the expressive syntax of GraphQL, you can precisely define data structures, reducing over-fetching and under-fetching issues. The combination of Node.js's event-driven architecture and TypeScript's static typing enhances code maintainability and catches potential errors during development. With seamless integration of popular libraries and tools, such as Express, Mongoose, among others constructing robust and feature-rich APIs becomes both intuitive and efficient. GraphQL's introspective nature, coupled with the convenience of TypeScript, fosters a streamlined development process and facilitates collaboration among team members. Ultimately, adopting GraphQL in a Node.js and TypeScript environment empowers creation of APIs that align closely with client needs, fostering a more efficient and enjoyable development experience.
Important Links
Posted on January 12, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.