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
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
{
"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"]
}
Once typescript is installed and configured we install express
, the framework to handle HTTP calls.
npm i express
npm i -D @types/express
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
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}`)
})
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"
}
...
}
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
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;
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
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);
...
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>
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"
}
]
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",
}
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
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"
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
}
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
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
}
}
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
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',
};
};
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
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"
},
...
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>
})
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
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
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
}
}
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
})
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;
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
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
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())
}
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,
});
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
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)
}
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
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"});
}
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;
}
}
}
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
}
}
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
...
import authRouter from './auth.route';
...
router.use('/auth',authRouter);
...
export default router
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"
}
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"});
}
}
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
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
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
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
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(),
}));
}
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}`)
})
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
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
Top comments (3)
Hi @ismaventuras ππ½
Great article. I noticed a errors in the Primsa installation step. It refers to
prism
instead ofprisma
.Thank you very much for your words and for taking your time to read the article! I've fixed the typo. π
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"