The best node web framework (part 3)

This article is the third part of the series Creating the modern developer stack template. I definitely recommend reading the first one before this to get more context.

Introduction

Hello folks,

Today we will try to identify the best framework to build a REST API that we will use to interact with our frontend. There exist quite a few web frameworks for the node.js ecosystem. I will be covering the top 3 open source projects based on this query. I will be looking for ease of use; so how easy it is to get started with the framework along with the quality of the documentation. Popularity is another big factor that goes hands down with the ease of use as you will be able to find solutions to all the common problems a newbie can have. Performance is also something to keep in mind. Still, it will have lesser importance than the 2 previous points as I believe most web frameworks nowadays will provide decent enough performance for most of the use cases. Thus, you don't want to sacrifice development speed for some very marginal performance gain. Code readability will also be taken into consideration, and I will lay out an example of creating a simple GET route for all of the frameworks for comparison.

Comparisons

socket.io

Our most popular contender isn't your typical restful api as it uses 2-way communication between the client and the server.

image.png

This allows you to have real-time applications. With great power comes great responsibilities. Providing a completely real-time web app is a tough challenge. Although this adds great value for the end-user, you need skilled and experienced developers to pull this off. From network connectivity issues to testing difficulties to scaling, you will surely get many bumps in the road. If you want to open your API to other developers, they will have a harder time consuming it rather than a regular REST API. If your app requires everything to be fully real-time, socket.io is definitely a good option with its wide usage and good documentation.

Example:

socket.on('/todos', () => {
  const todos = { CODE_TO_RETRIEVE_TODOS }

  io.emit(todos)
});

Express

Even though it has fewer stars than socket.io on GitHub, this is probably the most widely used framework to build APIs. It describes itself as a fast, unopinionated, minimalist web framework for node. It supports robust routing functionalities, fast performance, support for redirection and caching. Interestingly enough, even though express claims itself that it is fast, it's actually one of the slowest out there:

image.png

Documentation could definitely be improved, but you should be able to find everything you need since it's one of the most popular. Anything missing from the documentation should already be covered elsewhere.

Example:

app.get('/todos', function (req, res) {
  const todos = { CODE_TO_RETRIEVE_TODOS }

  res.send(todos)
})

Nest

This framework shouldn't be confused with the company nest that google acquired in 2014. This is a web framework, and it describes itself as a framework for building efficient, scalable Node.js server-side applications. It uses modern JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming). Under the hood, Nest makes use of Express and provides compatibility with a wide range of other libraries, like, e.g. Fastify, allowing for easy use of the myriad third-party plugins available. Testing is made really easy as it enforces great patterns like dependency injection. Nest uses the latest TypeScript features async-await and decorators. It is growing very rapidly, and the community absolutely loves it. This might be related to the main author love of cats. The documentation is the absolute best compared to the previous ones. You get so much information on everything you could think of. It is well written, concise and accurate.

Example:

import { Get, Controller } from '@nestjs/common';
import { TodosService } from './todos.service';

@Controller()
export class TodosController {
  constructor(private readonly todosService: todosService) {}

  @Get()
  render() {
    const todos = this.todosService.getTodos();

    return todos;
  }
}

The winner

For all of its future proof functionalities, it's highly abstracted architecture, it's amazing documentation, and it's rapid growth, Nest is our clear winner. The above-provided examples aren't very complete. Thus I will implement one on my open-source template. It will be a backend for a simple todo application with CRUD endpoints. A front-end will be built in the following article.

Implementation in Stator

Since I'm using NX, I can easily generate the scaffolding required for node backend using nest. We first need to install the plugin:

npm install -D @nrwl/nest

We then generate the application:

nx generate @nrwl/nest:application api

Our apps folder now contains the newly generated API:

image.png

As we have seen in the previous benchmark, express isn't exactly fast. Thankfully nest provides support for fastify. We need to install the plugin:

npm i --save @nestjs/platform-fastify

We then update our main.ts file to this in order to use fastify:

import { Logger } from "@nestjs/common"
import { NestFactory } from "@nestjs/core"

import { AppModule } from "./app/app.module"
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify"

async function bootstrap() {
  const globalPrefix = "api"
  const port = parseInt(process.env.PORT) || 3333
  const fastifyOptions = { logger: true }

  const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(fastifyOptions))

  app.setGlobalPrefix(globalPrefix)

  await app.listen(port)
}

bootstrap()

Let's install the nest CLI to help us generate our todos controller:

npm install -g @nestjs/cli

Generate the controller:

nest g controller todos

To make our life easier handling the CRUD operations, we will be using the amazing plugin @nextjsx/crud, along with the mongo driver:

npm i @nestjsx/crud class-transformer class-validator @nestjsx/crud-typeorm @nestjs/typeorm typeorm mongo

In the src, create a file todo.entity.ts:

import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"

@Entity()
export class Todo {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  name: string
}

Create the controller:

import { Controller } from "@nestjs/common"
import { Crud, CrudController } from "@nestjsx/crud"
import { Todo } from "./todo.entity"
import { TodosService } from "./todos.service"

@Crud({ model: { type: Todo } })
@Controller("todos")
export class TodosController implements CrudController<Todo> {
  constructor(public service: TodosService) {}
}

Create the service:

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { TypeOrmCrudService } from "@nestjsx/crud-typeorm";
import { Todo } from "./todo.entity"


@Injectable()
export class TodosService extends TypeOrmCrudService<Todo> {
  constructor(@InjectRepository(Todo) repo) {
    super(repo);
  }
}

Link everything together with the module:

import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";

import { TodosService } from "./todos.service";
import { TodosController } from "./todos.controller";
import { Todo } from "./todo.entity"

@Module({
  imports: [TypeOrmModule.forFeature([Todo])],
  providers: [TodosService],
  exports: [TodosService],
  controllers: [TodosController],
})
export class TodosModule {}

Now in our app.module.ts, let's add the connection to mongo and make our API aware of our todos capabilities, update the @Module property with:

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "mongodb",
      host: "127.0.0.1",
      port: 27017,
      database: "stator",
      username: "stator",
      password: "secret",
      entities: [Todo],
      synchronize: true
    }),
    TodosModule
  ],
  controllers: [AppController],
  providers: [AppService],
})

Now it would be great to see what endpoints are available. Let's generate some API documentation using swagger:

npm install --save @nestjs/swagger fastify-swagger

Now add this do to main.ts:

const swaggerOptions = new DocumentBuilder()
    .setTitle('Todos')
    .setDescription('The todos API description')
    .setVersion('1.0')
    .addTag('todos')
    .build();

  const document = SwaggerModule.createDocument(app, swaggerOptions);
  SwaggerModule.setup('documentation', app, document);

If you go to `localhost:3333/documentation, you will see this:

image.png

Surely enough, swagger UI is nice, but definitely, redoc does look better. Let's see how we can implement this.

Create a index.html file in src/assets directory:

<!DOCTYPE html>
<html>
  <head>
    <title>ReDoc</title>
    <!-- needed for adaptive design -->
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet" />

    <!--
  ReDoc doesn't change outer page styles
  -->
    <style>
      body {
        margin: 0;
        padding: 0;
      }
    </style>
  </head>
  <body>
    <redoc spec-url="http://127.0.0.1:3333/documentation/json"></redoc>
    <script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
  </body>
</html>

Now in your app.module.ts add this to your @Module import property:

ServeStaticModule.forRoot({
      rootPath: `${__dirname}/assets`,
      exclude: ['/api*'],
    }),

If you access localhost:3333 with any browser, you will see the new beautiful interface:

image.png

Now that we have created all of this, let's make sure it works by writing tests. First off, install the testing package that nest provides:

npm i --save-dev @nestjs/testing

Create todos.e2e.spec.ts which will contain our end to end tests:

describe("Todos", () => {
  let app: INestApplication
  let repository: Repository<Todo>

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: "mongodb",
          host: "127.0.0.1",
          port: 27017,
          database: "stator-test",
          username: "stator",
          password: "secret",
          entities: [Todo],
          synchronize: true,
        }),
        TodosModule,
      ],
    }).compile()

    app = module.createNestApplication()
    await app.init()

    repository = module.get("TodoRepository")
  })

  afterEach(async () => {
    await repository.delete({})
  })

  afterAll(async () => {
    await app.close()
  })

  describe("GET /todos", () => {
    it("should return an array of todos", async () => {
      await repository.save([{ text: "test-name-0" }, { text: "test-name-1" }])

      const { body } = await supertest
        .agent(app.getHttpServer())
        .get("/todos")
        .set("Accept", "application/json")
        .expect("Content-Type", /json/)
        .expect(200)

      expect(body).toEqual([
        { id: expect.any(Number), text: "test-name-0" },
        { id: expect.any(Number), text: "test-name-1" },
      ])
    })

    it("should return a single todo", async () => {
      const todo = await repository.save({ text: "test-name-0" })

      const { body } = await supertest
        .agent(app.getHttpServer())
        .get(`/todos/${todo.id}`)
        .set("Accept", "application/json")
        .expect("Content-Type", /json/)
        .expect(200)

      expect(body).toEqual({ id: todo.id, text: "test-name-0" })
    })

    it("should create one todo", async () => {
      const todo = { text: "test-name-0" }

      const { body } = await supertest
        .agent(app.getHttpServer())
        .post("/todos")
        .send(todo)
        .set("Accept", "application/json")
        .expect("Content-Type", /json/)
        .expect(201)

      expect(body).toEqual({ id: expect.any(Number), text: "test-name-0" })
    })

    it("should create multiple todos", async () => {
      const todos = [{ text: "test-name-0" }, { text: "test-name-1" }]

      const { body } = await supertest
        .agent(app.getHttpServer())
        .post("/todos/bulk")
        .send({ bulk: todos })
        .set("Accept", "application/json")
        .expect("Content-Type", /json/)
        .expect(201)

      expect(body).toEqual([
        { id: expect.any(Number), text: "test-name-0" },
        { id: expect.any(Number), text: "test-name-1" },
      ])
    })

    it("should update the name of a todo", async () => {
      const todo = await repository.save({ text: "test-name-0" })

      const { body } = await supertest
        .agent(app.getHttpServer())
        .put(`/todos/${todo.id}`)
        .send({ text: "updated-name" })
        .set("Accept", "application/json")
        .expect("Content-Type", /json/)
        .expect(200)

      expect(body).toEqual({ id: expect.any(Number), text: "updated-name" })
    })

    it("should delete one todo", async () => {
      const todo = await repository.save({ text: "test-name-0" })

      await supertest
        .agent(app.getHttpServer())
        .delete(`/todos/${todo.id}`)
        .set("Accept", "application/json")
        .expect(200)
      const missingTodo = await repository.findOne({ id: todo.id })

      expect(missingTodo).toBe(undefined)
    })
  })
})

When we run the tests, we have a very unpleasant surprise that it simply doesn't work. That is because typeorm doesn't play well with MongoDB. After trying to find solutions, I concluded the best one was to switch the database completely. Since typeorm supports mainly SQL databases, I've decided to go with Postgres as it supports NoSQL.

Here is the docker-compose.yml file:

version: "3.6"
services:
  database:
    image: postgres:latest
    container_name: stator-postgres
    ports:
      - 5433:5432
    restart: always
    command:
      - postgres
      - -c
      - listen_addresses=*
    environment:
      POSTGRES_DB: stator
      POSTGRES_HOST_AUTH_METHOD: "trust" # Not recommended, only for demo purposes
    volumes:
      # seeding
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
      # named volume
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

We simply need to update the @Module of the app.module.ts and todos.e2e.spec.ts files:

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: "mongodb",
      host: "127.0.0.1",
      port: 27017,
      database: "stator",
      username: "stator",
      password: "secret",
      entities: [Todo],
      synchronize: true
    }),
    TodosModule
  ],
  controllers: [AppController],
  providers: [AppService],
})

Now when running:

nx test api

You should see this:

Screenshot_20201014_203014.png

Conclusion

Through this article, we have analyzed 3 different web frameworks. We picked the most interesting one according to the ease of use, popularity, performance and code readability. We implemented a complete backend example of this framework, along with other complementary technologies. We have stumbled upon some issues but easily managed to overcome them due to good development patterns. Hopefully, this detailed article has made you discover some new upcoming technologies. If you want to see the complete example implemented, check out stator.

You can read the next article here.

No Comments Yet