Published
8/23/2022
Categories
E-Commerce, Software

NestJS and React Admin Deployed to Amazon Lambda

A computer screen with javascript code visible in a code editor

Quick Summary

In this article we’re going to take you step by step through setting up a NestJS project for your API, a React Admin frontend for your interface, and then deploying to Amazon’s serverless platform, Lambda.

NestJS

If you’re not already familiar with it, NestJS is a framework for developing server-side applications with Node, for instance, an API. It goes beyond the typical Node / Express setup, providing organizational patterns for your code and utilities to help expedite creation of commonly needed systems.

Let’s get our NestJS Project set up:

First, install the NestJS CLI which will let you create a new NestJS project and run various commands to scaffold your code:

npm i -g @nestjs/cli cd [my_project_path] nest new [my_project_name]

You’ll be asked what package manager to use, let’s stick with npm.

cd [my_project_name]

Next, let’s install several packages we’ll be needing to implement our API this will include:

  • An ORM, namely TypeORM:

    • npm i @nestjs/typeorm

    • npm i typeorm

  • The MySQL Adaptor:

    • npm i mysql2

  • Swagger, which helps us visualize and test our API:

    • npm i @nestjs/swagger swagger-ui-express

  • A quick NestJS CRUD scaffolding utility plus tools for validation and creating slugs:

    • npm i @nestjsx/crud class-transformer class-validator slugify

  • The TypeORM adaptor for that CRUD utility:

    • npm i @nestjsx/crud-typeorm

  • And finally the NestJS config package:

    • npm i @nestjs/config

Here’s a handy one-liner to install them all!

npm i @nestjs/typeorm mysql2 typeorm @nestjs/swagger swagger-ui-express @nestjsx/crud class-transformer class-validator slugify @nestjsx/crud-typeorm @nestjs/config

Alright! We’re ready to go to start building a CRUD API. Generally the first thing you’ll want to do is start defining your model, and with NestJS you’ll organize these as “resources”. Go ahead and create a resource like this:

nest g res users

And let’s go ahead and choose REST API.

And let’s answer “Yes” to generating CRUD entry points… it will be useful to see what NestJS does automatically, even though we’re going to end up removing that in favor of what our nestjsx/crud package provides us.

OK! Tada! Go ahead and open the project in your IDE and check out what NestJS generated for us. Hopefully you can start to see the architectural patterns:

  • You have a “users” folder to organize everything about your “users” resource.

  • You have a “dto” folder which stands for Data Transfer Object. It’s a place to define schemas for data that would be transferring into your API. This is ONE way to organize validation logic for incoming data. Generally your DTO’s will relate to your entities and will reference that subset of entity fields that actually need to be input and edited by your users.

  • You have an “entities” folder, which will house your entity (AKA model) definitions.

  • You have a “controller” file for your endpoints, and a corresponding “controller.spec” file for tests.

  • You have a “module” file for configuring your users module.

  • You have a “service” file for defining the services your module provides, and a corresponding “service.spec” file for tests.

OK fabulous! Let’s now define our User entity. We’ll be making use of the Decorator Pattern a lot. TypeORM provides us a library of annotations we can use to decorate our properties so that TypeORM will know what to do with them when it comes time to create / update the fields and persist them to the database.

import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; import { UserType } from './userType.entity'; @Entity() export class User { @PrimaryGeneratedColumn() id: string; @Column() firstName: string; @Column() lastName: string; @Column({ unique: true, }) email: string; @ManyToOne(() => UserType) userType: UserType; @Column() isActive: boolean; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; }

And let’s also add a new file “userType.entity.ts” so that we can demonstrate a ManyToOne relationship. Its contents are:

import { BeforeInsert, Column, Entity, PrimaryGeneratedColumn, Unique, } from 'typeorm'; import slugify from 'slugify'; import { ApiProperty } from '@nestjs/swagger'; import {IsNotEmpty} from "class-validator"; @Entity() export class UserType { @PrimaryGeneratedColumn() id: number; @ApiProperty() @Column({ unique: true, }) name: string; @Column() slug: string; @BeforeInsert() createSlug() { this.slug = slugify(this.name, { lower: true, strict: true, }); } }

Hopefully most of that is self-explanatory. If you want to study the TypeORM Decorator Reference, here it is!

Next, we’re going to modify our user controller class, to take advantage of the shortcuts the nestjsx/crud package provides us. With that package we don’t have to individually define all our endpoints like the default scaffolding indicates as decorating with @Crud will give us the standard set of endpoints.

So, completely replace the controller class contents with this:

import { Controller } from '@nestjs/common'; import { User } from './entities/user.entity'; import { Crud, CrudController } from '@nestjsx/crud'; import { UsersService, UserTypesService } from './users.service'; import { UserType } from './entities/userType.entity'; @Crud({ model: { type: User, }, }) @Controller('users') export class UsersController implements CrudController<User> { constructor(public service: UsersService) {} } @Crud({ model: { type: UserType, }, }) @Controller('user-types') export class UserTypesController implements CrudController<UserType> { constructor(public service: UserTypesService) {} }

And then completely replace the user service file contents with the following, which will provide the TypeORM CRUD services via decorator to match the CRUD controller endpoints we just added via decorator.

import { Injectable } from '@nestjs/common'; import { TypeOrmCrudService } from '@nestjsx/crud-typeorm'; import { User } from './entities/user.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { UserType } from './entities/userType.entity'; @Injectable() export class UsersService extends TypeOrmCrudService<User> { constructor(@InjectRepository(User) repo) { super(repo); } } @Injectable() export class UserTypesService extends TypeOrmCrudService<UserType> { constructor(@InjectRepository(UserType) repo) { super(repo); } }

Next, tie everything together in your user module file:

import { Module } from '@nestjs/common'; import { UsersService, UserTypesService } from './users.service'; import { UsersController, UserTypesController } from './users.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; import { UserType } from './entities/userType.entity'; @Module({ imports: [TypeOrmModule.forFeature([User, UserType])], controllers: [UsersController, UserTypesController], providers: [UsersService, UserTypesService], }) export class UsersModule {}

OK our users resource is pretty much ready to go, but we have to still configure our application more generally, for instance, help it to connect to a database and expose the Swagger UI for our API.

So update your app.module.ts file with the following. This is defining a way to manage configuration by environment, getting TypeORM setup to connect to your database, and generally making the app aware of modules and root level controllers / services.

import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; import configuration from './configuration/configuration'; import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ ConfigModule.forRoot({ envFilePath: ['.env.local', '.env.dev', '.env.prod', '.env'], load: [configuration], isGlobal: true, }), TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: async (config: ConfigService) => config.get('database'), inject: [ConfigService], }), UsersModule, ], controllers: [AppController], providers: [AppService], }) export class AppModule {}

I like to control error handling a bit more than the default… so create a file called “app.exception-filter.ts” and give it these contents:

// from: https://stackoverflow.com/a/67556738 import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; import { QueryFailedError, EntityNotFoundError, CannotCreateEntityIdMapError, } from 'typeorm'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); let message = (exception as any).message.message; let code = 'HttpException'; Logger.error( message, (exception as any).stack, `${request.method} ${request.url}`, ); let status = HttpStatus.INTERNAL_SERVER_ERROR; switch (exception.constructor) { case HttpException: status = (exception as HttpException).getStatus(); message = (exception as HttpException).message; break; case QueryFailedError: // this is a TypeOrm error status = HttpStatus.UNPROCESSABLE_ENTITY; message = (exception as QueryFailedError).message; code = (exception as any).code; break; case EntityNotFoundError: // this is another TypeOrm error status = HttpStatus.UNPROCESSABLE_ENTITY; message = (exception as EntityNotFoundError).message; code = (exception as any).code; break; case CannotCreateEntityIdMapError: // and another status = HttpStatus.UNPROCESSABLE_ENTITY; message = (exception as CannotCreateEntityIdMapError).message; code = (exception as any).code; break; default: status = HttpStatus.INTERNAL_SERVER_ERROR; message = (exception as Error).message; } response .status(status) .json(GlobalResponseError(status, message, code, request)); } } export const GlobalResponseError: ( statusCode: number, message: string, code: string, request: Request, ) => IResponseError = ( statusCode: number, message: string, code: string, request: Request, ): IResponseError => { return { statusCode: statusCode, message, code, timestamp: new Date().toISOString(), path: request.url, method: request.method, }; }; export interface IResponseError { statusCode: number; message: string; code: string; timestamp: string; path: string; method: string; }

Now, update main.ts with the following which gets Swagger, validation, and our custom error handling plumbed.

import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { GlobalExceptionFilter } from './app.exception-filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); const config = new DocumentBuilder() .setTitle('Nest Test') .setDescription('Nest Test API Description') .setVersion('1.0') .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, document); app.useGlobalPipes(new ValidationPipe()); app.useGlobalFilters(new GlobalExceptionFilter()); await app.listen(3000); } bootstrap();

Now, create your configuration by making a “configuration” folder and a configuration.ts file within it, then paste this content.

export default () => ({ database: { host: process.env.DB_HOST, type: 'mysql', port: process.env.DB_PORT || 3306, username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, entities: ['dist/**/*.entity{.ts,.js}'], synchronize: process.env.DB_SYNCHRONIZE === 'true', logging: process.env.DB_LOGGING === 'true', }, });

And finally, create a “.env” file in your project root directory, paste the following contents, and then modify the values to connect to your own database.

DB_HOST=127.0.0.1 DB_PORT=8889 DB_USER=root DB_PASSWORD=root DB_DATABASE=nest-test DB_SYNCHRONIZE=true DB_LOGGING=true

Alright… did it work?! Let’s try

npm run start:dev

And visit: http://localhost:3000/

You should see “Hello Word”… which is the default route found in app.controller.ts

Now let’s see if your API is exposed, visit:

http://localhost:3000/api

You should see the Swagger UI with all the endpoints automatically generated for your User and UserType entities.

Tada! Now we’re ready to spin up a frontend to manage our User entities.

React Admin

So, let’s install React Admin:

npx create-react-app admin

Then change into admin and start the server.

npm start

You’ll be asked to confirm using a different port than 3000 (since Nest is already running on that) so respond affirmatively with “Y”.

Access http://localhost:3001/ and you should see the React logo and a message to update your App.js file.

Before we do that, let’s install React Admin

npm i react-admin

And also the package “ra-data-nestjsx-crud” which does the work of translating data formats between Nest’s API response and React Admin’s requirements

Now, update App.js with the following:

import React from 'react'; import { Admin, Resource, ShowGuesser, ListGuesser } from 'react-admin'; import crudProvider from 'ra-data-nestjsx-crud'; import {UserCreate, UserEdit, UserList} from './Users'; const dataProvider = crudProvider('http://localhost:3001'); const App = () => ( <Admin dataProvider={dataProvider}> <Resource name="users" list={ListGuesser} create={UserCreate} edit={UserEdit} show={ShowGuesser} /> </Admin> ); export default App;