From ffdc603700ab212479d772a07ae0e48f1f196a06 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sun, 17 Sep 2023 23:33:14 -0700 Subject: [PATCH] feat: add "loadPath" options to runtime API and server adapter options --- packages/runtime/src/constants.ts | 5 ++ packages/runtime/src/enhancements/enhance.ts | 4 +- packages/runtime/src/enhancements/index.ts | 2 +- .../runtime/src/enhancements/model-meta.ts | 21 ----- ...rite-vistor.ts => nested-write-visitor.ts} | 0 packages/runtime/src/enhancements/omit.ts | 11 +-- packages/runtime/src/enhancements/password.ts | 14 ++-- .../src/enhancements/policy/handler.ts | 2 +- .../runtime/src/enhancements/policy/index.ts | 52 ++----------- .../src/enhancements/policy/policy-utils.ts | 2 +- packages/runtime/src/enhancements/types.ts | 10 +++ packages/runtime/src/index.ts | 1 + packages/runtime/src/loader.ts | 78 +++++++++++++++++++ packages/schema/src/plugins/plugin-utils.ts | 4 +- packages/server/src/api/base.ts | 64 +++++++++++---- packages/server/src/api/rest/index.ts | 5 +- packages/server/src/api/rpc/index.ts | 5 +- packages/server/src/express/middleware.ts | 15 +--- packages/server/src/fastify/plugin.ts | 18 ++--- packages/server/src/next/app-route-handler.ts | 14 +--- .../server/src/next/pages-route-handler.ts | 14 +--- packages/server/src/shared.ts | 20 +++++ packages/server/src/sveltekit/handler.ts | 14 +--- packages/server/src/types.ts | 60 ++------------ packages/server/tests/adapter/express.test.ts | 26 +++++++ packages/server/tests/adapter/fastify.test.ts | 32 ++++++++ packages/server/tests/adapter/next.test.ts | 19 +++++ .../server/tests/adapter/sveltekit.test.ts | 28 +++++++ packages/testtools/src/schema.ts | 19 ++++- .../enhancements/with-omit/with-omit.test.ts | 68 +++++++++++----- .../with-password/with-password.test.ts | 41 +++++++--- .../enhancements/with-policy/options.test.ts | 58 ++++++++++++++ 32 files changed, 482 insertions(+), 244 deletions(-) rename packages/runtime/src/enhancements/{nested-write-vistor.ts => nested-write-visitor.ts} (100%) create mode 100644 packages/runtime/src/loader.ts create mode 100644 packages/server/src/shared.ts create mode 100644 tests/integration/tests/enhancements/with-policy/options.test.ts diff --git a/packages/runtime/src/constants.ts b/packages/runtime/src/constants.ts index 10f4276ee..96d04604b 100644 --- a/packages/runtime/src/constants.ts +++ b/packages/runtime/src/constants.ts @@ -1,3 +1,8 @@ +/** + * Default path for loading CLI-generated code + */ +export const DEFAULT_RUNTIME_LOAD_PATH = '.zenstack'; + /** * Default length of password hash salt (used by bcryptjs to hash password) */ diff --git a/packages/runtime/src/enhancements/enhance.ts b/packages/runtime/src/enhancements/enhance.ts index 4da36dbc8..42a504bdf 100644 --- a/packages/runtime/src/enhancements/enhance.ts +++ b/packages/runtime/src/enhancements/enhance.ts @@ -1,4 +1,4 @@ -import { getDefaultModelMeta } from './model-meta'; +import { getDefaultModelMeta } from '../loader'; import { withOmit, WithOmitOptions } from './omit'; import { withPassword, WithPasswordOptions } from './password'; import { withPolicy, WithPolicyContext, WithPolicyOptions } from './policy'; @@ -29,7 +29,7 @@ export function enhance( let result = prisma; if (hasPassword === undefined || hasOmit === undefined) { - const modelMeta = options?.modelMeta ?? getDefaultModelMeta(); + const modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); const allFields = Object.values(modelMeta.fields).flatMap((modelInfo) => Object.values(modelInfo)); hasPassword = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@password')); hasOmit = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@omit')); diff --git a/packages/runtime/src/enhancements/index.ts b/packages/runtime/src/enhancements/index.ts index 5efb76762..df45e34c1 100644 --- a/packages/runtime/src/enhancements/index.ts +++ b/packages/runtime/src/enhancements/index.ts @@ -1,5 +1,5 @@ export * from './model-meta'; -export * from './nested-write-vistor'; +export * from './nested-write-visitor'; export * from './omit'; export * from './password'; export * from './policy'; diff --git a/packages/runtime/src/enhancements/model-meta.ts b/packages/runtime/src/enhancements/model-meta.ts index 953ff5ba9..626a7f26e 100644 --- a/packages/runtime/src/enhancements/model-meta.ts +++ b/packages/runtime/src/enhancements/model-meta.ts @@ -1,29 +1,8 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { lowerCaseFirst } from 'lower-case-first'; -import path from 'path'; import { FieldInfo } from '../types'; import { ModelMeta } from './types'; -/** - * Load model meta from standard location. - */ -export function getDefaultModelMeta(): ModelMeta { - try { - // normal load - return require('.zenstack/model-meta').default; - } catch { - if (process.env.ZENSTACK_TEST === '1') { - try { - // special handling for running as tests, try resolving relative to CWD - return require(path.join(process.cwd(), 'node_modules', '.zenstack', 'model-meta')).default; - } catch { - throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); - } - } - throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); - } -} - /** * Resolves a model field to its metadata. Returns undefined if not found. */ diff --git a/packages/runtime/src/enhancements/nested-write-vistor.ts b/packages/runtime/src/enhancements/nested-write-visitor.ts similarity index 100% rename from packages/runtime/src/enhancements/nested-write-vistor.ts rename to packages/runtime/src/enhancements/nested-write-visitor.ts diff --git a/packages/runtime/src/enhancements/omit.ts b/packages/runtime/src/enhancements/omit.ts index a23f1e7d3..236151981 100644 --- a/packages/runtime/src/enhancements/omit.ts +++ b/packages/runtime/src/enhancements/omit.ts @@ -1,27 +1,28 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { getDefaultModelMeta } from '../loader'; import { DbClientContract } from '../types'; -import { getDefaultModelMeta, resolveField } from './model-meta'; +import { resolveField } from './model-meta'; import { DefaultPrismaProxyHandler, makeProxy } from './proxy'; -import { ModelMeta } from './types'; +import { CommonEnhancementOptions, ModelMeta } from './types'; import { enumerate, getModelFields } from './utils'; /** * Options for @see withOmit */ -export type WithOmitOptions = { +export interface WithOmitOptions extends CommonEnhancementOptions { /** * Model metadata */ modelMeta?: ModelMeta; -}; +} /** * Gets an enhanced Prisma client that supports @omit attribute. */ export function withOmit(prisma: DbClient, options?: WithOmitOptions): DbClient { - const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(); + const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); return makeProxy( prisma, _modelMeta, diff --git a/packages/runtime/src/enhancements/password.ts b/packages/runtime/src/enhancements/password.ts index e6cb513df..154e14c83 100644 --- a/packages/runtime/src/enhancements/password.ts +++ b/packages/runtime/src/enhancements/password.ts @@ -3,27 +3,27 @@ import { hash } from 'bcryptjs'; import { DEFAULT_PASSWORD_SALT_LENGTH } from '../constants'; +import { getDefaultModelMeta } from '../loader'; import { DbClientContract, PrismaWriteActionType } from '../types'; -import { getDefaultModelMeta } from './model-meta'; -import { NestedWriteVisitor } from './nested-write-vistor'; +import { NestedWriteVisitor } from './nested-write-visitor'; import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; -import { ModelMeta } from './types'; +import { CommonEnhancementOptions, ModelMeta } from './types'; /** * Options for @see withPassword */ -export type WithPasswordOptions = { +export interface WithPasswordOptions extends CommonEnhancementOptions { /** - * Model metatadata + * Model metadata */ modelMeta?: ModelMeta; -}; +} /** * Gets an enhanced Prisma client that supports @password attribute. */ export function withPassword(prisma: DbClient, options?: WithPasswordOptions): DbClient { - const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(); + const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); return makeProxy( prisma, _modelMeta, diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index 334e9cbc5..dc3e9cf30 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -7,7 +7,7 @@ import { CrudFailureReason, PRISMA_TX_FLAG } from '../../constants'; import { AuthUser, DbClientContract, DbOperations, FieldInfo, PolicyOperationKind } from '../../types'; import { ModelDataVisitor } from '../model-data-visitor'; import { resolveField } from '../model-meta'; -import { NestedWriteVisitor, NestedWriteVisitorContext } from '../nested-write-vistor'; +import { NestedWriteVisitor, NestedWriteVisitorContext } from '../nested-write-visitor'; import { PrismaProxyHandler } from '../proxy'; import type { ModelMeta, PolicyDef, ZodSchemas } from '../types'; import { enumerate, formatObject, getIdFields, prismaClientValidationError } from '../utils'; diff --git a/packages/runtime/src/enhancements/policy/index.ts b/packages/runtime/src/enhancements/policy/index.ts index afd548750..5cb49c113 100644 --- a/packages/runtime/src/enhancements/policy/index.ts +++ b/packages/runtime/src/enhancements/policy/index.ts @@ -1,14 +1,13 @@ /* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import path from 'path'; import semver from 'semver'; import { PRISMA_MINIMUM_VERSION } from '../../constants'; +import { getDefaultModelMeta, getDefaultPolicy, getDefaultZodSchemas } from '../../loader'; import { AuthUser, DbClientContract } from '../../types'; import { hasAllFields } from '../../validation'; -import { getDefaultModelMeta } from '../model-meta'; import { makeProxy } from '../proxy'; -import type { ModelMeta, PolicyDef, ZodSchemas } from '../types'; +import type { CommonEnhancementOptions, ModelMeta, PolicyDef, ZodSchemas } from '../types'; import { getIdFields } from '../utils'; import { PolicyProxyHandler } from './handler'; @@ -22,7 +21,7 @@ export type WithPolicyContext = { /** * Options for @see withPolicy */ -export type WithPolicyOptions = { +export interface WithPolicyOptions extends CommonEnhancementOptions { /** * Policy definition */ @@ -42,7 +41,7 @@ export type WithPolicyOptions = { * Whether to log Prisma query */ logPrismaQuery?: boolean; -}; +} /** * Gets an enhanced Prisma client with access policy check. @@ -68,9 +67,9 @@ export function withPolicy( ); } - const _policy = options?.policy ?? getDefaultPolicy(); - const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(); - const _zodSchemas = options?.zodSchemas ?? getDefaultZodSchemas(); + const _policy = options?.policy ?? getDefaultPolicy(options?.loadPath); + const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); + const _zodSchemas = options?.zodSchemas ?? getDefaultZodSchemas(options?.loadPath); // validate user context if (context?.user) { @@ -103,40 +102,3 @@ export function withPolicy( 'policy' ); } - -function getDefaultPolicy(): PolicyDef { - try { - return require('.zenstack/policy').default; - } catch { - if (process.env.ZENSTACK_TEST === '1') { - try { - // special handling for running as tests, try resolving relative to CWD - return require(path.join(process.cwd(), 'node_modules', '.zenstack', 'policy')).default; - } catch { - throw new Error( - 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' - ); - } - } - throw new Error( - 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' - ); - } -} - -function getDefaultZodSchemas(): ZodSchemas | undefined { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('.zenstack/zod'); - } catch { - if (process.env.ZENSTACK_TEST === '1') { - try { - // special handling for running as tests, try resolving relative to CWD - return require(path.join(process.cwd(), 'node_modules', '.zenstack', 'zod')); - } catch { - return undefined; - } - } - return undefined; - } -} diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 259df9247..a27d689b4 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -17,7 +17,7 @@ import { import { AuthUser, DbClientContract, DbOperations, FieldInfo, PolicyOperationKind } from '../../types'; import { getVersion } from '../../version'; import { getFields, resolveField } from '../model-meta'; -import { NestedWriteVisitorContext } from '../nested-write-vistor'; +import { NestedWriteVisitorContext } from '../nested-write-visitor'; import type { InputCheckFunc, ModelMeta, PolicyDef, ReadFieldCheckFunc, ZodSchemas } from '../types'; import { enumerate, diff --git a/packages/runtime/src/enhancements/types.ts b/packages/runtime/src/enhancements/types.ts index a3d0e6a6f..6645951db 100644 --- a/packages/runtime/src/enhancements/types.ts +++ b/packages/runtime/src/enhancements/types.ts @@ -9,6 +9,16 @@ import { HAS_FIELD_LEVEL_POLICY_FLAG, } from '../constants'; +/** + * Common options for PrismaClient enhancements + */ +export interface CommonEnhancementOptions { + /** + * Path for loading CLI-generated code + */ + loadPath?: string; +} + /** * Metadata for a model-level unique constraint * e.g.: @@unique([a, b]) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index a964d72ed..57df37ee4 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,7 @@ export * from './constants'; export * from './enhancements'; export * from './error'; +export * from './loader'; export * from './types'; export * from './validation'; export * from './version'; diff --git a/packages/runtime/src/loader.ts b/packages/runtime/src/loader.ts new file mode 100644 index 000000000..66c6bcef7 --- /dev/null +++ b/packages/runtime/src/loader.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import path from 'path'; +import { DEFAULT_RUNTIME_LOAD_PATH } from './constants'; +import { ModelMeta, PolicyDef, ZodSchemas } from './enhancements'; + +/** + * Load model metadata. + * + * @param loadPath The path to load model metadata from. If not provided, + * will use default load path. + */ +export function getDefaultModelMeta(loadPath: string | undefined): ModelMeta { + loadPath = loadPath ? path.resolve(loadPath, 'model-meta') : `${DEFAULT_RUNTIME_LOAD_PATH}/model-meta`; + try { + // normal load + return require(loadPath).default; + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(loadPath)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', loadPath, 'model-meta')).default; + } catch { + throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); + } + } + throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); + } +} + +/** + * Load access policies. + * + * @param loadPath The path to load access policies from. If not provided, + * will use default load path. + */ +export function getDefaultPolicy(loadPath: string | undefined): PolicyDef { + loadPath = loadPath ? path.resolve(loadPath, 'policy') : `${DEFAULT_RUNTIME_LOAD_PATH}/policy`; + try { + return require(loadPath).default; + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(loadPath)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', loadPath, 'policy')).default; + } catch { + throw new Error( + 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' + ); + } + } + throw new Error( + 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' + ); + } +} + +/** + * Load zod schemas. + * + * @param loadPath The path to load zod schemas from. If not provided, + * will use default load path. + */ +export function getDefaultZodSchemas(loadPath: string | undefined): ZodSchemas | undefined { + loadPath = loadPath ? path.resolve(loadPath, 'zod') : `${DEFAULT_RUNTIME_LOAD_PATH}/zod`; + try { + return require(loadPath); + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(loadPath)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', loadPath, 'zod')); + } catch { + return undefined; + } + } + return undefined; + } +} diff --git a/packages/schema/src/plugins/plugin-utils.ts b/packages/schema/src/plugins/plugin-utils.ts index dfbb2334f..b1a988617 100644 --- a/packages/schema/src/plugins/plugin-utils.ts +++ b/packages/schema/src/plugins/plugin-utils.ts @@ -1,4 +1,4 @@ -import type { PolicyOperationKind } from '@zenstackhq/runtime'; +import { DEFAULT_RUNTIME_LOAD_PATH, type PolicyOperationKind } from '@zenstackhq/runtime'; import { PluginGlobalOptions } from '@zenstackhq/sdk'; import fs from 'fs'; import path from 'path'; @@ -73,5 +73,5 @@ export function getDefaultOutputFolder(globalOptions?: PluginGlobalOptions) { runtimeModulePath = path.join(runtimeModulePath, '..'); } const modulesFolder = getNodeModulesFolder(runtimeModulePath); - return modulesFolder ? path.join(modulesFolder, '.zenstack') : undefined; + return modulesFolder ? path.join(modulesFolder, DEFAULT_RUNTIME_LOAD_PATH) : undefined; } diff --git a/packages/server/src/api/base.ts b/packages/server/src/api/base.ts index d7e986b3d..0753a2c8e 100644 --- a/packages/server/src/api/base.ts +++ b/packages/server/src/api/base.ts @@ -1,17 +1,55 @@ -import { ModelMeta, getDefaultModelMeta } from '@zenstackhq/runtime'; +import { DbClientContract, ModelMeta, ZodSchemas } from '@zenstackhq/runtime'; +import { LoggerConfig } from '../types'; + +/** + * API request context + */ +export type RequestContext = { + /** + * The PrismaClient instance + */ + prisma: DbClientContract; + + /** + * The HTTP method + */ + method: string; + + /** + * The request endpoint path (excluding any prefix) + */ + path: string; + + /** + * The query parameters + */ + query?: Record; + + /** + * The request body object + */ + requestBody?: unknown; + + /** + * Model metadata. By default loaded from the @see loadPath path. You can pass + * it in explicitly to override. + */ + modelMeta?: ModelMeta; + + /** + * Zod schemas for validating create and update payloads. By default loaded from + * the @see loadPath path. You can pass it in explicitly to override. + */ + zodSchemas?: ZodSchemas; + + /** + * Logging configuration. Set to `null` to disable logging. + * If unset or set to `undefined`, log will be output to console. + */ + logger?: LoggerConfig; +}; /** * Base class for API handlers */ -export abstract class APIHandlerBase { - // model meta loaded from default location - protected readonly defaultModelMeta: ModelMeta | undefined; - - constructor() { - try { - this.defaultModelMeta = getDefaultModelMeta(); - } catch { - // noop - } - } -} +export abstract class APIHandlerBase {} diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 1a39aee53..2e7c80832 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -17,8 +17,8 @@ import { upperCaseFirst } from 'upper-case-first'; import UrlPattern from 'url-pattern'; import z, { ZodError } from 'zod'; import { fromZodError } from 'zod-validation-error'; -import { LoggerConfig, RequestContext, Response } from '../../types'; -import { APIHandlerBase } from '../base'; +import { LoggerConfig, Response } from '../../types'; +import { APIHandlerBase, RequestContext } from '../base'; import { logWarning, registerCustomSerializers } from '../utils'; const urlPatterns = { @@ -223,7 +223,6 @@ class RequestHandler extends APIHandlerBase { modelMeta, zodSchemas, }: RequestContext): Promise { - modelMeta = modelMeta ?? this.defaultModelMeta; if (!modelMeta) { throw new Error('Model meta is not provided or loaded from default location'); } diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index 20dee5e33..7f6cb40ee 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -11,8 +11,8 @@ import SuperJSON from 'superjson'; import { upperCaseFirst } from 'upper-case-first'; import { ZodError } from 'zod'; import { fromZodError } from 'zod-validation-error'; -import { RequestContext, Response } from '../../types'; -import { APIHandlerBase } from '../base'; +import { Response } from '../../types'; +import { APIHandlerBase, RequestContext } from '../base'; import { logError, registerCustomSerializers } from '../utils'; registerCustomSerializers(); @@ -37,7 +37,6 @@ class RequestHandler extends APIHandlerBase { zodSchemas, logger, }: RequestContext): Promise { - modelMeta = modelMeta ?? this.defaultModelMeta; if (!modelMeta) { throw new Error('Model meta is not provided or loaded from default location'); } diff --git a/packages/server/src/express/middleware.ts b/packages/server/src/express/middleware.ts index b17d3f030..cdf5a3c6e 100644 --- a/packages/server/src/express/middleware.ts +++ b/packages/server/src/express/middleware.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import type { Handler, Request, Response } from 'express'; import RPCAPIHandler from '../api/rpc'; +import { loadAssets } from '../shared'; import { AdapterBaseOptions } from '../types'; /** @@ -30,16 +30,7 @@ export interface MiddlewareOptions extends AdapterBaseOptions { * Creates an Express middleware for handling CRUD requests. */ const factory = (options: MiddlewareOptions): Handler => { - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - try { - zodSchemas = require('@zenstackhq/runtime/zod'); - } catch { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler || RPCAPIHandler(); if (options.useSuperJson !== undefined) { @@ -74,7 +65,7 @@ const factory = (options: MiddlewareOptions): Handler => { query, requestBody: request.body, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); diff --git a/packages/server/src/fastify/plugin.ts b/packages/server/src/fastify/plugin.ts index 02f5f341a..480c4ba8d 100644 --- a/packages/server/src/fastify/plugin.ts +++ b/packages/server/src/fastify/plugin.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import RPCApiHandler from '../api/rpc'; import { logInfo } from '../api/utils'; +import { loadAssets } from '../shared'; import { AdapterBaseOptions } from '../types'; /** @@ -29,17 +29,9 @@ const pluginHandler: FastifyPluginCallback = (fastify, options, d const prefix = options.prefix ?? ''; logInfo(options.logger, `ZenStackPlugin installing routes at prefix: ${prefix}`); - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); - const requestHanler = options.handler ?? RPCApiHandler(); + const requestHandler = options.handler ?? RPCApiHandler(); if (options.useSuperJson !== undefined) { console.warn( 'The option "useSuperJson" is deprecated. The server APIs automatically use superjson for serialization.' @@ -54,13 +46,13 @@ const pluginHandler: FastifyPluginCallback = (fastify, options, d } try { - const response = await requestHanler({ + const response = await requestHandler({ method: request.method, path: (request.params as any)['*'], query: request.query as Record, requestBody: request.body, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); diff --git a/packages/server/src/next/app-route-handler.ts b/packages/server/src/next/app-route-handler.ts index 58f78244f..538f4ceb5 100644 --- a/packages/server/src/next/app-route-handler.ts +++ b/packages/server/src/next/app-route-handler.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import { NextRequest, NextResponse } from 'next/server'; import { AppRouteRequestHandlerOptions } from '.'; import RPCAPIHandler from '../api/rpc'; +import { loadAssets } from '../shared'; type Context = { params: { path: string[] } }; @@ -17,15 +17,7 @@ type Context = { params: { path: string[] } }; export default function factory( options: AppRouteRequestHandlerOptions ): (req: NextRequest, context: Context) => Promise { - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler || RPCAPIHandler(); if (options.useSuperJson !== undefined) { @@ -69,7 +61,7 @@ export default function factory( query, requestBody, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); diff --git a/packages/server/src/next/pages-route-handler.ts b/packages/server/src/next/pages-route-handler.ts index 2baf0f1a4..bd2fbf643 100644 --- a/packages/server/src/next/pages-route-handler.ts +++ b/packages/server/src/next/pages-route-handler.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import { NextApiRequest, NextApiResponse } from 'next'; import { PagesRouteRequestHandlerOptions } from '.'; import RPCAPIHandler from '../api/rpc'; +import { loadAssets } from '../shared'; /** * Creates a Next.js API endpoint (traditional "pages" route) request handler which encapsulates Prisma CRUD operations. @@ -15,15 +15,7 @@ import RPCAPIHandler from '../api/rpc'; export default function factory( options: PagesRouteRequestHandlerOptions ): (req: NextApiRequest, res: NextApiResponse) => Promise { - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler || RPCAPIHandler(); if (options.useSuperJson !== undefined) { @@ -52,7 +44,7 @@ export default function factory( query: req.query as Record, requestBody: req.body, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); diff --git a/packages/server/src/shared.ts b/packages/server/src/shared.ts new file mode 100644 index 000000000..6001fbbaa --- /dev/null +++ b/packages/server/src/shared.ts @@ -0,0 +1,20 @@ +import { ZodSchemas, getDefaultModelMeta, getDefaultZodSchemas } from '@zenstackhq/runtime'; +import { AdapterBaseOptions } from './types'; + +export function loadAssets(options: AdapterBaseOptions) { + // model metadata + const modelMeta = options.modelMeta ?? getDefaultModelMeta(options.loadPath); + + // zod schemas + let zodSchemas: ZodSchemas | undefined; + if (typeof options.zodSchemas === 'object') { + zodSchemas = options.zodSchemas; + } else if (options.zodSchemas === true) { + zodSchemas = getDefaultZodSchemas(options.loadPath); + if (!zodSchemas) { + throw new Error('Unable to load zod schemas from default location'); + } + } + + return { modelMeta, zodSchemas }; +} diff --git a/packages/server/src/sveltekit/handler.ts b/packages/server/src/sveltekit/handler.ts index f45eaf9db..be1d831d8 100644 --- a/packages/server/src/sveltekit/handler.ts +++ b/packages/server/src/sveltekit/handler.ts @@ -1,8 +1,8 @@ import type { Handle, RequestEvent } from '@sveltejs/kit'; -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import RPCApiHandler from '../api/rpc'; import { logInfo } from '../api/utils'; +import { loadAssets } from '../shared'; import { AdapterBaseOptions } from '../types'; /** @@ -26,15 +26,7 @@ export interface HandlerOptions extends AdapterBaseOptions { export default function createHandler(options: HandlerOptions): Handle { logInfo(options.logger, `ZenStackHandler installing routes at prefix: ${options.prefix}`); - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler ?? RPCApiHandler(); if (options.useSuperJson !== undefined) { @@ -73,8 +65,8 @@ export default function createHandler(options: HandlerOptions): Handle { query, requestBody, prisma, + modelMeta, zodSchemas, - modelMeta: options.modelMeta, }); return new Response(JSON.stringify(r.body), { diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index ed307ab2e..dc72fea25 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,5 +1,5 @@ import type { ModelMeta, ZodSchemas } from '@zenstackhq/runtime'; -import { DbClientContract } from '@zenstackhq/runtime'; +import { RequestContext } from './api/base'; type LoggerMethod = (message: string, code?: string) => void; @@ -13,57 +13,6 @@ export type LoggerConfig = { error?: LoggerMethod; }; -/** - * API request context - */ -export type RequestContext = { - /** - * The PrismaClient instance - */ - prisma: DbClientContract; - - /** - * The HTTP method - */ - method: string; - - /** - * The request endpoint path (excluding any prefix) - */ - path: string; - - /** - * The query parameters - */ - query?: Record; - - /** - * The request body object - */ - requestBody?: unknown; - - /** - * Model metadata. By default loaded from the standard output location - * of the `@zenstackhq/model-meta` plugin. You can pass it in explicitly - * if you configured the plugin to output to a different location. - */ - modelMeta?: ModelMeta; - - /** - * Zod schemas for validating create and update payloads. By default - * loaded from the standard output location of the `@zenstackhq/zod` - * plugin. You can pass it in explicitly if you configured the plugin - * to output to a different location. - */ - zodSchemas?: ZodSchemas; - - /** - * Logging configuration. Set to `null` to disable logging. - * If unset or set to `undefined`, log will be output to console. - */ - logger?: LoggerConfig; -}; - /** * API response */ @@ -99,6 +48,11 @@ export interface AdapterBaseOptions { */ zodSchemas?: ZodSchemas | boolean; + /** + * Path to load model metadata and zod schemas from. Defaults to `node_modules/.zenstack`. + */ + loadPath?: string; + /** * Api request handler function. Can be created using `@zenstackhq/server/api/rest` or `@zenstackhq/server/api/rpc` factory functions. * Defaults to RPC-style API handler created with `/api/rpc`. @@ -107,6 +61,8 @@ export interface AdapterBaseOptions { /** * Whether to use superjson for serialization/deserialization. Defaults to `false`. + * + * @deprecated Not needed anymore and will be removed in a future release. */ useSuperJson?: boolean; } diff --git a/packages/server/tests/adapter/express.test.ts b/packages/server/tests/adapter/express.test.ts index 518f7ccd0..14ec66f84 100644 --- a/packages/server/tests/adapter/express.test.ts +++ b/packages/server/tests/adapter/express.test.ts @@ -85,6 +85,32 @@ describe('Express adapter tests - rpc handler', () => { expect(r.body.data.count).toBe(1); }); + it('custom load path', async () => { + const { prisma } = await loadSchema(schema, { output: './zen' }); + + const app = express(); + app.use(bodyParser.json()); + app.use('/api', ZenStackMiddleware({ getPrisma: () => prisma, loadPath: './zen', zodSchemas: true })); + + const r = await request(app) + .post('/api/user/create') + .send({ + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }); + + expect(r.status).toBe(201); + }); + it('invalid path or args', async () => { const { prisma, zodSchemas } = await loadSchema(schema); diff --git a/packages/server/tests/adapter/fastify.test.ts b/packages/server/tests/adapter/fastify.test.ts index a67480d7a..4e4775d50 100644 --- a/packages/server/tests/adapter/fastify.test.ts +++ b/packages/server/tests/adapter/fastify.test.ts @@ -112,6 +112,38 @@ describe('Fastify adapter tests - rpc handler', () => { expect(r.json().data.count).toBe(1); }); + it('custom load path', async () => { + const { prisma } = await loadSchema(schema, { output: './zen' }); + + const app = fastify(); + app.register(ZenStackFastifyPlugin, { + prefix: '/api', + getPrisma: () => prisma, + loadPath: './zen', + zodSchemas: true, + handler: RPC(), + }); + + const r = await app.inject({ + method: 'POST', + url: '/api/user/create', + payload: { + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }, + }); + expect(r.statusCode).toBe(201); + }); + it('invalid path or args', async () => { const { prisma, zodSchemas } = await loadSchema(schema); diff --git a/packages/server/tests/adapter/next.test.ts b/packages/server/tests/adapter/next.test.ts index c125a7801..4715273d8 100644 --- a/packages/server/tests/adapter/next.test.ts +++ b/packages/server/tests/adapter/next.test.ts @@ -162,6 +162,25 @@ model M { expect(await prisma.m.count()).toBe(0); }); + it('custom load path', async () => { + const model = ` +model M { + id String @id @default(cuid()) + value Int +} + `; + + const { prisma } = await loadSchema(model, { output: './zen' }); + + await makeTestClient('/m/create', { getPrisma: () => prisma, zodSchemas: true, loadPath: './zen' }) + .post('/') + .send({ data: { id: '1', value: 1 } }) + .expect(201) + .expect((resp) => { + expect(resp.body.data.value).toBe(1); + }); + }); + it('access policy crud', async () => { const model = ` model M { diff --git a/packages/server/tests/adapter/sveltekit.test.ts b/packages/server/tests/adapter/sveltekit.test.ts index c56128498..534378987 100644 --- a/packages/server/tests/adapter/sveltekit.test.ts +++ b/packages/server/tests/adapter/sveltekit.test.ts @@ -78,6 +78,34 @@ describe('SvelteKit adapter tests - rpc handler', () => { expect(r.status).toBe(200); expect((await unmarshal(r)).data.count).toBe(1); }); + + it('custom load path', async () => { + const { prisma } = await loadSchema(schema, { output: './zen' }); + + const handler = SvelteKitHandler({ + prefix: '/api', + getPrisma: () => prisma, + zodSchemas: true, + loadPath: './zen', + }); + + const r = await handler( + makeRequest('POST', '/api/user/create', { + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }) + ); + expect(r.status).toBe(201); + }); }); describe('SvelteKit adapter tests - rest handler', () => { diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 6a6a80137..d230d6c77 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -98,10 +98,12 @@ export type SchemaLoadOptions = { extraDependencies?: string[]; compile?: boolean; customSchemaFilePath?: string; + output?: string; logPrismaQuery?: boolean; provider?: 'sqlite' | 'postgresql'; dbUrl?: string; pulseApiKey?: string; + getPrismaOnly?: boolean; }; const defaultOptions: SchemaLoadOptions = { @@ -177,12 +179,14 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) { run('npm install'); + const outputArg = opt.output ? ` --output ${opt.output}` : ''; + if (opt.customSchemaFilePath) { - run(`npx zenstack generate --schema ${zmodelPath} --no-dependency-check`, { + run(`npx zenstack generate --schema ${zmodelPath} --no-dependency-check${outputArg}`, { NODE_PATH: './node_modules', }); } else { - run('npx zenstack generate --no-dependency-check', { NODE_PATH: './node_modules' }); + run(`npx zenstack generate --no-dependency-check${outputArg}`, { NODE_PATH: './node_modules' }); } if (opt.pushDb) { @@ -220,6 +224,17 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) { run('npx tsc --project tsconfig.json'); } + if (options?.getPrismaOnly) { + return { + prisma, + projectDir: projectRoot, + withPolicy: undefined as any, + withOmit: undefined as any, + withPassword: undefined as any, + enhance: undefined as any, + }; + } + let policy: any; let modelMeta: any; let zodSchemas: any; diff --git a/tests/integration/tests/enhancements/with-omit/with-omit.test.ts b/tests/integration/tests/enhancements/with-omit/with-omit.test.ts index 03e39142e..61d44b440 100644 --- a/tests/integration/tests/enhancements/with-omit/with-omit.test.ts +++ b/tests/integration/tests/enhancements/with-omit/with-omit.test.ts @@ -1,3 +1,4 @@ +import { withOmit } from '@zenstackhq/runtime'; import { loadSchema } from '@zenstackhq/testtools'; import path from 'path'; @@ -12,26 +13,27 @@ describe('Omit test', () => { process.chdir(origDir); }); + const model = ` + model User { + id String @id @default(cuid()) + password String @omit + profile Profile? + + @@allow('all', true) + } + + model Profile { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String @unique + image String @omit + + @@allow('all', true) + } + `; + it('omit tests', async () => { - const { withOmit } = await loadSchema( - ` - model User { - id String @id @default(cuid()) - password String @omit - profile Profile? - - @@allow('all', true) - } - - model Profile { - id String @id @default(cuid()) - user User @relation(fields: [userId], references: [id]) - userId String @unique - image String @omit - - @@allow('all', true) - } ` - ); + const { withOmit } = await loadSchema(model); const db = withOmit(); const r = await db.user.create({ @@ -75,4 +77,32 @@ describe('Omit test', () => { expect(e.profile.image).toBeUndefined(); }); }); + + it('customization', async () => { + const { prisma } = await loadSchema(model, { getPrismaOnly: true, output: './zen' }); + + const db = withOmit(prisma, { loadPath: './zen' }); + const r = await db.user.create({ + include: { profile: true }, + data: { + id: '1', + password: 'abc123', + profile: { create: { image: 'an image' } }, + }, + }); + expect(r.password).toBeUndefined(); + expect(r.profile.image).toBeUndefined(); + + const db1 = withOmit(prisma, { modelMeta: require(path.resolve('./zen/model-meta')).default }); + const r1 = await db1.user.create({ + include: { profile: true }, + data: { + id: '2', + password: 'abc123', + profile: { create: { image: 'an image' } }, + }, + }); + expect(r1.password).toBeUndefined(); + expect(r1.profile.image).toBeUndefined(); + }); }); diff --git a/tests/integration/tests/enhancements/with-password/with-password.test.ts b/tests/integration/tests/enhancements/with-password/with-password.test.ts index f0502e5e4..62e30636b 100644 --- a/tests/integration/tests/enhancements/with-password/with-password.test.ts +++ b/tests/integration/tests/enhancements/with-password/with-password.test.ts @@ -1,3 +1,4 @@ +import { withPassword } from '@zenstackhq/runtime'; import { loadSchema } from '@zenstackhq/testtools'; import { compareSync } from 'bcryptjs'; import path from 'path'; @@ -13,16 +14,16 @@ describe('Password test', () => { process.chdir(origDir); }); + const model = ` + model User { + id String @id @default(cuid()) + password String @password(saltLength: 16) + + @@allow('all', true) + }`; + it('password tests', async () => { - const { withPassword } = await loadSchema( - ` - model User { - id String @id @default(cuid()) - password String @password(saltLength: 16) - - @@allow('all', true) - }` - ); + const { withPassword } = await loadSchema(model); const db = withPassword(); const r = await db.user.create({ @@ -41,4 +42,26 @@ describe('Password test', () => { }); expect(compareSync('abc456', r1.password)).toBeTruthy(); }); + + it('customization', async () => { + const { prisma } = await loadSchema(model, { getPrismaOnly: true, output: './zen' }); + + const db = withPassword(prisma, { loadPath: './zen' }); + const r = await db.user.create({ + data: { + id: '1', + password: 'abc123', + }, + }); + expect(compareSync('abc123', r.password)).toBeTruthy(); + + const db1 = withPassword(prisma, { modelMeta: require(path.resolve('./zen/model-meta')).default }); + const r1 = await db1.user.create({ + data: { + id: '2', + password: 'abc123', + }, + }); + expect(compareSync('abc123', r1.password)).toBeTruthy(); + }); }); diff --git a/tests/integration/tests/enhancements/with-policy/options.test.ts b/tests/integration/tests/enhancements/with-policy/options.test.ts new file mode 100644 index 000000000..2c661ceb4 --- /dev/null +++ b/tests/integration/tests/enhancements/with-policy/options.test.ts @@ -0,0 +1,58 @@ +import { withPolicy } from '@zenstackhq/runtime'; +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('Password test', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(async () => { + process.chdir(origDir); + }); + + it('load path', async () => { + const { prisma } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + x Int + + @@allow('create', x > 0) + }`, + { getPrismaOnly: true, output: './zen' } + ); + + const db = withPolicy(prisma, undefined, { loadPath: './zen' }); + await expect( + db.foo.create({ + data: { x: 0 }, + }) + ).toBeRejectedByPolicy(); + }); + + it('overrides', async () => { + const { prisma } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + x Int + + @@allow('create', x > 0) + }`, + { getPrismaOnly: true, output: './zen' } + ); + + const db = withPolicy(prisma, undefined, { + modelMeta: require(path.resolve('./zen/model-meta')).default, + policy: require(path.resolve('./zen/policy')).default, + }); + await expect( + db.foo.create({ + data: { x: 0 }, + }) + ).toBeRejectedByPolicy(); + }); +});