Custom API server with basic CRUD — JS, Apollo, GraphQL & MongoDB

richardevcom

richardevcom

Posted on January 16, 2023

Custom API server with basic CRUD — JS, Apollo, GraphQL & MongoDB

[...] All that was left to do for my cross-platform hybrid web app was to test it with actual content and create CRUD. First thought, obviously, was to write middleware for back-end that will communicate with database. However, I had planned on adding more features over time, so doing that on multiple platforms would be overkill. Therefore, I quickly decided on building API server, that could... well... do it all.

APIs, APIs everywhere

The most commonly used data format for transferring data to a client is JSON and for controlling it, well.. used to be REST (don't throw rocks at me just yet 😅). If you gradually increase your app's complexity as well as database, it can and will be a very resource-heavy solution in long term. Luckily for us, there are 2 much better alternatives --- GraphQL and gRPC. Even better --- there is also Node.js friendly Apollo Server (GraphQL server).

So, in this article "slash" tutorial, I'll try to dig into creating a custom API Apollo Server and as a bonus, we'll write some basic CRUD for it.

Let's dig in! 👏

Before you jump in

It goes without saying, that you'll need basic knowledge in Node.js, NPM and how to use its command-line tool. At the moment of writing this tutorial, I had the following versions set-up:

node -v
v16.16.0
npm -v
8.19.2
Enter fullscreen mode Exit fullscreen mode

What about the database? I personally prefer going with good old MongoDB. If you have avoided it until now, get to know it better here, it's intuitive and fast. Oh, and we'll also use it the OOP way*,* so meet Mongoose--- it will be our "driver" for MongoDB.

We'll be running our MongoDB server on Docker. Read ---how to install MongoDB on Docker. Alternatively, you can use MongoDB Atlas (which is remote & ready solution).

I installed and initialized MongoDB within Docker like so (replace mongoadmin and mongopasswd to whatever you want):

docker pull mongo
docker run -d  --name mongodb  -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=mongoadmin -e MONGO_INITDB_ROOT_PASSWORD=mongopasswd mongo
Enter fullscreen mode Exit fullscreen mode

Lastly, instead of writing our API core ourselves, we'll be using the star of this episode --- Apollo Server (a.k.a. GraphQL server). It has detailed documentation available here.

Initialization

Let's dig in and start by creating our project folder:

mkdir mag-api-server
cd mag-api-server
Enter fullscreen mode Exit fullscreen mode

We'll go with MAG as in MongoDB, Apollo and GraphQL for the sake of... me loving abbreviations. 🤷‍♂️

Then initialize our Node.js project:

npm init -y
Enter fullscreen mode Exit fullscreen mode

Let's update our package.json by setting type to module (so we can load our JS as ES modules) and changing npm test command to npm start:

...
"type": "module",
"scripts": {
   "start": "node index.js"
},
...
Enter fullscreen mode Exit fullscreen mode

Apollo Server setup

Our project directory is set up, so let's install Apollo Server with GraphQL:

npm install @apollo/server graphql -S
Enter fullscreen mode Exit fullscreen mode

Then create an index.js file in your project root folder and import packages from above in it.

import { ApolloServer } from "@apollo/server"
import { startStandaloneServer } from "@apollo/server/standalone"
Enter fullscreen mode Exit fullscreen mode

Define temporary movie GraphQL schema for testing purposes below:

...
const typeDefs = `#graphql
  type Movie {
    title: String
    director: String
  }

  type Query {
    movies: [Movie]
  }
`
Enter fullscreen mode Exit fullscreen mode

And add movies data set below:

...
const movies = [
   {
      title: "Edward Scissorhands",
      director: "Tim Burton",
   },
   {
      title: "The Terrifier 2",
      director: "Damien Leone",
   },
]
Enter fullscreen mode Exit fullscreen mode

Next, we'll need to define a resolver.

⚡"Resolver tells Apollo Server how to fetch the data associated with a particular type." Because our movies array is hard-coded, the corresponding resolver is straightforward.

...
const resolvers = {
   Query: {
      movies: () => movies,
   },
}
Enter fullscreen mode Exit fullscreen mode

Let's define our Apollo Server instance:

const server = new ApolloServer({
   typeDefs,
   resolvers,
})

const { url } = await startStandaloneServer(server, {
   listen: { port: 4000 },
})

console.log(`🚀  Server ready at: ${url}`)
Enter fullscreen mode Exit fullscreen mode

And test run our server:

npm start
Enter fullscreen mode Exit fullscreen mode

Which should print back:

> mage-api-server@1.0.0 start
> node index.js
🚀  Server ready at: http://localhost:4000/
Enter fullscreen mode Exit fullscreen mode

If you open http://localhost:4000/, you will see a sandbox environment, where we will be executing GraphQL queries.

Go ahead and run this query in the operations tab:

query Movies {
   movies {
      title
      director
   }
}
Enter fullscreen mode Exit fullscreen mode

If everything is set up correctly, you will receive this JSON response:

{
   "data": {
      "movies": [
         {
            "title": "Edward Scissorhands",
            "director": "Tim Burton"
         },
         {
            "title": "The Terrifier 2",
            "director": "Damien Leone"
         }
      ]
   }
}
Enter fullscreen mode Exit fullscreen mode

Restructure Schemas & resolvers

With our Apollo Server running and querying, let's restructure our project files and create more automated type schema inclusion. We'll start by creating ./schemas/ folder which will hold our GraphQL type schemas.

For resolvers --- create ./resolvers/ folder as well as ./resolvers/movie.js file. Next, cut our movies constant data set and resolvers definitions from index.js and paste them into ./resolvers/movie.js. Finally, prefix resolvers with export and rename it to moviesResolvers:

const movies = [
   {
      title: "Edward Scissorhands",
      director: "Tim Burton",
   },
   {
      title: "The Terrifier 2",
      director: "Damien Leone",
   },
]

export const moviesResolvers = {
   Query: {
      movies: () => movies,
   },
}
Enter fullscreen mode Exit fullscreen mode

Next, create ./schemas/Movie.graphql file, cut Movie type schema from ./index.js and paste it in our newly made file (without GraphQL syntax and JS definition):

type Movie {
   title: String
   director: String
}

type Query {
   movies: [Movie]
}
Enter fullscreen mode Exit fullscreen mode

Before we create a loader for resolvers and schemas, let's install the necessary GraphQL Tools:

npm i @graphql-tools/load @graphql-tools/schema @graphql-tools/graphql-file-loader -S
Enter fullscreen mode Exit fullscreen mode

Now create ./loader.js in the project root folder and import both scheme and resolvers using our new tools:

import { loadSchema } from "@graphql-tools/load"
import { GraphQLFileLoader } from "@graphql-tools/graphql-file-loader"
import { moviesResolvers } from "./resolvers/movie.js"

export const typeDefs = await loadSchema("./schemas/**/*.graphql", { loaders: [new GraphQLFileLoader()] })
export const resolvers = [moviesResolvers]
Enter fullscreen mode Exit fullscreen mode

⚡Using loaders in the manner above will not only automize module, resolver and schema import but also make code more readable and writable in long term.

Let's go back to ./index.js and import schemas and resolvers:

...
import { resolvers, typeDefs } from "./loader.js"
...
Enter fullscreen mode Exit fullscreen mode

To test if everything works fine, re-run the server npm start command and query movies in http://localhost:4000/.

MongoDB via Mongoose

We got our type query-able schema and resolvers. Let's remove the hard-coded data set in ./resolvers/movie.js and connect the database instead.

Adding MongoDB should be pretty intuitive, however thinking ahead we'd probably want our data the OOP way, right? This is where Mongoose comes in. It's a great "driver" for that.

As mentioned in "Before you jump in" section, we will be using Dockerized MongoDB approach. To test if our MongoDB container is running, let's run this command:

docker ps -a
Enter fullscreen mode Exit fullscreen mode

If you see your container and its status indicates it's running, we're ready to continue.

So let's continue by installing Mongoose:

npm i mongoose -S
Enter fullscreen mode Exit fullscreen mode

Then import it in our ./index.js:

import mongoose from "mongoose"
Enter fullscreen mode Exit fullscreen mode

Now, initialize our MongoDB connection somewhere above our server constant (replace mongoadmin and mongopasswd to whatever you provided when initializing Docker container):

...
mongoose.Promise = global.Promise
mongoose.set("strictQuery", false)
mongoose.connect("mongodb://mongoadmin:mongopasswd@localhost:27017/?authSource=admin")
......
Enter fullscreen mode Exit fullscreen mode

So, we have our connection to MongoDB, but we still need to replace hard-coded data set with a model.

Create ./models folder and create ./models/movie.js. Open it up and create movie database schema:

import mongoose from "mongoose"

const Schema = mongoose.Schema
const MovieSchema = new Schema(
   {
      title: {
         type: String,
         default: "",
         required: true,
      },
      director: {
         type: String,
         default: "",
         required: true,
      },
   },
   {
      timestamps: {
         createdAt: "created_at",
         updatedAt: "updated_at",
      },
   }
)

export default mongoose.model("movie", MovieSchema)
Enter fullscreen mode Exit fullscreen mode

In theory ---  this is it. However, I mentioned something about CRUD before, so let's dig into that and customize our MAG API a little bit more. 😅

CRUD

Because I love writing so much, I want us to create simple CRUD logic in our only resolver ./resolvers/movie.js. If you haven't already, remove hard-coded dummy data set and import ./models/movie.js.

import Movie from "../models/movie.js"
...
Enter fullscreen mode Exit fullscreen mode

Also, let's redefine our movies query in ./resolvers/movie.js :

...
export const moviesResolvers = {
  Query: {
    async movies(root, {}, ctx) {
      return await Movie.find()
    },
  },
}
...
Enter fullscreen mode Exit fullscreen mode

Lastly, before we continue --- it is always smart to use some kind of unique identifiers for your records, therefore let's go ahead and use MongoDB default one _id and reuse it in our movie schema ./schemas/Movie.graphql:

type Movie {
  _id: ID!
  title: String
  director: String
}
...
Enter fullscreen mode Exit fullscreen mode

Create (CRUD)

Define CREATE mutation in our ./schemas/movie.graphql type schema:

...
type Mutation {
   addMovie(title: String!, director: String!): Movie!
}
Enter fullscreen mode Exit fullscreen mode

Next, CREATE mutation resolver below in ./resolvers/movie.js:

...
export const moviesResolvers = {
  ...
  Mutation: {
     async addMovie(root, { title, director }, ctx) {
        return await Movie.create({
           title,
           director,
        })
     },
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's restart our server and test if we can add new movie via sandbox at http://localhost:4000/ by querying this mutation:

mutation Mutation($director: String!, $title: String!) {
  addMovie(director: $director, title: $title) {
    _id
    title
    director
  }
}
Enter fullscreen mode Exit fullscreen mode

And for variables let's enter data from a previously deleted data set. Write couple of different ones for the diversity of things.

{
  "title": "Prometheus",
  "director": "Ridley Scott"
}
Enter fullscreen mode Exit fullscreen mode

When you're ready ---  run the mutation query.

To confirm that we actually saved the movie, open up a new query tab in sandbox and re-run:

query Query {
  movies {
    _id
    title
    director
  }
}
Enter fullscreen mode Exit fullscreen mode

You will receive your newly created movie within JSON response.

Now, we have to mirror our steps above and create read, update and delete logic. There is no CRUD without RUD. 🥁

Read (CRUD)

We already have a method to READ all movies, so let's just define READ query in our ./schemas/movie.graphql type schema for reading a single movie (using its ID):

...
type Query {
  ...
  getMovie(_id: ID!): Movie
}
Enter fullscreen mode Exit fullscreen mode

Define READ query resolver below in ./resolvers/movie.js:

...
Query: {
  ...
  async getMovie(root, { _id }, ctx) {
    return await Movie.findOne({ _id })
  }
}
...
Enter fullscreen mode Exit fullscreen mode

Restart the server and test if you can get a movie by its _id via sandbox at http://localhost:4000/ using this query:

query Query($id: ID!) {
  getMovie(_id: $id) {
    _id
    director
    title
  }
}
Enter fullscreen mode Exit fullscreen mode

And use your newly added movie's _id as a variable value:

{
  "id": "63c224272a9dd1ef31d73de5"
}
Enter fullscreen mode Exit fullscreen mode

Update (CRUD)

Define UPDATE mutation in our ./schemas/movie.graphql type schema:

...
type Mutation {
  ...
  updateMovie(_id: ID!, title: String, director: String): Movie
}
Enter fullscreen mode Exit fullscreen mode

Now, to update the movie, we'll UPDATE mutation in out ./resolvers/movie.js like so:

...
Mutation: {
  ...
  async updateMovie(root, { _id, title, director }, ctx) {
    return await Movie.findOneAndUpdate({ _id }, { title, director })
  },
}
Enter fullscreen mode Exit fullscreen mode

Restart the server, define the query and provide _id and one or both variables for it to update:

mutation Mutation($id: ID!, $title: String, $director: String) {
  updateMovie(_id: $id, title: $title, director: $director) {
    director
    title
  }
}
Enter fullscreen mode Exit fullscreen mode

I'm updating "Prometheus" movie title and director, so I'm providing its _id value from the previous query response and the rest below it:

{
  "id": "63c224272a9dd1ef31d73de5",
  "title": "Antichrist",
  "director": "Lars von Trier"
}
Enter fullscreen mode Exit fullscreen mode

Delete (CRUD)

Time flies and taste in movies changes, so we will obviously need a way to delete a movie in future, therefore let's go and define DELETE mutation in our ./schemas/movie.graphql type schema:

...
type Mutation {
 ...
 deleteMovie(_id: ID!): Movie
}
Enter fullscreen mode Exit fullscreen mode

Next, define DELETE mutation resolver below in ./resolvers/movie.js:

Mutation: {
  ...
  async deleteMovie(root, { _id }, ctx) {
    return await Movie.findOneAndDelete({ _id })
  },
 },
Enter fullscreen mode Exit fullscreen mode

Finally, let's go ahead, restart and test with this query:

mutation Mutation($id: ID!) {
  deleteMovie(_id: $id) {
    _id
    director
    title
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's define _id of movie that we'll be deleting:

{
  "id": "63c224272a9dd1ef31d73de5"
}
Enter fullscreen mode Exit fullscreen mode

Finally, restart the server and query all movies to see if it all worked!

🎉Congratulations!

You did it! You read this lengthy "how-to" tutorial and created your own API server with basic CRUD. 👏

Leave a comment below if and where you had trouble running the server, or if you simply want to make a suggestion.


What's next?

This API server is bare-bone by itself, so your journey doesn't end here. "One simply does not CRUD without an auth". Surely you wouldn't want to lose all your precious po.. I mean movies!? 👀

So, here are some ideas on what to do next:

  • What is API without a key? Try integrating JWT with basic key based permission logic;

  • Integrate this with a front-end framework, for example, Vue.js;

  • Containerize your API server and MongoDB for Docker (make an easily deployable container);

  • Write sanitizers & conditions to work with duplicates or queries returning errors on non-existing records;

  • Secure queries and mutations by writing checks for inputs and customizing callbacks (I might do part 2 regarding this);

💖 💪 🙅 🚩
richardevcom
richardevcom

Posted on January 16, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related