[...] 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.
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
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
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
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
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"
},
...
Apollo Server setup
Our project directory is set up, so let's install Apollo Server with GraphQL:
npm install @apollo/server graphql -S
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"
Define temporary movie
GraphQL schema for testing purposes below:
...
const typeDefs = `#graphql
type Movie {
title: String
director: String
}
type Query {
movies: [Movie]
}
`
And add movies
data set below:
...
const movies = [
{
title: "Edward Scissorhands",
director: "Tim Burton",
},
{
title: "The Terrifier 2",
director: "Damien Leone",
},
]
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,
},
}
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}`)
And test run our server:
npm start
Which should print back:
> mage-api-server@1.0.0 start
> node index.js
🚀 Server ready at: http://localhost:4000/
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
}
}
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"
}
]
}
}
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,
},
}
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]
}
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
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]
⚡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"
...
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
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
Then import it in our ./index.js
:
import mongoose from "mongoose"
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")
......
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)
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"
...
Also, let's redefine our movies
query in ./resolvers/movie.js
:
...
export const moviesResolvers = {
Query: {
async movies(root, {}, ctx) {
return await Movie.find()
},
},
}
...
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
}
...
Create (CRUD)
Define CREATE mutation in our ./schemas/movie.graphql
type schema:
...
type Mutation {
addMovie(title: String!, director: String!): Movie!
}
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,
})
},
}
}
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
}
}
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"
}
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
}
}
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
}
Define READ query resolver below in ./resolvers/movie.js
:
...
Query: {
...
async getMovie(root, { _id }, ctx) {
return await Movie.findOne({ _id })
}
}
...
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
}
}
And use your newly added movie's _id
as a variable value:
{
"id": "63c224272a9dd1ef31d73de5"
}
Update (CRUD)
Define UPDATE mutation in our ./schemas/movie.graphql
type schema:
...
type Mutation {
...
updateMovie(_id: ID!, title: String, director: String): Movie
}
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 })
},
}
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
}
}
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"
}
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
}
Next, define DELETE mutation resolver below in ./resolvers/movie.js
:
Mutation: {
...
async deleteMovie(root, { _id }, ctx) {
return await Movie.findOneAndDelete({ _id })
},
},
Finally, let's go ahead, restart and test with this query:
mutation Mutation($id: ID!) {
deleteMovie(_id: $id) {
_id
director
title
}
}
Now let's define _id
of movie
that we'll be deleting:
{
"id": "63c224272a9dd1ef31d73de5"
}
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);
Top comments (1)
What other tech-stack would you use to create similar API server with CRUD? 🤔