DEV Community

Cover image for Node.js and GraphQL Tutorial: How to build a GraphQL API with an Apollo server
STEVE
STEVE

Posted on

Node.js and GraphQL Tutorial: How to build a GraphQL API with an Apollo server

INTRODUCTION
Welcome to my tutorial on building a GraphQL API with an Apollo Server using Node.js! In this guide, I will show you how to harness the power of GraphQL to create efficient and flexible APIs for your applications.

So, what exactly is GraphQL? Imagine a world where you can request exactly the data you need from your server, no more and no less. GraphQL is a query language that allows you to do just that. Unlike traditional REST APIs where you often receive a fixed set of data in predefined endpoints, GraphQL empowers you to shape your queries to match your specific requirements. It's like having a tailor-made API at your fingertips.

Comparing GraphQL to REST is like comparing a custom-made suit to off-the-rack clothing. With REST, you might end up over-fetching or under-fetching data, causing inefficiencies. But with GraphQL, you have the freedom to ask for only the fields you need, eliminating unnecessary data transfer and optimizing your app's performance.

But that's not all! GraphQL also excels in its ability to consolidate multiple data sources into a single query. No more juggling between different endpoints to assemble the data you need. GraphQL brings it all together in one elegant request.

Whether you're building a simple to-do app or a complex e-commerce platform, GraphQL's flexibility and efficiency can revolutionize the way you interact with APIs. Throughout this tutorial, I'll guide you step by step in creating a GraphQL API using an Apollo Server with Node.js. You'll learn how to define your data schema and fetch data from MongoDB using resolvers for a seamless experience.

So, if you're ready to dive into the world of GraphQL and unlock its potential, let's get started!

FOLDER STRUCTURE
When diving into building a GraphQL API with an Apollo Server, having a clear and organized folder structure is key. Here's a suggested layout for your project's folder structure, drawing parallels to how things might be organized in a traditional RESTful API project.

project-root/
|-- src/
|   |-- schema/           # GraphQL schema definitions
|   |-- resolvers/        # Resolver functions for handling queries and mutations
|   |-- models/           # Data models or database schemas
|   |-- app.js            # GraphQL server setup
|-- package.json          # Project dependencies and scripts
|-- .gitignore            # Git ignore configurations
|-- .env                  # Environment variables (optional)

Enter fullscreen mode Exit fullscreen mode

Here's what each folder represents:

  • schema: Think of the "schema" folder as similar to the routes or endpoints in a RESTful API. Here, you define your GraphQL schema using a Schema Definition Language (SDL). This schema outlines the types, queries, mutations, and even subscriptions that your API will support. It's the heart of your API's structure.

  • resolvers: Just as controllers handle the logic for different routes in a RESTful API, resolvers in the "resolvers" folder handle the logic for various fields in your GraphQL schema. Each resolver function corresponds to a specific field and contains the actual code that fetches data, interacts with databases, and performs the required operations. Resolvers are where the "magic" happens, similar to how controllers execute the actions in a RESTful API.

  • models: The "models" folder houses your data models, which are equivalent to the database schema in a RESTful API context. These models define the structure and relationships of your data. In resolvers, you utilize these models to interact with your data sources, just as you would in a RESTful API's database layer.

  • app.js: The "app.js" file takes on the role of setting up and configuring your GraphQL server. This is equivalent to the server configuration and middleware setup you might do in the main file of a RESTful API.

  • package.json: This file remains the same regardless of whether you're working with REST or GraphQL. It lists your project's dependencies and scripts for managing your API, much like in a RESTful API project.

  • .gitignore: Similar to a RESTful API project, the ".gitignore" file helps you specify files and directories that should be ignored by version control (Git).

  • .env: Optionally, you can use the ".env" file to store environment variables for your application, just like in a RESTful API.

Remember, while this suggested folder structure draws parallels to RESTful design, it's adaptable to your project's specific needs and your development preferences. It provides a roadmap for organizing your GraphQL API project effectively, leveraging concepts you might already be familiar with from working with RESTful APIs.

PACKAGES
For this project, you will need to install the following packages:
mongoose, bcryptjs, dotenv, @apollo/server @graphql-tools/merge

Let's briefly talk about these packages:

  1. mongoose: ODM library for MongoDB and Node.js, aiding data modeling and interaction.
  2. bcryptjs: Library for secure password hashing and comparison.
  3. dotenv: Loads environment variables from .env files, ensuring secure configuration.
  4. @apollo/server: GraphQL server implementation for streamlined schema execution and validation.
  5. @graphql-tools/merge: Utility for combining multiple GraphQL schemas into one cohesive schema.

Because I am using typescript, my folder structure and package.json file now look like this.

folder structure

Now update the ./src/db/connect.ts file with the code below to establish a connection to your Mongo database.

import mongoose from "mongoose";

export const connectDB = (url : string) => {
    return mongoose.connect(url)
    .then(() => console.log("Connected to database"))
    .catch((err) => console.log(err));
}
Enter fullscreen mode Exit fullscreen mode

For this project, I created two models- User and Products. This is what they look like:

./src/model/user.ts

import {Schema, Model, model, Document} from 'mongoose';
import bcrypt from 'bcryptjs';

export interface IUser extends Document {
    username: string;
    email: string;
    password: string;
    isValidPassword: (password: string) => Promise<boolean>;
}

const UserSchema: Schema = new Schema({
    username: {type: String, required: true, unique: true},
    email: {type: String, required: true},
    password: { type: String, required: true}
})

UserSchema.pre('save', async function() {
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
})

UserSchema.methods.isValidPassword = async function(password: string) {
    const compare = await bcrypt.compare(password, this.password);
    return compare;
}

export const User: Model<IUser> = model<IUser>('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

./src/model/product.ts

import {Schema, Model, model, Document} from 'mongoose'

export interface IProduct extends Document {
    name: string;
    price: number;
}

const ProductSchema: Schema = new Schema({
    name: {type: String, required: true},
    price: {type: Number, required: true}
})

export const Product: Model<IProduct> = model<IProduct>('Product', ProductSchema);
Enter fullscreen mode Exit fullscreen mode

Now that we have successfully created a database schema for our API let us head straight to create the schema for our GraphQL queries and Mutations.

For the users schema, update ./src/schema/user.ts to look like this:

import { buildSchema } from "graphql";

export const usersGQLSchema = buildSchema(`
    type User {
        id: String!
        username: String!
        email: String!
        password: String!
    }

    type Query {
        users: usersInfoResponse!
        user(id: String!): User!
    }

    type usersInfoResponse {
        success: Boolean!
        total: Int!
        users: [User!]!
    }

    type Mutation {
        regUser(username: String!, email: String!, password: String!): User!
        loginUser(email: String!, password: String!): User!
        updateUser(id: String!, username: String, email: String, password: String): User!
        deleteUser(id: String!): deleteResponse!
    }

    type deleteResponse {
        success: Boolean!
        message: String!
        id: String!
    }

`)
Enter fullscreen mode Exit fullscreen mode

Let me break down everything this code presents:

1. Types - Like Data Structures
Type User: Just as in RESTful APIs, a "type" in GraphQL defines what data looks like. For instance, "User" is like a blueprint for a user's data, including properties such as ID, username, email, and password.

2. Queries - Retrieving Data
Query users: Think of this as a way to request a list of users. Similar to a RESTful API endpoint, you're asking for user information. The exclamation point (!) after usersInfoResponse indicates that this query always returns a response with users' information.

Query user(id): This is like getting details about one user, just as you would in a RESTful API by providing an ID. The id parameter is marked with an exclamation point (!) to show that it's required for the query to work. The exclamation point after User indicates that this query always returns user information.

3. Mutations - Modifying Data
Mutation regUser/loginUser: Similar to creating a new resource in REST, this mutation lets you signup/signin a new user. The exclamation points after username, email, and password indicate that these fields are required for creating a user. The exclamation point after User indicates that the mutation always returns the newly created user's information.

Mutation updateUser(id): This is like updating a user's information, comparable to editing a resource in REST. The id is required, and you can modify username, email, or password. If you don't provide an exclamation point, it means the field is optional.

Mutation deleteUser(id): Just as you might delete a resource in REST, this mutation removes a user. The id is required, and the exclamation point after deleteResponse indicates that it always returns a response.

4. Custom Response Types - Structured Responses
Type usersInfoResponse: This is like the response you might get when requesting a list of users in REST. The exclamation point after success, total, and users means that these fields are always included in the response.

Type deleteResponse: Comparable to a response when deleting a resource in REST, this type always includes success, message, and id.

In essence, GraphQL's exclamation points highlight required fields just like in RESTful APIs. They ensure that when you make a query or mutation, you get back the data you need with certainty.

After creating our User schema, let us create the resolver (or controllers) to implement the logic.

Update ./src/resolver/user.ts with this code:

import {User} from '../model/user';

interface Args {
    id: string;
    username: string;
    email: string;
    password: string;
}

export const UsersResolver = {
    Query : {
        users: async () => {
            try {
                const users = await User.find({});
                if (!users) throw new Error('No users found');
                return {
                    success: true,
                    total: users.length,
                    users
                };
            } catch (error) {
                throw error;
            }
        },    

        user: async (_ : any, args : Args) => {
            try {
                if (!args.id) throw new Error('No id provided');
                const user = await User.findById(args.id);
                if (!user) throw new Error('No user found');
                return user;
            } catch (error) {
                throw error;
            }
        }
    },

    Mutation : {
        regUser: async (_ : any, args : Args) => {
            try {
                const user = await User.findOne({email: args.email});
                if (user) throw new Error('User already exists');
                const newUser = await User.create({
                    username: args.username,
                    email: args.email,
                    password: args.password
                })
                return newUser;
            } catch (error) {
                throw error;
            }
        },

        loginUser: async (_ : any, args : Args) => {
            try {
                const user = await User.findOne({email: args.email});
                if (!user) throw new Error('User not found');
                const isValid = await user.isValidPassword(args.password);
                if (!isValid) throw new Error('Invalid password');
                return user;
            } catch (error) {
                throw error;
            }
        },

        updateUser: async (_ : any, args : Args) => {
            try {
                const id = args.id;
                if (!id) throw new Error('No id provided');
                const user = await User.findById(args.id);
                if (!user) throw new Error('User not found');
                const updateUser = await User.findByIdAndUpdate(id, {...args}, {new: true, runValidators: true});
                return updateUser;
            } catch (error) {
                throw error;
            }
        },

        deleteUser: async (_ : any, args : Args) => {
            try {
                const id = args.id;
                if (!id) throw new Error('No id provided');
                const user = await User.findById(args.id);
                if (!user) throw new Error('User not found');
                const deleteUser = await User.findByIdAndDelete(id);
                return {
                    success: true,
                    message: 'User deleted successfully',
                    id: deleteUser?._id
                };
            } catch (error) {
                throw error;
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

In the code above:

The UsersResolver object is created, containing resolvers for both queries and mutations.

The Query section contains two resolvers:

users: Fetches all users from the database and returns a response containing information about users.
user: Fetches a user by their provided ID from the database.
The Mutation section contains several resolvers for various operations, each performing a specific action:

regUser: Registers a new user if they don't already exist in the database.
loginUser: Validates user credentials and returns the user if login is successful.
updateUser: Updates a user's information based on their ID.
deleteUser: Deletes a user by their ID.
Each resolver uses asynchronous code (async/await) to interact with the database and handle potential errors.

The Args interface defines the expected arguments for the resolver functions. For instance, each mutation requires id, username, email, and password parameters.

This code demonstrates how GraphQL resolvers work to fetch, create, update, and delete data while interacting with a User model from an external module. It's similar to the logic you might use in controllers for RESTful APIs, where each resolver corresponds to a specific API operation.

Great work, now that we have successfully implemented the logic for Users let us repeat the same process by creating a schema and a resolver for Products.

./src/schema/products.ts

import {buildSchema} from "graphql"

export const productsGQLSchema = buildSchema(`
    type Product {
        id: String!
        name: String!
        price: Int!
    }

    type Query {
        products: productsInfoResponse!
        product(id: String!): Product!
    }

    type productsInfoResponse {
        success: Boolean!
        total: Int!
        products: [Product!]!
    }

    type Mutation {
        addProduct(name: String!, price: Int!): Product!
        updateProduct(id: String!, name: String, price: Int): Product!
        deleteProduct(id: String!): deleteResponse!
    }

    type deleteResponse {
        success: Boolean!
        message: String!
        id: String!
    }
`)
Enter fullscreen mode Exit fullscreen mode

./src/resolvers/products.ts

import { Product } from "../model/products";

interface Args {
    id: string;
    name: string;
    price: number;
}

export const ProductsResolver = {
    Query : {
        products: async () => {
            try {
                const products = await Product.find({});
                if (!products) throw new Error('No products found');
                return {
                    success: true,
                    total: products.length,
                    products
                };
            } catch (error) {
                throw error;
            }
        },

        product: async (_ : any, args : Args) => {
            try {
                if (!args.id) throw new Error('No id provided');
                const product = await Product.findById(args.id);
                if (!product) throw new Error('No product found');
                return product;
            } catch (error) {
                throw error;
            }
        }
    },

    Mutation : {
        addProduct: async (_ : any, args : Args) => {
            try {
                const product = await Product.findOne({name: args.name});
                if (product) throw new Error('Product already exists');
                const newProduct = await Product.create({
                    name: args.name,
                    price: args.price
                })
                return newProduct;
            } catch (error) {
                throw error;
            }
        },

        updateProduct: async (_ : any, args : Args) => {
            try {
                const id = args.id;
                if (!id) throw new Error('No id provided');
                const product = await Product.findById(args.id);
                if (!product) throw new Error('No product found');
                const updateProduct = await Product.findByIdAndUpdate(id, {...args}, {new: true, runValidators : true});
                return updateProduct;
            } catch (error) {
                console.log(error)
            }
        },

        deleteProduct: async (_ : any, args : Args) => {
            try {
                const id = args.id;
                if (!id) throw new Error('No id provided');
                const product = await Product.findById(args.id);
                if (!product) throw new Error('No product found');
                const deleteProduct = await Product.findByIdAndDelete(id);
                return {
                    success: true,
                    message: 'Product deleted successfully',
                    id: deleteProduct?._id
                };
            } catch (error) {
                throw error;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Similar to the implementation for User entities, the schema and resolver for Product entities collaborate seamlessly to offer a comprehensive and structured approach to managing product-related operations. This cohesive interaction ensures that querying, creating, updating, and deleting products within your GraphQL API are handled efficiently and logically.

The product schema serves as a blueprint, defining the structure of a Product type. Just as with the User type, this schema outlines the fields that compose a product, such as ID, name, description, price, and more. It specifies not only the fields themselves but also their data types and whether they're required or optional.

On the other hand, the product resolver takes care of the functional aspects. In a manner akin to the user resolver, it encapsulates the actual logic behind queries and mutations involving products. For instance, when querying for a list of products, the resolver fetches the products from a data source (e.g., a database. In our case - MongoDB) and constructs a well-formed response with details about each product. Similarly, when creating, updating, or deleting a product, the resolver handles the necessary data manipulation, validation, and interaction with the data source.

In tandem, the schema and resolver form a cohesive unit that makes it straightforward to understand, implement, and maintain product-related operations in your GraphQL API. This separation of concerns between defining the structure (schema) and implementing the functionality (resolver) contributes to a clean and organized codebase, making your API development experience smoother and more structured.

Now it's time for us to combine all the Schema and resolvers so that we can import a merged type definition and schema into the app.ts file.

Update ./src/schema/index.ts

import {mergeTypeDefs} from "@graphql-tools/merge"

import { usersGQLSchema } from "./user"
import { productsGQLSchema } from "./products"

export const mergedGQLSchema = mergeTypeDefs([usersGQLSchema, productsGQLSchema])

Enter fullscreen mode Exit fullscreen mode

By doing this, you're creating a unified GraphQL schema that incorporates all the types and operations defined in the user and product schemas. This merged schema can then be imported into your app.ts file to create a cohesive GraphQL API that supports both user and product-related functionalities.

Update ./src/resolver/index.ts

import { UsersResolver } from "./user";
import { ProductsResolver } from "./product";

export const resolvers = [UsersResolver, ProductsResolver]
Enter fullscreen mode Exit fullscreen mode

When we set up your GraphQL server, you'll use this combined array of resolvers along with the merged schema to create a fully functional GraphQL API.

We're nearing completion, and the final step is to construct our app.ts file, where we'll consolidate the various components we've developed so far. This file will serve as the backbone of our GraphQL application.

To begin, we load environment variables from a .env file using the dotenv library, ensuring the secure configuration of sensitive data like database connection details and port numbers.

Importing essential dependencies follows suit. These include the function responsible for connecting to the database (connectDB), as well as the ApolloServer and startStandaloneServer modules that facilitate GraphQL server creation.

Within this context, we define a constant named PORT to encapsulate the port number for our server. This value is either extracted from environment variables or defaults to 3000.

Our ApolloServer instance takes center stage. We configure it with the merged GraphQL schema (mergedGQLSchema) and the combined resolvers (resolvers). Moreover, we enable introspection, a valuable tool for introspecting the schema using tools like GraphQL Playground.

To bring it all to life, the start function emerges. As an asynchronous function, it orchestrates the setup process:

  • It employs the connectDB function to establish a connection to the MongoDB database, using the URI from environment variables.
  • The startStandaloneServer function is then invoked, initiating the Apollo server's operation. This server listens attentively on the specified port (PORT).
  • The culmination of this sequence is marked by a console message announcing the server's successful launch.

With the completion of these steps, we culminate the process by invoking the start function. This action ignites the journey, connecting the database, and propelling the GraphQL server into operation.

This is what our app.ts file looks like:

require("dotenv").config()

import { connectDB } from "./db/connect";

import { ApolloServer } from '@apollo/server';

import { startStandaloneServer } from '@apollo/server/standalone';

import { mergedGQLSchema } from "./schema";
import { resolvers } from "./resolvers";

const PORT = parseInt(process.env.PORT as string) || 3000

const server = new ApolloServer({
    typeDefs : mergedGQLSchema,
    resolvers : resolvers,
    introspection : true
  });

const start = async () => {
    try {
        connectDB(process.env.MONGO_URI as string)
        startStandaloneServer(server, { listen: { port: PORT } });
        console.log(`Server is listening on port ${PORT}`)
    } catch (error) {
        console.log(error)
    }
}

start()
Enter fullscreen mode Exit fullscreen mode

And with that, we're all set. To set the server in motion, simply execute the command npm run dev or npm start. Afterwards, pinpoint the endpoint at http://localhost:3000. If the components fall into place as anticipated, your browser will transport you to the interactive Apollo GraphQL Playground.

This serves as an excellent feature as GraphQL is inherently self-documenting. The GraphQL Playground, an integrated query tool, allows you to test and construct queries with ease. Much like the functionality found in tools like Postman, you can formulate queries, explore the schema, and gain insights into your API's capabilities firsthand.

It would look like this:

Apollo graphql playground

Query to get all users:

get all users

Mutation to register a user:

register user

Mutation to update a product

Update product

Notice that all endpoints are accessible via http://localhost:3000/, unlike a RESTful design where you would need to define different endpoints for each route. This is because of GraphQL's single endpoint architecture and its ability to handle complex data retrieval in a more dynamic manner.

In a traditional RESTful API design, each endpoint typically corresponds to a specific resource or route. If you wanted to retrieve different types of data, you would need to create distinct endpoints for each resource. For example, to fetch user information, you might have an endpoint like GET /users, and for products, another endpoint like GET /products.

However, GraphQL takes a different approach. With GraphQL, there's a single endpoint that serves as the entry point for all data operations. This endpoint is usually accessed via an HTTP POST request. Instead of defining multiple endpoints for different resources, GraphQL employs a flexible querying system that allows you to request exactly the data you need, and nothing more.

This is where the power of the GraphQL query language shines. The client can specify the shape and structure of the data it requires by creating queries that match the types and fields defined in the GraphQL schema. It's like asking for a custom-made data response tailored to your application's needs.

Behind the scenes, the GraphQL server processes the query and retrieves only the requested data. This eliminates the need to create and manage numerous endpoints for different use cases. The single endpoint approach simplifies the API structure, reduces redundancy, and provides a more efficient way to interact with data.

In essence, GraphQL's single endpoint design, coupled with its dynamic querying capabilities, offers a more streamlined and adaptable approach to handling complex data retrieval compared to the more rigid endpoint structure of traditional RESTful APIs. This contributes to the efficiency and flexibility that GraphQL brings to modern API development.

CONCLUSION
In conclusion, GraphQL presents a host of advantages that make it a compelling choice for modern API development. Its flexible querying system empowers clients to precisely request only the data they need, eliminating over-fetching and under-fetching of data commonly associated with RESTful APIs. This optimization in data transfer enhances performance, reduces unnecessary network traffic, and results in faster, more efficient interactions.

Unlike RESTful APIs that often require multiple endpoints for distinct resources, GraphQL's single endpoint architecture simplifies API management and reduces the need for versioning. With GraphQL, you have the freedom to evolve your API without causing disruption to existing clients, as fields can be added or deprecated without changing the endpoint structure.

Furthermore, GraphQL's introspection capabilities grant developers access to in-depth schema documentation, making it a self-documenting API. This, coupled with the integrated query tools like the GraphQL Playground, streamlines development, debugging, and testing.

However, it's essential to acknowledge that GraphQL might not be the optimal solution for every scenario. Its flexibility might lead to complex queries, potentially putting a heavier load on servers. RESTful APIs, on the other hand, can offer a clearer mapping to underlying data models and cache management due to their predictable nature.

In comparison, GraphQL and RESTful APIs each have their strengths and weaknesses, catering to different project requirements. While GraphQL excels in scenarios that prioritize flexibility, efficient data retrieval, and a unified endpoint, RESTful APIs can be more suitable for situations where a clear, resource-oriented structure and caching mechanisms are vital.

In the journey of building GraphQL APIs, we've traversed the process of creating schemas, defining types, crafting resolvers, and setting up the server. The completed code for this tutorial is accessible on Github via GRAPHQL-API, offering a practical reference for your endeavors.

To all the readers embarking on this exploration, I extend my appreciation for joining this tutorial. Whether you choose GraphQL or RESTful APIs, may your coding journeys be filled with innovation, efficiency, and transformative experiences. Happy coding!

Top comments (4)

Collapse
 
brunomonteiro1 profile image
Bruno Monteiro

Great article! I would just rethink about naming TypeScript interfaces with "I" prefix. That is a C# convention, and Microsoft guidelines for the development of TypeScript (not the usage) advise against it.

Collapse
 
realsteveig profile image
STEVE

Oh thank you for pointing that out.

Collapse
 
clericcoder profile image
Abdulsalaam Noibi

Wow,what a detailed Article. Thanks My Mentor

Collapse
 
realsteveig profile image
STEVE

You’re welcome chief.