Basics of Building a CRUD API with Node (no framework)

Alex Merced - Jul 30 '21 - - Dev Community

In this tutorial we once again create a full CRUD api without a database. In this article we won't use a pre-existing framework but the standard node libraries that underpin all our favorite frameworks.

Summary of RESTful Convention

THe restful convention gives us a blueprint of making the basic routes for CRUD (Create, Read, Update, Delete) functionality in a uniform way.

API Restful Routes

Name of Route Request Method Endpoint Result
Index GET /model returns list of all items
Show GET /model/:id returns item with matching id
Create Post /model creates a new item, returns item or confirmation
Update Put/Patch /model/:id Updated item with matching ID
Destroy Delete /model/:id Deletes item with matching ID

If we weren't build an API but instead rendering pages on the server there would be two additional routes. New, which renders a page with a form to create a new object, submitting the form triggers the create route. Edit, which renders a page with a form to edit an existing object, submitting the form triggers the Update route.

Since we are building an api, Edit and New aren't necessary as the burden of collecting the information to submit to the Create and Update route will be on whoever builds the applications that consume the API. (Frontend Applications built in frameworks)

Building an API

Setup

  • Must have nodeJS installed

  • create an empty folder and navigate terminal into it

  • create a server.js file and create an npm project touch server.js && npm init -y

Since we are using the standard library there is no need to install any other libraries. There are two libraries to be aware of, "http" and "https". They are pretty much the same but you use the latter for handling https connections (the things our frameworks figure out for us).

So to start our server...

server.js

// Import http library
const http = require("http")
// use env variable to define port with default
const PORT = process.env.PORT || 4000

//create our server object
const server = http.createServer()

// get the server to start listening
server.listen(PORT, err => {
  // error checking
  err ? console.error(err) : console.log(`listening on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

If you run the server (node server.js) and go to localhost:4000 it just hangs cause we have no instructions built into our server to handle the incoming request. Essentially our server will pass the request details to a function for every request. So the next step is to create the function that will handle EVERY request.

There is two approaches:

The Event Based Approach

// Import http library
const http = require("http")
// use env variable to define port with default
const PORT = process.env.PORT || 4000

//create our server object
const server = http.createServer()

// We define a function that runs in response to the request event
server.on("request", (request, response) => {
  // handle request based on method then URL
  switch (request.method) {
    case "GET":
      switch (request.url) {
        // response for unexpected get requests
        default:
          response.statusCode = 400
          response.write(`CANNOT GET ${request.url}`)
          response.end()
      }
      break

    case "POST":
      break

    case "PUT":
      break

    case "DELETE":
      break

    default:
      // Send response for requests with no other response
      response.statusCode = 400
      response.write("No Response")
      response.end()
  }
})

// get the server to start listening
server.listen(PORT, err => {
  // error checking
  err ? console.error(err) : console.log(`listening on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

The Callback Approach

You could also pass this function as a callback to the createServer function.

// Import http library
const http = require("http")
// use env variable to define port with default
const PORT = process.env.PORT || 4000

//create our server object, pass server function as callback argument
const server = http.createServer((request, response) => {
  // handle request based on method then URL
  switch (request.method) {
    case "GET":
      switch (request.url) {
        // response for unexpected get requests
        default:
          response.statusCode = 400
          response.write(`CANNOT GET ${request.url}`)
          response.end
      }
      break

    case "POST":
      break

    case "PUT":
      break

    case "DELETE":
      break

    default:
      // Send response for requests with no other response
      response.statusCode = 400
      response.write("No Response")
      response.end()
  }
})

// get the server to start listening
server.listen(PORT, err => {
  // error checking
  err ? console.error(err) : console.log(`listening on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

Now you can handle the request in infinite ways. The way I'm doing it is a switch statement based on the method followed by more switch statements based on url. This is the kind of thing that would already be handled by Koa/Fastify/Express's routing logic. Another issue is we won't have URL params since that isn't build into node, that is done by some string/url parsing magic in our favorite frameworks which we could try to replicate but we won't to keep this exercise manageable to understand.

Let's Simplify This

Having a bunch of switches inside of switches may get a little harder to read, so let's break out all the sub-switches into their own functions in another file.

  • touch get.js post.js put.js delete.js

get.js

module.exports = (request, response) => {
  switch (request.url) {
    // response for unexpected get requests
    default:
      response.statusCode = 400
      response.write(`CANNOT GET ${request.url}`)
      response.end()
  }
}
Enter fullscreen mode Exit fullscreen mode

post.js

module.exports = (request, response) => {
  switch (request.url) {
    // response for unexpected get requests
    default:
      response.statusCode = 400
      response.write(`CANNOT POST ${request.url}`)
      response.end()
  }
}
Enter fullscreen mode Exit fullscreen mode

put.js

module.exports = (request, response) => {
    switch(request.url){

        // response for unexpected get requests
        default:
            response.statusCode = 400
            response.write(`CANNOT PUT ${request.url}`)
            response.end()

    }
}
Enter fullscreen mode Exit fullscreen mode

delete.js

module.exports = (request, response) => {
  switch (request.url) {
    // response for unexpected get requests
    default:
      response.statusCode = 400
      response.write(`CANNOT DELETE ${request.url}`)
      response.end()
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's import these functions into server.js and clean it up, you can think of these four files as our "routers".

server.js

// Import http library
const http = require("http")
// use env variable to define port with default
const PORT = process.env.PORT || 4000

// Import our routers
const get = require("./get")
const post = require("./post")
const put = require("./put")
// add an extra R since delete is a reserved word
const deleteR = require("./delete")

//create our server object, pass server function as callback argument
const server = http.createServer((request, response) => {
  // handle request based on method then URL
  switch (request.method) {
    case "GET":
      get(request, response)
      break

    case "POST":
      post(request, response)
      break

    case "PUT":
      put(request, response)
      break

    case "DELETE":
      deleteR(request, response)
      break

    default:
      // Send response for requests with no other response
      response.statusCode = 400
      response.write("No Response")
      response.end()
  }
})

// get the server to start listening
server.listen(PORT, err => {
  // error checking
  err ? console.error(err) : console.log(`listening on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

So now all our sub-switches are handled inside the function making our server.js cleaner and easier to read.

Our Dataset

To focus on just writing the API we aren't bringing a database, so for a dataset we will just use an array of objects. This data will not persist meaning it will reset when you reset your server, this can always be fixed later by using a database, many to choose from.

  • create a file called data.js with the following
module.exports = [{ title: "The first post", body: "body of the first post" }]
Enter fullscreen mode Exit fullscreen mode

import it into server.js, we will store this array of posts in the request object so all other routes will have access to it there since they are passed the request object.

// Import http library
const http = require("http")
// use env variable to define port with default
const PORT = process.env.PORT || 4000

// import data
const posts = require("./data")

// Import our routers
const get = require("./get")
const post = require("./post")
const put = require("./put")
// add an extra R since delete is a reserved word
const deleteR = require("./delete")

//create our server object, pass server function as callback argument
const server = http.createServer((request, response) => {
  // add the data to the request object so our routes can access it
  request.posts = posts

  // handle request based on method then URL
  switch (request.method) {
    case "GET":
      get(request, response)
      break

    case "POST":
      post(request, response)
      break

    case "PUT":
      put(request, response)
      break

    case "DELETE":
      deleteR(request, response)
      break

    default:
      // Send response for requests with no other response
      response.statusCode = 400
      response.write("No Response")
      response.end()
  }
})

// get the server to start listening
server.listen(PORT, err => {
  // error checking
  err ? console.error(err) : console.log(`listening on port ${PORT}`)
})
Enter fullscreen mode Exit fullscreen mode

Ok... we've written a lot of code and haven't written really any routes yet. See why we all love Koa/Express/Fastify (or even my obscure attempt, Merver).

Index Route

The index route is a get request to "/posts" that will return us the JSON of all the posts! We will create the route in get.js.

module.exports = (request, response) => {
  switch (request.url) {
    case "/posts":
      response.statusCode = 200
      response.setHeader("Content-Type", "application/json")
      response.write(JSON.stringify(request.posts))
      response.end()
      break

    // response for unexpected get requests
    default:
      response.statusCode = 400
      response.write(`CANNOT GET ${request.url}`)
      response.end()
  }
}
Enter fullscreen mode Exit fullscreen mode

The Show Route

Well, url queries and params aren't handled out of the box in the nice tidy way we are use to in Koa/Fastify/Express. Params would require some heavy engineering to pull off so we'll get by making queries available. We'll store a URL object in the request object that we can use to get queries with.

server.js

// Import http library
const http = require("http");
// use env variable to define port with default
const PORT = process.env.PORT || 4000;
// import the url standard library for parsing query string
require("url")

// import data
const posts = require("./data");

// Import our routers
const get = require("./get");
const post = require("./post");
const put = require("./put");
// add an extra R since delete is a reserved word
const deleteR = require("./delete");

//create our server object, pass server function as callback argument
const server = http.createServer((request, response) => {

  // add the data to the request object so our routes can access it
  request.posts = posts

  // adding the query to the request object
  request.query = new URL(request.url, `http://${request.headers.host}`)

  // handle request based on method then URL
  switch (request.method) {
    case "GET":
      get(request, response);
      break;

    case "POST":
      post(request, response);
      break;

    case "PUT":
      put(request, response);
      break;

    case "DELETE":
      deleteR(request, response);
      break;

    default:
      // Send response for requests with no other response
      response.statusCode = 400;
      response.write("No Response");
      response.end();
  }
});

// get the server to start listening
server.listen(PORT, (err) => {
  // error checking
  err ? console.error(err) : console.log(`listening on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

now we can add the show route which gets a particular item based on an id below (id will be based via url query "?id=0").

get.js

module.exports = (request, response) => {

    // remove queries from the url, turn "/posts?id=0" into "/posts"
    const url = request.url.split("?")[0]

    switch(url){

        case "/posts":
            // if the id query is present return the show result
            if (request.query.searchParams.get("id")){
                const id = request.query.searchParams.get("id")
                response.statusCode = 200
                response.setHeader("Content-Type", "application/json")
                response.write(JSON.stringify(request.posts[id]))
                response.end()
            } else {
                // else return all posts (index)
                response.statusCode = 200
                response.setHeader("Content-Type", "application/json")
                response.write(JSON.stringify(request.posts))
                response.end()
            }
            break

        // response for unexpected get requests
        default:
            response.statusCode = 400
            response.write(`CANNOT GET ${request.url}`)
            response.end()
            break

    }
}
Enter fullscreen mode Exit fullscreen mode

The Create Route

Here is where we will really miss having a framework on our side as we parse the request body. We're going to have to do what all those body parser middlewares do and work with a data stream.

  • create a getBody.js with the function that'll act like a traditional middleware handling the request/response object then passing it to the next function in line.
module.exports = (request, response, next) => {
    let data = []

    // assemble stream of data from request body
    request.on("data", dataChunk => {
        data.push(dataChunk)
    })

    request.on("end", () => {
        request.body = Buffer.concat(data).toString()
        if (request.headers["content-type"] === "application/json"){

            request.body = JSON.parse(request.body)
        }

        // move on to next step in handling respone
        next(request, response)
    })
}
Enter fullscreen mode Exit fullscreen mode

Now let's wrap our routing functions with this bodyParsing middleware in server.js

server.js

// Import http library
const http = require("http");
// use env variable to define port with default
const PORT = process.env.PORT || 4000;
// import the url standard library for parsing query string
require("url")

// import data
const posts = require("./data");

// Import our routers
const get = require("./get");
const post = require("./post");
const put = require("./put");
// add an extra R since delete is a reserved word
const deleteR = require("./delete");
// require function to parse body
const getBody = require("./getBody")

//create our server object, pass server function as callback argument
const server = http.createServer((request, response) => {

  // add the data to the request object so our routes can access it
  request.posts = posts

  // adding the query to the request object
  request.query = new URL(request.url, `http://${request.headers.host}`)

  // handle request based on method then URL
  switch (request.method) {
    case "GET":
      getBody(request, response, get);
      break;

    case "POST":
      getBody(request, response, post);
      break;

    case "PUT":
        getBody(request, response, put);
      break;

    case "DELETE":
        getBody(request, response, deleteR);
      break;

    default:
      // Send response for requests with no other response
      response.statusCode = 400;
      response.write("No Response");
      response.end();
  }
});

// get the server to start listening
server.listen(PORT, (err) => {
  // error checking
  err ? console.error(err) : console.log(`listening on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

so now, regardless of method it will parse the body before passing the request and response to our routing functions. Now let's make our create route which will allow us to send a json body via post request to "/posts". You will need a tool like postman or insomnia to test this route.

post.js

module.exports = (request, response) => {
  switch (request.url) {
    case "/posts":
      request.posts.push(request.body);
      response.statusCode = 200;
      response.setHeader("Content-Type", "application/json");
      response.write(JSON.stringify(request.posts));
      response.end();
      break;

    // response for unexpected get requests
    default:
      response.statusCode = 400;
      response.write(`CANNOT POST ${request.url}`);
      response.end();
  }
};
Enter fullscreen mode Exit fullscreen mode

Update Route

So we will use a url query again to specify id/index of the item to be updated. So in this case a put request to "/posts?id=x" will use the request body to update that object.

Since we already solved for url queries and the request body we just need to add the case to our put router function.

module.exports = (request, response) => {

    // remove queries from the url, turn "/posts?id=0" into "/posts"
    const url = request.url.split("?")[0]

    switch(url){

        case "/posts":
            const id = request.query.searchParams.get("id")
            response.statusCode = 200
            response.setHeader("Content-Type", "application/json")
            request.posts[id] = request.body
            response.write(JSON.stringify(request.posts[id]))
            response.end()
            break

        // response for unexpected get requests
        default:
            response.statusCode = 400
            response.write(`CANNOT PUT ${request.url}`)
            response.end()
            break

    }
}
Enter fullscreen mode Exit fullscreen mode

Destroy Route

By making a delete request to "/posts?id=x" you should be able to delete any item from the array of posts.

delete.js

module.exports = (request, response) => {
  // remove queries from the url, turn "/posts?id=0" into "/posts"
  const url = request.url.split("?")[0];

  switch (url) {
    case "/posts":
      const id = request.query.searchParams.get("id");
      response.statusCode = 200;
      response.setHeader("Content-Type", "application/json");
      request.posts.splice(id, 1);
      response.write(JSON.stringify(request.posts));
      response.end();
      break;

    // response for unexpected get requests
    default:
      response.statusCode = 400;
      response.write(`CANNOT DELETE ${request.url}`);
      response.end();
      break;
  }
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

Well, we created a very crude full crud json api using raw node and no frameworks like Express, KOA, or Fastify or any of the robust frameworks built on top of them. We would still need to handle a lot more to get to the same level of basic functionaltiy.

  • creating routing params
  • setting up cors headers
  • being able to parse urlEncoded or XML bodies
  • adding https support with the "https" library

So while I doubt you'll be making a raw api like this again anytime soon. I hope having done this has given you a deeper appreciation for the abstractions and patterns you'll find in express, koa and fastify.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .