Get Started With NestJS
NestJS is one of the most popular Node.js/TypeScript backend frameworks for building APIs. ZenStack provides a module for easily integrating with NestJS applications that use Prisma ORM. With the integration, you'll have access to an enhanced Prisma service with built-in access control, while continue enjoying the same Prisma APIs that you're familiar with.
Let's see how it works by creating a simple blogging API. You can find the final build result here.
Requirements
Our target app should meet the following requirements:
- Users can create posts for themselves.
- Post owner can update their own posts.
- Users cannot make changes to posts that do not belong to them.
- Published posts can be viewed by everyone.
Let's get started 🚀.
Prerequisite
- Make sure you have Node.js 18 or above installed.
- Install the VSCode extension for editing data models.
Building the app
1. Create a new NestJS project
npx @nestjs/cli@latest new -p npm my-blog-app
cd my-blog-app
2. Set up Prisma
npm install -D prisma
npx prisma init
This will create a Prisma schema under prisma/schema.prisma
. Replace its content with the following:
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
model User {
id Int @id() @default(autoincrement())
name String
posts Post[]
}
model Post {
id Int @id() @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt()
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int @default(auth().id)
}
Now, generate PrismaClient
and push the schema to the database:
npx prisma generate
npx prisma db push
Create a PrismaService
which will be injected into the API controllers later.
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
Finally, add the PrismaService
to the app module as a provider:
import { PrismaService } from './prisma.service';
@Module({
imports: [],
controllers: [AppController],
providers: [PrismaService],
})
export class AppModule {}
3. Create CRUD controllers
Now let's create the CRUD API controller for User
and Post
models. In a real application, you'll want to have UserService
and PostService
to encapsulate database operations. For simplicity, we'll put everything in the controller here.
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Controller()
export class AppController {
constructor(private readonly prismaService: PrismaService) {}
@Post('users')
async signup(@Body() userData: { name: string }) {
return this.prismaService.user.create({ data: userData });
}
@Get('posts')
async getAllPosts() {
return this.prismaService.post.findMany();
}
@Post('posts')
async createDraft(@Body() postData: { title: string; authorId: number }) {
return this.prismaService.post.create({
data: postData,
});
}
@Put('posts/publish/:id')
async publishPost(@Param('id') id: string) {
return this.prismaService.post.update({
where: { id: Number(id) },
data: { published: true },
});
}
}
Now, we can start the dev server:
npm run start:dev
Let's make a few requests to create a user and two posts:
curl -X POST -H "Content-Type: application/json" -d '{"name": "Joey"}' localhost:3000/users
curl -X POST -H "Content-Type: application/json" -d '{"title": "My first post", "authorId": 1}' localhost:3000/posts
curl -X POST -H "Content-Type: application/json" -d '{"title": "My second post", "authorId": 1}' localhost:3000/posts
curl localhost:3000/posts
The result should look like:
[
{
"authorId" : 1,
"createdAt" : "2024-03-27T18:16:27.289Z",
"id" : 1,
"published" : false,
"title" : "My first post",
"updatedAt" : "2024-03-27T18:16:27.289Z"
},
{
"authorId" : 1,
"createdAt" : "2024-03-27T18:16:35.302Z",
"id" : 2,
"published" : false,
"title" : "My second post",
"updatedAt" : "2024-03-27T18:16:35.302Z"
}
]
4. Set up authentication
Our basic CRUD APIs are up and running. However it's not secured yet. Protecting an API involves two parts: authentication (identifying who's making the request) and authorization (deciding if the requester is allowed to perform the operation).
Let's deal with authentication first. NestJS has detailed documentation for implementing authentication. In this guide, we'll simply use a fake one that directly passes user ID in a HTTP header. To allow services and controllers to access the authenticatd user, we'll use the nestjs-cls package to put the user information into Node.js's AsyncLocalStorage.
First, install the nestjs-cls
package:
npm install nestjs-cls
Then, mount the CLS module:
import { ClsModule, ClsService } from 'nestjs-cls';
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
},
}),
],
...
})
export class AppModule {}
Now, let's create a NestJS interceptor to extract the user ID from the HTTP header and put it into the CLS context:
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
@Injectable()
export class AuthInterceptor implements NestInterceptor {
constructor(private readonly cls: ClsService) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const userId = request.headers['x-user-id'];
if (userId) {
this.cls.set('auth', { id: Number(userId) });
}
return next.handle();
}
}
Then, add the interceptor to AppModule
:
import { APP_INTERCEPTOR } from '@nestjs/core';
import { AuthInterceptor } from './auth.interceptor';
@Module({
...
providers: [
PrismaService,
{
provide: APP_INTERCEPTOR,
useClass: AuthInterceptor,
},
],
})
export class AppModule {}
Now we will be able to inject the ClsService
into the controllers and services as needed to fetch the current authenticated user.
5. Set up ZenStack
ZenStack allows you to define access policies inside your data schema. Let's install it first.
npx zenstack@latest init
The command installs a few NPM dependencies. If the project already has a Prisma schema at prisma/schema.prisma
, it's copied over to schema.zmodel
. Otherwise, a sample schema.zmodel
file is created.
Moving forward, you will keep updating schema.zmodel
file, and prisma/schema.prisma
will be automatically generated from it.
Add the following access policies to the User
and Post
models:
model User {
id Int @id() @default(autoincrement())
name String
posts Post[]
// anyone can sign up, and user profiles are public
@@allow('create,read', true)
// users have full access to their own profile
@@allow('all', auth() == this)
}
model Post {
id Int @id() @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt()
title String
published Boolean @default(false)
author User? @relation(fields: [authorId], references: [id])
authorId Int?
// author has full access
@@allow('all', auth() == author)
// published posts are readable to all
@@allow('read', published)
}
By default, all operations are denied for a model. You can use the @@allow
attribute to open up some permissions.
@@allow
takes two parameters, the first is operation: create/read/update/delete. You can use a comma separated string to pass multiple operations, or use 'all' to abbreviate all operations. The second parameter is a boolean expression that verdicts if the rule should be activated.
Similarly, @@deny
can be used to explicitly turn off some operations. It has the same syntax as @@allow
but the opposite effect.
Whether an operation is permitted is determined as follows:
- If any
@@deny
rule evaluates to true, it's denied. - If any
@@allow
rule evaluates to true, it's allowed. - Otherwise, it's denied.
Check out Understanding Access Policies for more details.
Now regenerate PrismaClient
and other supporting files needed by ZenStack:
npx zenstack generate
6. Use ZenStack in the controller
One of the main things ZenStack does is to create an "enhanced" PrismaClient
that automatically enforces access policies. To do that, simply call the enhance
API with an existing client and a user context:
const enhancedPrisma = enhance(prisma, { user: ... });
In a NestJS application, since everything is a dependency injection, we need to create the enhanced client in a DI-compatible way. Fortunately, ZenStack offers a module to make such integration easy. First, install the server adapter package:
npm install @zenstackhq/server@latest
Then, register the ZenStackModule
onto the app module:
import { ZenStackModule } from '@zenstackhq/server/nestjs';
import { enhance } from '@zenstackhq/runtime';
@Module({
imports: [
...
ZenStackModule.registerAsync({
useFactory: (prisma: PrismaService, cls: ClsService) => {
return {
getEnhancedPrisma: () => enhance(prisma, { user: cls.get('auth') }),
};
},
inject: [PrismaService, ClsService],
extraProviders: [PrismaService],
}),
],
...
})
export class AppModule {}
Note that the ZenStackModule
registration is done with a factory function that returns a config used for creating an enhanced prisma service. The config contains a callback function where you should create and return an enhanced PrismaClient
. It'll be called each time a Prisma method is invoked. It's important to fetch the auth data inside the callback so that it correctly returns the data bound to the current request context.
The enhanced clients are lightweighted Javascript proxies. They are cheap to create and don't incur new connections to the database.
The ZenStackModule
provides an enhanced PrismaService
with the token name ENHANCED_PRISMA
. You can use both the regular PrismaService
and enhanced one in your services and controllers. To use the regular prisma client, simply inject the PrismaService
as usual. To use the enhanced one, inject it with token name ENHANCED_PRISMA
.
Let's change our controller to use the enhanced prisma service:
import { ENHANCED_PRISMA } from '@zenstackhq/server/nestjs';
@Controller()
export class AppController {
constructor(
@Inject(ENHANCED_PRISMA) private readonly prismaService: PrismaService,
) {}
...
}
7. Test the secured API
Now, restart the dev server, and let's make a few requests to see if the access policies are enforced.
-
Listing posts without a user identity should return an empty array:
curl localhost:3000/posts
[]
-
Listing posts with a user identity should return all posts owned by the user:
curl -H "x-user-id:1" localhost:3000/posts
[
{
"authorId" : 1,
"createdAt" : "2024-03-27T18:16:27.289Z",
"id" : 1,
"published" : false,
"title" : "My first post",
"updatedAt" : "2024-03-27T18:16:27.289Z"
},
{
"authorId" : 1,
"createdAt" : "2024-03-27T18:16:35.302Z",
"id" : 2,
"published" : false,
"title" : "My second post",
"updatedAt" : "2024-03-27T18:16:35.302Z"
}
] -
Published posts are readable to all:
First, publish a post with its owner's identity.
curl -X PUT -H "x-user-id:1" localhost:3000/posts/publish/1
{
"authorId" : 1,
"createdAt" : "2024-03-27T18:16:27.289Z",
"id" : 1,
"published" : true,
"title" : "My first post",
"updatedAt" : "2024-03-27T18:42:19.043Z"
}Then, list all posts without a user identity:
curl localhost:3000/posts
{
"authorId" : 1,
"createdAt" : "2024-03-27T18:16:27.289Z",
"id" : 1,
"published" : true,
"title" : "My first post",
"updatedAt" : "2024-03-27T18:42:19.043Z"
}
Wrap up
🎉 Congratulations! You've made a simple but secure blogging API without writing any authorization code. Pretty cool, isn't it?
If you have trouble following the building process, you can find the final result here. For more details about ZenStack, please refer to the Reference and Guides parts of the documentation.
Have fun building cool stuff 🚀!