DEV Community

Cover image for Building a Scalable REST API with TypeScript, Express, Drizzle ORM, and Turso Database: A Step-by-Step Guide
Ibrahim Ayuba
Ibrahim Ayuba

Posted on

Building a Scalable REST API with TypeScript, Express, Drizzle ORM, and Turso Database: A Step-by-Step Guide

Building REST APIs with Express.js is straightforward and a must-have skill for every web developer. In this guide, we'll learn a step-by-step approach to building a REST API. We'll use the following technologies to build an API for a note-taking application:

  • TypeScript: Literally JavaScript with static typing. It compiles down to JavaScript. Suitable for building large-scale JavaScript applications with confidence.
  • Express: A minimalist, unopinionated Node.js framework for building server-side applications.
  • Drizzle: An SQL Object-Relational Mapping (ORM) tool that makes it easy to interact with the database.
  • Turso: A fast and scalable SQLite database technology for building production-ready applications. Easy to set up and has a generous free tier.

Why Learn Express?

Using opinionated Node.js frameworks such as Nest.js and Sails.js is great, especially for large-scale applications. However, they're not ideal for beginners because they abstract away a lot of how the server actually works. In fact, Nest.js is an abstraction on top of Express.js.

Express.js, on the other hand, is unopinionated and allows for more flexibility and a better learning experience. It's lightweight and easy to get started with, especially if you're coming from frontend JavaScript. Moreover, understanding Express will give you the knowledge and confidence to pick up other Node.js backend frameworks.

What the Guide Covers

We will cover the following topics in this article:

  • How to set up a TypeScript Project with Express
  • How Express middlewares work
  • Setting up Turso database
  • Connecting and interacting with Turso database using Drizzle ORM
  • Creating a CRUD (Create Read Update Update Delete) API
  • How to validate and sanitize data using the third-party express-validator middlewares
  • Testing an API with Thunder Client VS Code extension

Prerequisites

To effortlessly follow this tutorial, you should have a basic understanding of the following:

  • JavaScript (We will be using TypeScript, but JavaScript knowledge is enough to follow this guide)
  • Node.js
  • Structure Query Language (SQL)
  • How a server works

Let's begin!

Setting Up A TypeScript Project with Express

The first steps to building a REST API with Express.js and TypeScript are:

  1. Generating and configuring package.json
  2. Installing basic dependencies necessary to initially run our app
  3. Generating and configuring tsconfig.json

1. Generating and configuring package.json file

npm init -y
Enter fullscreen mode Exit fullscreen mode

This command generates a package.json file in the root directory.
Next, update the file to look like this:

{
  "name": "article",
  "version": "1.0.0",
  "main": "dist/index.js",
  "type": "module",
  "scripts": {
    "dev": "node --loader=ts-node/esm --env-file=.env --watch src/index.ts",
    "build": "npx tsc",
    "start": "node --env-file=.env dist/index.js"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

The main option in the above package.json file points to the destination of the JavaScript file after compilation. The dist/index.js file will be generated (usually for production) when you execute the build script command by running npm run build on the terminal. The start command is used to start the server in production.

The dev script command is used to run the application in development mode. It contains the following commands:

  • node: Uses the Node runtime to execute the code.
  • --loader=ts-node/esm: Uses the ts-node package (we'll later install) to compile our code to JavaScript during development.
  • --env-file=.env: Tells Node where the .env file is located.
  • -watch: Reruns our code whenever we make an update, enabling automatic reloading during development.

2. Installing starter dependencies

We'll install some packages needed to kickstart our application. Aside from typescript and express, we will install the following packages:

  • ts-node: A package that compiles our TypeScript code to JavaScript during development, allowing us to run our application without the need for explicit compilation.
  • @types/express: A package that provides type definitions for Express.js, helping TypeScript to recognize and understand the types and interfaces of Express.js, enabling better code completion, error reporting, and overall development experience.
npm i express; npm i -D typescript ts-node @types/express
Enter fullscreen mode Exit fullscreen mode

The -D flag, short for --save-dev, indicates that the packages being installed are development dependencies, meaning they are only required for development purposes and will not be needed in production. By using the -D flag, we are telling npm to include these packages in the devDependencies section of our package.json file, rather than the dependencies section, which is used for production dependencies.

3. Generating and configuring tsconfig.json

The following command generates the tsconfig.json file in the root directory:

npx tsc --init
Enter fullscreen mode Exit fullscreen mode

The generated tsconfig.json file will contain many commented out TypeScript compiler options. Uncomment out the following options and update them accordingly:

{
  "compilerOptions": {
    ....

    "module": "NodeNext",
    "rootDir": "./src",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    ....
  }
}
Enter fullscreen mode Exit fullscreen mode

Recommended starter file structure for the project

Create the necessary folders and files
Your file structure should look like this:

├── .env
├── .gitignore
├── package-lock.json
├── package.json
├── src
│   ├── db
│   ├── handlers  
│   ├── routes
│   ├── lib
│   ├── middleware
│   ├── index.ts
│   └── server.ts
└── tsconfig.js
Enter fullscreen mode Exit fullscreen mode

Express Middleware

We'll use middleware functions throughout the project. So, let's take some time to understand them. If you're already familiar with Express middleware, you can skip this section.

Without middleware

To best illustrate this concept, I made a little sketch using excalidraw:

Server without middleware

Normally, this is how a client will communicate with a server. In this case, the client requests all the notes in the database. The server makes a request to the database and, depending on the database's response, returns the requested resources or an error.
This approach keeps the server open for all kinds of requests. As API developers, we want to ensure that the client is requesting the right way and is not a malicious actor. So, we introduce middleware into the equation:

With middleware

Server with middleware

The middleware sits between the client's request and the server's handler function. Whenever the server receives a request from the client, the request has to pass through the middleware and gets securitized in the process. In the above sketch, the middleware modified the request object. Express middleware can do more, including the following:

  • Execute any code
  • Terminate the request
  • Make changes to the request or response object
  • Detect errors
  • Authentication and authorization

How to define an express middleware

import express, {Request, Response, NextFunction} from "express"
const app = express()

const logRequestMethod = (req: Request, res: Response, next: NextFunction) => {
    console.log(req.method)
    next()
}
const logHostname = (req: Request, res: Response, next: NextFunction) => {
    console.log(req.hostname)
    next()
}
Enter fullscreen mode Exit fullscreen mode

logRequestMethod and logHostname are middlewares responsible for logging the request's HTTP method and hostname respectively. Typically, an Express middleware has three arguments:

  • request
  • response
  • next

If we don't call the next() function, respond back to the client, or terminate the request, the server will hang in the middleware. The next() function transfers control to the next middleware or handler.

How to use an express middleware

Basically, there are two ways of using an Express middleware:

  • On an application level: An application-level middleware will execute whenever a request reaches the server and not terminated by other middleware before it. We can invoke application-level middleware using app.use(middlewareFunction). For example, let's use the above logRequestMethod on the app level:
app.use(logRequestMethod)
Enter fullscreen mode Exit fullscreen mode
  • Route-specific middleware: We can restrict a middleware to a particular route. This type of middleware will only execute when a request is made to that particular route. Note that whether we're using a middleware on the app or route level, we can have one or more of them separated by commas. For instance, let's use logRequestMethod and logHostname only when the client makes a request to the /about path:
app.get("/about", logRequestMethod, logHostname, (req, res) => {})
Enter fullscreen mode Exit fullscreen mode

The app.use() can also take a path as the first argument and used for a route-specific middleware: app.use("/about", logRequestMethod).

Error middleware

Whenever a client request results in an unhandled error, Express.js has a default error middleware that sends the error message back to the client in HTML format. There are two common ways an error can go unhandled:

  • Not wrapping a code block that may result in an error in a try-catch
  • The client sending a request to a route that doesn't exist

We would create the following middlewares and use them on the application level to handle errors:

  • A custom error middleware that handles all errors and sends back an error response in JSON format to the client.
  • A not-found middleware that gets triggered whenever the client requests resources from a route that doesn't exist. This not-found middleware will forward an error message to the custom error middleware, which will send back an appropriate JSON response to the client.

Setting up the Error Middleware

As mentioned earlier, the next() function triggers a jump from the current middleware in execution to the next middleware or handler. However, if we pass an argument to the next() function, it jumps to the next error middleware on the stack. If we haven't defined a custom error middleware, it transfers control to Express's default error middleware.

Typically, you want to pass an Error object to the next() function. The issue with the inbuilt error class is that it only accepts one argument, which is the error message string. However, we want our error object to contain an error message and status code that we would use to send back the appropriate error message back to the client.

Let's create a child class of the error class and make it receive an extra statusCode argument. So, within the lib folder, create a custom-error.ts file and insert the following lines of code:

src/lib/custom-error.ts

export class CustomError extends Error {
  message: string;
  statusCode: number;
  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}
Enter fullscreen mode Exit fullscreen mode

Our CustomError class can form error objects with two arguments — the message and status code. We'll use CustomError throughout our code to forward errors that happen in our server to the error middleware we'll create in a moment.

Next, we'll create an error middleware that will receive the error object and form a proper JSON response for the client. Unlike the traditional middleware functions, error middleware takes four arguments — error, request, response, next.

Within the middleware folder, create error.ts file. Within, paste the following lines of code:

src/middleware/error.ts

import { Request, Response, NextFunction } from "express";
import { CustomError } from "../lib/custom-error.ts";

export function error(
  err: CustomError,
  req: Request,
  res: Response,
  next: NextFunction
) {
  try {
    const msg = JSON.parse(err.message);
    res.status(err.status).json({ msg });
  } catch (error) {
    res.status(err.status).json({ msg: err.message });
  }
}
Enter fullscreen mode Exit fullscreen mode

The err argument is the same error that is passed into the next() function from some middleware or handler. The error middleware can handle both JSON strings and text strings.

By handling both JSON and text strings, our error middleware can flexibly accommodate different types of error messages, making it more robust and adaptable to various scenarios.

Setting up not-found middleware

We need to create a middleware that handles requests made to routes that do not exist. This is straightforward. Express executes all middleware in the stack except the request is terminated by some middleware. So, we'll simply create a middleware that forwards an error message to our error middleware and place it at the tail end of the middleware stack. At that position, it only gets hit when our Express app has scanned through all the routes we have defined and doesn't find any that matches the one the client is requesting.

Inside the middleware folder, create a not-found.ts file and paste the following lines of code:

src/middleware/not-found.ts

import { Response, Request, NextFunction } from "express";
import { CustomError } from "../lib/custom-error.ts";

export function notFound(req: Request, res: Response, next: NextFunction) {
  return next(new CustomError("Route not found", 404));
}
Enter fullscreen mode Exit fullscreen mode

This not-found middleware creates a CustomError object with a 404 status code and passes it to the next error middleware using the next() function.

Setting up server.ts file

Let's set up the application server.ts file and implement the two middlewares we just created. Within the src folder, create the server.ts file:

src/server.ts

import express, { urlencoded, json } from "express";
import { notFound } from "./middleware/not-found.ts";
import { error } from "./middleware/error.ts";

const app = express();
app.use(urlencoded({ extended: true }));
app.use(json());

// ... other middlewares and routes ...

app.use(notFound);
app.use(error);

export default app;
Enter fullscreen mode Exit fullscreen mode

The order of middleware matters in Express, and it's important to place the error middleware last, as it will catch any errors that occur in the previous middlewares or handlers. The not-found middleware should be placed just above the error middleware, as it will handle any requests that don't match any of the previous routes.

The urlencoded({ extended: true }) and json() middlewares are built-in Express middleware functions that parse requests with urlencoded and JSON payloads, respectively. They should be placed early in the middleware stack, as they need to parse the request body before any other middlewares or routes can handle the request.

Setting up the Turso Database and Drizzle ORM

On this section, we’ll learn how to set up and use Turso database and Drizzle ORM in the API.

How to set up a Turso database

To use Turso database, you need the connection URL and authentication token. Follow these steps to obtain the credentials:

  1. Go to the Turso website
  2. Click on the Sign Up button
  3. Sign up with either Gmail or Github
  4. Choose a username
  5. Skip the "About you" form
  6. Click on Create Database
  7. Name your database (e.g., express-api-project)
  8. Click on Create Database
  9. Scroll down and click on Continue to Dashboard
  10. In the dashboard's side menu, click on Databases
  11. Click on the name of the database you created (e.g., express-api-project) at the base of the page
  12. Scroll down, copy the URL, and click on the Generate Token button
  13. Leave everything as it is and click on Generate Token
  14. Copy the token
  15. Paste the URL and token in your .env file as follows:
TURSO_AUTH_TOKEN=paste-token-here
TURSO_CONNECTION_URL=paste-url-here
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Installing necessary packages for our database

npm i @libsql/client drizzle-orm; npm i -D drizzle-kit
Enter fullscreen mode Exit fullscreen mode

Create the Drizzle configuration file

After installing the necessary packages, create a drizzle.config.ts file in the root directory and paste the following configuration options:

drizzle.config.ts

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./src/db/migrations",
  driver: "turso",
  dbCredentials: {
    url: process.env.TURSO_CONNECTION_URL,
    authToken: process.env.TURSO_AUTH_TOKEN,
  },
  dialect: "sqlite",
  verbose: true,
  strict: true,
});
Enter fullscreen mode Exit fullscreen mode

This file contains the configurations for drizzle ORM, including location of our schema and migrations. The verbose: true property prints out every action executed when making changes to the database. strict:true property ensures caution, forcing confirmation of any changes you want to make to the database.

Connect Turso database with Drizzle

Next, create the db.ts file in the db folder and paste the following lines of code:

src/db/db.ts

import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
const client = createClient({
  url: process.env.TURSO_CONNECTION_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,
});
export const db = drizzle(client);
Enter fullscreen mode Exit fullscreen mode

The drizzle(client) function establishes a connection between the database client and Drizzle ORM, enabling the use of the ORM’s capabilities to interact with the database.

Create Notes Schema and Migrations

Now that we have set up our database, let's create the notes schema, generate migrations, and apply the migrations to the Turso database.

Creating the notes schema

Let's define our schema file for the notes app project. In the db folder, create a new file named schema.ts and add the following code:

src/db/schema.ts

import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const NotesTable = sqliteTable("note", {
  id: integer("id").primaryKey(),
  title: text("title").notNull(),
  body: text("body"),
});
Enter fullscreen mode Exit fullscreen mode

This is similar to the CREATE TABLE statement in MySQL. It creates a table with the following columns:

  • id: A primary key that auto-increments, uniquely identifying each note
  • title: A text column that must not be null, representing the title of the note
  • body: A text column that can be null, representing the body or content of the note

Generating migrations

Next, we'll generate migrations using the following command in your terminal:

npx drizzle-kit generate
Enter fullscreen mode Exit fullscreen mode

This command will create our database migrations in the "/src/db/migrations" directory, as specified in the drizzle.config.ts file.

Apply migrations

Next, apply the generated migrations to the Turso database by running the following command in your terminal:

npx drizzle-kit migrate
Enter fullscreen mode Exit fullscreen mode

Creating Notes Router in a Modular Way

When building an API using Express, we can place all our routes directly on the Express app:

import express from "express";

const app = express();

//get request
app.get("/", handlerFunction1)

//post request
app.post("/add-something", handlerFunction2)
Enter fullscreen mode Exit fullscreen mode

Using this approaching for small and demo applications is fine. But in large-scale projects, it can lead to a cluttered and hard-to-maintain codebase. Let’s see a better way.

Express Router function explained

Express.js provides the Router() function, which creates a router object. This object acts as a middleware, but with the added capability to attach HTTP methods (such as GET, POST, PUT, and DELETE) to it. This allows for modular and organized routing.

Here's an example of creating a simple router:

import { Router } from "express";

//defining the router
const routerObject = Router()

routerObject.get("/get-something", (req, res) => {})
routerObject.post("/post-something", (req, res) => {})
routerObject.delete("/delete-something", (req, res) => {})

//using the router in our app
app.use("/our-api", routerObject)
Enter fullscreen mode Exit fullscreen mode

When a request URL starts with /our-api, the routerObject will be invoked, and the appropriate handler will be executed based on the HTTP method and the remaining part of the URL. For instance, if the request URL is /our-api/get-something, the GET handler attached to the /get-something route will be executed.

Creating the notesRouter object

The notes API will have the following endpoints:

API Endpoint Method Description
/add-note POST Add new note
/get-note/:id GET Get note with id
/get-all-notes GET Get all notes
/update-note/:id PUT Update note with id
/delete-note/:id DELETE Delete note with id

Within the routes folder, create notes.ts file and insert the following lines of code:

src/routes/notes.ts

import { Router } from "express";
import { addNote, deleteNote, getAllNotes, getNote, updateNote} from "../handlers/notes.ts";

const notesRouter = Router();

notesRouter.get("/get-note/:id", getNote);
notesRouter.get("/get-all-notes", getAllNotes);
notesRouter.post("/add-note", addNote);
notesRouter.put("/update-note/:id", updateNote);
notesRouter.delete("/delete-note/:id", deleteNote);

export default notesRouter;
Enter fullscreen mode Exit fullscreen mode

Now that our notesRouter object is ready, let’s hook it to our app in our server.ts file:

src/server.ts

import express, { urlencoded, json } from "express";
import { notFound } from "./middleware/not-found.ts";
import { error } from "./middleware/error.ts";
import notesRouter from "./routes/notes.ts";

const app = express();
app.use(urlencoded({ extended: true }));
app.use(json());

app.use("/api", notesRouter);

app.use(notFound);
app.use(error);

export default app;
Enter fullscreen mode Exit fullscreen mode

The URL to request any notes resource must start with /api. As we've seen, Express Router makes our application easier to manage and scale.

Next, we'll define our handlers, including addNote and getNote. Although we've imported them, we haven't created them yet.

Creating the Notes Handlers

Handlers are technically middlewares. Just like middleware functions, they have three arguments — request, response, and next. Typically, they respond to the client or send an error message to the error middleware using the next() function.

More importantly, we define the logic to interact with the database in the handlers. This is where Drizzle shines. Within the handlers folder, create a new file named notes.ts and add the following code:

src/handlers/notes.ts

import { eq } from "drizzle-orm";
import { db } from "../db/db.ts";
import { NotesTable } from "../db/schema.ts";
import { Response, Request, NextFunction } from "express";
import { CustomError } from "../lib/custom-error.ts";

export async function addNote(req: Request, res: Response, next: NextFunction) {
  try {
    const note = await db.insert(NotesTable).values(req.body).returning();
    res.status(201).json({ note });
  } catch (error) {
    next(new CustomError("Failed to add note", 500));
  }
}

export async function getAllNotes(req: Request,res: Response, next: NextFunction) {
  try {
    const notes = await db.select().from(NotesTable);
    res.status(200).json({ notes });
  } catch (error) {
    next(new CustomError("Failed to fetch notes", 500));
  }
}

export async function getNote(req: Request, res: Response, next: NextFunction) {
  try {
    const note = await db
      .select()
      .from(NotesTable)
      .where(eq(NotesTable.id, +req.params.id));
    res.status(200).json({ note });
  } catch (error) {
    next(new CustomError("Failed to fetch note", 500));
  }
}

export async function deleteNote(req: Request, res: Response, next: NextFunction) {
  try {
    const note = await db
      .delete(NotesTable)
      .where(eq(NotesTable.id, +req.params.id))
      .returning({
        deletedNoteId: NotesTable.id,
      });
    res.status(200).json({ note });
  } catch (error) {
    next(new CustomError("Failed to delete note", 500));
  }
}

export async function updateNote(req: Request, res: Response, next: NextFunction) {
  try {
    const note = await db
      .update(NotesTable)
      .set(req.body)
      .where(eq(NotesTable.id, +req.params.id))
      .returning();

    res.status(201).json({ note });
  } catch (error) {
    next(new CustomError("Failed to update note", 500));
  }
}
Enter fullscreen mode Exit fullscreen mode

Similar to SQL, Drizzle provides methods like insert, select, update, delete, set, where, and more to interact with the database. The eq function is used to compare two entities. The returning function at the end of the insert, update, and delete functions specifies what Drizzle should return after successful execution. If left empty, it will return all fields. Drizzle offers a wide range of functions to help you achieve your API goals, and they are well-documented in the official documentation.

Validating and Sanitizing Data from Client’s Request

Our app is still missing a crucial component. Consider a scenario where we expect a numerical value in the request body but receive a string instead, or anticipate an email address but receive plain text. Even worse, a malicious actor might attempt to inject code into our database to steal or compromise stored information. We must prevent these scenarios from occurring, and one way to do so is to thoroughly examine client data before it reaches the handler.

We could write custom middlewares to validate and sanitize client data, but a more efficient approach is to utilize the battle-tested express-validator npm package. This package provides a set of middlewares that enable us to inspect client data and ensure it meets our requirements, making our app more secure and robust.

Installing express-validator

npm i express-validator
Enter fullscreen mode Exit fullscreen mode

Defining express-validator middlewares

Within the src/lib folder, create the validator-functions.ts file and include the following lines of code:

src/lib/validator-functions.ts

import { body, param } from "express-validator";

//Validating and sanitizing title from request body
export function validateNoteTitle() {
  return body("title").notEmpty().isString().trim().escape();
}

//Validating and sanitizing body from request body
export function validateNoteBody() {
  return body("body").notEmpty().isString().trim().escape();
}

//Validating id from route parameter
export function validateIdParam() {
  return param("id").toInt().isInt();
}
Enter fullscreen mode Exit fullscreen mode

To validate individual values from the client request, we utilize method chaining, where the output of the previous method becomes the input for the current method. For the title and body fields, we employ the following methods:

  • notEmpty(): Validates that the value is not empty
  • isString(): Validates that the value is a string
  • trim(): A sanitizer that removes whitespace from both ends of the value, if present
  • escape(): A sanitizer that replaces special characters, such as < and >, with HTML entities, protecting the server from Cross-Site Scripting (XSS) attacks

For a comprehensive list of validators and sanitizers, along with their uses, refer to the validator.js GitHub repository.

Implementing express-validator middleware functions

Now that we have written the validator functions, we can now implement them in our routes. Let’s update our src/routes/notes.ts file:

src/routes/notes.ts

import { Router } from "express";
import { addNote, deleteNote, getAllNotes, getNote, updateNote} from "../handlers/notes.ts";
import { validateIdParam, validateNoteBody, validateNoteTitle} from "../lib/validator-functions.ts";

const notesRouter = Router();

notesRouter.get("/get-note/:id", validateIdParam(), getNote);
notesRouter.get("/get-all-notes", getAllNotes);
notesRouter.post("/add-note", validateNoteBody(), validateNoteTitle(), addNote);
notesRouter.put("/update-note/:id", validateIdParam(), validateNoteBody(), validateNoteTitle(), updateNote);
notesRouter.delete("/delete-note/:id", validateIdParam(), deleteNote);

export default notesRouter;
Enter fullscreen mode Exit fullscreen mode

When an express-validator middleware encounters a validation error, it doesn't terminate the request immediately. Instead, it collects the errors and passes control to the next middleware or handler in the chain. This allows for more flexibility in handling validation errors.

The validationResult() function is a key part of this process. It takes the request object as an argument and returns an object containing the validation errors, if any. By checking the result of validationResult(), we can determine if there were any validation errors and handle them accordingly in the handlers.

Capturing validation errors

Let’s update our src/handlers/notes.ts to account for validation errors:

src/handlers/notes.ts

import { eq } from "drizzle-orm";
import { db } from "../db/db.ts";
import { NotesTable } from "../db/schema.ts";
import { Response, Request, NextFunction } from "express";
import { CustomError } from "../lib/custom-error.ts";
import { validationResult } from "express-validator";

export async function addNote(req: Request, res: Response, next: NextFunction) {
  const result = validationResult(req);
  console.log(result);
  if (!result.isEmpty()) {
    return next(new CustomError(JSON.stringify(result.array()), 400));
  }
  try {
    const note = await db.insert(NotesTable).values(req.body).returning();
    res.status(201).json({ note });
  } catch (error) {
    next(new CustomError("Failed to add note", 500));
  }
}

export async function getAllNotes(req: Request, res: Response, next: NextFunction) {
  try {
    const notes = await db.select().from(NotesTable);
    res.status(200).json({ notes });
  } catch (error) {
    next(new CustomError("Failed to fetch notes", 500));
  }
}

export async function getNote(req: Request, res: Response, next: NextFunction) {
  const result = validationResult(req);
  if (!result.isEmpty()) {
    return next(new CustomError(JSON.stringify(result.array()), 400));
  }
  try {
    const note = await db
      .select()
      .from(NotesTable)
      .where(eq(NotesTable.id, +req.params.id));
    res.status(200).json({ note });
  } catch (error) {
    next(new CustomError("Failed to fetch note", 500));
  }
}

export async function deleteNote(req: Request, res: Response, next: NextFunction) {
  const result = validationResult(req);
  if (!result.isEmpty()) {
    return next(new CustomError(JSON.stringify(result.array()), 400));
  }
  try {
    const note = await db
      .delete(NotesTable)
      .where(eq(NotesTable.id, +req.params.id))
      .returning({
        deletedNoteId: NotesTable.id,
      });
    res.status(200).json({ note });
  } catch (error) {
    next(new CustomError("Failed to delete note", 500));
  }
}

export async function updateNote(req: Request, res: Response,next: NextFunction) {
  const result = validationResult(req);
  if (!result.isEmpty()) {
    return next(new CustomError(JSON.stringify(result.array()), 400));
  }
  try {
    const note = await db
      .update(NotesTable)
      .set(req.body)
      .where(eq(NotesTable.id, +req.params.id))
      .returning();

    res.status(201).json({ note });
  } catch (error) {
    next(new CustomError("Failed to update note", 500));
  }
}
Enter fullscreen mode Exit fullscreen mode

In this updated code, we're using validationResult(req) to check for validation errors. If there are any errors, we're returning a 400 response with the error details. If there are no errors, we can proceed with the logic.

result.array() returns an array of objects, and the CustomError class expects a string message. By using JSON.stringify(), we can convert the error message to a string, which can then be passed to the CustomError class. Remember that we designed our error middleware to accept both JSON and text strings? This is where it comes in handy in this API.

Testing the app

To test the app, insert the following lines of code in the index.ts file:

src/index.ts

import app from "./server.ts";
const port = process.env.PORT || 8000;

app.listen(port, () => {
  console.log(`Server is listening at port ${port}`);
});
Enter fullscreen mode Exit fullscreen mode

Run the app with the following command on the terminal:

npm run dev
Enter fullscreen mode Exit fullscreen mode

I am using Thunder Client VS Code extension (which you can easily install) to test the API endpoints. However, you can any API client of your choice, such as Insomnia and Postman.

Create note with the /api/add-note POST endpoint

Post note request
Great, let's verify that our notes are being stored in the database! Here are the steps to follow:

  1. Open your Turbo dashboard
  2. Click on Databases
  3. Select the database named express-api-project (or the name you chose for your database)
  4. Scroll down and click on the Edit Tables button next to the Generate Token button
  5. In the Tables tab, click on the notes table
  6. You should see a list of notes that you've created using the API

If you see the following screen, that means your server is working correctly and storing data in the database!

Database with notes

Fetching all notes with the /api/get-all-notes GET endpoint

Request to get all notes
These endpoints worked! You can try the other endpoints yourself.

Conclusion

Great job if you stuck around throughout the guide! You now have a solid foundation on how to build an API using TypeScript, Express, Drizzle, and Turso database. For reference, check out the project's Github Repository. I hope you found this article helpful and enjoyed building this project as much as I did.

Top comments (0)