API with Node.js, Express and Prisma

Presentation Layer

Just as dividing the application into modules is a good practice, so is dividing the application into layers, so that each one is independent and responsible for one thing at a time. The presentation layer will be the outermost and most visible layer of the application, accessible by clients using the API. Its responsibility will be to manage requests and responses through the HTTP protocol interface, but not data.

Router and routes in Express

One of the main components of express after middlewares is the Router, which allows you to define routes (routes). As mentioned earlier, endpoints are defined based on a route, an HTTP verb, and a callback (middleware).

Defining routes

Previously, you defined a middleware to handle requests to the root route of the API with the GET verb:

app.get('/', (req, res, next) => {
  res.json({
    message: 'Welcome to the API',
  });
});

...

In the same way, you can define all the routes needed. For example:

app.get('/api/todos', (req, res, next) => {
  res.json({
    message: 'GET all todos',
  });
});

...

Within each middleware, the request processing is done. However, imagine for a moment that you define 10 more routes with different HTTP verbs for different operations related to this resource. Now, for some reason, you have to change the resource name from /api/todos to /api/tasks/. This would require modifying each route one by one, which is not very dynamic and could lead to errors. To address this, Express has an object called route that allows you to establish a mount point for the resource (e.g., todos) and attach all the operations with the required HTTP verbs. Therefore, using the previous example, you would only need to modify the resource in the mount point and not in all the routes.

Modify the previously written definition to retrieve all items of the resource using the route method from express:

app.route('/api/todos')
  .get((req, res, next) => {
    res.json({
    message: 'GET all todos',
  });
});

...

A significant change has been made in the structure of the definition. The mount point is established using the route method, and in this case, the get method (GET) is attached with the corresponding middleware. Different methods like POST, PUT, and DELETE, can also be attached. Interestingly, these methods depend solely on the first parameter: the route name.

It’s advisable to organize resources. Therefore, create a directory containing all the directories and files related to the API:

mkdir -p app/api
touch app/api/index.js

In the newly created file, move the route definition from the app/index.js file and turn it into a module with the following content:

// app/api/index.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

router.route('/todos').get((req, res, next) => {
  res.json({
    message: 'GET all todos',
  });
});

In the above code snippet, only the Router is imported, not the entire express library. This demonstrates that the Router allows you to create a definition independent of the express application you intend to use.

Modify the (app/index.js) file by importing the previously defined module and replacing the route definition with the mount point:

// app/index.js

import express from 'express';

import { router as api } from './api/index.js';

export const app = express();

// Setup router and routes
app.use('/api', api);

// No route found handler
...

Significant changes have been made in terms of organization, responsibility, and modularity:

  1. A Router was created to handle all operations related to the todos resource. These routes are now independent of the prefix that will be used, meaning there is no longer a hard-coded /api prefix before each route in the code.
  2. The /api mount point was added, and also the Router that contains all operations for the /todos resource was attached (for now). This means that to access the resource, the path remains /api/todos.

This provides a significant advantage when organizing and renaming resources.

Upon visiting the address localhost:3000/api/todos, you will obtain the following result:

{
  "message": "Get all todos"
}

More information:

API Versioning

It’s common for a REST API to have different versions over time due to the introduction of new features or significant changes. However, when making changes, it’s important to ensure that the current version of the API, which is already published, remains functional, as other systems or clients might be using it. This is not the case with other modern paradigms like GraphQL, which uses a language where the client structures the server’s response, and static routes aren’t defined.

More information:

It’s advisable to version the API from its inception. Create a new directory for this purpose and move the previously created file into it:

mkdir -p app/api/v1
mv app/api/index.js app/api/v1/index.js

Update the API’s mount point in the main application file:

// app/index.js

import express from 'express';

import { router as api } from './api/v1/index.js';

export const app = express();

// Setup router and routes
app.use('/api/v1', api);
app.use('/api', api);

...

This change allows the use of two different mount points using the same API definition. Normally, the latest version of the API is used under the /api prefix alone, and if the client wants to use a specific version, they would use the prefix /api/v1. This leverages the independence provided by Express’s Router.

If in the future, version 2 of the API is released, simply create a directory named v2 (at the same level as v1) and associate it with the mount point /api/v2 and also /api, ensuring that clients who want to access the “legacy” version (Version 1) of the API could exclusively use the prefix /api/v1.

API Structure

Up to this point, you’ve improved the organization of the API to some extent, but there’s still room for improvement. The /todos route and all subsequent routes created from it are still tied to the resource name prefix. As a best practice, it’s a good idea to create a directory for each resource, along with individual files for each component. The first component to be created is the routes’ module. Create the necessary directory and file structure:

mkdir -p app/api/v1/todos
touch app/api/v1/todos/routes.js

Copy the content of app/api/v1/index.js to the newly created file app/api/v1/todos/routes.js, but with a slight change: replace the mount point /todos with /:

// app/api/v1/todos/routes.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

router.get('/', (req, res, next) => {
  res.json({
    message: 'GET all todos',
  });
});

Now, rewrite the main API file and set the mount point for the /todos route:

// app/api/v1/index.js

import { Router } from 'express';

import { router as todos } from './todos/routes.js';

// eslint-disable-next-line new-cap
export const router = Router();

router.use('/todos', todos);

This is an important point of granularity for the route mounts, and now they are independent of their mount points. For example, if in the future there’s a need to change the resource name from /todos to /tasks, it can be done simply in the main API file. The routes will continue to work without any issues. They no longer depend on prefixes.

However, the organization is incomplete; you still need to separate the different components. So, in the todos resource, make the following change:

// app/api/v1/todos/index.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

router
  .route('/')
  .get('/', (req, res, next) => {})
  .post('/', (req, res, next) => {});

As you can see, different verbs can be attached to the same route. In this case, you have used GET and POST, and you’ve minimized the middleware.

Next, define the minimum REST contract for the todos resource, including its respective routes and associated verbs:

// app/api/v1/todos/index.js

import { Router } from 'express';

// eslint-disable-next-line new-cap
export const router = Router();

/*
 * /api/todos/ POST      - CREATE
 * /api/todos/ GET       - READ ALL
 * /api/todos/:id GET    - READ ONE
 * /api/todos/:id PUT    - UPDATE
 * /api/todos/:id DELETE - DELETE
 */

router
  .route('/')
  .post((req, res, next) => {})
  .get((req, res, next) => {});

router
  .route('/:id')
  .get((req, res, next) => {})
  .put((req, res, next) => {})
  .delete((req, res, next) => {});

Note that the :id string is a dynamic parameter, which will be covered in more detail in the next section.

Now the routes for the todos resource have at least the basic REST API contract, but one final step remains to fully organize the middlewares associated with them.

It’s worth mentioning that routes also support regular expressions and wildcards (*), which allows for building a wide variety of routes based on specific needs.

Controllers

Introduce a conceptual term: the controller, which will contain the actions associated with each verb of the route. This corresponds to the Model-View-Controller design pattern, but in this case, your views are the routes.

Create the controllers file:

touch app/api/v1/todos/controller.js

Use the following content:

// app/api/v1/todos/controller.js

export const create = (req, res, next) => {
  res.json({});
};

export const all = (req, res, next) => {
  res.json({});
};

export const read = (req, res, next) => {
  res.json({});
};

export const update = (req, res, next) => {
  res.json({});
};

export const remove = (req, res, next) => {
  res.json({});
};

It’s important to highlight that:

  • Actions don’t necessarily have to be named after the verbs, for example, all, which will be used to retrieve the list of all tasks when a GET request is made to the root of the todos entity (/api/v1/todos).
  • Each of the actions is being individually exported.
  • Currently, a generic JSON object with empty data is being returned in each action.

Finally, link the actions to the routes, and it’s worth noting that the same action can be used for different verbs on different routes if needed.

// app/api/v1/todos/index.js

import { Router } from 'express';

import * as controller from './controller.js';

// eslint-disable-next-line new-cap
export const router = Router();

/*
 * /api/todos/ POST      - CREATE
 * /api/todos/ GET       - READ ALL
 * /api/todos/:id GET    - READ ONE
 * /api/todos/:id PUT    - UPDATE
 * /api/todos/:id DELETE - DELETE
 */

router.route('/').post(controller.create).get(controller.all);

router.route('/:id').get(controller.read).put(controller.update).delete(controller.remove);

With this structure, you’ve achieved the desired level of organization. Conveniently, even the actions in the controllers can be used by other routes of different resources, which will be used further on.

Process Route Parameters

Previously, it was established in the REST contract what the route would be to obtain a task: api/todos/:id. In Express, you can create dynamic parameters in the routes, this is known as params, in this case, :id is one of them.

It works as follows: when sending the request, if the route matches the prefix /api/todos/, everything after this prefix will be stored in the parameter named id.

Modify the action in the task controller to obtain this parameter that will help you find a task:

// /app/api/v1/todos/controller.js
export const read = async (req, res, next) => {
  const { params = {} } = req;
  const { id = '' } = params;

  res.json({
    data: {
      id
    }
  });
};

...

In the previous code, Express automatically extracts the id parameter from the request route that matched the declared dynamic route. By default, Express places all the parameters sent by the URL into an object called params within the request object (req).

Something essential to keep in mind is that id doesn’t strictly have to be numbers; in fact, it will take whatever matches the route:

  • /api/todos/abc1, then the id parameter will have the value of abc1
  • /api/todos/filter, then the id parameter will have the value of filter

Additionally, you can declare all the dynamic parameters needed in a route, for example:

/api/todos/:year/:month/:day

Capture Parameters from the URL

Previously, it was mentioned that resources should be paginated. This and other operations can be performed by sending specific parameters appended to the URL through query params.

For each request, Express automatically recognizes, interprets, and stores each of these parameters in an object in the request called query.

GET /api/v1/todos?limit=10&offset=0

Thus, modify the following file:

// app/api/v1/todos/controller.js

export const create = (req, res, next) => {
  res.json({});
};

export const all = (req, res, next) => {
  const { query = {} } = req;
  const { limit = 10, offset = 0 } = query;

  res.json({
    meta: {
      limit,
      offset,
    },
  });
};

...

In the previous code, the query object is extracted from the request object req. Additionally, the needed query params such as limit and offset are extracted. It’s always a good practice to provide default values to the variables created, and finally, these values are returned to the response.

More information:

Capture Data Sent as JSON Objects

In requests, the data sent by the POST, PUT, or PATCH methods is sent in the request body (body). Therefore, they cannot be obtained through params or query, but through body. This task isn’t performed automatically by Express as with the previous ones. Instead, it needs to be specified with a middleware:

// app/index.js

import express from 'express';

import { router as apiV1 } from './api/v1/index.js';

export const app = express();

// Parse JSON
app.use(express.json());

...

From now on, this first middleware will process every request, and if it contains data that was sent in the request body of type JSON, it will format them and place them as a property called body in the req object. Modify the controller to see its use:

// app/api/v1/todos/controller.js

export const create = (req, res, next) => {
  const { body = {} } = req;

  res.json({
    data: body,
  });
};

...

More information: