Skip to content

Instantly share code, notes, and snippets.

@mikahimself
Last active September 21, 2022 06:11
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mikahimself/342d6503c205564445c34916dedac2a0 to your computer and use it in GitHub Desktop.
Save mikahimself/342d6503c205564445c34916dedac2a0 to your computer and use it in GitHub Desktop.
Notes from API Design in Node.js from Frontend Masters

API Design in Node.js

Introduction

REST

Most popular API design pattern; definition rather blurry. The REST (Representational State Transfer) pattern defines:

  • Database connections
  • Route paths
  • HTTP verbs that let the users perform actions

REST works best with basic, relational and flat data models, but doesn't lend itself well to complex structures with lots of nodes etc.

Node.js

Node.js is asyncronous and event driven, and thus works well with high concurrency API that are not CPU intensive.

Express

Express is Node.js' almost standard tool for building APIs. It handles all of the tedious tasks, such as managing sockets, matching routes, asynchronous operations and error handling. Error handling is especially important because Node.js is single-threaded.

MongoDB

Non-relational document store that is easy to get started with and scales well.

Express

Testing API and routes with HTTPie

Simple way to test API endpoints and routes, especially POST requests is to use HTTPie:

// Get everything
http GET localhost:3000/api/item
// Get by id
http GET localhost:3000/api/item/item-id
// Update
http PUT localhost:3000/api/item/item-id
// Add new
http POST localhost:3000/api/item body:='{ "new-item": "new-item-data" }'
// Delete
http DELETE localhost:3000/api/item/item-id

Middleware

Middleware, in Express and other server contexts, is a list of functions that execute in order before your controllers. Controller means the callback function below. They sit between your request and your response.

app.get('/', (req, res) => {
  res.send({ message: 'Hello' })
})

Middleware lets you execute functions on an incoming request with guaranteed order. Middleware can be used for authenticating, transforming the requests, tracking and error handling. The order in which you register your middleware is the order they will be executed in.

// Examples of middleware
app.use(cors());
app.use(json());
app.use(urlencoded({ extended: true }))
app.use(morgan('dev'))

Middleware can also respond to a request like a controller, but that is not their intended purpose.

Custom middleware

Since middleware is essentially a function that runs at a certain time, creating custom middleware is simple. First, we need to define a function:

const log = (req, res, next) => {
  console.log("Logging things");
  next();
}

Here, it is worth noting that we added a new parameter to the function, next. This function is Express' way of tying up the middleware. Our function doesn't know it's place in the middleware order, so by calling the next() function, it can tell Express that it is all done, and that Express can move on to the middleware that's next in line. To take our custom middleware into use on the app level, we can simply use app.use():

app.use(log)

Or, if we would prefer to use it in a single route, we can define it in that route:

app.get('/', log, (req, res) => {
  res.send({ message: 'Hello' })
})

If we needed to add multiple middleware to a single route, we can use an array to register them in the required order:

app.get('/', [log, log2, log3], (req, res) => {
  res.send({ message: 'Hello' })
})

REST routes with Express

Express has been designed with REST in mind, and has all the features you need to match routes and verbs.

  • Express' route matching system allows you to use exact, regex, glob and parameter matching
  • Express also supports HTTP verbs on a route based level
// Regex that matches all routes that start with 'me'
app.get('^(/me*)', (req, res) => {
  res.send({ message: 'Hello from me route' })
})

// Glob that matches all routes that start with 'user' and have something after that
app.get('/user/*', (req, res) => {
  res.send({ message: 'Hello from user route' })
})

//
app.post(...)      // Create
app.get(...)       // Read
app.put(...)       // Update
app.delete(...)    // Delete

When creating routes, note that the order in which you define the routes matters. If you create two identical routes, Express will use the first matching route that it finds, and executes that. The second one will not be used.

Router and sub routers

For abstraction, Express lets you create sub routers that combine to make a full router. Subrouters can have their own middleware or inherit from the parent router. Subrouter cannot listen to ports, but like the app created with express(), routers can define verbs and route paths. After you have defined a router, you must register, or mount the router with the base App.

// Create a new router.
const router = express.Router()

// Define a route in the router
router.get('/you', (req, res) => {
  res.send({ you: 'Jane ' })
})

// Mount the router to the main app
app.use('/api', router)

Using the definitions above, any calls to http://hostname:3000/api would be delegated to the router we created. In other words, the GET route that we defined would be accessible through http://hostname:3000/api/you.

If you need to define middleware for the router, you can use the same pattern as with the base app, for example, router.use(json());.

Router verb methods

Writing lots of routes can get frustrating especially if you have multiple similar routes where the only difference is the verb thats being used. Express lets us use verb methods to clean up writing these:

app.route('/cat')
  .get()
  .post()

app.route(/cat/:id)
  .get()
  .put()
  .delete()

Data modeling with MongoDB

MongoDB is a schemaless DB, so how to create a schema for it. Schemas help you validate your data before it goes into the database, which helps you avoid all sorts of nonsense, like validating data on the frontend. MongoDB has added support for creating schemas, but Mongoose is an app that will make schema creation much easier for you. Mongoose lets you create a schema that maps to a MongoDB collection and it defines the shape of each document within that collection. A schema holds the instructions for models that are created based on the schema; therein, you define the keys, validations, names, indexes and hooks (or Middleware. Essentially, functions that are run during the execution of asynchronous functions).

const userSchema = new mongoose.Schema(
  {
    firstName: String,
    lastName: {
      type: String,
      required: true
    },
    nationality: {
      type: String,
      default: 'Finnish'
    },
    email: {
      type: String,
      required: true,
      unique: true,
      trim: true
      },
    // You can also define references to other collections in Mongoose schemas.
    // Here, we're saying that we want to look in the "user" collection and look
    // for a user with a certain ID. The "user" here is a reference to the name of
    // the Mongoose Model. An ObjectID is how MongoDb does IDs; uniquely generated
    // ID strings represented as objects.
    createdBy: {
      type: mongoose.SchemaTypes.ObjectId,
      required: true,
      ref: 'user'
    },
  },
  { timestamps: true}
);

// If we were to link users to a list model, we could define the list so that it only
// accepts a single instance of a firstName and lastName pair by defining a Compound Index
userSchema.index({ list: 1, firstName: 1, lastName: 1 }, { unique: true })

You can then use the schema to create a Model which you can then use to create documents into and read documents from and underlying MongoDB database.

In practice, you must create schemas/models for each REST resource you want to expose through an API.

Controllers and models

Routes and controllers

Controllers are essentially middleware, but with the intent of returning some data based on a request. Controllers' task is to handle what data combination of a route and a verb can acces from a database or another data source. You can think of controller as the final middleware in the stack for a request. There is no intent to proceed to another middleware function after the controller (the only case might be in the case of an error handler).

Express response object

router.route('/')
  .get((req, res) = {
    res.end()                                      // End the response; no message sent back
    res.status(404)                                // Set the status code of the response
    res.status(404).end()                          // Status is chainable; the 404 can be sent out like this.
    res.status(404).send({ msg: 'Not found' })     // ...Or by combining it with a message.
    res.json({ message: 'Hello' })                 // Send tries to figure out the type you'se sending,
                                                   // but you can be explicit about it, too
  })

Refactoring CRUD routes with Models

Controllers implement the logic that interacts with database models created with Mongoose. You can generalize controllers to work with many different models if you use a REST approach that requires CRUD actions on resources. If you have models that are identical in the sense of which methods you can use on them, you could create a single, generalized controller to handle all of these models, and pass in the model in use to the controller.

Using models

Mongoose models map nicely with CRUD:

  • C - model.create(), new model()
  • R - model.find(), model.findOne(), model.findById()
  • U - model.update(), model.findByIdAndUpdate(), model.findOneAndUpdate()
  • D - model.remove(), model.findByIdAndRemove(), model.findOneAndRemove()

Creating a document

import  { Item } from './item.model'
import mongoose from 'mongoose'

const run = async () => {
  // Connect to database first.
  const item = await Item.create({
    name: 'Clean up',
    createdBy: mongoose.Types.ObjectId(),  // Creates a fake id
    list: mongoose.Types.ObjectId()
  })
  
  console.log(item)
}

run()

Read, update and delete documents

When reading a document, you generally use one of the find functions to locate the document from the database. The exec() at the end turns the operation into a real promise and executes it.

  console.log(await Item.findById(item._id).exec())

For updating a document, use one of the update functions listed above. A special thing to note about updating a document is that the response in REST is waiting back the updated object but Mongoose does not send one back by default. You must do this by using the { new: true } parameter when calling update.

  const updated = await Item.findByIdAndUpdate(item._id, { name: 'eat' }, { new: true }).exec()

When deleting a document, use one of the delete functions listed above.

  const updated = await Item.findByIdAndDelete(item._id, { new: true }).exec()

CRUD Controller design

After we have defined routes and models, we need to hook the routes to the models to be able to perform CRUD operations on the models based on route+verb combinations. To do that, we need to utilize controllers. Making generalized controllers is fairly straightforward:

First, you need to define some controller functions:

export const getOne = model => async (req, res) => {
  const id = req.params.id
  const userId = req.user._id

  const item = await model.findOne({ _id: id, createdBy: userId }).exec()

  if (!item) {
    return res.status(400).end()
  }
  res.status(200)
  res.json({ data: item })
}

export const getMany = model => async (req, res) => {
  const user = req.user._id
  const items = await model.find({ createdBy: user }).exec()
  res.status(200)
  res.json({ data: items })
}

Next, to make these available to the general public, you need to export them:

export const crudControllers = model => ({
  removeOne: removeOne(model),
  updateOne: updateOne(model),
  ...
})

The crudControllers function, given a model, returns an object that references the functions defined above and passes the model into them. Finally, we need to import and use this:

import { crudControllers } from '../../utils/crud'
import { Item } from './item.model'

export default crudControllers(Item)

If necessary, we can override certain controllers using spread:

export default {
  ...crudControllers(Item),
  getOne() {
     ...
  }
}

Authentication

You can never protect your API 100%, but by requiring authentication, you can make your API somewhat safer. Authentication, in this case means controlling if an incoming request can proceed or not. Authorization, on the other hand, means controlling if an authenticated request has the correct permissions to access the resource it is trying to access. Identification is determining who the requestor is.

JWT authentication

One way to handle API authentication is through Json Web Tokens (JWTs) in a stateless manner. This manner of authentication is called a bearer token strategy. JWTs are created by a combination of secrets on the API and a payload, such as a user object (with a certain role, for instance).

The JWT must be sent with every request, and the API will then try and verify if the token was created with the secrets the API expects it to be created with. After successful verification, the JWT payload is accessible to the server, and it can be used for authorization and indentification.

JWT Module

Testing with Jest

Jest runs tests in paraller, which makes working with Jest and MongoDb slightly complicated. One way around this is to write a script that creates a new database for each describe block and removes it after the test is run. This way, we can avoid running stateful tests that know about the tests that ran before it. You can set up this test in package.json along with some other properties:

"jest": {
  "verbose": true,
  "testUrl: "http://localhost",
  "testEnvironment": "node",
  "setupTestFrameworkScriptFile": "<rootDir>/test-db-setup.js"
  "testPathIgnorePatterns: [
    "dist/"
  ],
  "restoreMocks": true
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment