August 20, 2020
Backend with TypeScript, PostgreSQL & Prisma: REST, Validation & Tests
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:
Topic | Part |
---|---|
Data modeling | Part 1 |
CRUD | Part 1 |
Aggregations | Part 1 |
REST API layer | Part 2 (current) |
Validation | Part 2 (current) |
Testing | Part 2 (current) |
Authentication | Coming up |
Authorization | Coming up |
Integration with external APIs | Coming up |
Deployment | Coming 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:
- 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.
- Validation: Add payload validation rules to ensure that user input matches the expected types of the Prisma schema.
- 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:
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:
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:
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
, andDELETE
. The HTTP method will determine the type of operation an endpoint exposes, for example theGET /users
endpoint will allow fetching users andPOST /users
endpoint will allow creating users. - Handler: The code (in this case TypeScript) which will handle requests for an endpoint.
- Path, e.g.
- 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, and400
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):
Resource | HTTP Method | Route | Description |
---|---|---|---|
User | POST | /users | Create a user (and optionally associate with courses) |
User | GET | /users/{userId} | Get a user |
User | PUT | /users/{userId} | Update a user |
User | DELETE | /users/{userId} | Delete a user |
User | GET | /users | Get users |
CourseEnrollment | GET | /users/{userId}/courses | Get a user's enrollement incourses |
CourseEnrollment | POST | /users/{userId}/courses | Enroll a user to a course (as student or teacher) |
CourseEnrollment | DELETE | /users/{userId}/courses/{courseId} | Delete a user's enrollment to a course |
Course | POST | /courses | Create a course |
Course | GET | /courses | Get courses |
Course | GET | /courses/{courseId} | Get a course |
Course | PUT | /courses/{courseId} | Update a course |
Course | DELETE | /courses/{courseId} | Delete a course |
Test | POST | /courses/{courseId}/tests | Create a test for a course |
Test | GET | /courses/tests/{testId} | Get a test |
Test | PUT | /courses/tests/{testId} | Update a test |
Test | DELETE | /courses/tests/{testId} | Delete a test |
Test Result | GET | /users/{userId}/test-results | Get a user's test results |
Test Result | POST | /courses/tests/{testId}/test-results | Create test result for a test associated with a user |
Test Result | GET | /courses/tests/{testId}/test-results | Get multiple test results for a test |
Test Result | PUT | /courses/tests/test-results/{testResultId} | Update a test result (associated with a user and a test) |
Test Result | DELETE | /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. inwww.myapi.com/users/13
theuserId
is13
.
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:
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
:
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 dev
script which will use ts-node-dev
to automatically transpile the TypeScript code and restart the server when you make changes: npm run dev
:
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:
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:
- Define a plugin in a new file.
- Register the plugin before calling
server.start()
Defining the plugin
Begin by creating a new folder in src/
named plugins
:
Create a new file named status.ts
in the src/plugins/
folder:
And add the following to the file:
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:
In the start
function, replace the route()
call from the previous step with the following server.register()
call:
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:
You just split replaced the start
function with two functions:
createServer()
: Registers the plugins and initializes the serverstartServer()
: 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:
Lastly, update the dev
script in package.json
to start src/index.ts
instead of src/server.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:
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:
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 Prisma 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:
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:
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:
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:
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:
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:
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:
Then define the createUserHandler
function as follows:
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:
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:
Note: Types and Interfaces have many similarities in TypeScript.
Then add the type assertion:
The plugin should look as follows:
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:
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:
The Joi corresponding validation object would look as follows:
Next, you have to configure the route handler to use the validator object userInputValidator
. Add the following to your route definition object:
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:
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
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:
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
:
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:
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:
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:
Note: when calling
findUnique
, Prisma will returnnull
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
:
Then add another test for the delete user logic in which you will delete the user created in the create user test:
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:
After you've defined the route, define the deleteUserHandler
as follows:
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
:
Add another test for the update user endpoint in which you update the user's firstName
and lastName
fields (for the user created in the create user test):
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:
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:
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):
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:
After you've defined the route, define the updateUserHandler
function as follows:
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
Don’t miss the next post!
Sign up for the Prisma Newsletter