DEV Community

ismaventuras
ismaventuras

Posted on • Updated on

Developing a REST API using NodeJS, Express and Typescript

Today I want to explain how to create a REST API using the Javascript programming language. This language is very popular when making web pages but thanks to a technology called NodeJS we can also use this language to write code on a server.

In this article I am not going to explain how to install NodeJS nor am I going to delve into its operation, but I am going to focus on explaining how to install the necessary libraries and write the code to start creating an API.

To create this API we are going to use the following libraries, among others:

  • Typescript: to make the code easier to read
  • Prism: To manage databases,
  • Express: Handle HTTP calls
  • Jest: To test the code
  • Zod: Data validation
  • Bcrypt: hash passwords

The complete code can be found at: ismaventuras/rest-api-typescript (github.com)

Requirements

  • NodeJS 18.10.0
  • Docker 20.10.13

Starting the project

First we need to start a project using a package manager, i’m going to use npm but you can youse any other alternative like yarn

npm init --y
Enter fullscreen mode Exit fullscreen mode

Now that we have our project started we crate the folder structure of the project. All our code will be inside the src folder and we will configure Typescript to transpile the code to Javascript inside a folder called dist . We install Typescript and create the tsconfig.json file to configure typescript.

npm install -D typescript 
Enter fullscreen mode Exit fullscreen mode
{
  "compilerOptions": {
    "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "commonjs",                                /* Specify what module code is generated. */
    "outDir": "./dist",                                   /* Specify an output folder for all emitted files. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */

    "strict": true,                                      /* Enable all strict type-checking options. */

    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
  },
  "exclude": ["test"]
}
Enter fullscreen mode Exit fullscreen mode

Once typescript is installed and configured we install express , the framework to handle HTTP calls.

npm i express
npm i -D @types/express

Enter fullscreen mode Exit fullscreen mode

Create the app.ts file in the src folder, this file will export an express application and start the web server from the src/index.ts file

In the src/app.ts file we create an express application, use a couple of middlewares, define a GET path, and export the application.

The middlewares we use are:

  • json() - To be able to use the body of the requests as an object.
  • urlencoded() - To be able to use body if the information is sent from an html form.

The route returns the HTTP code 200 and a hello world message.

import express from "express";

const app = express()

app.use(express.json());
app.use(express.urlencoded({extended:false}));

app.get('/',(req, res, next) => {
     return res.status(200).json({message: 'hello world'});
})

export default app

Enter fullscreen mode Exit fullscreen mode

In the src/index.ts file we import the application created in the src/app.ts file and start listening on port 3000.

import app from "./app";

const port = 3000;

app.listen(port, ()=> {
     console.log(`listening on <http://127.0.0.1>:${port}`)
})

Enter fullscreen mode Exit fullscreen mode

In order to start the project, we need to edit the package.json file and so that when we run npm start it will transpilate the code to Javascript and run the src/index.js file in the dist folder.

{
...
"scripts":{
"start": "tsc --build --force && node dist/src/index.js"
}
...
}

Enter fullscreen mode Exit fullscreen mode

We execute npm start and we would have our web server running. If we access http://localhost:3000 in our browser or by consulting the API making http calls manually we will see the hello world message.

Once the project has started, the directory and file structure would be as follows:

πŸ“¦rest-api
 ┣ πŸ“‚dist
 ┣ πŸ“‚node_modules
 ┣ πŸ“‚src
    ┣ πŸ“œapp.ts
    β”— πŸ“œindex.ts
 ┣ πŸ“œpackage-lock.json
 ┣ πŸ“œpackage.json
 β”— πŸ“œtsconfig.json
Enter fullscreen mode Exit fullscreen mode

Our application right now only returns a hello world message, to give it some more functionality we are going to create an endpoint to create, read, edit and delete tasks.

We create the routes folder inside src and inside this we create the index.ts and todo.routes.ts files. The index file will import all the routes we write and export a router object to be used from the main application.

In the todo.route.ts file we write a route to handle tasks, as at the moment we don't have a database, the data returned by the api are test data

import { Router } from "express";

const router = Router()

router.route('/')
     .get((req, res, next) => {
         res.status(200).json([
             {
                 id: 1,
                 title: 'first all',
                 content: 'a task to execute'
             },
             {
                 id: 2,
                 title: 'second todo',
                 content: 'another task to execute'
             },
         ])
     })
     .post((req, res, next) => {
         res.status(201).json(req.body)
     })

router.route('/:id')
     .get((req, res, next) => {
         const id = req.params.id
         res.status(200).json({
             Yo hice,
             title: 'first all',
             content: 'a task to execute'
         })
     })
     .put((req, res, next) => {
         const id = req.params.id
         res.status(200).json({
             Yo hice,
             ...req.body
         })
     })
     .delete((req, res, next) => {
         const id = req.params.id
         res.status(200).json({
             Yo hice,
             ...req.body
         })
     })

export default router;

Enter fullscreen mode Exit fullscreen mode

The src/routes/index.ts file imports the above file, creates a router, and assigns the /todo endpoint to the previously created router. We export this router and use it from src/app.ts

import { Router } from "express";
import todoRouter from './todo.route';

const router = Router()

router.use('/todo',todoRouter);

export default router

Enter fullscreen mode Exit fullscreen mode

We edit the src/app.ts file, import the router and use it by assigning the /api path

...
import router from "./routes";
...

app.use('/api', router);

...

Enter fullscreen mode Exit fullscreen mode

To test endpoints, you can use curl or Postman but I am going to use an extension to the Visual Studio Code IDE called REST Client which allows us to create a .rest file to make HTTP calls. The file would look like this to do the first tests with the path /todo

GET <http://localhost:3000/api/all>

###
POST <http://localhost:3000/api/todo>
Content-Type: application/json

{
     "title": "all test",
     "content":"a test task"
}

###
GET <http://localhost:3000/api/todo/1>
###
PUT <http://localhost:3000/api/todo/1>
Content-Type: application/json

{
     "title": "all test update",
     "content":"a test task update"
}

###
DELETE <http://localhost:3000/api/todo/1>

Enter fullscreen mode Exit fullscreen mode

We run npm start and click on β€œSend request” above each http call inside the .rest file to check that the routes work. The following response comes after executing the first line of the .rest file, where we ask for the list of all tasks.

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 128
ETag: W/"80-90sh6WsZ1XzUGCAyZh4CPQBd81s"
Date: Sat, 03 Dec 2022 11:29:16 GMT
Connection: close

[
  {
    "id": 1,
    "title": "first todo",
    "content": "a task to execute"
  },
  {
    "id": 2,
    "title": "second todo",
    "content": "another task to execute"
  }
]
Enter fullscreen mode Exit fullscreen mode

Creating a development database

Now that we've verified that the route to handle tasks works, now it's time to add a database. For this article I am going to use PostgreSQL 14 as the database engine and in order to have a development environment I am going to use docker , thus separating what would be the database that would be used in production and the database used in development or testing.

We edit the package.json file to add a couple of scripts that start and stop the database.

"scripts": {
     "start": "tsc --build --force && node dist/src/index.js",
     "db:start": "docker run --name rest-api-db-dev -e POSTGRES_PASSWORD=postgres -d -p 5433:5432 postgres",
     "db:stop": "docker rm rest-api-db-dev -f",
}

Enter fullscreen mode Exit fullscreen mode

The npm run db:start command starts a docker with name rest-api-db-dev , with password postgres and listening on port 5433. The npm run db:stop command will stop and kill the docker.

Installing and using Prisma

We need to be able to use the application's database and for this we are going to use an ORM called Prisma to generate the models for the database.

We need to install prisma to declare the database models, convert them to SQL and create them in the database. We also installed @prisma/client to generate the models in TypeScript and be able to use the types within the application, as well as use the prisma client to use the database within the application.

npm i @prisma/client
npm i -D prisma
npx prisma init

Enter fullscreen mode Exit fullscreen mode

The last command will create the prisma folder and also create a .env file from where it will read the URL to use the database, in our case the .env file should look like this:

DATABASE_URL="postgresql://postgres:postgres@localhost:5433/rest-api"

Enter fullscreen mode Exit fullscreen mode

Now we go to the prisma/schema.prisma file and add the model to create a task

...

pattern all {
   id Int @id @default(autoincrement())
   titleString
   contentString
}

Enter fullscreen mode Exit fullscreen mode

To create the model in the database, we first make sure that docker is started and run the command npx prisma migrate dev --name todo , which will generate the SQL code to create the model in the database and will create in the test database.

Once the model has been migrated to the database, we execute npx prisma generate to generate the models in Typescript and to be able to use them directly using prisma in the application code.

We are going to start using prisma in our application, we are going to create the src/db folder and inside we are going to create the prisma.ts file. In this file we are going to create an instance of the prisma client and export it for use in the application, we do this instead of calling the prisma client from each application component to avoid creating multiple database connections with the same url.

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export default prisma

Enter fullscreen mode Exit fullscreen mode

Creating and testing the controllers

With the database and prisma client ready to use, we are now going to create the src/controllers folder where we will put the code that will interact with the database and return β€œsomething” to the routes. We create the file src/controllers/todo.controller.ts where we will use prisma

import prism from "../db/prism"
import type {All} from '@prisma/client'

type UpdateTodoType = Partial<Todo>
type CreateTodoType = Pick<Todo, "content" | "title">
type SingleTodoType = Pick<Todo,"id">;

export const createTodo = async(todo: CreateTodoType) => {
     try {
         return await prisma.todo.create({
             data:all
         });
     } catch(error) {
         throw error
     }
}

export const allAll = async() => {
     try {
         return await prisma.todo.findMany();
     } catch(error) {
         throw error
     }
}
export const getTodo = async(todo: SingleTodoType) => {
     try {
         return await prisma.todo.findUniqueOrThrow({
             where:{
                 id:all.id
             }
         });
     } catch(error) {
         throw error
     }
}
export const deleteTodo = async(todo: SingleTodoType) => {
     try {
         return await prisma.todo.delete({
             where:{
                 id:all.id
             }
         });
     } catch(error) {
         throw error
     }
}

export const updateTodo = async(todoParams: SingleTodoType, todoBody: UpdateTodoType) => {
     try {
         return await prisma.todo.update({
             where:{
                 id:allParams.id
             },
             data:todoBody
         });
     } catch(error) {
         throw error
     }
}

Enter fullscreen mode Exit fullscreen mode

In order to test them we would have to validate the data in the routes before passing it to the controller so in order to test the controllers we are going to do some separate tests using a library called Jest

npm install --save-dev jest typescript ts-jest @types/jest ts-node

Enter fullscreen mode Exit fullscreen mode

Create the jest configuration file: jest.config.ts

import type {Config} from 'jest';

export default async(): Promise<Config> => {
   return {
     verbose: true,
     preset: 'ts-jest',
     testEnvironment: 'node',
   };
};

Enter fullscreen mode Exit fullscreen mode

Now we need another database to run the tests, different from the production and development databases.

First we are going to install the dotenv-cli library to be able to load different files with environment variables.

npm i dotenv-cli

Enter fullscreen mode Exit fullscreen mode

Let's also create the .env.test file to load environment variables, such as the database URL, in different scenarios. When doing tests we do not want to touch the production database since we need to create, edit or delete tasks, so we need a separate database to be able to test that everything works, so we also make sure that when moving the changes to production we will not break anything.

We add a new script to our package.json to execute the tests, the process will be the following: we will restart the docker, then we will migrate the prisma models and we will execute the tests to finally stop the docker.

...
"scripts": {
     "start": "tsc --build --force && node dist/src/index.js",
     "db:start": "docker run --name rest-api-db-dev -e POSTGRES_PASSWORD=postgres -d -p 5433:5432 postgres",
     "db:stop": "docker rm rest-api-db-dev -f",
     "test:docker:stop": "docker rm postgres-test -f",
     "test:docker:start": "npm run test:docker:stop && docker run --name postgres-test -e POSTGRES_PASSWORD=postgres -d -p 5434:5432 postgres",
"test": "npm run test:docker:start && dotenv -e .env.test npx prisma migrate deploy && dotenv -e .env.test jest -i"
  },
...

Enter fullscreen mode Exit fullscreen mode

The npm run test:docker:start command starts a docker like the development one but on port 5434 to avoid conflicts, the npm run test:docker:stop command stops the docker. The npm test command starts docker, uses dotenv to load the environment variables from the env.test file, creates the database in docker, and runs the tests inside the test folder.

We create the test folder and inside it we create the test/todo.controller.test.ts file. The test imports prisma and the controllers, creates some test tasks and once the tests are finished, it deletes all the transactions from the database in case it is going to be used for other tests.

import prisma from "../src/db/prisma";
import { createTodo, allTodos, deleteTodo, getTodo,updateTodo } from "../src/controllers/todo.controller"
import type {Todo} from '@prisma/client'

beforeAll(async () => {
    await prisma.todo.createMany({
      data: [{ title: 'Todo 1', content:'A task to execute' }, { title: 'Todo 2', content:'A task to execute' }],
    })
    console.log('✨ Seeded db with 2 todos');
});

afterAll(async () => {
    const deleteTodos = prisma.todo.deleteMany();

    await prisma.$transaction([
        deleteTodos
    ]);

    await prisma.$disconnect();
})

test('allTodos: should get all todos', async ()=> {
    const todos = await allTodos();
    expect(todos).toMatchObject<Todo[]>
})

test('createTodo: should create a todo', async () => {
    const todo = await createTodo({title:'test', content:'test'});
    expect(todo).toMatchObject<Todo>
})
test('updateTodo: should update a todo', async () => {
    const todo = await updateTodo({id:1},{title:'test', content:'test'});
    expect(todo).toMatchObject<Todo>
})
test('getTodo: should get a todo', async () => {
    const todo = await getTodo({id:1});
    expect(todo).toMatchObject<Todo>
})
test('deleteTodo: should delete a todo', async () => {
    const todo = await deleteTodo({id:1});
    expect(todo).toMatchObject<Todo>
})
Enter fullscreen mode Exit fullscreen mode

When running npm test we will see the results of the tests and we will have verified that the controllers we have created work.

PASS  test/todo.controller.test.ts
  √ allTodos: should get all todos (5 ms)
  √ createTodo: should create a todo (12 ms)
  √ updateTodo: should update a todo (13 ms)
  √ getTodo: should get a todo (5 ms)
  √ deleteTodo: should delete a todo (11 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        2.001 s
Enter fullscreen mode Exit fullscreen mode

Validation of information in http requests using Zod

Before starting to use the controllers, we are going to install a library to validate the data that comes to us in each request, the library is zod

npm i zod
Enter fullscreen mode Exit fullscreen mode

We create the schema folder and inside it the index.ts and todo.schema.ts files

In the index.ts file we will create a function where we will pass the request (which will include the body and the params) and the schema against which we want to validate the request data.

import { Request } from "express"
import { AnyZodObject, z } from "zod"

export async function zParse<T extends AnyZodObject>(schema: T, req: Request): Promise<z.infer<T>> {
    try {
        return schema.parseAsync(req)
    } catch (error) {
        throw error
    }
}
Enter fullscreen mode Exit fullscreen mode

In the src/schema/todo.schema.ts file we create schemas that validate if the title or the content of the tasks are valid and also convert the id from text to number to be able to use it in the controller.

import {z} from 'zod';

const titleSchema =  z.string({
    description: "title",
    invalid_type_error: "title must be a string",
    required_error: "title is required"
}).max(32, "max title length is 32 characters");

const contentSchema = z.string({
    description: "content",
    invalid_type_error: "content must be a string",
    required_error: "content is required"
});

const idSchema = z.object({
    id:z.preprocess(
        (id) => parseInt(id as string, 10),
        z.number({
            description: "id",
            invalid_type_error: "id must be a number",
            required_error: "id is required"
        }).positive()
    )    
})

export const createTodoSchema = z.object({
    body: z.object({
        title: titleSchema,
        content: contentSchema,
    })
})  

export const getTodoSchema = z.object({
    params: idSchema
})

export const deleteTodoSchema = getTodoSchema

export const updateTodoSchema = z.object({
    body: z.object({
        title: titleSchema.optional(),
        content: contentSchema.optional(),
    }),
    params: idSchema
})
Enter fullscreen mode Exit fullscreen mode

Now we use the previous code in the src/routes/todo.route.ts file to handle the tasks to validate what we get in the request and if it is not what we expect, return an error.

import { Router } from "express";
import { allTodos, createTodo, deleteTodo, getTodo, updateTodo } from "../controllers/todo.controller";
import { zParse } from "../schema";
import { createTodoSchema, deleteTodoSchema, getTodoSchema, updateTodoSchema } from "../schema/todo.schema";

const router = Router()

router.route('/')
    .get(async (req, res, next) => {
        try {
            const todos = await allTodos();
            return res.status(200).json(todos)
        } catch (error) {
            return res.status(500).json({error:'internal server error'});
        }
    })
    .post(async (req, res, next) => {
        try {            
            const {body} = await zParse(createTodoSchema, req);
            const todo = await createTodo(body);;
            return res.status(201).json(todo);
        } catch (error) {
            return res.status(500).json({error:'internal server error'});
        }
    })

router.route('/:id')
    .get(async (req, res, next) => {        
        try {
            const {params} = await zParse(getTodoSchema, req);
            const todo = await getTodo(params);
            return res.status(200).json(todo);
        } catch (error) {
            return res.status(500).json({error:'internal server error'});
        }
    })
    .put(async (req, res, next) => {
        try {
            const {params, body} = await zParse(updateTodoSchema, req);
            const todo = await updateTodo(params, body);
            return res.status(200).json(todo);
        } catch (error) {
            return res.status(500).json({error:'internal server error'});
        }
    })
    .delete(async (req, res, next) => {
        try {
            const {params} = await zParse(deleteTodoSchema, req);
            const todo = await deleteTodo(params);
            return res.status(200).json(todo);
        } catch (error) {
            return res.status(500).json({error:'internal server error'});
        }
    })

export default router;
Enter fullscreen mode Exit fullscreen mode

Error handling

If we start the application with npm start and retest the routes using the .rest file, we will see that everything works but the server returns internal server error for any error. For that we are going to create a middleware for express , where we can handle all the errors and send a response according to the user. This middleware will be applied after the routes, so we can use the next() function to send the error to the next middleware, in this case the error handler.

Create the middleware folder inside src and create the src/middleware/errorHandler.ts file

import { Prisma } from "@prisma/client";
import { ErrorRequestHandler } from "express";
import { ZodError } from 'zod'

const errorHandler: ErrorRequestHandler = async (err, req, res, next) => {
    if (err instanceof ZodError) {
        const errorMessage = err.errors.map(error => error.message).join(',');
        return res.status(400).json({ error: errorMessage })
    }
    if (err.name === "NotFoundError") return res.status(404).json({ error: "item not found" }) //prisma
    if (err instanceof Prisma.PrismaClientKnownRequestError) {
        if (err.code === 'P2002') return res.status(409).json({ error: 'already exists' });
        if (err.code === 'P2025') return res.status(409).json({ error: "not found" });
    }
    if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'wrong formatted JSON' });
    console.error(err);

    return res.status(500).json({ error: 'internal server error' });
}

export default errorHandler
Enter fullscreen mode Exit fullscreen mode

Zod validation errors will be returned to the user contained with commas, so if the user leaves out both the content and the title of the task they will receive both messages.

If it's a prisma error, then it's always handled by sending either not found or doesn't exist, since there are no other possibilities other than a database connection failure.

It is also validated if the message sent is a valid JSON, for example that it does not have a trailing comma at the end of the document.

If it is an uncontrolled error, a generic error message is returned and the error is logged by the console.

We also change all catch inside the /src/routes/todo.route.ts file to next(error)

Finally we use the middleware in the express app in the /src/app.ts file.

...
import errorHandler from "./middleware/errorHandler";
...
app.use('/api', router);

app.use(errorHandler);

export default app
Enter fullscreen mode Exit fullscreen mode

Authentication

It may be that we want only certain people to be able to create tasks and for that we have to implement an authentication system, for this we need to add a new model to the prisma/schema.prisma file and add the model for the user that will have a username and password , once added we execute npx prisma migrate dev --name auth to generate the Typescript types and create the model in the database. In addition to the model we will need to create the routes, controllers, validation scheme and a middleware to validate if a user is authenticated before using a protected route.

...
model User {
  id Int @id @default(autoincrement())
  username String @unique
  password String
  createdAt DateTime @default(now())  
}
Enter fullscreen mode Exit fullscreen mode

We create the file src/schema/auth.schema.ts that will validate the user and the user's password when logging in or registering.

import { z } from 'zod';

const authUsernameSchema = z.string({
    description: "Username",
    invalid_type_error: "username must be a string",
    required_error: "username is required"
})
const authPasswordSchema = z.string({
    description: "Password",
    invalid_type_error: "password must be a string",
    required_error: "password is required",
}).min(8, 'password must be greater than 8 characters')

const authBody = z.object({
    username: authUsernameSchema,
    password: authPasswordSchema,
});

export const authSchema = z.object({
    body: authBody,
});
Enter fullscreen mode Exit fullscreen mode

Before creating the routes we must create the controllers, since we are going to deal with passwords we have to add some extra steps since we cannot store plain text passwords in the database. In order to save the passwords, what we will do is generate a hash of the password and save it in the database and for this we will use bcrypt .

We install bcrypt

npm and bcrypt
npm i -D @types/bcrypt

Enter fullscreen mode Exit fullscreen mode

We create the src/utils folder and within it we create the bcryptUtils.ts file which will have two functions: one to hash a password and another to check that a password in plain text is equivalent to the hash we have in the database of data.

import bcrypt from 'bcrypt'

export const hashPassword = async (password: string) => {
     const saltRounds = 13;
     const salt = await bcrypt.genSalt(saltRounds)
     return await bcrypt.hash(password, salt)
}

export const comparePassword = async(password: string, hashedPassword:string) => {
     return await bcrypt.compare(password,hashedPassword)
}

Enter fullscreen mode Exit fullscreen mode

Users will be able to register and login, as we are making an API the login must be using a token or similar, we could use basic authentication using the username and password in the request header but in this case what we are going to do is return a token using jsonwebtoken.

We install jsonwebtoken

npm i jsonwebtoken
npm i -D @types/jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

The function will sign a token using the login user's secret and assign an expiration time. the secret should be read from the environment variables so we edit the .env file to add the SECRET variable and some text that should not be shown to anyone. The function will return a valid JWT token.

import jwt from 'jsonwebtoken';

export const generateAccessToken = (username: string) => {
     return jwt.sign({username}, process.env.SECRET, {expiresIn: "1800s"});
}

Enter fullscreen mode Exit fullscreen mode

If we try to transpile the jwtUtils.ts file to Javascript we will get an error stating that process.env.SECRET must be a string and right now it is string | null. To fix this we need to declare the types of our .env file and for that we create the env.d.ts file. Now we will see how the properties inside process.env are autocompleted.

export { };

declare global {
   namespace NodeJS {
     interface ProcessEnv {
       NODE_ENV: 'development' | 'production';
       DATABASE_URL: string;
       SECRET: string;
     }
   }
}

Enter fullscreen mode Exit fullscreen mode

Now we have to create the controllers that will go in the routes, so we create the file src/controllers/auth.controller.ts , where we will create the functions to register a user and to check if it exists. Before saving the password in the database we hashe it using bcrypt and when logging in we check that the password they enter is the same.

import prisma from "../db/prisma";
import { AuthBody } from "../schema/auth.schema";
import { comparePassword, hashPassword } from "../utils/bcrypt-utils";
import { generateAccessToken } from "../utils/jwt-utils";

export const createUser = async (userInput: AuthBody) => {
    try {
        const hashedPassword = await hashPassword(userInput.password)
        const user = await prisma.user.create({
            data:{
                ...userInput,
                password:hashedPassword
            }
        })
        return user
    } catch (error) {
        throw error
    }
}
export const loginUser = async (userInput: AuthBody) => {
    try {
        const {username, password} = userInput;
        const user = await prisma.user.findUniqueOrThrow({
            where:{
                username
            }
        })        
        let isValid = await comparePassword(user.password, password);
        if(!isValid) throw Error("INVALID CREDENTIALS");
        return generateAccessToken(username);
    } catch (error) {
        throw error
    }
}
Enter fullscreen mode Exit fullscreen mode

Having the controllers now we are missing the routes so we create the file src/routes/auth.route.ts and import the routes in the file src/routes/index.ts

import { Router } from "express";
import { createUser, loginUser } from "../controllers/auth.controller";
import { zParse } from "../schema";
import { authSchema } from "../schema/auth.schema";

const router = Router();

router
    .route('/register')
    .post(async (req, res, next) => {
        try {
            const { body } = await zParse(authSchema, req);
            const user = await createUser(body);
            res.status(201).json(user);
        } catch (error) {
            next(error);
        }
    });

router
    .route('/login')
    .post(async (req, res, next) => {
        try {
            const { body } = await zParse(authSchema, req);
            const token = await loginUser(body);
            res.status(200).json({ token });
        } catch (error) {
            next(error)
        }
    })

export default router
Enter fullscreen mode Exit fullscreen mode
...
import authRouter from './auth.route';
...
router.use('/auth',authRouter);
...
export default router
Enter fullscreen mode Exit fullscreen mode

To test that it works, we are going to add some lines to the .rest file to check that everything works

...

POST http://localhost:3000/api/auth/register
Content-Type: application/json

{
    "username":"ismael",
    "password":"SecretPassword"
}

###

POST http://localhost:3000/api/auth/login
Content-Type: application/json

{
    "username":"ismael",
    "password":"SecretPassword"
}
Enter fullscreen mode Exit fullscreen mode

The last thing we have left is to create a middleware that is executed when the request arrives but before using the route, to validate if the user is authenticated or not, for that we are going to create the file src/middleware/authenticateJWT.ts

import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";

export const authenticateJWT = async (req: Request, res:Response, next:NextFunction) => {
     const authHeader = req.headers['authorization']
     const token = authHeader && authHeader.split(' ')[1]

     if(!token) return res.sendStatus(401);
     try {
         const decoded = jwt.verify(token, process.env.SECRET, {})
         next()
     } catch(error) {
         res.status(403).json({error:"invalid token"});
     }

}

Enter fullscreen mode Exit fullscreen mode

Now, in order to use it, we go, for example, to the src/routes/index.ts file and make it so that only authenticated users can use the routes for tasks.

import { Router } from "express";
import todoRouter from './todo.route';
import authRouter from './auth.route';
import { authenticateJWT } from "../middleware/authenticateJWT";

const router = Router()

router.use('/todo',authenticateJWT,todoRouter);
router.use('/auth',authRouter);

export default router

Enter fullscreen mode Exit fullscreen mode

Now, if we try to use any of the routes to handle the tasks, it will return the error code 401 and an Unathorized message. In order to use the route you have to register, login and use the token that is generated when you login. The way to use it is by adding the authorization header with the BEARER <token> content.

Extra

Handle 404 errors

If we don't handle any of the errors, express sends a message on its own, to avoid this we create a new middleware that we will use between the routes and the error handling middleware.

We create the src/middleware/notFound.ts file where we return a 404 error.

import { NextFunction, Request, Response } from "express";

const notFound = async (req:Request, res:Response, next:NextFunction) => res.status(404).json({error: 'ENDPOINT NOT FOUND'});

export default notFound

Enter fullscreen mode Exit fullscreen mode

And we use the middleware in the src/app.ts file

import express from "express";
import errorHandler from "./middleware/errorHandler";
import notFound from "./middleware/notFound";
import router from "./routes";

const app = express()

app.use(express.json());
app.use(express.urlencoded({extended:false}));

app.use('/api', router);

app.use(notFound);
app.use(errorHandler);

export default app

Enter fullscreen mode Exit fullscreen mode

The ENDPOINT NOT FOUND message and 404 error code will now be returned when an endpoint is not found.

Logging

In a development environment it is fine to use console.log to print information to the console but in production it is better to rely on functions that are not synchronous and block the process while writing. For this we are going to use winston, a library made to measure for this and that allows us to save the logs in a file.

we install winston

npm i winston

Enter fullscreen mode Exit fullscreen mode

We create the file src/utils/logger.ts which will create a logger which will add the messages that we send from the error level to the error.log file.

import winston from 'winston';

export const logger = winston.createLogger({
     format: winston.format.simple(),
     transportation:[
         new winston.transports.File({filename:'error.log', level:'error'}),
     ]
})

//
// If we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
//
if (process.env.NODE_ENV !== 'production') {
     logger.add(new winston.transports.Console({
         format: winston.format.simple(),
     }));
}

Enter fullscreen mode Exit fullscreen mode

Edit the src/index.ts file so that it logs the startup message

import app from "./app";
import { logger } from "./utils/logger";

const port = 3000;

app.listen(port, ()=> {
     logger.info(`listening on <http://127.0.0.1>:${port}`)
})

Enter fullscreen mode Exit fullscreen mode

And finally we edit the src/middleware/errorHandler.ts file so that it logs any errors we may have to the error.log file.

import { Prisma } from "@prisma/client";
import { ErrorRequestHandler } from "express";
import { ZodError } from 'zod'
import { logger } from "../utils/logger";

const errorHandler: ErrorRequestHandler = async (err, req, res, next) => {
    if (err instanceof ZodError) {
        const errorMessage = err.errors.map(error => error.message).join(',');
        return res.status(400).json({ error: errorMessage })
    }
    if (err.name === "NotFoundError") return res.status(404).json({ error: "item not found" }) //prisma
    if (err instanceof Prisma.PrismaClientKnownRequestError) {
        if (err.code === 'P2002') return res.status(409).json({ error: 'already exists' });
        if (err.code === 'P2025') return res.status(409).json({ error: "not found" });
    }
    if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'wrong formatted JSON' });

    logger.error(err);
    return res.status(500).json({ error: 'internal server error' });
}

export default errorHandler
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article we have created a REST API using Typescript and NodeJS along with many other libraries. We have created a development database and a test database using docker and we have written some tests for HTTP calls and to test code using Jest. We have validated that the information that comes to us via HTTP request is correct and we have created middleware for error handling. We've learned how to use prisma to build models that work on different database engines and have created controllers that use prisma's models. We have added authentication using JWT so that some routes are private. Finally we have added some improvements such as logging or handling 404 errors.

The final directory tree would be as follows

πŸ“¦rest-api-typescript
 ┣ πŸ“‚dist
 ┣ πŸ“‚node_modules
 ┣ πŸ“‚prisma
 ┃ ┣ πŸ“‚migrations
 ┃ ┃ ┣ πŸ“‚20221205183545_todo
 ┃ ┃ ┃ β”— πŸ“œmigration.sql
 ┃ ┃ ┣ πŸ“‚20221208145352_auth
 ┃ ┃ ┃ β”— πŸ“œmigration.sql
 ┃ ┃ β”— πŸ“œmigration_lock.toml
 ┃ β”— πŸ“œschema.prisma
 ┣ πŸ“‚src
 ┃ ┣ πŸ“‚controllers
 ┃ ┃ ┣ πŸ“œauth.controller.ts
 ┃ ┃ β”— πŸ“œtodo.controller.ts
 ┃ ┣ πŸ“‚db
 ┃ ┃ β”— πŸ“œprisma.ts
 ┃ ┣ πŸ“‚middleware
 ┃ ┃ ┣ πŸ“œauthenticateJWT.ts
 ┃ ┃ ┣ πŸ“œerrorHandler.ts
 ┃ ┃ β”— πŸ“œnotFound.ts
 ┃ ┣ πŸ“‚routes
 ┃ ┃ ┣ πŸ“œauth.route.ts
 ┃ ┃ ┣ πŸ“œindex.ts
 ┃ ┃ β”— πŸ“œtodo.route.ts
 ┃ ┣ πŸ“‚schema
 ┃ ┃ ┣ πŸ“œauth.schema.ts
 ┃ ┃ ┣ πŸ“œindex.ts
 ┃ ┃ β”— πŸ“œtodo.schema.ts
 ┃ ┣ πŸ“‚utils
 ┃ ┃ ┣ πŸ“œbcryptUtils.ts
 ┃ ┃ ┣ πŸ“œjwtUtils.ts
 ┃ ┃ β”— πŸ“œlogger.ts
 ┃ ┣ πŸ“œapp.ts
 ┃ β”— πŸ“œindex.ts
 ┣ πŸ“‚test
 ┃ β”— πŸ“œtodo.controller.test.ts
 ┣ πŸ“œ.env
 ┣ πŸ“œ.env.test
 ┣ πŸ“œ.gitignore
 ┣ πŸ“œ.rest
 ┣ πŸ“œenv.d.ts
 ┣ πŸ“œjest.config.ts
 ┣ πŸ“œpackage-lock.json
 ┣ πŸ“œpackage.json
 β”— πŸ“œtsconfig.json
Enter fullscreen mode Exit fullscreen mode

Top comments (3)

Collapse
 
ruheni profile image
Ruheni Alex

Hi @ismaventuras πŸ‘‹πŸ½

Great article. I noticed a errors in the Primsa installation step. It refers to prism instead of prisma.

Collapse
 
ismaventuras profile image
ismaventuras

Thank you very much for your words and for taking your time to read the article! I've fixed the typo. 😁

Collapse
 
newcss profile image
Newcss • Edited

Hi @ismaventuras , thanks for a good manual, but you have a small bug...
package.json
"start": "tsc --build --force && node dist/src/index.js"
Need to change to:
"start": "tsc --build --force && node dist/index.js"