diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 87cf08cc3..e065cea09 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -29,6 +29,7 @@ - [Choosing a database](choosing-a-database.md) - [Evolving model with migration](evolving-model-with-migration.md) - [Integrating authentication](integrating-authentication.md) + - [Server-side rendering](server-side-rendering.md) - [Set up logging](setup-logging.md) - [Telemetry](telemetry.md) diff --git a/docs/building-your-app.md b/docs/building-your-app.md index 41eafdfdf..ab3cf4950 100644 --- a/docs/building-your-app.md +++ b/docs/building-your-app.md @@ -144,24 +144,7 @@ const post = await del(id); Since doing CRUD with hooks is already secure, in many cases, you can implement your business logic right in the frontend code. -In case you need to do server-side coding, either through implementing an API endpoint or by using `getServerSideProps` for SSR, you can directly access the database client generated by Prisma: +ZenStack also supports server-side programming for conducting CRUD without sending HTTP requests, or even direct database access (bypassing access policy checks). Please check the following documentation for details: -```ts -import service from '@zenstackhq/runtime'; - -export const getServerSideProps: GetServerSideProps = async () => { - const posts = await service.db.post.findMany({ - where: { published: true }, - include: { author: true }, - }); - return { - props: { posts }, - }; -}; -``` - -The Typescript types of data models, filters, sorting, etc., are all shared between the frontend and the backend. - -_Note_ Server-side database access is **NOT PROTECTED** by access policies. This is by-design so as to provide a way of bypassing the policies. Please make sure you implement authorization properly. - -_TBD_ In the future we'll provide a utility for explicitly validating access policies in backend code, so that you can reuse your policy definitions in the model. +- [Server runtime API](runtime-api.md#zenstackhqruntimeserver) +- [Server-side rendering](server-side-rendering.md) diff --git a/docs/runtime-api.md b/docs/runtime-api.md index 8b81ef77a..42282bf84 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -44,18 +44,22 @@ A `useXXX` API is generated fo each data model for getting the React hooks. The const { get, find, create, update, del } = useUser(); ``` -### RequestOptions +### `RequestOptions` Options controlling hooks' fetch behavior. ```ts -type RequestOptions = { +type RequestOptions = { // indicates if fetch should be disabled - disabled: boolean; + disabled?: boolean; + + // provides initial data, which is immediately available + // before fresh data is fetched (usually used with SSR) + initialData?: T; }; ``` -### HooksError +### `HooksError` Error thrown for failure of `create`, `update` and `delete` hooks. @@ -69,7 +73,7 @@ export type HooksError = { }; ``` -#### ServerErrorCode +#### `ServerErrorCode` | Code | Description | | ------------------------------ | --------------------------------------------------------------------------------------------- | @@ -80,7 +84,7 @@ export type HooksError = { | REFERENCE_CONSTRAINT_VIOLATION | Violation of database reference constraint (aka. foreign key constraints) | | READ_BACK_AFTER_WRITE_DENIED | A write operation succeeded but the result cannot be read back due to policy control | -### get +### `get` ```ts function get( @@ -90,7 +94,7 @@ function get( ): SWRResponse; ``` -### find +### `find` ```ts function find( @@ -99,34 +103,91 @@ function find( ): SWRResponse; ``` -### create +### `create` ```ts function create(args?: UserCreateArgs): Promise; ``` -### update +### `update` ```ts function update(id: string, args?: UserUpdateArgs): Promise; ``` -### del +### `del` ```ts -function del(id: string): Promise; +function del(id: string, args?: UserDeleteArgs): Promise; ``` ## `@zenstackhq/runtime/server` This module contains API for server-side programming. The following declarations are exported: -### `default` +### `service` The default export of this module is a `service` object which encapsulates most of the server-side APIs. +#### Server-side CRUD + +The `service` object contains members for each of the data models, each containing server-side CRUD APIs. These APIs can be used for doing CRUD operations without HTTP request overhead, while still fully protected by access policies. + +The server-side CRUD APIs have similar signature with client-side hooks, except that they take an extra `queryContext` parameter for passing in the current login user. They're usually used for implementing SSR or custom API endpoints. + +- get + + ```ts + async get( + context: QueryContext, + id: string, + args?: UserFindFirstArgs + ): Promise; + ``` + +- find + + ```ts + async find( + context: QueryContext, + args?: UserFindManyArgs + ): Promise; + ``` + +- create + + ```ts + async find( + context: QueryContext, + args?: UserCreateArgs + ): Promise; + ``` + +- update + + ```ts + async get( + context: QueryContext, + id: string, + args?: UserUpdateArgs + ): Promise; + ``` + +- del + ```ts + async get( + context: QueryContext, + id: string, + args?: UserDeleteArgs + ): Promise; + ``` + +#### Direct database access + The `service.db` object contains a member field for each data model defined, which you can use to conduct database operations for that model. +_NOTE_ These database operations are **NOT** protected by access policies. + Take `User` model for example: ```ts diff --git a/docs/server-side-rendering.md b/docs/server-side-rendering.md new file mode 100644 index 000000000..e2ce76829 --- /dev/null +++ b/docs/server-side-rendering.md @@ -0,0 +1,26 @@ +# Server-side rendering + +You can use the `service` object to conduct CRUD operations on the server side directly without the overhead of HTTP requests. The `service` object contains members for each of the data model defined. + +The server-side CRUD methods are similar signature with client-side hooks, except that they take an extra `queryContext` parameter for passing in the current login user. Like client-side hooks, the CRUD operations are fully protected by access policies defined in ZModel. + +These methods are handy for implementing SSR (or custom API endpoints). Here's an example (using Next-Auth for authentication): + +```ts +import service from '@zenstackhq/runtime/server'; +import { unstable_getServerSession } from 'next-auth'; +... + +export const getServerSideProps = async ({ + req, + res, + params, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const queryContext = { user: session?.user }; + const posts = await service.post.find(queryContext); + return { + props: { posts }, + }; +}; +``` diff --git a/package.json b/package.json index a86a32fd4..a6f88f6ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "0.3.19", + "version": "0.3.22", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b492230d0..2261ea886 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.19", + "version": "0.3.22", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/client.ts b/packages/runtime/src/client.ts deleted file mode 100644 index a27d2b4dc..000000000 --- a/packages/runtime/src/client.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ServerErrorCode, RequestOptions } from './types'; -export * as request from './request'; -export * from './validation'; diff --git a/packages/runtime/src/handler/data/crud.ts b/packages/runtime/src/handler/data/crud.ts new file mode 100644 index 000000000..4d6a6a7d7 --- /dev/null +++ b/packages/runtime/src/handler/data/crud.ts @@ -0,0 +1,450 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import cuid from 'cuid'; +import { TRANSACTION_FIELD_NAME } from '../../constants'; +import { + DbClientContract, + DbOperations, + getServerErrorMessage, + QueryContext, + ServerErrorCode, + Service, +} from '../../types'; +import { ValidationError } from '../../validation'; +import { CRUDError } from '../types'; +import { + and, + checkPolicyForIds, + injectTransactionId, + preprocessWritePayload, + preUpdateCheck, + queryIds, + readWithCheck, +} from './policy-utils'; + +const PRISMA_ERROR_MAPPING: Record = { + P2002: ServerErrorCode.UNIQUE_CONSTRAINT_VIOLATION, + P2003: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, + P2025: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, +}; + +/** + * Request handler for /data endpoint which processes data CRUD requests. + */ +export class CRUD { + constructor(private readonly service: Service) {} + + private get db() { + return this.service.db as DbClientContract; + } + + async get( + model: string, + id: string, + args: any, + context: QueryContext + ): Promise { + args = args ?? {}; + args.where = and(args.where, { id }); + + let entities: unknown[]; + try { + entities = await readWithCheck( + model, + args, + this.service, + context, + this.db + ); + } catch (err) { + throw this.processError(err, 'get', model); + } + + return entities[0]; + } + + async find( + model: string, + args: any, + context: QueryContext + ): Promise { + try { + return await readWithCheck( + model, + args ?? {}, + this.service, + context, + this.db + ); + } catch (err) { + throw this.processError(err, 'find', model); + } + } + + async create( + model: string, + args: any, + context: QueryContext + ): Promise { + if (!args) { + throw new CRUDError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + 'body is required' + ); + } + if (!args.data) { + throw new CRUDError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + 'data field is required' + ); + } + + let createResult: { id: string }; + + try { + await this.service.validateModelPayload(model, 'create', args.data); + + // preprocess payload to modify fields as required by attribute like @password + await preprocessWritePayload(model, args, this.service); + + const transactionId = cuid(); + + // start an interactive transaction + createResult = await this.db.$transaction( + async (tx: Record) => { + // inject transaction id into update/create payload (direct and nested) + const { createdModels } = await injectTransactionId( + model, + args, + 'create', + transactionId, + this.service + ); + + // conduct the create + this.service.verbose( + `Conducting create: ${model}:\n${JSON.stringify(args)}` + ); + const createResult = (await tx[model].create(args)) as { + id: string; + }; + + // verify that the created entity pass policy check + await checkPolicyForIds( + model, + [createResult.id], + 'create', + this.service, + context, + tx + ); + + // verify that nested creates pass policy check + await Promise.all( + createdModels.map(async (model) => { + const createdIds = await queryIds(model, tx, { + [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, + }); + this.service.verbose( + `Validating nestedly created entities: ${model}#[${createdIds.join( + ', ' + )}]` + ); + await checkPolicyForIds( + model, + createdIds, + 'create', + this.service, + context, + tx + ); + }) + ); + + return createResult; + } + ); + } catch (err) { + throw this.processError(err, 'create', model); + } + + // verify that return data requested by query args pass policy check + const readArgs = { ...args, where: { id: createResult.id } }; + delete readArgs.data; + + try { + const result = await readWithCheck( + model, + readArgs, + this.service, + context, + this.db + ); + if (result.length === 0) { + throw new CRUDError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } + return result[0]; + } catch (err) { + if ( + err instanceof CRUDError && + err.code === ServerErrorCode.DENIED_BY_POLICY + ) { + throw new CRUDError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } else { + throw err; + } + } + } + + async update( + model: string, + id: string, + args: any, + context: QueryContext + ): Promise { + if (!args) { + throw new CRUDError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + 'body is required' + ); + } + + try { + await this.service.validateModelPayload(model, 'update', args.data); + + // preprocess payload to modify fields as required by attribute like @password + await preprocessWritePayload(model, args, this.service); + + args.where = { ...args.where, id }; + + const transactionId = cuid(); + + await this.db.$transaction( + async (tx: Record) => { + // make sure the entity (including ones involved in nested write) pass policy check + await preUpdateCheck( + model, + id, + args, + this.service, + context, + tx + ); + + // inject transaction id into update/create payload (direct and nested) + const { createdModels } = await injectTransactionId( + model, + args, + 'update', + transactionId, + this.service + ); + + // conduct the update + this.service.verbose( + `Conducting update: ${model}:\n${JSON.stringify(args)}` + ); + await tx[model].update(args); + + // verify that nested creates pass policy check + await Promise.all( + createdModels.map(async (model) => { + const createdIds = await queryIds(model, tx, { + [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, + }); + this.service.verbose( + `Validating nestedly created entities: ${model}#[${createdIds.join( + ', ' + )}]` + ); + await checkPolicyForIds( + model, + createdIds, + 'create', + this.service, + context, + tx + ); + }) + ); + } + ); + } catch (err) { + throw this.processError(err, 'update', model); + } + + // verify that return data requested by query args pass policy check + const readArgs = { ...args }; + delete readArgs.data; + + try { + const result = await readWithCheck( + model, + readArgs, + this.service, + context, + this.db + ); + if (result.length === 0) { + throw new CRUDError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } + return result[0]; + } catch (err) { + if ( + err instanceof CRUDError && + err.code === ServerErrorCode.DENIED_BY_POLICY + ) { + throw new CRUDError( + ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED + ); + } else { + throw err; + } + } + } + + async del( + model: string, + id: string, + args: any, + context: QueryContext + ): Promise { + let result: unknown; + + try { + // ensures the item under deletion passes policy check + await checkPolicyForIds( + model, + [id], + 'delete', + this.service, + context, + this.db + ); + + args = args ?? {}; + args.where = { ...args.where, id }; + + result = await this.db.$transaction( + async (tx: Record) => { + // first fetch the data that needs to be returned after deletion + let readResult: any; + try { + const items = await readWithCheck( + model, + args, + this.service, + context, + tx + ); + readResult = items[0]; + } catch (err) { + if ( + err instanceof CRUDError && + err.code === ServerErrorCode.DENIED_BY_POLICY + ) { + // can't read back, just return undefined, outer logic handles it + } else { + throw err; + } + } + + // conduct the deletion + this.service.verbose( + `Conducting delete ${model}:\n${JSON.stringify(args)}` + ); + await tx[model].delete(args); + + return readResult; + } + ); + } catch (err) { + throw this.processError(err, 'del', model); + } + + if (result) { + return result; + } else { + throw new CRUDError(ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED); + } + } + + private isPrismaClientKnownRequestError( + err: any + ): err is { code: string; message: string } { + return ( + err.__proto__.constructor.name === 'PrismaClientKnownRequestError' + ); + } + + private isPrismaClientValidationError( + err: any + ): err is { message: string } { + return err.__proto__.constructor.name === 'PrismaClientValidationError'; + } + + private processError( + err: unknown, + operation: 'get' | 'find' | 'create' | 'update' | 'del', + model: string + ) { + if (err instanceof CRUDError) { + return err; + } + + if (this.isPrismaClientKnownRequestError(err)) { + this.service.warn( + `Prisma request error: ${operation} ${model}: ${err}` + ); + + // errors thrown by Prisma, try mapping to a known error + if (PRISMA_ERROR_MAPPING[err.code]) { + return new CRUDError( + PRISMA_ERROR_MAPPING[err.code], + getServerErrorMessage(PRISMA_ERROR_MAPPING[err.code]) + ); + } else { + return new CRUDError( + ServerErrorCode.UNKNOWN, + 'an unhandled Prisma error occurred: ' + err.code + ); + } + } else if (this.isPrismaClientValidationError(err)) { + this.service.warn( + `Prisma validation error: ${operation} ${model}: ${err}` + ); + + // prisma validation error + return new CRUDError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + getServerErrorMessage(ServerErrorCode.INVALID_REQUEST_PARAMS) + ); + } else if (err instanceof ValidationError) { + this.service.warn( + `Field constraint validation error: ${operation} ${model}: ${err.message}` + ); + + return new CRUDError( + ServerErrorCode.INVALID_REQUEST_PARAMS, + err.message + ); + } else { + // generic errors + this.service.error( + `An unknown error occurred: ${JSON.stringify(err)}` + ); + if (err instanceof Error && err.stack) { + this.service.error(err.stack); + } + return new CRUDError( + ServerErrorCode.UNKNOWN, + getServerErrorMessage(ServerErrorCode.UNKNOWN) + ); + } + } +} diff --git a/packages/runtime/src/handler/data/handler.ts b/packages/runtime/src/handler/data/handler.ts index fcf42dd03..baab57bd5 100644 --- a/packages/runtime/src/handler/data/handler.ts +++ b/packages/runtime/src/handler/data/handler.ts @@ -1,33 +1,13 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import cuid from 'cuid'; import { NextApiRequest, NextApiResponse } from 'next'; -import { TRANSACTION_FIELD_NAME } from '../../constants'; import { RequestHandlerOptions } from '../../request-handler'; import { DbClientContract, - DbOperations, - getServerErrorMessage, QueryContext, ServerErrorCode, Service, } from '../../types'; -import { ValidationError } from '../../validation'; -import { RequestHandler, RequestHandlerError } from '../types'; -import { - and, - checkPolicyForIds, - injectTransactionId, - preprocessWritePayload, - preUpdateCheck, - queryIds, - readWithCheck, -} from './policy-utils'; - -const PRISMA_ERROR_MAPPING: Record = { - P2002: ServerErrorCode.UNIQUE_CONSTRAINT_VIOLATION, - P2003: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, - P2025: ServerErrorCode.REFERENCE_CONSTRAINT_VIOLATION, -}; +import { RequestHandler, CRUDError } from '../types'; +import { CRUD } from './crud'; /** * Request handler for /data endpoint which processes data CRUD requests. @@ -35,10 +15,14 @@ const PRISMA_ERROR_MAPPING: Record = { export default class DataHandler implements RequestHandler { + private readonly crud: CRUD; + constructor( private readonly service: Service, private readonly options: RequestHandlerOptions - ) {} + ) { + this.crud = new CRUD(service); + } async handle( req: NextApiRequest, @@ -79,7 +63,7 @@ export default class DataHandler break; } } catch (err: unknown) { - if (err instanceof RequestHandlerError) { + if (err instanceof CRUDError) { this.service.warn(`${method} ${model}: ${err}`); // in case of errors thrown directly by ZenStack @@ -99,59 +83,19 @@ export default class DataHandler }); break; + case ServerErrorCode.UNKNOWN: + res.status(500).send({ + code: err.code, + message: err.message, + }); + break; + default: res.status(400).send({ code: err.code, message: err.message, }); } - } else if (this.isPrismaClientKnownRequestError(err)) { - this.service.warn(`${method} ${model}: ${err}`); - - // errors thrown by Prisma, try mapping to a known error - if (PRISMA_ERROR_MAPPING[err.code]) { - res.status(400).send({ - code: PRISMA_ERROR_MAPPING[err.code], - message: getServerErrorMessage( - PRISMA_ERROR_MAPPING[err.code] - ), - }); - } else { - res.status(400).send({ - code: 'PRISMA:' + err.code, - message: 'an unhandled Prisma error occurred', - }); - } - } else if (this.isPrismaClientValidationError(err)) { - this.service.warn(`${method} ${model}: ${err}`); - - // prisma validation error - res.status(400).send({ - code: ServerErrorCode.INVALID_REQUEST_PARAMS, - message: getServerErrorMessage( - ServerErrorCode.INVALID_REQUEST_PARAMS - ), - }); - } else if (err instanceof ValidationError) { - this.service.warn( - `Field constraint validation error for model "${model}": ${err.message}` - ); - res.status(400).send({ - code: ServerErrorCode.INVALID_REQUEST_PARAMS, - message: err.message, - }); - } else { - // generic errors - this.service.error( - `An unknown error occurred: ${JSON.stringify(err)}` - ); - if (err instanceof Error && err.stack) { - this.service.error(err.stack); - } - res.status(500).send({ - code: ServerErrorCode.UNKNOWN, - message: getServerErrorMessage(ServerErrorCode.UNKNOWN), - }); } } } @@ -168,29 +112,14 @@ export default class DataHandler if (id) { // GET /:id, make sure "id" is injected - args.where = and(args.where, { id }); - - const result = await readWithCheck( - model, - args, - this.service, - context, - this.service.db - ); - - if (result.length === 0) { - throw new RequestHandlerError(ServerErrorCode.ENTITY_NOT_FOUND); + const result = await this.crud.get(model, id, args, context); + if (!result) { + throw new CRUDError(ServerErrorCode.ENTITY_NOT_FOUND); } - res.status(200).send(result[0]); + res.status(200).send(result); } else { // GET /, get list - const result = await readWithCheck( - model, - args, - this.service, - context, - this.service.db - ); + const result = await this.crud.find(model, args, context); res.status(200).send(result); } } @@ -201,114 +130,8 @@ export default class DataHandler model: string, context: QueryContext ) { - // validate args - const args = req.body; - if (!args) { - throw new RequestHandlerError( - ServerErrorCode.INVALID_REQUEST_PARAMS, - 'body is required' - ); - } - if (!args.data) { - throw new RequestHandlerError( - ServerErrorCode.INVALID_REQUEST_PARAMS, - 'data field is required' - ); - } - - await this.service.validateModelPayload(model, 'create', args.data); - - // preprocess payload to modify fields as required by attribute like @password - await preprocessWritePayload(model, args, this.service); - - const transactionId = cuid(); - - // start an interactive transaction - const r = await this.service.db.$transaction( - async (tx: Record) => { - // inject transaction id into update/create payload (direct and nested) - const { createdModels } = await injectTransactionId( - model, - args, - 'create', - transactionId, - this.service - ); - - // conduct the create - this.service.verbose( - `Conducting create: ${model}:\n${JSON.stringify(args)}` - ); - const createResult = (await tx[model].create(args)) as { - id: string; - }; - - // verify that the created entity pass policy check - await checkPolicyForIds( - model, - [createResult.id], - 'create', - this.service, - context, - tx - ); - - // verify that nested creates pass policy check - await Promise.all( - createdModels.map(async (model) => { - const createdIds = await queryIds(model, tx, { - [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, - }); - this.service.verbose( - `Validating nestedly created entities: ${model}#[${createdIds.join( - ', ' - )}]` - ); - await checkPolicyForIds( - model, - createdIds, - 'create', - this.service, - context, - tx - ); - }) - ); - - return createResult; - } - ); - - // verify that return data requested by query args pass policy check - const readArgs = { ...args, where: { id: r.id } }; - delete readArgs.data; - - try { - const result = await readWithCheck( - model, - readArgs, - this.service, - context, - this.service.db - ); - if (result.length === 0) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } - res.status(201).send(result[0]); - } catch (err) { - if ( - err instanceof RequestHandlerError && - err.code === ServerErrorCode.DENIED_BY_POLICY - ) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } else { - throw err; - } - } + const result = await this.crud.create(model, req.body, context); + res.status(201).send(result); } private async put( @@ -319,110 +142,14 @@ export default class DataHandler context: QueryContext ) { if (!id) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, 'missing "id" parameter' ); } - const args = req.body; - if (!args) { - throw new RequestHandlerError( - ServerErrorCode.INVALID_REQUEST_PARAMS, - 'body is required' - ); - } - - await this.service.validateModelPayload(model, 'update', args.data); - - // preprocess payload to modify fields as required by attribute like @password - await preprocessWritePayload(model, args, this.service); - - args.where = { ...args.where, id }; - - const transactionId = cuid(); - - await this.service.db.$transaction( - async (tx: Record) => { - // make sure the entity (including ones involved in nested write) pass policy check - await preUpdateCheck( - model, - id, - args, - this.service, - context, - tx - ); - - // inject transaction id into update/create payload (direct and nested) - const { createdModels } = await injectTransactionId( - model, - args, - 'update', - transactionId, - this.service - ); - - // conduct the update - this.service.verbose( - `Conducting update: ${model}:\n${JSON.stringify(args)}` - ); - await tx[model].update(args); - - // verify that nested creates pass policy check - await Promise.all( - createdModels.map(async (model) => { - const createdIds = await queryIds(model, tx, { - [TRANSACTION_FIELD_NAME]: `${transactionId}:create`, - }); - this.service.verbose( - `Validating nestedly created entities: ${model}#[${createdIds.join( - ', ' - )}]` - ); - await checkPolicyForIds( - model, - createdIds, - 'create', - this.service, - context, - tx - ); - }) - ); - } - ); - - // verify that return data requested by query args pass policy check - const readArgs = { ...args }; - delete readArgs.data; - - try { - const result = await readWithCheck( - model, - readArgs, - this.service, - context, - this.service.db - ); - if (result.length === 0) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } - res.status(200).send(result[0]); - } catch (err) { - if ( - err instanceof RequestHandlerError && - err.code === ServerErrorCode.DENIED_BY_POLICY - ) { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } else { - throw err; - } - } + const result = await this.crud.update(model, id, req.body, context); + res.status(200).send(result); } private async del( @@ -433,79 +160,14 @@ export default class DataHandler context: QueryContext ) { if (!id) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.INVALID_REQUEST_PARAMS, 'missing "id" parameter' ); } - // ensures the item under deletion passes policy check - await checkPolicyForIds( - model, - [id], - 'delete', - this.service, - context, - this.service.db - ); - const args = req.query.q ? JSON.parse(req.query.q as string) : {}; - args.where = { ...args.where, id }; - - const r = await this.service.db.$transaction( - async (tx: Record) => { - // first fetch the data that needs to be returned after deletion - let readResult: any; - try { - const items = await readWithCheck( - model, - args, - this.service, - context, - tx - ); - readResult = items[0]; - } catch (err) { - if ( - err instanceof RequestHandlerError && - err.code === ServerErrorCode.DENIED_BY_POLICY - ) { - // can't read back, just return undefined, outer logic handles it - } else { - throw err; - } - } - - // conduct the deletion - this.service.verbose( - `Conducting delete ${model}:\n${JSON.stringify(args)}` - ); - await tx[model].delete(args); - - return readResult; - } - ); - - if (r) { - res.status(200).send(r); - } else { - throw new RequestHandlerError( - ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED - ); - } - } - - private isPrismaClientKnownRequestError( - err: any - ): err is { code: string; message: string } { - return ( - err.__proto__.constructor.name === 'PrismaClientKnownRequestError' - ); - } - - private isPrismaClientValidationError( - err: any - ): err is { message: string } { - return err.__proto__.constructor.name === 'PrismaClientValidationError'; + const result = await this.crud.del(model, id, args, context); + res.status(200).send(result); } } diff --git a/packages/runtime/src/handler/data/policy-utils.ts b/packages/runtime/src/handler/data/policy-utils.ts index 5c15df1ea..6cd6e120e 100644 --- a/packages/runtime/src/handler/data/policy-utils.ts +++ b/packages/runtime/src/handler/data/policy-utils.ts @@ -15,7 +15,7 @@ import { ServerErrorCode, Service, } from '../../types'; -import { PrismaWriteActionType, RequestHandlerError } from '../types'; +import { PrismaWriteActionType, CRUDError } from '../types'; import { NestedWriteVisitor } from './nested-write-vistor'; //#region General helpers @@ -77,7 +77,7 @@ export async function queryIds( * * For to-many relations involved, items not satisfying policy are * silently trimmed. For to-one relation, if relation data fails policy - * an RequestHandlerError is thrown. + * an CRUDError is thrown. * * @param model the model to query for * @param queryArgs the Prisma query args @@ -395,7 +395,7 @@ export async function preUpdateCheck( /** * Given a list of ids for a model, check if they all match policy rules, and if not, - * throw a RequestHandlerError. + * throw a CRUDError. * * @param model the model * @param ids the entity ids @@ -435,7 +435,7 @@ export async function checkPolicyForIds( const filteredIds = filteredResult.map((item) => item.id); if (filteredIds.length < ids.length) { const gap = ids.filter((id) => !filteredIds.includes(id)); - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.DENIED_BY_POLICY, `denied by policy: entities failed '${operation}' check, ${model}#[${gap.join( ', ' @@ -514,7 +514,7 @@ function collectTerminalEntityIds( } if (!curr) { - throw new RequestHandlerError( + throw new CRUDError( ServerErrorCode.UNKNOWN, 'an unexpected error occurred' ); diff --git a/packages/runtime/src/handler/types.ts b/packages/runtime/src/handler/types.ts index b481f6208..54701690d 100644 --- a/packages/runtime/src/handler/types.ts +++ b/packages/runtime/src/handler/types.ts @@ -20,9 +20,9 @@ export interface RequestHandler { } /** - * Error thrown during request handling + * Error thrown during CRUD operations */ -export class RequestHandlerError extends Error { +export class CRUDError extends Error { constructor(public readonly code: ServerErrorCode, message?: string) { message = message ? `${getServerErrorMessage(code)}: ${message}` diff --git a/packages/runtime/src/request.ts b/packages/runtime/src/request.ts index 0d234b3ed..b5984beb4 100644 --- a/packages/runtime/src/request.ts +++ b/packages/runtime/src/request.ts @@ -103,10 +103,12 @@ function makeUrl(url: string, args: unknown) { export function get( url: string | null, args?: unknown, - options?: RequestOptions + options?: RequestOptions ): SWRResponse { const reqUrl = options?.disabled ? null : url ? makeUrl(url, args) : null; - return useSWR(reqUrl, fetcher); + return useSWR(reqUrl, fetcher, { + fallbackData: options?.initialData, + }); } export async function post( diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index e3e7ec6e0..4b8f85508 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -99,6 +99,15 @@ export interface Service { context: QueryContext ): Promise; + /** + * Validates the given write payload for the given model according to field constraints in model. + * + * @param model Model name + * @param mode Write mode + * @param payload Write payload + * + * @throws @see ValidationError + */ validateModelPayload( model: string, mode: 'create' | 'update', @@ -217,9 +226,10 @@ export type LogEvent = { /** * Client request options */ -export type RequestOptions = { +export type RequestOptions = { // disable data fetching - disabled: boolean; + disabled?: boolean; + initialData?: T; }; /** diff --git a/packages/schema/package.json b/packages/schema/package.json index 22cfd0de3..d07c55d7d 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.19", + "version": "0.3.22", "author": { "name": "ZenStack Team" }, @@ -94,7 +94,7 @@ "mixpanel": "^0.17.0", "node-machine-id": "^1.1.12", "pluralize": "^8.0.0", - "prisma": "^4.5.0", + "prisma": "~4.5.0", "promisify": "^0.0.3", "sleep-promise": "^9.1.0", "ts-morph": "^16.0.0", @@ -106,7 +106,7 @@ "vscode-uri": "^3.0.6" }, "devDependencies": { - "@prisma/internals": "^4.5.0", + "@prisma/internals": "~4.5.0", "@types/async-exit-hook": "^2.0.0", "@types/jest": "^29.2.0", "@types/node": "^14.18.32", diff --git a/packages/schema/src/cli/cli-util.ts b/packages/schema/src/cli/cli-util.ts index bab3b8d4a..27f2619c7 100644 --- a/packages/schema/src/cli/cli-util.ts +++ b/packages/schema/src/cli/cli-util.ts @@ -207,6 +207,8 @@ export async function runGenerator( if (err instanceof GeneratorError) { console.error(colors.red(err.message)); throw new CliError(err.message); + } else { + throw err; } } } diff --git a/packages/schema/src/generator/react-hooks/index.ts b/packages/schema/src/generator/react-hooks/index.ts index 013dc3190..242d1b964 100644 --- a/packages/schema/src/generator/react-hooks/index.ts +++ b/packages/schema/src/generator/react-hooks/index.ts @@ -137,7 +137,7 @@ export default class ReactHooksGenerator implements Generator { }, { name: 'options?', - type: 'RequestOptions', + type: `RequestOptions, Array>>>`, }, ], }) @@ -163,7 +163,7 @@ export default class ReactHooksGenerator implements Generator { }, { name: 'options?', - type: 'RequestOptions', + type: `RequestOptions>>`, }, ], }) diff --git a/packages/schema/src/generator/service/index.ts b/packages/schema/src/generator/service/index.ts index 8ba3f685a..3216832dd 100644 --- a/packages/schema/src/generator/service/index.ts +++ b/packages/schema/src/generator/service/index.ts @@ -1,8 +1,10 @@ -import { Context, Generator } from '../types'; -import { Project } from 'ts-morph'; -import * as path from 'path'; +import { DataModel, isDataModel } from '@lang/generated/ast'; +import { camelCase } from 'change-case'; import colors from 'colors'; +import * as path from 'path'; +import { Project } from 'ts-morph'; import { RUNTIME_PACKAGE } from '../constants'; +import { Context, Generator } from '../types'; /** * Generates ZenStack service code @@ -20,9 +22,18 @@ export default class ServiceGenerator implements Generator { { overwrite: true } ); + const models = context.schema.declarations.filter((d): d is DataModel => + isDataModel(d) + ); + sf.addStatements([ - `import { PrismaClient } from "../.prisma";`, + `import { Prisma as P, PrismaClient } from "../.prisma";`, `import { DefaultService } from "${RUNTIME_PACKAGE}/lib/service";`, + `import { CRUD } from "${RUNTIME_PACKAGE}/lib/handler/data/crud";`, + `import type { QueryContext } from "${RUNTIME_PACKAGE}/lib/types";`, + `import type { ${models + .map((m) => m.name) + .join(', ')} } from "../.prisma";`, ]); const cls = sf.addClass({ @@ -31,6 +42,11 @@ export default class ServiceGenerator implements Generator { extends: 'DefaultService', }); + cls.addProperty({ + name: 'private crud', + initializer: `new CRUD(this)`, + }); + cls.addMethod({ name: 'initializePrisma', }).setBodyText(` @@ -53,6 +69,26 @@ export default class ServiceGenerator implements Generator { return import('./field-constraint'); `); + // server-side CRUD operations per model + for (const model of models) { + cls.addGetAccessor({ + name: camelCase(model.name), + }).setBodyText(` + return { + get: (context: QueryContext, id: string, args?: P.SelectSubset>) => + this.crud.get('${model.name}', id, args, context) as Promise> | undefined>, + find: (context: QueryContext, args?: P.SelectSubset) => + this.crud.find('${model.name}', args, context) as Promise, Array>>>, + create: (context: QueryContext, args: P.${model.name}CreateArgs) => + this.crud.create('${model.name}', args, context) as Promise>>, + update: >(context: QueryContext, id: string, args: Omit) => + this.crud.update('${model.name}', id, args, context) as Promise>>, + del: >(context: QueryContext, id: string, args?: Omit) => + this.crud.del('${model.name}', id, args, context) as Promise>>, + } + `); + } + // Recommended by Prisma for Next.js // https://www.prisma.io/docs/guides/database/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices#problem sf.addStatements([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c90bcdb2..6759fbe7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ importers: packages/schema: specifiers: - '@prisma/internals': ^4.5.0 + '@prisma/internals': ~4.5.0 '@types/async-exit-hook': ^2.0.0 '@types/jest': ^29.2.0 '@types/node': ^14.18.32 @@ -77,7 +77,7 @@ importers: mixpanel: ^0.17.0 node-machine-id: ^1.1.12 pluralize: ^8.0.0 - prisma: ^4.5.0 + prisma: ~4.5.0 promisify: ^0.0.3 rimraf: ^3.0.2 sleep-promise: ^9.1.0 diff --git a/samples/todo/components/BreadCrumb.tsx b/samples/todo/components/BreadCrumb.tsx index 78de41bf9..f537f9c1b 100644 --- a/samples/todo/components/BreadCrumb.tsx +++ b/samples/todo/components/BreadCrumb.tsx @@ -1,15 +1,17 @@ -import { useCurrentSpace } from '@lib/context'; -import { useList } from '@zenstackhq/runtime/client'; +import { List, Space } from '@zenstackhq/runtime/types'; import Link from 'next/link'; import { useRouter } from 'next/router'; -export default function BreadCrumb() { +type Props = { + space: Space; + list?: List; +}; + +export default function BreadCrumb({ space, list }: Props) { const router = useRouter(); - const space = useCurrentSpace(); - const { get: getList } = useList(); const parts = router.asPath.split('/').filter((p) => p); - const [base, slug, listId] = parts; + const [base] = parts; if (base !== 'space') { return <>; } @@ -17,13 +19,12 @@ export default function BreadCrumb() { const items: Array<{ text: string; link: string }> = []; items.push({ text: 'Home', link: '/' }); - items.push({ text: space?.name || '', link: `/space/${slug}` }); + items.push({ text: space.name || '', link: `/space/${space.slug}` }); - const { data: list } = getList(listId); if (list) { items.push({ text: list?.title || '', - link: `/space/${slug}/${listId}`, + link: `/space/${space.slug}/${list.id}`, }); } diff --git a/samples/todo/components/Spaces.tsx b/samples/todo/components/Spaces.tsx index aaf52862d..eaac0f578 100644 --- a/samples/todo/components/Spaces.tsx +++ b/samples/todo/components/Spaces.tsx @@ -1,13 +1,14 @@ -import { useSpace } from '@zenstackhq/runtime/client'; +import { Space } from '@zenstackhq/runtime/types'; import Link from 'next/link'; -export default function Spaces() { - const { find } = useSpace(); - const spaces = find(); +type Props = { + spaces: Space[]; +}; +export default function Spaces({ spaces }: Props) { return (
    - {spaces.data?.map((space) => ( + {spaces?.map((space) => (
  • { +type Props = { + spaces: Space[]; +}; + +const Home: NextPage = ({ spaces }) => { const user = useCurrentUser(); return ( @@ -22,7 +30,7 @@ const Home: NextPage = () => { - + )} @@ -30,4 +38,15 @@ const Home: NextPage = () => { ); }; +export const getServerSideProps: GetServerSideProps = async ({ + req, + res, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const spaces = await service.space.find({ user: session?.user }); + return { + props: { spaces }, + }; +}; + export default Home; diff --git a/samples/todo/pages/space/[slug]/[listId]/index.tsx b/samples/todo/pages/space/[slug]/[listId]/index.tsx index a0e698992..9bb4e2569 100644 --- a/samples/todo/pages/space/[slug]/[listId]/index.tsx +++ b/samples/todo/pages/space/[slug]/[listId]/index.tsx @@ -1,24 +1,32 @@ -import { useList, useTodo } from '@zenstackhq/runtime/client'; -import { useRouter } from 'next/router'; +import { authOptions } from '@api/auth/[...nextauth]'; +import { useTodo } from '@zenstackhq/runtime/client'; import { PlusIcon } from '@heroicons/react/24/outline'; import { ChangeEvent, KeyboardEvent, useState } from 'react'; import { useCurrentUser } from '@lib/context'; import TodoComponent from 'components/Todo'; import BreadCrumb from 'components/BreadCrumb'; import WithNavBar from 'components/WithNavBar'; +import { List, Space, Todo, User } from '@zenstackhq/runtime/types'; +import { GetServerSideProps } from 'next'; +import { unstable_getServerSession } from 'next-auth'; +import service from '@zenstackhq/runtime/server'; +import { getSpaceBySlug } from '@lib/query-utils'; -export default function TodoList() { +type Props = { + space: Space; + list: List; + todos: (Todo & { owner: User })[]; +}; + +export default function TodoList(props: Props) { const user = useCurrentUser(); - const router = useRouter(); - const { get: getList } = useList(); const { create: createTodo, find: findTodos } = useTodo(); const [title, setTitle] = useState(''); - const { data: list } = getList(router.query.listId as string); const { data: todos, mutate: invalidateTodos } = findTodos( { where: { - listId: list?.id, + listId: props.list.id, }, include: { owner: true, @@ -27,19 +35,15 @@ export default function TodoList() { updatedAt: 'desc', }, }, - { disabled: !list } + { initialData: props.todos } ); - if (!list) { - return

    Loading ...

    ; - } - const _createTodo = async () => { const todo = await createTodo({ data: { title, ownerId: user!.id, - listId: list!.id, + listId: props.list.id, }, }); console.log(`Todo created: ${todo}`); @@ -49,10 +53,12 @@ export default function TodoList() { return (
    - +
    -

    {list?.title}

    +

    + {props.list?.title} +

    ); } + +export const getServerSideProps: GetServerSideProps = async ({ + req, + res, + params, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const queryContext = { user: session?.user }; + + const space = await getSpaceBySlug(queryContext, params?.slug as string); + + const list = await service.list.get(queryContext, params?.listId as string); + if (!list) { + throw new Error(`List not found: ${params?.listId}`); + } + + const todos = await service.todo.find(queryContext, { + where: { + listId: params?.id as string, + }, + include: { + owner: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + return { + props: { space, list, todos }, + }; +}; diff --git a/samples/todo/pages/space/[slug]/index.tsx b/samples/todo/pages/space/[slug]/index.tsx index e7814498b..9f767374c 100644 --- a/samples/todo/pages/space/[slug]/index.tsx +++ b/samples/todo/pages/space/[slug]/index.tsx @@ -1,3 +1,4 @@ +import { authOptions } from '@api/auth/[...nextauth]'; import { SpaceContext, UserContext } from '@lib/context'; import { ChangeEvent, @@ -13,6 +14,12 @@ import TodoList from 'components/TodoList'; import BreadCrumb from 'components/BreadCrumb'; import SpaceMembers from 'components/SpaceMembers'; import WithNavBar from 'components/WithNavBar'; +import { List, Space, User } from '@zenstackhq/runtime/types'; +import { GetServerSideProps } from 'next'; +import { unstable_getServerSession } from 'next-auth'; +import service from '@zenstackhq/runtime/server'; +import { useRouter } from 'next/router'; +import { getSpaceBySlug } from '@lib/query-utils'; function CreateDialog() { const user = useContext(UserContext); @@ -135,15 +142,21 @@ function CreateDialog() { ); } -export default function SpaceHome() { +type Props = { + space: Space; + lists: (List & { owner: User })[]; +}; + +export default function SpaceHome(props: Props) { const space = useContext(SpaceContext); const { find } = useList(); + const router = useRouter(); const { data: lists, mutate: invalidateLists } = find( { where: { space: { - id: space?.id, + slug: router.query.slug as string, }, }, include: { @@ -155,13 +168,14 @@ export default function SpaceHome() { }, { disabled: !space, + initialData: props.lists, } ); return (
    - +
    @@ -190,3 +204,31 @@ export default function SpaceHome() { ); } + +export const getServerSideProps: GetServerSideProps = async ({ + req, + res, + params, +}) => { + const session = await unstable_getServerSession(req, res, authOptions); + const queryContext = { user: session?.user }; + + const space = await getSpaceBySlug(queryContext, params?.slug as string); + + const lists = await service.list.find(queryContext, { + where: { + space: { + slug: params?.slug as string, + }, + }, + include: { + owner: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + return { + props: { space, lists }, + }; +}; diff --git a/tests/integration/tests/utils.ts b/tests/integration/tests/utils.ts index 4cdbf4d96..18f9857d2 100644 --- a/tests/integration/tests/utils.ts +++ b/tests/integration/tests/utils.ts @@ -36,7 +36,7 @@ export async function setup(schemaFile: string) { 'typescript', 'swr', 'react', - 'prisma', + 'prisma@~4.5.0', 'zod', '../../../../packages/schema', '../../../../packages/runtime/dist',