Creating a REST API in Node.js

Getting started with making web APIs can be confusing, even overwhelming at first. I'd like to share my process for creating APIs in Node.js.

The server

First let's create a package.json and add some dependencies:

terminal
mkdir rest_api && cd rest_api
npm init -y
npm i express
npm i -D typescript tsx @types/express

This is a simple 'hello world' with express:

Express is imported, then an instance is created and saved as app.

app.get() tells the Express instance to listen for GET requests to the specified route; here it's just /. When this route is requested, the given callback is fired, in this case it sends the string "Hello world!" as a response.

port is declared as a variable which can be set from the environment, or otherwise defaults to 3000 if none is specified. Express then listens on that port, and after the server has finally started it uses console.log to print a message in the terminal.

import express from 'express'
const app = express()
app.get('/', (req, res) => {
res.send('Hello world!')
})
const port = process.env.PORT || 3000
app.listen(port, () => {
console.log(`Express started on http://localhost:${port}\npress Ctrl+C to terminate.`)
})

Assuming everything went okay, you should then be able to run npx tsx src/index.ts and point your browser at http://localhost:3000/ and see the message "Hello world!"


Another way to test this instead of opening your browser is with either cURL or postman. cURL is great if you're the command-line junkie type (like me), postman is really pretty and slick but a bit too much hassle for me.

Once you have curl installed it should be as simple to get your Hello World in the shell with:

terminal
curl localhost:3000

Request parameters

A server that only responds with the same thing every time isn't very fun though. Let's make a new route that allows us to say hello when passed a name:

src/index.ts
const app = express()
app.get('/', (req, res) => {
res.send('Hello world!')
})
app.get('/:name', (req, res) => {
res.send(`Hello ${req.params.name}!`)
})

You'll want to add this before the app.listen() command so that it gets registered properly.

Then, with curl:

terminal
curl localhost:3000/foo

You should see "Hello foo!"

Automatic restart

To get the above change working, you first have to stop the server and then restart it again, which can get fairly annoying after a few changes. tsx has a built-in watch mode that will restart automatically when files change:

package.json
"scripts": {
"dev": "tsx watch src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},

Now you can run npm run dev and it will watch for changes and restart the server.

The database

Before making a database, you should first identify your data and what you'll be doing with it. I spent about 30 seconds debating what kind of data to use in this tutorial, and decided to do "something" with movies.

Among other things, a movie would have a title and a date it was released, and that seems like enough to work with for now.

We'll use PostgreSQL, the world's most advanced open source RDBMS. One of the easiest ways to run Postgres for development is with Docker:

services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: rest_api
ports:
- 5432:5432
terminal
docker compose up -d

Now let's create our movies table:

migrations/001-create-movies.sql
create table movies (
id serial primary key,
title text not null,
released integer not null
);
terminal
psql $DATABASE_URL -f migrations/001-create-movies.sql

Setting up Kysely

Rather than writing raw SQL for our queries, we're going to use a query builder called Kysely. Kysely lets you write queries in TypeScript that map closely to SQL, while giving you full autocompletion and compile-time type checking.

terminal
npm i kysely pg
npm i -D kysely-codegen @types/pg

We'll use kysely-codegen to introspect the database and automatically generate TypeScript types that match our schema:

terminal
npx kysely-codegen --dialect postgres --url $DATABASE_URL

Now we can create our database connection:

src/db.ts
import { Kysely, PostgresDialect } from 'kysely'
import type { DB } from 'kysely-codegen'
import pg from 'pg'
export const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({
connectionString: process.env.DATABASE_URL,
}),
}),
})

This creates and exports a single Kysely instance that we can re-use in other modules. The DB type generated by kysely-codegen gives us full type safety for all our queries.

Data methods

Now that we have a database and a table, we need some methods to populate the table with data.

This creates a function called create that inserts a new movie and returns the full row. Kysely's .returningAll() gives us back the complete record including the generated id.

Now that we can create movies, we should make a way to list them.

import { db } from './db'
export async function create(title: string, released: number) {
return db
.insertInto('movies')
.values({ title, released })
.returningAll()
.executeTakeFirstOrThrow()
}

Wiring it up

Now let's wire up our database to our server!

Going back to our src/index.ts, let's import our new movies module and make a route to use the list method:

src/index.ts
import express from 'express'
import * as movies from './movies'
const app = express()
app.get('/api/v1/movies', async (req, res) => {
res.json(await movies.list())
})
const port = process.env.PORT || 3000
app.listen(port, () => {
console.log(`Express started on http://localhost:${port}\npress Ctrl+C to terminate.`)
})

Requesting the data is the easy part, with curl we can simply:

terminal
curl localhost:3000/api/v1/movies

which right now will return an empty JSON array.

Express middleware

To be able to properly create movies, we need to introduce a piece of express middleware.

Middleware are functions that are run before a route handler. They usually alter the request or response objects in some way. There are tons of middleware modules for express, if you'd like to read more you can check out the official docs.

src/index.ts
app.use(express.json())

This parses incoming requests with JSON bodies and makes the parsed data available as req.body. Now we can add a route that responds to POST requests:

src/index.ts
import express from 'express'
import * as movies from './movies'
const app = express()
app.use(express.json())
app.get('/api/v1/movies', async (req, res) => {
res.json(await movies.list())
})
app.post('/api/v1/movies', async (req, res) => {
const { title, released } = req.body
res.json(await movies.create(title, released))
})
const port = process.env.PORT || 3000
app.listen(port, () => {
console.log(`Express started on http://localhost:${port}\npress Ctrl+C to terminate.`)
})

Using curl we can now insert data with:

terminal
curl -X POST -H 'Content-Type: application/json' \
-d '{"title": "nothing good", "released": 2017}' \
localhost:3000/api/v1/movies

Which will return the created movie, including its generated id.

Full CRUD support

Any good API endpoint usually provides four methods:

  • Create
  • Read
  • Update
  • Delete

We only have the first two, so let's finish it up and add the others.

Back in our src/movies.ts let's add two more functions:

src/movies.ts
import { db } from './db'
export async function create(title: string, released: number) {
return db
.insertInto('movies')
.values({ title, released })
.returningAll()
.executeTakeFirstOrThrow()
}
export async function list() {
return db
.selectFrom('movies')
.selectAll()
.execute()
}
export async function update(id: number, title: string, released: number) {
return db
.updateTable('movies')
.set({ title, released })
.where('id', '=', id)
.returningAll()
.executeTakeFirstOrThrow()
}
export async function del(id: number) {
return db
.deleteFrom('movies')
.where('id', '=', id)
.execute()
}

Back in src/index.ts we need to make two more routes:

src/index.ts
import express from 'express'
import * as movies from './movies'
const app = express()
app.use(express.json())
app.get('/api/v1/movies', async (req, res) => {
res.json(await movies.list())
})
app.post('/api/v1/movies', async (req, res) => {
const { title, released } = req.body
res.json(await movies.create(title, released))
})
app.put('/api/v1/movies/:id', async (req, res) => {
const { title, released } = req.body
res.json(await movies.update(Number(req.params.id), title, released))
})
app.delete('/api/v1/movies/:id', async (req, res) => {
await movies.del(Number(req.params.id))
res.json(await movies.list())
})
const port = process.env.PORT || 3000
app.listen(port, () => {
console.log(`Express started on http://localhost:${port}\npress Ctrl+C to terminate.`)
})

Now we can update movies:

terminal
curl -X PUT -H 'Content-Type: application/json' \
-d '{"title": "new title", "released": 2017}' \
localhost:3000/api/v1/movies/1

And delete them by id:

terminal
curl -X DELETE localhost:3000/api/v1/movies/1

Conclusion

We learned how to set up a PostgreSQL database with Docker, use Kysely for type-safe queries, and build a full CRUD REST API with Express and TypeScript.

Comments

  • Loading...