Modern Backend with TypeScript, PostgreSQL, and Prisma: REST API, Validation, and Testing

This article is part of a series of live streams and articles on building a backend with TypeScript, PostgreSQL, and Prisma. In this article, we'll look at how to build a REST API, validate input, and testing the API.


Introduction

The goal of the series is to explore and demonstrate different patterns, problems, and architectures for a modern backend by solving a concrete problem: a grading system for online courses. This is a good example because it features diverse relations types and is complex enough to represent a real-world use case.

The recording of the live stream is available above and covers the same ground as this article.

What the series will cover

The series will focus on the role of the database in every aspect of backend development covering:

TopicPart
Data modelingPart 1
CRUDPart 1
AggregationsPart 1
REST API layerPart 2 (current)
ValidationPart 2 (current)
TestingPart 2 (current)
AuthenticationComing up
AuthorizationComing up
Integration with external APIsComing up
DeploymentComing up

What you will learn today

In the first article, you designed a data model for the problem domain and wrote a seed script which uses Prisma Client to save data to the database.

In this second article of the series, you will build a REST API on top of the data model and Prisma schema from the first article. You will use Hapi to build the REST API. With the REST API, you'll be able to perform database operations via HTTP requests.

As part of the REST API, you will develop the following aspects:

  1. REST API: Implement an HTTP server with resource endpoints to handle CRUD for the different models. You will integrate Prisma with Hapi so as to allow accessing Prisma Client for the API endpoint handlers.
  2. Validation: Add payload validation rules to ensure that user input matches the expected types of the Prisma schema.
  3. Testing: Write tests for the REST endpoints with Jest and Hapi's server.inject that simulate HTTP requests verifying the validation and persistence logic of the REST endpoints.

By the end of this article you will have a REST API with endpoints for CRUD (Create, Read, Update, and Delete) operations and tests. The REST resources will map HTTP requests to the models in the Prisma schema, e.g. a GET /users endpoint will handle operations associated with the User model.

The next parts of this series will cover the other aspects from the list in detail.

Note: Throughout the guide you'll find various checkpoints that enable you to validate whether you performed the steps correctly.

Prerequisites

Assumed knowledge

This series assumes basic knowledge of TypeScript, Node.js, and relational databases. If you're experienced with JavaScript but haven't had the chance to try TypeScript, you should still be able to follow along. The series will use PostgreSQL, however, most of the concepts apply to other relational databases such as MySQL. Additionally, familiarity with REST concepts is useful. Beyond that, no prior knowledge of Prisma is required as that will be covered in the series.

Development environment

You should have the following installed:

  • Node.js
  • Docker (will be used to run a development PostgreSQL database)

If you're using Visual Studio Code, the Prisma extension is recommended for syntax highlighting, formatting, and other helpers.

Note: If you don't want to use Docker, you can set up a local PostgreSQL database or a hosted PostgreSQL database on Heroku.

Clone the repository

The source code for the series can be found on GitHub.

To get started, clone the repository and install the dependencies:

git clone -b part-2 git@github.com:2color/real-world-grading-app.git
cd real-world-grading-app
npm install

Note: By checking out the part-2 branch you'll be able to follow the article from the same starting point.

Start PostgreSQL

To start PostgreSQL, run the following command from the real-world-grading-app folder:

docker-compose up -d

Note: Docker will use the docker-compose.yml file to start the PostgreSQL container.

Building a REST API

Before diving into the implementation, we'll go through some basic concepts relevant in the context of REST APIs:

  • API: Application programming interface. A set of rules that allow programs to talk to each other. Typically the developer creates the API on the server and allows clients to talk to it.
  • REST: A set of conventions that developers follow to expose state-related (in this case state stored in the database) operations over HTTP requests. As an example, check out the GitHub REST API.
  • Endpoint: Entry point to the REST API which has the following properties (non-exhaustive):
    • Path, e.g. /users/, which is used to access the users endpoint. The path determines the URL used to access the endpoint, e.g. www.myapi.com/users/.
    • HTTP method, e.g. GET, POST, and DELETE. The HTTP method will determine the type of operation an endpoint exposes, for example the GET /users endpoint will allow fetching users and POST /users endpoint will allow creating users.
    • Handler: The code (in this case TypeScript) which will handle requests for an endpoint.
  • HTTP status codes: The response HTTP status code will inform the API consumer whether the operation was successful and if any errors occurred. Check out this list for the different HTTP status codes, e.g. 201 when a resource was created successfully, and 400 when consumer input fails validation.

Note: One of the key objectives of the REST approach is using HTTP as an application protocol to avoid reinventing the wheel by sticking to conventions.

The API endpoints

The API will have the following endpoints (HTTP method followed by path):

ResourceHTTP MethodRouteDescription
UserPOST/usersCreate a user (and optionally associate with courses)
UserGET/users/{userId}Get a user
UserPUT/users/{userId}Update a user
UserDELETE/users/{userId}Delete a user
UserGET/usersGet users
CourseEnrollmentGET/users/{userId}/coursesGet a user's enrollement incourses
CourseEnrollmentPOST/users/{userId}/coursesEnroll a user to a course (as student or teacher)
CourseEnrollmentDELETE/users/{userId}/courses/{courseId}Delete a user's enrollment to a course
CoursePOST/coursesCreate a course
CourseGET/coursesGet courses
CourseGET/courses/{courseId}Get a course
CoursePUT/courses/{courseId}Update a course
CourseDELETE/courses/{courseId}Delete a course
TestPOST/courses/{courseId}/testsCreate a test for a course
TestGET/courses/tests/{testId}Get a test
TestPUT/courses/tests/{testId}Update a test
TestDELETE/courses/tests/{testId}Delete a test
Test ResultGET/users/{userId}/test-resultsGet a user's test results
Test ResultPOST/courses/tests/{testId}/test-resultsCreate test result for a test associated with a user
Test ResultGET/courses/tests/{testId}/test-resultsGet multiple test results for a test
Test ResultPUT/courses/tests/test-results/{testResultId}Update a test result (associated with a user and a test)
Test ResultDELETE/courses/tests/test-results/{testResultId}Delete a test result

Note: The paths containing a parameter enclosed in {}, e.g. {userId} represent a variable that is interpolated in the URL, e.g. in www.myapi.com/users/13 the userId is 13.

The endpoints above have been grouped based on the main model/resource they're associated with. The categorization will help with organizing the code into separate modules for maintainability.

In this article, you will implement a subset of the endpoints above (the first four) to illustrate the different patterns for different CRUD operations. The full API will be available in the GitHub repository. These endpoints should provide an interface for most operations. While some resources do not have a DELETE endpoint for deleting resources, they can be added later.

Note: Throughout the article, the words endpoint and route will be used interchangeably. While they refer to the same thing, endpoint is the term used in the context of REST, while route is the term used in the context of HTTP servers.

Hapi

The API will be built with Hapi – a Node.js framework for building HTTP servers that support validation and testing out of the box.

Hapi consists of a core module named @hapi/hapi which is the HTTP server and modules that extend the core functionality. In this backend you will also use the following:

  • @hapi/joi for declarative input validation
  • @hapi/boom for HTTP-friendly error objects

For Hapi to work with TypeScript, you will need to add the types for Hapi and Joi. This is necessary because Hapi is written in JavaScript. By adding the types, you will have rich auto-completion and allow the TypeScript compiler to ensure the type safety of your code.

Install the following packages:

npm install --save @hapi/boom @hapi/hapi @hapi/joi
npm install --save-dev @types/hapi__hapi @types/hapi__joi

Creating the server

The first thing you need to do is create a Hapi server which will bind to an interface and port.

Add the following Hapi server to src/server.ts:

import Hapi from '@hapi/hapi'

const server: Hapi.Server = Hapi.server({
  port: process.env.PORT || 3000,
  host: process.env.HOST || 'localhost',
})

export async function start(): Promise<Hapi.Server> {
  await server.start()
  return server
}

process.on('unhandledRejection', err => {
  console.log(err)
  process.exit(1)
})

start()
  .then(server => {
    console.log(`Server running on ${server.info.uri}`)
  })
  .catch(err => {
    console.log(err)
  })

First, you import Hapi. Then you initialize a new Hapi.server() (of type Hapi.Server defined in @types/hapi__hapi package) with connection details containing a port number to listen on and the host information. After that you start the server and log that it's running.

To run the server locally during development, run the npm devscript which will use ts-node-dev to automatically transpile the TypeScript code and restart the server when you make changes: npm run dev:

npm run dev

> ts-node-dev --respawn ./src/server.ts

Using ts-node version 8.10.2, typescript version 3.9.6
Server running on http://localhost:3000

Checkpoint: If you open http://localhost:3000 in your browser, you should see the following: {"statusCode":404,"error":"Not Found","message":"Not Found"}

Congratulations, you have successfully created a server. However, the server has no routes defined. In the next step, you will define the first route.

Defining a route

To add a route, you will use the route() method on the Hapi server you instantiated in the previous step. Before defining routes related to business logic, it's good practice to add a /status endpoint which returns a 200 HTTP status code. This is useful to ensure the server is running correctly

To do so, update the start function in server.ts by adding the following to the top:

export async function start(): Promise<Hapi.Server> {
  server.route({
    method: 'GET',
    path: '/',
    handler: (_, h: Hapi.ResponseToolkit) => {
      return h.response({ up: true }).code(200)
    },
  })
  await server.start()
  console.log(`Server running on ${server.info.uri}`)
  return server
}

Here you defined the HTTP method, the path, and a handler which returns the object { up: true } and lastly set the HTTP status code to 200.

Checkpoint: If you open http://localhost:3000 in your browser, you should see the following: {"up":true}

Moving the route to a plugin

In the previous step you defined a status endpoint. Since the API will expose many different endpoints, it won't be maintainable to have them all defined in the start function.

Hapi has the concept of plugins as a way of breaking up the backend into isolated pieces of business logic. Plugins are a lean way to keep your code modular. In this step, you will move the route defined in the previous step into a plugin.

This requires two steps:

  1. Define a plugin in a new file.
  2. Register the plugin before calling server.start()
Defining the plugin

Begin by creating a new folder in src/ named plugins:

mkdir src/plugins

Create a new file named status.ts in the src/plugins/ folder:

touch src/plugins/status.ts

And add the following to the file:

import Hapi from '@hapi/hapi'

const plugin: Hapi.Plugin<undefined> = {
  name: 'app/status',
  register: async function(server: Hapi.Server) {
    server.route({
      method: 'GET',
      path: '/',
      handler: (_, h: Hapi.ResponseToolkit) => {
        return h.response({ up: true }).code(200)
      },
    })
  },
}

export default plugin

A Hapi plugin is an object with a name property and a register function which is where you would typically encapsulate the logic of the plugin. The name property the plugin name string and is used as a unique key.

Each plugin can manipulate the server through the standard server interface. In the app/status plugin above, server is used to define the status route in the register function.

Registering the plugin

To register the plugin, go back to server.ts and import the status plugin as follows:

import status from './plugins/status'

In the start function, replace the route() call from the previous step with the following server.register() call:

export async function start(): Promise<Hapi.Server> {
  await server.register([status])
  await server.start()
  console.log(`Server running on ${server.info.uri}`)
  return server
}

Checkpoint: If you open http://localhost:3000 in your browser, you should see the following: {"up":true}

Congratulations, you have successfully created a Hapi plugin which encapsulates the logic for the status endpoint.

In the next step, you will define a test to test the status endpoint.

Defining a test for the status endpoint

To test the status endpoint, you will use Jest as the test runner together with the Hapi's server.inject test helper that simulates an HTTP request to the server. This will allow you to verify that you correctly implemented the endpoint.

Splitting server.ts into two files

To use the server.inject method, you need access in your tests to the server object after the plugins have been registered but prior to starting the server so as to avoid the server listening to requests when tests run. To do so, modify the server.ts to look as follows:

const server: Hapi.Server = Hapi.server({
  port: process.env.PORT || 3000,
  host: process.env.HOST || 'localhost',
})

export async function createServer(): Promise<Hapi.Server> {
  await server.register([statusPlugin])
  await server.initialize()

  return server
}

export async function startServer(server: Hapi.Server): Promise<Hapi.Server> {
  await server.start()
  console.log(`Server running on ${server.info.uri}`)
  return server
}

process.on('unhandledRejection', err => {
  console.log(err)
  process.exit(1)
})

You just split replaced the start function with two functions:

  • createServer(): Registers the plugins and initializes the server
  • startServer(): Starts the server

Note: Hapi's server.initialize() initializes the server (starts the caches, finalizes plugin registration) but does not start listening on the connection port.

Now you can import server.ts and use createServer() in your tests to initialize the server and call server.inject() to simulate HTTP requests.

Next, you will create a new entry point for the application which will call both createServer() and startServer().

Create a new src/index.ts file and add the following to it:

import { createServer, startServer } from './server'

createServer()
  .then(startServer)
  .catch(err => {
    console.log(err)
  })

Lastly, update the dev script in package.json to start src/index.ts instead of src/server.ts:

- "dev": "ts-node-dev --respawn ./src/server.ts",
+ "dev": "ts-node-dev --respawn ./src/index.ts",
Creating the test

To create the test, create a folder named tests in the root of the project and create a file named status.test.ts and add the following to the file:

import { createServer } from '../src/server'
import Hapi from '@hapi/hapi'

describe('Status plugin', () => {
  let server: Hapi.Server

  beforeAll(async () => {
    server = await createServer()
  })

  afterAll(async () => {
    await server.stop()
  })

  test('status endpoint returns 200', async () => {
    const res = await server.inject({
      method: 'GET',
      url: '/',
    })
    expect(res.statusCode).toEqual(200)
    const response = JSON.parse(res.payload)
    expect(response.up).toEqual(true)
  })
})

In the test above, the beforeAll and afterAll are used as setup and teardown functions to create and stop the server.

Then, the server.inject is called to simulate a GET HTTP request to the root endpoint /. The test then asserts the HTTP status code and the payload to ensure it matches the handler.

Checkpoint: Run the test with npm test and you should see the following output:

PASS  tests/status.test.ts
  Status plugin
    ✓ status endpoint returns 200 (9 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.886 s, estimated 1 s
Ran all test suites.

Congratulations, you have created a plugin with a route and tested the route.

In the next step, you will define a Prisma plugin so that you can access the Prisma Client instance throughout the application.

Defining a Primsa plugin

Similar to how you created the status plugin, create a new file src/plugins/prisma.ts file for the Prisma plugin.

The goal of the Prisma plugin is to instantiate the Prisma Client, make it available to the rest of the application through the server.app object, and to disconnect from the database when the server is stopped. server.app provides a safe place to store server-specific run-time application data without potential conflicts with the framework internals. The data can be accessed whenever the server is accessible.

Add the following to the src/plugins/prisma.ts file:

import { PrismaClient } from '@prisma/client'
import Hapi from '@hapi/hapi'

// plugin to instantiate Prisma Client
const prismaPlugin: Hapi.Plugin<null> = {
  name: 'prisma',
  register: async function(server: Hapi.Server) {
    const prisma = new PrismaClient()

    server.app.prisma = prisma

    // Close DB connection after the server's connection listeners are stopped
    // Related issue: https://github.com/hapijs/hapi/issues/2839
    server.ext({
      type: 'onPostStop',
      method: async (server: Hapi.Server) => {
        server.app.prisma.disconnect()
      },
    })
  },
}

export default prismaPlugin

Here we define a plugin, instantiate Prisma Client, assign it to server.app, and add an extension function (can be thought of as a hook) that will run on the onPostStop event which gets called after the server's connection listeners are stopped.

To register the Prisma plugin, import the plugin in server.ts and add it to the array passed to the server.register call as follows:

await server.register([status, prisma])

If you're using VSCode, you will see a red squiggly line below server.app.prisma = prisma in the src/plugins/prisma.ts file. This is the first type error you encounter. If you don't see the line, you can run the compile script to run the TypeScript compiler:

npm run compile

src/plugins/prisma.ts:21:16 - error TS2339: Property 'prisma' does not exist on type 'ServerApplicationState'.

21     server.app.prisma = prisma

This reason for this error is that you've modified the server.app without updating its type. To resolve the error, add the following on top of the prismaPlugin definition:

declare module '@hapi/hapi' {
  interface ServerApplicationState {
    prisma: PrismaClient
  }
}

This will augment the module and assign the PrismaClient type to the server.app.prisma property.

Note: For more information about why module augmentation is necessary, check out this comment in the DefinitelyTyped repository.

Besides appeasing the TypeScript compiler, this will also make auto-completion work whenever server.app.prisma is accessed throughout the application.

Checkpoint: If you run npm run compile again, no errors should be emitted.

Well done! You have now defined two plugins and made Prisma Client available to the rest of the application. In the next step you will define a plugin for the user routes.

Defining a plugin for user routes with a dependency on the Prisma plugin

You will now define a new plugin for the user routes. This plugin will need to make use of Prisma Client that you defined in the Prisma plugin so that it can perform CRUD operation in the user-specific route handlers.

Hapi plugins have an optional dependencies property which can be used to indicate a dependency on other plugins. When specified, Hapi will ensure the plugins are loaded in the correct order.

Begin by creating a new file src/plugins/users.ts file for the users plugin.

Add the following to the file:

import Hapi from '@hapi/hapi'

// plugin to instantiate Prisma Client
const usersPlugin = {
  name: 'app/users',
  dependencies: ['prisma'],
  register: async function(server: Hapi.Server) {
    // here you can use server.app.prisma
  },
}
export default usersPlugin

Here you passed an array to the dependencies property to make sure Hapi loads the Prisma plugin first.

You can now define the user-specific routes in the register function knowing that Prisma Client will be accessible.

Lastly, you will need to import the plugin and register it in src/server.ts as follows:

-await server.register([status, prisma])
+await server.register([status, prisma, users])

In the next step, you will define a create user endpoint.

Defining the create user route

With the user plugin defined, you can now define the create user route.

The create user route will have the HTTP method POST and the path /users.

Begin by adding the following server.route call in src/plugins/users.ts inside the register function:

server.route([
  {
    method: 'POST',
    path: '/users',
    handler: createUserHandler,
  },
])

Then define the createUserHandler function as follows:

async function createUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
  const { prisma } = request.server.app
  const payload = request.payload

  try {
    const createdUser = await prisma.user.create({
      data: {
        firstName: payload.firstName,
        lastName: payload.lastName,
        email: payload.email,
        social: JSON.stringify(payload.social),
      },
      select: {
        id: true,
      },
    })
    return h.response(createdUser).code(201)
  } catch (err) {
    console.log(err)
  }
}

Here you access prisma from the server.app object (assigned in the Prisma plugin), and use the request payload in the prisma.user.create call to save the user in the database.

You should see a red squiggly line again below the lines accessing payload's properties', indicating a type error. If you don't see the error, run the TypeScript compiler again:

npm run compile
src/plugins/users.ts:27:28 - error TS2339: Property 'firstName' does not exist on type 'string | object | Buffer | Readable'.
  Property 'firstName' does not exist on type 'string'.

27         firstName: payload.firstName,
                              ~~~~~~~~~

This is because payload's value is determined at runtime, so the TypeScript compiler has no way of knowing its time. This can be fixed with a type assertion. Type assertion is a mechanism in TypeScript that allows you to override a variable's inferred type. TypeScript's type assertion is purely you telling the compiler that you know about the types better than it does as here.

To do so, define an interface for the expected payload:

interface UserInput {
  firstName: string
  lastName: string
  email: string
  social: {
    facebook?: string
    twitter?: string
    github?: string
    website?: string
  }
}

Note: Types and Interfaces have many similarities in TypeScript. To learn more about their differences, check out this article

Then add the type assertion:

const payload = request.payload as UserInput

The plugin should look as follows:

// plugin to instantiate Prisma Client
const usersPlugin = {
  name: 'app/users',
  dependencies: ['prisma'],
  register: async function(server: Hapi.Server) {
    server.route([
      {
        method: 'POST',
        path: '/users',
        handler: registerHandler,
      },
    ])
  },
}

export default usersPlugin

interface UserInput {
  firstName: string
  lastName: string
  email: string
  social: {
    facebook?: string
    twitter?: string
    github?: string
    website?: string
  }
}

async function registerHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
  const { prisma } = request.server.app
  const payload = request.payload as UserInput

  try {
    const createdUser = await prisma.user.create({
      data: {
        firstName: payload.firstName,
        lastName: payload.lastName,
        email: payload.email,
        social: JSON.stringify(payload.social),
      },
      select: {
        id: true,
      },
    })
    return h.response(createdUser).code(201)
  } catch (err) {
    console.log(err)
  }
}

Adding validation to the create user route

In this step, you will also add payload validation using Joi to ensure the route only handles requests with the correct data.

Validation can be thought of as a runtime type check. When using TypeScript, the type checks that the compiler performs are bound to what can be known at compile time. Since user API input cannot be known at compile-time, runtime validation help with such cases.

To do so, import Joi as follows:

import Joi from '@hapi/joi'

Joi allows you to define validation rules by creating a Joi validation object which can be assigned to the route handler so that Hapi will know to validate the payload.

In the create user endpoint, you want to validate that the user input fits the type you've defined above:

interface UserInput {
  firstName: string
  lastName: string
  email: string
  social: {
    facebook?: string
    twitter?: string
    github?: string
    website?: string
  }
}

The Joi corresponding validation object would look as follows:

const userInputValidator = Joi.object({
  firstName: Joi.string().required(),
  lastName: Joi.string().required(),
  email: Joi.string()
    .email()
    .required(),
  social: Joi.object({
    facebook: Joi.string().optional(),
    twitter: Joi.string().optional(),
    github: Joi.string().optional(),
    website: Joi.string().optional(),
  }).optional(),
})

Next, you have to configure the route handler to use the validator object userInputValidator. Add the following to your route definition object:

{
  method: 'POST',
  path: '/users',
  handler: registerHandler,
+  options: {
+    validate: {
+      payload: userInputValidator
+    }
+  },
}

Create a test for the create user route

In this step, you will create a test to verify the create user logic. The test will make a request to the POST /users endpoint with server.inject and check that the response includes the id field thereby verifying that the user has been created in the database.

Start by creating a tests/users.tests.ts file and add the following contents:

import { createServer } from '../src/server'
import Hapi from '@hapi/hapi'

describe('POST /users - create user', () => {
  let server: Hapi.Server

  beforeAll(async () => {
    server = await createServer()
  })

  afterAll(async () => {
    await server.stop()
  })

  let userId

  test('create user', async () => {
    const response = await server.inject({
      method: 'POST',
      url: '/users',
      payload: {
        firstName: 'test-first-name',
        lastName: 'test-last-name',
        email: `test-${Date.now()}@prisma.io`,
        social: {
          twitter: 'thisisalice',
          website: 'https://www.thisisalice.com'
        }
      }
    })

    expect(response.statusCode).toEqual(201)
    userId = JSON.parse(response.payload)?.id
    expect(typeof userId === 'number').toBeTruthy()
  })

})

The test injects a request with a payload and asserts the statusCode and that the id in the response is a number.

Note: The test avoids unique constraint errors by ensuring that the email is unique on every test run.

Now that you've written a test for the happpy path (creating a user sucessfully), you will write another test to verify the validation logic. You will do so by crafting another request with invalid payload, e.g. ommitting the required field firstName as follows:

test('create user validation', async () => {
  const response = await server.inject({
    method: 'POST',
    url: '/users',
    payload: {
      lastName: 'test-last-name',
      email: `test-${Date.now()}@prisma.io`,
      social: {
        twitter: 'thisisalice',
        website: 'https://www.thisisalice.com',
      },
    },
  })

  console.log(response.payload)
  expect(response.statusCode).toEqual(400)
})

Checkpoint: Run the tests with the npm test command and verify that all tests pass.

Defining and testing the get user route

In the step, you will first define a test for the get user endpoint and then implement the route handler.

As a reminder, the get user endpoint will have the GET /users/{userId} signature.

The practice of first writing the test and then the implementation is often referred to as test-driven development. Test-driven development can improve productivity by providing a fast mechanism to verify the correctness of changes while you work on the implementation.

Defining the test

First, you will test the route returning 404 when a user is not found.

Open the users.test.ts file and add the following test:

test('get user returns 404 for non existant user', async () => {
  const response = await server.inject({
    method: 'GET',
    url: '/users/9999',
  })

  expect(response.statusCode).toEqual(404)
})

The second test will test the happy path – a successfully retrieved user. You will use the userId variable set in the create user test created in the previous step. This will ensure that you fetch an existing user. Add the following test:

test('get user returns user', async () => {
  const response = await server.inject({
    method: 'GET',
    url: `/users/${userId}`,
  })
  expect(response.statusCode).toEqual(200)
  const user = JSON.parse(response.payload)

  expect(user.id).toBe(userId)
})

Since you haven't defined the route yet, running the tests now will result in failing tests. The next step will be to define the route.

Defining the route

Go to the users.ts (users plugin) and add the following route object to the server.route() call:

server.route([
  {
    method: 'GET',
    path: '/users/{userId}',
    handler: getUserHandler,
    options: {
      validate: {
        params: Joi.object({
          userId: Joi.number().integer(),
        }),
      },
    },
  },
])

Similar to how you defined validation rules for the create user endpoint, in the route definition above you validate the userId url parameter to ensure a number is passed.

Next, define the getUserHandler function as follows:

async function getUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
  const { prisma } = request.server.app
  const userId = parseInt(request.params.userId, 10)

  try {
    const user = await prisma.user.findOne({
      where: {
        id: userId,
      },
    })
    if (!user) {
      return h.response().code(404)
    } else {
      return h.response(user).code(200)
    }
  } catch (err) {
    console.log(err)
    return Boom.badImplementation()
  }
}

Note: when calling findOne, Prisma will return null if no result could be found.

In the handler, the userId is parsed from the request parameters and used in a Prisma Client query. If the user cannot be found 404 is returned, otherwise, the found user object is returned.

Checkpoint: Run the tests with npm test and verify that all tests have passed.

Defining and testing the delete user route

In the step, you will define a test for the delete user endpoint and then implement the route handler.

The delete user endpoint will have the DELETE /users/{userId} signature.

Defining the test

First, you will write a test for the route's parameter valdation. Add the following test to users.test.ts:

test('delete user fails with invalid userId parameter', async () => {
  const response = await server.inject({
    method: 'DELETE',
    url: `/users/aa22`,
  })
  expect(response.statusCode).toEqual(400)
})

Then add another test for the delete user logic in which you will delete the user created in the create user test:

test('delete user', async () => {
  const response = await server.inject({
    method: 'DELETE',
    url: `/users/${userId}`,
  })
  expect(response.statusCode).toEqual(204)
})

Note: The 204 status response code indicates that the request has succeeded, but the response has no content.

Defining the route

Go to the users.ts (users plugin) and add the following route object to the server.route() call:

server.route([
  {
    method: 'DELETE',
    path: '/users/{userId}',
    handler: deleteUserHandler,
    options: {
      validate: {
        params: Joi.object({
          userId: Joi.number().integer(),
        }),
      },
    },
  },
])

After you've defined the route, define the deleteUserHandler as follows:

async function deleteUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
  const { prisma } = request.server.app
  const userId = parseInt(request.params.userId, 10)

  try {
    await prisma.user.delete({
      where: {
        id: userId,
      },
    })
    return h.response().code(204)
  } catch (err) {
    console.log(err)
    return h.response().code(500)
  }
}

Checkpoint: Run the tests with npm test and verify that all tests have passed.

Defining and testing the update user route

In the step, you will define a test for the update user endpoint and then implement the route handler.

The update user endpoint will have the PUT /users/{userId} signature.

Writing the tests for the update user route

First, you will write a test for the route's parameter valdation. Add the following test to users.test.ts:

test('update user fails with invalid userId parameter', async () => {
  const response = await server.inject({
    method: 'PUT',
    url: `/users/aa22`,
  })
  expect(response.statusCode).toEqual(400)
})

Add another test for the update user endpoint in which you update the user's firstNameand lastName fields (for the user created in the create user test):

test('update user', async () => {
  const updatedFirstName = 'test-first-name-UPDATED'
  const updatedLastName = 'test-last-name-UPDATED'

  const response = await server.inject({
    method: 'PUT',
    url: `/users/${userId}`,
    payload: {
      firstName: updatedFirstName,
      lastName: updatedLastName,
    },
  })
  expect(response.statusCode).toEqual(200)
  const user = JSON.parse(response.payload)
  expect(user.firstName).toEqual(updatedFirstName)
  expect(user.lastName).toEqual(updatedLastName)
})
Defining the update user validation rules

In this step you will define the update user route. In terms of validation, the endpoint's payload should not require any specific fields (unlike the create user endpoint where email, firstName, and lastName are required). This will allow you to use the endpoint to update a single field, e.g. firstName.

To define the payload validation, you could use the userInputValidator Joi object, however, if you recall, some of the fields were required:

const userInputValidator = Joi.object({
  firstName: Joi.string().required(),
  lastName: Joi.string().required(),
  email: Joi.string()
    .email()
    .required(),
  social: Joi.object({
    facebook: Joi.string().optional(),
    twitter: Joi.string().optional(),
    github: Joi.string().optional(),
    website: Joi.string().optional(),
  }).optional(),
})

In the update user endpoint, all fields should be optional. Joi provides a way to create different alterations of the same Joi object using the tailor and alter methods. This is especially useful when defining create and update routes that have similar validation rules while keeping the code DRY.

Update the already defined userInputValidator as follows:

const userInputValidator = Joi.object({
  firstName: Joi.string().alter({
    create: schema => schema.required(),
    update: schema => schema.optional(),
  }),
  lastName: Joi.string().alter({
    create: schema => schema.required(),
    update: schema => schema.optional(),
  }),
  email: Joi.string()
    .email()
    .alter({
      create: schema => schema.required(),
      update: schema => schema.optional(),
    }),
  social: Joi.object({
    facebook: Joi.string().optional(),
    twitter: Joi.string().optional(),
    github: Joi.string().optional(),
    website: Joi.string().optional(),
  }).optional(),
})

const createUserValidator = userInputValidator.tailor('create')
const updateUserValidator = userInputValidator.tailor('update')
Updating the create user route's payload validation

Now you can update the create user route definition to use createUserValidator in src/plugins/users.ts (users plugin):

{
  method: 'POST',
  path: '/users',
  handler: createUserHandler,
  options: {
    validate: {
-      payload: userInputValidator,
+      payload: createUserValidator,
    }
  }
}
Defining the update user route

With the validation object for update defined, you can now define the update user route. Go to src/plugins/users.ts (users plugin) and add the following route object to the server.route() call:

server.route([
  {
    method: 'PUT',
    path: '/users/{userId}',
    handler: updateUserHandler,
    options: {
      validate: {
        params: Joi.object({
          userId: Joi.number().integer(),
        }),
       payload: createUserValidator,
    },
  },
])

After you've defined the route, define the updateUserHandler function as follows:

async function updateUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
  const { prisma } = request.server.app
  const userId = parseInt(request.params.userId, 10)
  const payload = request.payload as Partial<UserInput>

  try {
    const updatedUser = await prisma.user.update({
      where: {
        id: userId,
      },
      data: payload,
    })
    return h.response(updatedUser).code(200)
  } catch (err) {
    console.log(err)
    return h.response().code(500)
  }
}

Checkpoint: Run the tests with npm test and verify that all tests have passed.

Summary and next steps

If you've made it this far, congratulations. The article covered a lot of ground starting with REST concepts and then going into Hapi concepts such as routes, plugins, plugin dependencies, testing, and validation.

You implemented a Prisma plugin for Hapi, making Prisma available throughout your application and implemented routes that make use of it.

Moreover, TypeScript helped with auto-completion and verifying the correct use of types (in sync with the database schema) throughout the application.

The article covered the implementation of a subset of all the endpoints. As a next step, you could implement the other routes following the same principles.

You can find the full source code for the backend on GitHub.

The focus of the article was implementing a REST API, however, concepts such as validation and testing apply in other situations too.

While Prisma aims to make working with relational databases easy, it can be helpful to have a deeper understanding of the underlying database.

Check out the Prisma's Data Guide to learn more about how databases work, how to choose the right one, and how to use databases with your applications to their full potential.

In the next parts of the series, you'll learn more about:

  • Authentication: Implementing passwordless authentication with emails and JWT.
  • Continues Integration: Building a GitHub Actions pipeline to automate testing of the backend.
  • Integration with external APIs: Using a transactional email API to send emails.
  • Authorization: Provide different levels of access to different resources.
  • Deployment

Join the discussion

Follow @prisma on Twitter

Don’t miss the next post!

Sign up for the Prisma newsletter

Newsletter

Stay up to date with the latest features and changes to Prisma

Find Us

Made with ❤️ in Berlin