From 657a9c2db58698378acbd87b267699635e559fe8 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 27 Mar 2024 15:54:51 -0700 Subject: [PATCH 1/4] docs: add NestJS tutorial --- docs/quick-start/_access-policy.md | 4 +- docs/quick-start/_zenstack-init-tips.md | 2 +- docs/quick-start/backend.mdx | 2 +- docs/quick-start/nestjs.mdx | 476 ++++++++++++++++++++++++ docs/upgrade.md | 4 + docusaurus.config.js | 1 + 6 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 docs/quick-start/nestjs.mdx diff --git a/docs/quick-start/_access-policy.md b/docs/quick-start/_access-policy.md index 78525599..ef0a3e40 100644 --- a/docs/quick-start/_access-policy.md +++ b/docs/quick-start/_access-policy.md @@ -1,8 +1,8 @@ -:::tip +:::info By default, all operations are denied for a model. You can use `@@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 verdicting if the rule should be activated. +`@@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. diff --git a/docs/quick-start/_zenstack-init-tips.md b/docs/quick-start/_zenstack-init-tips.md index fde01723..472efd3f 100644 --- a/docs/quick-start/_zenstack-init-tips.md +++ b/docs/quick-start/_zenstack-init-tips.md @@ -1,4 +1,4 @@ -:::tip +:::info 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. diff --git a/docs/quick-start/backend.mdx b/docs/quick-start/backend.mdx index 95d41e15..e3c10468 100644 --- a/docs/quick-start/backend.mdx +++ b/docs/quick-start/backend.mdx @@ -1,6 +1,6 @@ --- description: Steps for using ZenStack only in backend development. -sidebar_position: 7 +sidebar_position: 8 --- import InitTips from './_zenstack-init-tips.md'; diff --git a/docs/quick-start/nestjs.mdx b/docs/quick-start/nestjs.mdx new file mode 100644 index 00000000..65121634 --- /dev/null +++ b/docs/quick-start/nestjs.mdx @@ -0,0 +1,476 @@ +--- +title: NestJS +description: Steps for using ZenStack in a NestJS application. +sidebar_position: 7 +--- + +import InitTips from './_zenstack-init-tips.md'; +import AccessPolicy from './_access-policy.md'; + +# 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](https://github.com/zenstackhq/docs-tutorial-nestjs). + +## Requirements + +Our target app should meet the following requirements: + +1. Users can create posts for themselves. +1. Post owner can update their own posts. +1. Users cannot make changes to posts that do not belong to them. +1. Published posts can be viewed by everyone. + +Let's get started 🚀. + +## Prerequisite + +1. Make sure you have Node.js 18 or above installed. +1. Install the [VSCode extension](https://marketplace.visualstudio.com/items?itemName=zenstack.zenstack) for editing data models. + +## Building the app + +### 1. Create a new NestJS project + +```bash +npx @nestjs/cli@latest new -p npm my-blog-app +cd my-blog-app +``` + +### 2. Set up Prisma + +```bash +npm install -D prisma +npx prisma init +``` + +This will create a Prisma schema under `prisma/schema.prisma`. Replace its content with the following: + +```zmodel title="/prisma/schema.prisma" +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 +} +``` + +Now, generate `PrismaClient` and push the schema to the database: + +```bash +npx prisma generate +npx prisma db push +``` + +Create a `PrismaService` which will be injected into the API controllers later. + +```ts title="/src/prisma.service.ts" +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: + +```ts title="/src/app.module.ts" +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. + +```ts title="/src/app.controller.ts" +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: + +```bash +npm run start:dev +``` + +Let's make a few requests to create a user and two posts: + +```bash +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: + +```json +[ + { + "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](https://docs.nestjs.com/security/authentication) 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](https://www.npmjs.com/package/nestjs-cls) package to put the user information into Node.js's [AsyncLocalStorage](https://nodejs.org/api/async_context.html#async_context_class_asynclocalstorage). + +First, install the `nestjs-cls` package: + +```bash +npm install nestjs-cls +``` + +Then, mount the CLS module: + +```ts title="/src/app.module.ts" +import { ClsModule, ClsService } from 'nestjs-cls'; + +@Module({ + imports: [ + // highlight-start + ClsModule.forRoot({ + global: true, + middleware: { + mount: true, + }, + }), + // highlight-end + ], + ... +}) +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: + +```ts title="/src/auth.interceptor.ts" +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`: + +```ts title="/src/app.module.ts" +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { AuthInterceptor } from './auth.interceptor'; + +@Module({ + ... + + providers: [ + PrismaService, + // highlight-start + { + provide: APP_INTERCEPTOR, + useClass: AuthInterceptor, + }, + // highlight-end + ], +}) +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. + +```bash +npx zenstack@next init +``` + + + +Add the following access policies to the `User` and `Post` models: + +```zmodel title='/schema.zmodel' +model User { + id Int @id() @default(autoincrement()) + name String + posts Post[] + + // highlight-start + // 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) + // highlight-end +} + +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? + + // highlight-start + // author has full access + @@allow('all', auth() == author) + + // published posts are readable to all + @@allow('read', published) + // highlight-end +} +``` + + + +Now regenerate `PrismaClient` and other supporting files needed by ZenStack: + +```bash +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: + +```ts +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: + +```bash +npm install @zenstackhq/server@next +``` + +Then, register the `ZenStackModule` onto the app module: + +```ts title="/src/app.module.ts" +import { ZenStackModule } from '@zenstackhq/server/nestjs'; +import { enhance } from '@zenstackhq/runtime'; + +@Module({ + imports: [ + ... + + // highlight-start + ZenStackModule.registerAsync({ + useFactory: (prisma: PrismaService, cls: ClsService) => { + return { + getEnhancedPrisma: () => enhance(prisma, { user: cls.get('auth') }), + }; + }, + inject: [PrismaService, ClsService], + extraProviders: [PrismaService], + }), + // highlight-end + ], + + ... +}) +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. + +:::info +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: + +```ts title="/src/app.controller.ts" +import { ENHANCED_PRISMA } from '@zenstackhq/server/nestjs'; + +@Controller() +export class AppController { + constructor( + // highlight-next-line + @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: + + ```bash + curl localhost:3000/posts + ``` + + ``` + [] + ``` + +- Listing posts with a user identity should return all posts owned by the user: + + ```bash + curl -H "x-user-id:1" localhost:3000/posts + ``` + + ```json + [ + { + "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 + ``` + + ```json + { + "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: + + ```bash + curl localhost:3000/posts + ``` + + ```json + { + "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](https://github.com/zenstackhq/docs-tutorial-nestjs). For more details about ZenStack, please refer to the [Reference](../category/reference) and [Guides](../category/recipes) parts of the documentation. + +Have fun building cool stuff 🚀! diff --git a/docs/upgrade.md b/docs/upgrade.md index 60b9f7c6..8aacae9d 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -175,6 +175,10 @@ We may introduce a new config file format in the future. ### 3. Server Adapter +#### HTTP status code `422` is used to represent data validation errors + +In V1, when a [data validation](../docs/reference/zmodel-language.md#data-validation) error happens (due to violation of rules represented by `@email`, `@length`, `@@validate` etc.), the server adapters used `403` to represent such error. This is changed in V2 to use `422` to align with the [HTTP status code definition](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422). + #### The deprecated `useSuperJSON` initialization options is removed The server adapters always use SuperJSON for serialization and deserialization. diff --git a/docusaurus.config.js b/docusaurus.config.js index bb31df38..24badaf6 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -196,6 +196,7 @@ const config = { prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, + additionalLanguages: ['json'], }, zoom: { config: { From ec77afd4a930d619e97df68ec09759ae0e947d95 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 27 Mar 2024 16:26:40 -0700 Subject: [PATCH 2/4] add nestjs api docs --- docs/reference/server-adapters/nestjs.mdx | 123 ++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/reference/server-adapters/nestjs.mdx diff --git a/docs/reference/server-adapters/nestjs.mdx b/docs/reference/server-adapters/nestjs.mdx new file mode 100644 index 00000000..af47ce3a --- /dev/null +++ b/docs/reference/server-adapters/nestjs.mdx @@ -0,0 +1,123 @@ +--- +title: NestJS +description: Adapter for integrating with NestJS +sidebar_position: 6 +--- + +import ErrorHandling from './_error-handling.md'; +import AdapterOptions from './_options.mdx'; +import UsingAPI from './_using-api.mdx'; + +# NestJS Adapter + +The `@zenstackhq/server/nestjs` module provides a quick way to install an ZenStack-enhanced Prisma service as a dependency injection provider onto a [NestJS](https://nestjs.com/) application. + +### Installation + +```bash +npm install @zenstackhq/server +``` + +### Registering the provider + +You can register the enhanced Prisma service by importing the `ZenStackModule` NestJS module. + +```ts +import { ZenStackModule } from '@zenstackhq/server/nestjs'; +import { enhance } from '@zenstackhq/runtime'; +import { PrismaService } from './prisma.service'; + +@Module({ + imports: [ + ZenStackModule.registerAsync({ + useFactory: (prisma: PrismaService) => { + return { + getEnhancedPrisma: () => enhance(prisma, { user: ... }), + }; + }, + inject: [PrismaService], + extraProviders: [PrismaService], + }), + ], +}) +export class AppModule {} +``` + +The `registerAsync` API takes as input 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. + +You'll usually pass in a user context when calling `enhance` inside the callback. The way how the user context is fetched depends on your authentication mechanism. You can check the [NestJS quick start guide](../../quick-start/nestjs) for a reference solution. + +### Using the enhanced Prisma service + +Inside your NestJS controllers or services, you can inject the enhanced Prisma service and use it as you would with the regular Prisma service. Just use the special token name `ENHANCED_PRISMA` when injecting the service. + +```ts +import { ENHANCED_PRISMA } from '@zenstackhq/server/nestjs'; + +@Controller() +export class MyController { + constructor( + @Inject(ENHANCED_PRISMA) private readonly prismaService: PrismaService, + ) {} + + ... +} +``` + +You can still use the regular Prisma service by injecting as usual. + +### API reference + +#### `ZenStackModule.registerAsync` + +##### Signature + +```ts +registerAsync(options: ZenStackModuleAsyncOptions): DynamicModule; +``` + +##### Parameter `options` + +```ts +interface ZenStackModuleAsyncOptions { + /** + * Optional list of imported modules that export the providers which are + * required in this module. + */ + imports?: Array | DynamicModule | Promise | ForwardReference>; + + /** + * Whether the module is global-scoped. + */ + global?: boolean; + + /** + * The token to export the enhanced Prisma service. Default is `'ENHANCED_PRISMA'`. + */ + exportToken?: string; + + /** + * The factory function to create the enhancement options. + */ + useFactory: (...args: unknown[]) => Promise | ZenStackModuleOptions; + + /** + * The dependencies to inject into the factory function. + */ + inject?: FactoryProvider['inject']; + + /** + * Extra providers to facilitate dependency injection. + */ + extraProviders?: Provider[]; +} +``` + +```ts +interface ZenStackModuleOptions { + /** + * A callback for getting an enhanced `PrismaClient`. + */ + getEnhancedPrisma: () => unknown; +} +``` From 10a8f3c0b17c48d9cb53165ca018ad8e6ba23de4 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 27 Mar 2024 16:30:24 -0700 Subject: [PATCH 3/4] fix grammar --- docs/quick-start/_access-policy.md | 8 ++++---- .../version-1.x/quick-start/_access-policy.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/quick-start/_access-policy.md b/docs/quick-start/_access-policy.md index ef0a3e40..fcef0835 100644 --- a/docs/quick-start/_access-policy.md +++ b/docs/quick-start/_access-policy.md @@ -1,16 +1,16 @@ :::info -By default, all operations are denied for a model. You can use `@@allow` attribute to open up some permissions. +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 the follows: +Whether an operation is permitted is determined as follows: 1. If any `@@deny` rule evaluates to true, it's denied. -1. If any `@@allow` rule evaluates to true, it's allowed. -1. Otherwise, it's denied. +2. If any `@@allow` rule evaluates to true, it's allowed. +3. Otherwise, it's denied. Check out [Understanding Access Policies](/docs/the-complete-guide/part1/access-policy) for more details. ::: diff --git a/versioned_docs/version-1.x/quick-start/_access-policy.md b/versioned_docs/version-1.x/quick-start/_access-policy.md index 78525599..276589b4 100644 --- a/versioned_docs/version-1.x/quick-start/_access-policy.md +++ b/versioned_docs/version-1.x/quick-start/_access-policy.md @@ -1,16 +1,16 @@ :::tip -By default, all operations are denied for a model. You can use `@@allow` attribute to open up some permissions. +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 verdicting if the rule should be activated. +`@@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 the follows: +Whether an operation is permitted is determined as follows: 1. If any `@@deny` rule evaluates to true, it's denied. -1. If any `@@allow` rule evaluates to true, it's allowed. -1. Otherwise, it's denied. +2. If any `@@allow` rule evaluates to true, it's allowed. +3. Otherwise, it's denied. Check out [Understanding Access Policies](/docs/the-complete-guide/part1/access-policy) for more details. ::: From ec1df4e6cc7f57617a8ea5b6699655ba7ebe9a55 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 27 Mar 2024 16:31:36 -0700 Subject: [PATCH 4/4] fix --- docs/reference/server-adapters/nestjs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/server-adapters/nestjs.mdx b/docs/reference/server-adapters/nestjs.mdx index af47ce3a..b31305d9 100644 --- a/docs/reference/server-adapters/nestjs.mdx +++ b/docs/reference/server-adapters/nestjs.mdx @@ -10,7 +10,7 @@ import UsingAPI from './_using-api.mdx'; # NestJS Adapter -The `@zenstackhq/server/nestjs` module provides a quick way to install an ZenStack-enhanced Prisma service as a dependency injection provider onto a [NestJS](https://nestjs.com/) application. +The `@zenstackhq/server/nestjs` module provides a quick way to install a ZenStack-enhanced Prisma service as a dependency injection provider onto a [NestJS](https://nestjs.com/) application. ### Installation