From fc50deb6e70acc78dcb66b17e564a6fc84475970 Mon Sep 17 00:00:00 2001 From: Yiming Date: Mon, 18 Sep 2023 12:41:15 -0700 Subject: [PATCH] feat: add "loadPath" options to runtime API and server adapter options (#696) --- packages/plugins/swr/tests/swr.test.ts | 2 +- 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 | 53 +- packages/server/src/api/rest/index.ts | 6 +- packages/server/src/api/rpc/index.ts | 6 +- 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/server/tests/api/rest.test.ts | 3576 ++++++++--------- packages/server/tests/api/rpc.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 + 35 files changed, 2286 insertions(+), 2037 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/plugins/swr/tests/swr.test.ts b/packages/plugins/swr/tests/swr.test.ts index 4659718da..fca774d64 100644 --- a/packages/plugins/swr/tests/swr.test.ts +++ b/packages/plugins/swr/tests/swr.test.ts @@ -58,7 +58,7 @@ ${sharedModel} { provider: 'postgresql', pushDb: false, - extraDependencies: [`${origDir}/dist`, 'react', '@types/react', 'swr'], + extraDependencies: [`${origDir}/dist`, 'react@18.2.0', '@types/react@18.2.0', 'swr@^2'], compile: true, } ); 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..0b90a5b26 --- /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 { + const toLoad = loadPath ? path.resolve(loadPath, 'model-meta') : `${DEFAULT_RUNTIME_LOAD_PATH}/model-meta`; + try { + // normal load + return require(toLoad).default; + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(toLoad)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', toLoad)).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 { + const toLoad = loadPath ? path.resolve(loadPath, 'policy') : `${DEFAULT_RUNTIME_LOAD_PATH}/policy`; + try { + return require(toLoad).default; + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(toLoad)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', toLoad)).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 { + const toLoad = loadPath ? path.resolve(loadPath, 'zod') : `${DEFAULT_RUNTIME_LOAD_PATH}/zod`; + try { + return require(toLoad); + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(toLoad)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', toLoad)); + } 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..ba385f31c 100644 --- a/packages/server/src/api/base.ts +++ b/packages/server/src/api/base.ts @@ -1,4 +1,53 @@ -import { ModelMeta, getDefaultModelMeta } from '@zenstackhq/runtime'; +import { DbClientContract, ModelMeta, ZodSchemas, getDefaultModelMeta } 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 @@ -9,7 +58,7 @@ export abstract class APIHandlerBase { constructor() { try { - this.defaultModelMeta = getDefaultModelMeta(); + this.defaultModelMeta = getDefaultModelMeta(undefined); } catch { // noop } diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index 1a39aee53..b85d86bcf 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 = { @@ -225,7 +225,7 @@ class RequestHandler extends APIHandlerBase { }: RequestContext): Promise { modelMeta = modelMeta ?? this.defaultModelMeta; if (!modelMeta) { - throw new Error('Model meta is not provided or loaded from default location'); + throw new Error('Model metadata is not provided or loaded from default location'); } if (!this.serializers) { diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index 20dee5e33..983e79154 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(); @@ -39,7 +39,7 @@ class RequestHandler extends APIHandlerBase { }: RequestContext): Promise { modelMeta = modelMeta ?? this.defaultModelMeta; if (!modelMeta) { - throw new Error('Model meta is not provided or loaded from default location'); + throw new Error('Model metadata is not provided or loaded from default location'); } const parts = path.split('/').filter((p) => !!p); 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/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index da40a54ab..7b084ef8a 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -7,13 +7,19 @@ import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import makeHandler from '../../src/api/rest'; -let prisma: any; -let zodSchemas: any; -let modelMeta: ModelMeta; -let handler: (any: any) => Promise<{ status: number; body: any }>; +describe('REST server tests', () => { + let prisma: any; + let zodSchemas: any; + let modelMeta: ModelMeta; + let handler: (any: any) => Promise<{ status: number; body: any }>; -describe('REST server tests - regular prisma', () => { - const schema = ` + beforeEach(async () => { + run('npx prisma migrate reset --force'); + run('npx prisma db push'); + }); + + describe('REST server tests - regular prisma', () => { + const schema = ` model User { myId String @id @default(cuid()) createdAt DateTime @default (now()) @@ -58,116 +64,164 @@ describe('REST server tests - regular prisma', () => { } `; - beforeAll(async () => { - const params = await loadSchema(schema); + beforeAll(async () => { + const params = await loadSchema(schema); - prisma = params.prisma; - zodSchemas = params.zodSchemas; - modelMeta = params.modelMeta; + prisma = params.prisma; + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); - handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); - }); + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + }); - beforeEach(async () => { - run('npx prisma migrate reset --force'); - run('npx prisma db push'); - }); + describe('CRUD', () => { + describe('GET', () => { + it('invalid type, id, relationship', async () => { + let r = await handler({ + method: 'get', + path: '/foo', + prisma, + }); + expect(r.status).toBe(404); - describe('CRUD', () => { - describe('GET', () => { - it('invalid type, id, relationship', async () => { - let r = await handler({ - method: 'get', - path: '/foo', - prisma, - }); - expect(r.status).toBe(404); + r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); + expect(r.status).toBe(404); - r = await handler({ - method: 'get', - path: '/user/user1/posts', - prisma, - }); - expect(r.status).toBe(404); - - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { title: 'Post1' }, + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { title: 'Post1' }, + }, }, - }, - }); + }); - r = await handler({ - method: 'get', - path: '/user/user1/relationships/foo', - prisma, - }); - expect(r.status).toBe(404); + r = await handler({ + method: 'get', + path: '/user/user1/relationships/foo', + prisma, + }); + expect(r.status).toBe(404); - r = await handler({ - method: 'get', - path: '/user/user1/foo', - prisma, + r = await handler({ + method: 'get', + path: '/user/user1/foo', + prisma, + }); + expect(r.status).toBe(404); }); - expect(r.status).toBe(404); - }); - it('returns an empty array when no item exists', async () => { - const r = await handler({ - method: 'get', - path: '/user', - prisma, - }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - data: [], - links: { - self: 'http://localhost/api/user', - }, + it('returns an empty array when no item exists', async () => { + const r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: [], + links: { + self: 'http://localhost/api/user', + }, + }); }); - }); - it('returns all items when there are some in the database', async () => { - // Create users first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { title: 'Post1' }, + it('returns all items when there are some in the database', async () => { + // Create users first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { title: 'Post1' }, + }, }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { title: 'Post2' }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { title: 'Post2' }, + }, }, - }, - }); + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + }); - const r = await handler({ - method: 'get', - path: '/user', - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user', + }, + meta: { + total: 2, + }, + data: [ + { + type: 'user', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [{ type: 'post', id: 1 }], + }, + }, + }, + { + type: 'user', + id: 'user2', + attributes: { email: 'user2@abc.com' }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [{ type: 'post', id: 2 }], + }, + }, + }, + ], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user', - }, - meta: { - total: 2, - }, - data: [ - { + it('returns a single item when the ID is specified', async () => { + // Create a user first + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' }, @@ -184,1131 +238,1108 @@ describe('REST server tests - regular prisma', () => { }, }, }, - { - type: 'user', - id: 'user2', - attributes: { email: 'user2@abc.com' }, - links: { - self: 'http://localhost/api/user/user2', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user2/relationships/posts', - related: 'http://localhost/api/user/user2/posts', - }, - data: [{ type: 'post', id: 2 }], - }, - }, - }, - ], + }); }); - }); - it('returns a single item when the ID is specified', async () => { - // Create a user first - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, - }); + it('fetch a related resource', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); - const r = await handler({ - method: 'get', - path: '/user/user1', - prisma, - }); + const r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - data: { - type: 'user', - id: 'user1', - attributes: { email: 'user1@abc.com' }, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ links: { - self: 'http://localhost/api/user/user1', + self: 'http://localhost/api/user/user1/posts', }, - relationships: { - posts: { + data: [ + { + type: 'post', + id: 1, + attributes: { + title: 'Post1', + authorId: 'user1', + published: false, + viewCount: 0, + }, links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + }, }, - data: [{ type: 'post', id: 1 }], }, - }, - }, + ], + }); }); - }); - it('fetch a related resource', async () => { - // Create a user first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, + it('fetch a relationship', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, }, - }, - }); + }); - const r = await handler({ - method: 'get', - path: '/user/user1/posts', - prisma, + const r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [{ type: 'post', id: 1 }], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/posts', - }, - data: [ - { - type: 'post', - id: 1, - attributes: { - title: 'Post1', - authorId: 'user1', - published: false, - viewCount: 0, + it('returns 404 if the specified ID does not exist', async () => { + const r = await handler({ + method: 'get', + path: '/user/nonexistentuser', + prisma, + }); + + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + { + code: 'not-found', + status: 404, + title: 'Resource not found', }, - links: { - self: 'http://localhost/api/post/1', + ], + }); + }); + + it('toplevel filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, }, - relationships: { - author: { - links: { - self: 'http://localhost/api/post/1/relationships/author', - related: 'http://localhost/api/post/1/author', - }, - }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, }, }, - ], - }); - }); + }); - it('fetch a relationship', async () => { - // Create a user first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); + // id filter + let r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user2' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + // String filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email]']: 'user1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@abc' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@bc' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - const r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - prisma, - }); + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'user1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'ser1' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [{ type: 'post', id: 1 }], - }); - }); + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // Int filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gt]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gte]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lt]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - it('returns 404 if the specified ID does not exist', async () => { - const r = await handler({ - method: 'get', - path: '/user/nonexistentuser', - prisma, - }); + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lte]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // Boolean filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // deep to-one filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author][email]']: 'user1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + + // deep to-many filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts][published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + + // filter to empty + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user3' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // to-many relation collection filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '2' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '1,2,3' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + + // multi filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // to-one relation filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author]']: 'user1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // invalid filter field + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[foo]']: '1' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); - expect(r.status).toBe(404); - expect(r.body).toEqual({ - errors: [ - { - code: 'not-found', - status: 404, - title: 'Resource not found', - }, - ], + // invalid filter value + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: 'a' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-value', + title: 'Invalid value for type', + }, + ], + }); + + // invalid filter operation + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$foo]']: '1' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); }); - }); - it('toplevel filtering', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, + it('related data filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, }, - }, - }); + }); - // id filter - let r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user2' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user2' }); - - // String filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email]']: 'user1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$contains]']: '1@abc' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$contains]']: '1@bc' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$startsWith]']: 'user1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$startsWith]']: 'ser1' }, - prisma, + r = await handler({ + method: 'get', + path: '/user/user2/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); }); - expect(r.body.data).toHaveLength(0); - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$endsWith]']: '1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$endsWith]']: '1@abc' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // Int filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$gt]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$gte]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$lt]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); + it('relationship filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, + }, + }); - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$lte]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // Boolean filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // deep to-one filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[author][email]']: 'user1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - - // deep to-many filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts][published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - - // filter to empty - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user3' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // to-many relation collection filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts]']: '2' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user2' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts]']: '1,2,3' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - - // multi filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // to-one relation filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[author]']: 'user1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // invalid filter field - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[foo]']: '1' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-filter', - title: 'Invalid filter', - }, - ], + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user/user2/relationships/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); }); - // invalid filter value - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount]']: 'a' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-value', - title: 'Invalid value for type', + it('toplevel sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', viewCount: 1, published: true }, + }, }, - ], - }); - - // invalid filter operation - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$foo]']: '1' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-filter', - title: 'Invalid filter', + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 2, published: false }, + }, }, - ], - }); - }); + }); - it('related data filtering', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, - }, - }, - }); + // basic sorting + let r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // basic sorting desc + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation id + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author.email' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // multi-field sorting + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'published,viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount,published' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount,-published' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // invalid field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); - let r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); + // sort with collection + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'comments' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); - r = await handler({ - method: 'get', - path: '/user/user2/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, + // sort with regular field in the middle + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount.foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); }); - expect(r.body.data).toHaveLength(1); - }); - it('relationship filtering', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + it('related data sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { + id: 1, + title: 'Post1', + viewCount: 1, + published: true, + setting: { create: { boost: 1 } }, + }, + { + id: 2, + title: 'Post2', + viewCount: 2, + published: false, + setting: { create: { boost: 2 } }, + }, + ], + }, }, - }, - }); - - let r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - r = await handler({ - method: 'get', - path: '/user/user2/relationships/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - }); + }); - it('toplevel sorting', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1', viewCount: 1, published: true }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 2, published: false }, - }, - }, + // asc + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: 'viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // desc + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: '-viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // relation field + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: '-setting.boost' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); }); - // basic sorting - let r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // basic sorting desc - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // by relation id - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-author' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // by relation field - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-author.email' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // multi-field sorting - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'published,viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount,published' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-viewCount,-published' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // invalid field - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', + it('relationship sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { + id: 1, + title: 'Post1', + viewCount: 1, + published: true, + setting: { create: { boost: 1 } }, + }, + { + id: 2, + title: 'Post2', + viewCount: 2, + published: false, + setting: { create: { boost: 2 } }, + }, + ], + }, }, - ], - }); + }); - // sort with collection - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'comments' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', - }, - ], + // asc + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: 'viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // desc + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: '-viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // relation field + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: '-setting.boost' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); }); - // sort with regular field in the middle - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount.foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', + it('including', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } }, + }, + profile: { + create: { gender: 'male' }, + }, }, - ], - }); - }); - - it('related data sorting', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: [ - { - id: 1, - title: 'Post1', - viewCount: 1, - published: true, - setting: { create: { boost: 1 } }, - }, - { + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', - viewCount: 2, - published: false, - setting: { create: { boost: 2 } }, - }, - ], - }, - }, - }); - - // asc - let r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { sort: 'viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // desc - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { sort: '-viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // relation field - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { sort: '-setting.boost' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - }); - - it('relationship sorting', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: [ - { - id: 1, - title: 'Post1', viewCount: 1, published: true, - setting: { create: { boost: 1 } }, + comments: { create: { content: 'Comment2' } }, }, - { - id: 2, - title: 'Post2', - viewCount: 2, - published: false, - setting: { create: { boost: 2 } }, - }, - ], - }, - }, - }); - - // asc - let r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { sort: 'viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // desc - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { sort: '-viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // relation field - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { sort: '-setting.boost' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - }); - - it('including', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } }, - }, - profile: { - create: { gender: 'male' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { - id: 2, - title: 'Post2', - viewCount: 1, - published: true, - comments: { create: { content: 'Comment2' } }, }, }, - }, - }); + }); - // collection query include - let r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts' }, - prisma, - }); - expect(r.body.included).toHaveLength(2); - expect(r.body.included[0]).toMatchObject({ - type: 'post', - id: 1, - attributes: { title: 'Post1' }, - }); + // collection query include + let r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts' }, + prisma, + }); + expect(r.body.included).toHaveLength(2); + expect(r.body.included[0]).toMatchObject({ + type: 'post', + id: 1, + attributes: { title: 'Post1' }, + }); - // single query include - r = await handler({ - method: 'get', - path: '/user/user1', - query: { include: 'posts' }, - prisma, - }); - expect(r.body.included).toHaveLength(1); - expect(r.body.included[0]).toMatchObject({ - type: 'post', - id: 1, - attributes: { title: 'Post1' }, - }); + // single query include + r = await handler({ + method: 'get', + path: '/user/user1', + query: { include: 'posts' }, + prisma, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'post', + id: 1, + attributes: { title: 'Post1' }, + }); - // related query include - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { include: 'posts.comments' }, - prisma, - }); - expect(r.body.included).toHaveLength(1); - expect(r.body.included[0]).toMatchObject({ - type: 'comment', - attributes: { content: 'Comment1' }, - }); + // related query include + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments' }, + prisma, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'comment', + attributes: { content: 'Comment1' }, + }); - // related query include with filter - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { include: 'posts.comments', ['filter[published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // deep include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts.comments' }, - prisma, - }); - expect(r.body.included).toHaveLength(3); - expect(r.body.included[2]).toMatchObject({ - type: 'comment', - attributes: { content: 'Comment1' }, - }); + // related query include with filter + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments', ['filter[published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // deep include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments' }, + prisma, + }); + expect(r.body.included).toHaveLength(3); + expect(r.body.included[2]).toMatchObject({ + type: 'comment', + attributes: { content: 'Comment1' }, + }); - // multiple include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts.comments,profile' }, - prisma, - }); - expect(r.body.included).toHaveLength(4); - const profile = r.body.included.find((item: any) => item.type === 'profile'); - expect(profile).toMatchObject({ - type: 'profile', - attributes: { gender: 'male' }, - }); + // multiple include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments,profile' }, + prisma, + }); + expect(r.body.included).toHaveLength(4); + const profile = r.body.included.find((item: any) => item.type === 'profile'); + expect(profile).toMatchObject({ + type: 'profile', + attributes: { gender: 'male' }, + }); - // invalid include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [{ status: 400, code: 'unsupported-relationship' }], + // invalid include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [{ status: 400, code: 'unsupported-relationship' }], + }); }); - }); - it('toplevel pagination', async () => { - for (const i of Array(5).keys()) { - await prisma.user.create({ - data: { - myId: `user${i}`, - email: `user${i}@abc.com`, - }, + it('toplevel pagination', async () => { + for (const i of Array(5).keys()) { + await prisma.user.create({ + data: { + myId: `user${i}`, + email: `user${i}@abc.com`, + }, + }); + } + + // limit only + let r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: null, + next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3', }); - } - - // limit only - let r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(3); - expect(r.body.meta.total).toBe(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=3', - last: 'http://localhost/api/user?page%5Boffset%5D=3', - prev: null, - next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3', - }); - // limit & offset - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '3', ['page[offset]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - expect(r.body.meta.total).toBe(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=3', - last: 'http://localhost/api/user?page%5Boffset%5D=3', - prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3', - next: null, - }); + // limit & offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3', ['page[offset]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3', + next: null, + }); - // limit trimmed - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '10' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); + // limit trimmed + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '10' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); - // offset overflow - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[offset]']: '10' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); + // offset overflow + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '10' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); - // minus offset - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[offset]']: '-1' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); + // minus offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '-1' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); - // zero limit - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, + // zero limit + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); }); - }); - it('related data pagination', async () => { - await prisma.user.create({ - data: { - myId: `user1`, - email: `user1@abc.com`, - posts: { - create: [...Array(10).keys()].map((i) => ({ - id: i, - title: `Post${i}`, - })), + it('related data pagination', async () => { + await prisma.user.create({ + data: { + myId: `user1`, + email: `user1@abc.com`, + posts: { + create: [...Array(10).keys()].map((i) => ({ + id: i, + title: `Post${i}`, + })), + }, }, - }, - }); + }); - // default limiting - let r = await handler({ - method: 'get', - path: '/user/user1/posts', - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/posts', - first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=5', - last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5', - prev: null, - next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', - }); + // default limiting + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=5', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5', + prev: null, + next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', + }); - // explicit limiting - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { ['page[limit]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(3); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/posts', - first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', - prev: null, - next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', - }); + // explicit limiting + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['page[limit]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', + prev: null, + next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); - // offset - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { ['page[limit]']: '3', ['page[offset]']: '8' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/posts', - first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', - prev: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', - next: null, + // offset + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['page[limit]']: '3', ['page[offset]']: '8' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', + prev: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', + next: null, + }); }); - }); - it('relationship pagination', async () => { - await prisma.user.create({ - data: { - myId: `user1`, - email: `user1@abc.com`, - posts: { - create: [...Array(10).keys()].map((i) => ({ - id: i, - title: `Post${i}`, - })), + it('relationship pagination', async () => { + await prisma.user.create({ + data: { + myId: `user1`, + email: `user1@abc.com`, + posts: { + create: [...Array(10).keys()].map((i) => ({ + id: i, + title: `Post${i}`, + })), + }, }, - }, - }); + }); - // default limiting - let r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/relationships/posts', - first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=5', - last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5', - prev: null, - next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', - }); + // default limiting + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=5', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5', + prev: null, + next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', + }); - // explicit limiting - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { ['page[limit]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(3); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/relationships/posts', - first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', - prev: null, - next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', - }); + // explicit limiting + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['page[limit]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', + prev: null, + next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); - // offset - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { ['page[limit]']: '3', ['page[offset]']: '8' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/relationships/posts', - first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', - prev: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', - next: null, + // offset + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['page[limit]']: '3', ['page[offset]']: '8' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', + prev: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', + next: null, + }); }); }); - }); - describe('POST', () => { - it('creates an item without relation', async () => { - const r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { - data: { type: 'user', attributes: { myId: 'user1', email: 'user1@abc.com' } }, - }, - prisma, - }); + describe('POST', () => { + it('creates an item without relation', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { myId: 'user1', email: 'user1@abc.com' } }, + }, + prisma, + }); - expect(r.status).toBe(201); - expect(r.body).toMatchObject({ - jsonapi: { version: '1.1' }, - data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, + }); }); - }); - it('creates an item with collection relations', async () => { - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, - }); + it('creates an item with collection relations', async () => { + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); - const r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { myId: 'user1', email: 'user1@abc.com' }, + relationships: { + posts: { + data: [ + { type: 'post', id: 1 }, + { type: 'post', id: 2 }, + ], + }, + }, + }, + }, + prisma, + }); + + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, data: { type: 'user', - attributes: { myId: 'user1', email: 'user1@abc.com' }, + id: 'user1', + attributes: { + email: 'user1@abc.com', + }, + links: { + self: 'http://localhost/api/user/user1', + }, relationships: { posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, data: [ { type: 'post', id: 1 }, { type: 'post', id: 2 }, @@ -1316,207 +1347,209 @@ describe('REST server tests - regular prisma', () => { }, }, }, - }, - prisma, + }); }); - expect(r.status).toBe(201); - expect(r.body).toMatchObject({ - jsonapi: { version: '1.1' }, - data: { - type: 'user', - id: 'user1', - attributes: { - email: 'user1@abc.com', - }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', + it('creates an item with single relation', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'post', + path: '/post', + query: {}, + requestBody: { + data: { + type: 'post', + attributes: { title: 'Post1' }, + relationships: { + author: { + data: { type: 'user', id: 'user1' }, + }, }, - data: [ - { type: 'post', id: 1 }, - { type: 'post', id: 2 }, - ], }, }, - }, - }); - }); - - it('creates an item with single relation', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com' }, - }); + prisma, + }); - const r = await handler({ - method: 'post', - path: '/post', - query: {}, - requestBody: { + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1', + }, data: { type: 'post', - attributes: { title: 'Post1' }, + id: 1, + attributes: { + title: 'Post1', + authorId: 'user1', + published: false, + viewCount: 0, + }, + links: { + self: 'http://localhost/api/post/1', + }, relationships: { author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, data: { type: 'user', id: 'user1' }, }, }, }, - }, - prisma, - }); - - expect(r.status).toBe(201); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/post/1', - }, - data: { - type: 'post', - id: 1, - attributes: { - title: 'Post1', - authorId: 'user1', - published: false, - viewCount: 0, - }, - links: { - self: 'http://localhost/api/post/1', - }, - relationships: { - author: { - links: { - self: 'http://localhost/api/post/1/relationships/author', - related: 'http://localhost/api/post/1/author', - }, - data: { type: 'user', id: 'user1' }, - }, - }, - }, - }); - }); - - it('create single relation disallowed', async () => { - await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, + }); }); - const r = await handler({ - method: 'post', - path: '/post/1/relationships/author', - query: {}, - requestBody: { - data: { type: 'user', id: 'user1' }, - }, - prisma, - }); + it('create single relation disallowed', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-verb', - title: 'The HTTP verb is not supported', + const r = await handler({ + method: 'post', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { type: 'user', id: 'user1' }, }, - ], - }); - }); + prisma, + }); - it('create a collection of relations', async () => { - await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-verb', + title: 'The HTTP verb is not supported', + }, + ], + }); }); - const r = await handler({ - method: 'post', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { + it('create a collection of relations', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [ + { type: 'post', id: 1 }, + { type: 'post', id: 2 }, + ], + }, + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, data: [ { type: 'post', id: 1 }, { type: 'post', id: 2 }, ], - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [ - { type: 'post', id: 1 }, - { type: 'post', id: 2 }, - ], - }); - }); + it('create relation for nonexistent entity', async () => { + let r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 1 }], + }, + prisma, + }); - it('create relation for nonexistent entity', async () => { - let r = await handler({ - method: 'post', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { - data: [{ type: 'post', id: 1 }], - }, - prisma, - }); + expect(r.status).toBe(404); - expect(r.status).toBe(404); + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com' }, - }); + r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { data: [{ type: 'post', id: 1 }] }, + prisma, + }); - r = await handler({ - method: 'post', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { data: [{ type: 'post', id: 1 }] }, - prisma, + expect(r.status).toBe(404); }); - - expect(r.status).toBe(404); }); - }); - describe('PUT', () => { - it('updates an item if it exists', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - }, - }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, - }); + describe('PUT', () => { + it('updates an item if it exists', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + }, + }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'put', + path: '/user/user1', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { email: 'user2@abc.com' }, + relationships: { + posts: { + data: [ + { type: 'post', id: 1 }, + { type: 'post', id: 2 }, + ], + }, + }, + }, + }, + prisma, + }); - const r = await handler({ - method: 'put', - path: '/user/user1', - query: {}, - requestBody: { + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1', + }, data: { type: 'user', - attributes: { email: 'user2@abc.com' }, + id: 'user1', + attributes: { + email: 'user2@abc.com', + }, + links: { + self: 'http://localhost/api/user/user1', + }, relationships: { posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, data: [ { type: 'post', id: 1 }, { type: 'post', id: 2 }, @@ -1524,343 +1557,312 @@ describe('REST server tests - regular prisma', () => { }, }, }, - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1', - }, - data: { - type: 'user', - id: 'user1', - attributes: { - email: 'user2@abc.com', - }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', - }, - data: [ - { type: 'post', id: 1 }, - { type: 'post', id: 2 }, - ], + it('returns 404 if the user does not exist', async () => { + const r = await handler({ + method: 'put', + path: '/user/nonexistentuser', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { email: 'user2@abc.com' }, }, }, - }, - }); - }); + prisma, + }); - it('returns 404 if the user does not exist', async () => { - const r = await handler({ - method: 'put', - path: '/user/nonexistentuser', - query: {}, - requestBody: { - data: { - type: 'user', - attributes: { email: 'user2@abc.com' }, - }, - }, - prisma, + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + { + code: 'not-found', + status: 404, + title: 'Resource not found', + }, + ], + }); }); - expect(r.status).toBe(404); - expect(r.body).toEqual({ - errors: [ - { - code: 'not-found', - status: 404, - title: 'Resource not found', - }, - ], - }); - }); + it('update a single relation', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); - it('update a single relation', async () => { - await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'user', + id: 'user1', + }, + }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + jsonapi: { + version: '1.1', + }, + links: { + self: 'http://localhost/api/post/1/relationships/author', + }, data: { type: 'user', id: 'user1', }, - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - jsonapi: { - version: '1.1', - }, - links: { - self: 'http://localhost/api/post/1/relationships/author', - }, - data: { - type: 'user', - id: 'user1', - }, - }); - }); + it('remove a single relation', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); - it('remove a single relation', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { data: null }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { data: null }, - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1/relationships/author', + }, + data: null, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/post/1/relationships/author', - }, - data: null, - }); - }); + it('update a collection of relations', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); - it('update a collection of relations', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, - }); + const r = await handler({ + method: 'patch', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 2 }], + }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, data: [{ type: 'post', id: 2 }], - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [{ type: 'post', id: 2 }], - }); - }); + it('update a collection of relations to empty', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); - it('update a collection of relations to empty', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); + const r = await handler({ + method: 'patch', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { data: [] }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { data: [] }, - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [], - }); - }); + it('update relation for nonexistent entity', async () => { + let r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'user', + id: 'user1', + }, + }, + prisma, + }); + expect(r.status).toBe(404); - it('update relation for nonexistent entity', async () => { - let r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { - data: { - type: 'user', - id: 'user1', + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'user', + id: 'user1', + }, }, - }, - prisma, - }); - expect(r.status).toBe(404); + prisma, + }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, + expect(r.status).toBe(404); }); + }); - r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { + describe('DELETE', () => { + it('deletes an item if it exists', async () => { + // Create a user first + await prisma.user.create({ data: { - type: 'user', - id: 'user1', + myId: 'user1', + email: 'user1@abc.com', }, - }, - prisma, - }); - - expect(r.status).toBe(404); - }); - }); + }); - describe('DELETE', () => { - it('deletes an item if it exists', async () => { - // Create a user first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - }, - }); + const r = await handler({ + method: 'delete', + path: '/user/user1', + prisma, + }); - const r = await handler({ - method: 'delete', - path: '/user/user1', - prisma, + expect(r.status).toBe(204); + expect(r.body).toBeUndefined(); }); - expect(r.status).toBe(204); - expect(r.body).toBeUndefined(); - }); + it('returns 404 if the user does not exist', async () => { + const r = await handler({ + method: 'delete', + path: '/user/nonexistentuser', + prisma, + }); - it('returns 404 if the user does not exist', async () => { - const r = await handler({ - method: 'delete', - path: '/user/nonexistentuser', - prisma, + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + { + code: 'not-found', + status: 404, + title: 'Resource not found', + }, + ], + }); }); - expect(r.status).toBe(404); - expect(r.body).toEqual({ - errors: [ - { - code: 'not-found', - status: 404, - title: 'Resource not found', - }, - ], - }); - }); + it('delete single relation disallowed', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); - it('delete single relation disallowed', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); + const r = await handler({ + method: 'delete', + path: '/post/1/relationships/author', + query: {}, + prisma, + }); - const r = await handler({ - method: 'delete', - path: '/post/1/relationships/author', - query: {}, - prisma, + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-verb', + title: 'The HTTP verb is not supported', + }, + ], + }); }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-verb', - title: 'The HTTP verb is not supported', + it('delete a collection of relations', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], + }, }, - ], - }); - }); + }); - it('delete a collection of relations', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: [ - { id: 1, title: 'Post1' }, - { id: 2, title: 'Post2' }, - ], + const r = await handler({ + method: 'delete', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 1 }], }, - }, - }); + prisma, + }); - const r = await handler({ - method: 'delete', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { - data: [{ type: 'post', id: 1 }], - }, - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + jsonapi: { + version: '1.1', + }, + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [{ type: 'post', id: 2 }], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - jsonapi: { - version: '1.1', - }, - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [{ type: 'post', id: 2 }], + it('delete relations for nonexistent entity', async () => { + const r = await handler({ + method: 'delete', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 1 }], + }, + prisma, + }); + expect(r.status).toBe(404); }); }); - it('delete relations for nonexistent entity', async () => { - const r = await handler({ - method: 'delete', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { - data: [{ type: 'post', id: 1 }], - }, - prisma, - }); - expect(r.status).toBe(404); - }); - }); + describe('validation error', () => { + it('creates an item without relation', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { myId: 'user1', email: 'user1.com' } }, + }, + prisma, + }); - describe('validation error', () => { - it('creates an item without relation', async () => { - const r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { - data: { type: 'user', attributes: { myId: 'user1', email: 'user1.com' } }, - }, - prisma, + expect(r.status).toBe(400); + expect(r.body.errors[0].code).toBe('invalid-payload'); + expect(r.body.errors[0].reason).toBe(CrudFailureReason.DATA_VALIDATION_VIOLATION); + expect(r.body.errors[0].zodErrors).toBeTruthy(); }); - - expect(r.status).toBe(400); - expect(r.body.errors[0].code).toBe('invalid-payload'); - expect(r.body.errors[0].reason).toBe(CrudFailureReason.DATA_VALIDATION_VIOLATION); - expect(r.body.errors[0].zodErrors).toBeTruthy(); }); }); }); -}); -describe('REST server tests - enhanced prisma', () => { - const schema = ` + describe('REST server tests - enhanced prisma', () => { + const schema = ` model Foo { id Int @id value Int @@ -1877,65 +1879,61 @@ describe('REST server tests - enhanced prisma', () => { } `; - beforeAll(async () => { - const params = await loadSchema(schema); - - prisma = withPolicy(params.prisma, undefined, params); - zodSchemas = params.zodSchemas; - modelMeta = params.modelMeta; - - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); - handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); - }); + beforeAll(async () => { + const params = await loadSchema(schema); - beforeEach(async () => { - run('npx prisma migrate reset --force'); - run('npx prisma db push'); - }); + prisma = withPolicy(params.prisma, undefined, params); + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; - it('update policy rejection test', async () => { - let r = await handler({ - method: 'post', - path: '/foo', - query: {}, - requestBody: { - data: { type: 'foo', attributes: { id: 1, value: 0 } }, - }, - prisma, + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); - expect(r.status).toBe(201); - - r = await handler({ - method: 'put', - path: '/foo/1', - query: {}, - requestBody: { - data: { type: 'foo', attributes: { value: 1 } }, - }, - prisma, + + it('update policy rejection test', async () => { + let r = await handler({ + method: 'post', + path: '/foo', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { id: 1, value: 0 } }, + }, + prisma, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'put', + path: '/foo/1', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { value: 1 } }, + }, + prisma, + }); + expect(r.status).toBe(403); + expect(r.body.errors[0].code).toBe('forbidden'); + expect(r.body.errors[0].reason).toBe(CrudFailureReason.ACCESS_POLICY_VIOLATION); }); - expect(r.status).toBe(403); - expect(r.body.errors[0].code).toBe('forbidden'); - expect(r.body.errors[0].reason).toBe(CrudFailureReason.ACCESS_POLICY_VIOLATION); - }); - it('read-back policy rejection test', async () => { - const r = await handler({ - method: 'post', - path: '/bar', - query: {}, - requestBody: { - data: { type: 'bar', attributes: { id: 1, value: 0 } }, - }, - prisma, + it('read-back policy rejection test', async () => { + const r = await handler({ + method: 'post', + path: '/bar', + query: {}, + requestBody: { + data: { type: 'bar', attributes: { id: 1, value: 0 } }, + }, + prisma, + }); + expect(r.status).toBe(403); + expect(r.body.errors[0].reason).toBe(CrudFailureReason.RESULT_NOT_READABLE); }); - expect(r.status).toBe(403); - expect(r.body.errors[0].reason).toBe(CrudFailureReason.RESULT_NOT_READABLE); }); -}); -describe('REST server tests - NextAuth project regression', () => { - const schema = ` + describe('REST server tests - NextAuth project regression', () => { + const schema = ` model Post { id String @id @default(cuid()) title String @@ -1994,66 +1992,62 @@ describe('REST server tests - NextAuth project regression', () => { } `; - beforeAll(async () => { - const params = await loadSchema(schema); - - prisma = withPolicy(params.prisma, undefined, params); - zodSchemas = params.zodSchemas; - modelMeta = params.modelMeta; - - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); - handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); - }); + beforeAll(async () => { + const params = await loadSchema(schema); - beforeEach(async () => { - run('npx prisma migrate reset --force'); - run('npx prisma db push'); - }); + prisma = withPolicy(params.prisma, undefined, params); + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; - it('crud test', async () => { - let r = await handler({ - method: 'get', - path: '/user', - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(0); - - r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { - data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, - }, - prisma, + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); - expect(r.status).toBe(201); - r = await handler({ - method: 'get', - path: '/user', - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(1); - - r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { - data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, - }, - prisma, + it('crud test', async () => { + let r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, + }, + prisma, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, + }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body.errors[0].prismaCode).toBe('P2002'); }); - expect(r.status).toBe(400); - expect(r.body.errors[0].prismaCode).toBe('P2002'); }); -}); -describe('REST server tests - field type coverage', () => { - const schema = ` + describe('REST server tests - field type coverage', () => { + const schema = ` model Foo { id Int @id string String @@ -2075,131 +2069,133 @@ describe('REST server tests - field type coverage', () => { } `; - it('field types', async () => { - const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); - - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); - handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); - - await prisma.bar.create({ data: { id: 1, bytes: Buffer.from([7, 8, 9]) } }); - - const decimalValue1 = new Decimal('0.046875'); - const decimalValue2 = new Decimal('0.0146875'); - - const createAttrs = { - string: 'string', - int: 123, - bigInt: BigInt(534543543534), - date: new Date(), - float: 1.23, - decimal: decimalValue1, - boolean: true, - bytes: Buffer.from([1, 2, 3, 4]), - }; - - const { json: createPayload, meta: createMeta } = SuperJSON.serialize({ - data: { - type: 'foo', - attributes: { id: 1, ...createAttrs }, - relationships: { - bars: { - data: [{ type: 'bar', id: 1 }], + it('field types', async () => { + const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); + + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + + await prisma.bar.create({ data: { id: 1, bytes: Buffer.from([7, 8, 9]) } }); + + const decimalValue1 = new Decimal('0.046875'); + const decimalValue2 = new Decimal('0.0146875'); + + const createAttrs = { + string: 'string', + int: 123, + bigInt: BigInt(534543543534), + date: new Date(), + float: 1.23, + decimal: decimalValue1, + boolean: true, + bytes: Buffer.from([1, 2, 3, 4]), + }; + + const { json: createPayload, meta: createMeta } = SuperJSON.serialize({ + data: { + type: 'foo', + attributes: { id: 1, ...createAttrs }, + relationships: { + bars: { + data: [{ type: 'bar', id: 1 }], + }, }, }, - }, - }); + }); - let r = await handler({ - method: 'post', - path: '/foo', - query: {}, - requestBody: { - ...(createPayload as any), - meta: { - serialization: createMeta, + let r = await handler({ + method: 'post', + path: '/foo', + query: {}, + requestBody: { + ...(createPayload as any), + meta: { + serialization: createMeta, + }, }, - }, - prisma, - }); - expect(r.status).toBe(201); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - let serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - let deserialized: any = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - let data = deserialized.data.attributes; - expect(typeof data.bigInt).toBe('bigint'); - expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); - expect(data.date instanceof Date).toBeTruthy(); - expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); - - const updateAttrs = { - bigInt: BigInt(1534543543534), - date: new Date(), - decimal: decimalValue2, - bytes: Buffer.from([5, 2, 3, 4]), - }; - const { json: updatePayload, meta: updateMeta } = SuperJSON.serialize({ - data: { - type: 'foo', - attributes: updateAttrs, - }, - }); + prisma, + }); + expect(r.status).toBe(201); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + let serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + let deserialized: any = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + let data = deserialized.data.attributes; + expect(typeof data.bigInt).toBe('bigint'); + expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); + expect(data.date instanceof Date).toBeTruthy(); + expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); + + const updateAttrs = { + bigInt: BigInt(1534543543534), + date: new Date(), + decimal: decimalValue2, + bytes: Buffer.from([5, 2, 3, 4]), + }; + const { json: updatePayload, meta: updateMeta } = SuperJSON.serialize({ + data: { + type: 'foo', + attributes: updateAttrs, + }, + }); - r = await handler({ - method: 'put', - path: '/foo/1', - query: {}, - requestBody: { - ...(updatePayload as any), - meta: { - serialization: updateMeta, + r = await handler({ + method: 'put', + path: '/foo/1', + query: {}, + requestBody: { + ...(updatePayload as any), + meta: { + serialization: updateMeta, + }, }, - }, - prisma, - }); - expect(r.status).toBe(200); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - - serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - data = deserialized.data.attributes; - expect(data.bigInt).toEqual(updateAttrs.bigInt); - expect(data.date).toEqual(updateAttrs.date); - expect(data.decimal.equals(updateAttrs.decimal)).toBeTruthy(); - expect(data.bytes.toString('base64')).toEqual(updateAttrs.bytes.toString('base64')); - - r = await handler({ - method: 'get', - path: '/foo/1', - query: {}, - prisma, - }); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - data = deserialized.data.attributes; - expect(typeof data.bigInt).toBe('bigint'); - expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); - expect(data.date instanceof Date).toBeTruthy(); - expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); - - r = await handler({ - method: 'get', - path: '/foo', - query: { include: 'bars' }, - prisma, + prisma, + }); + expect(r.status).toBe(200); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + data = deserialized.data.attributes; + expect(data.bigInt).toEqual(updateAttrs.bigInt); + expect(data.date).toEqual(updateAttrs.date); + expect(data.decimal.equals(updateAttrs.decimal)).toBeTruthy(); + expect(data.bytes.toString('base64')).toEqual(updateAttrs.bytes.toString('base64')); + + r = await handler({ + method: 'get', + path: '/foo/1', + query: {}, + prisma, + }); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + data = deserialized.data.attributes; + expect(typeof data.bigInt).toBe('bigint'); + expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); + expect(data.date instanceof Date).toBeTruthy(); + expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); + + r = await handler({ + method: 'get', + path: '/foo', + query: { include: 'bars' }, + prisma, + }); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + const included = deserialized.included[0]; + expect(Buffer.isBuffer(included.attributes.bytes)).toBeTruthy(); }); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - const included = deserialized.included[0]; - expect(Buffer.isBuffer(included.attributes.bytes)).toBeTruthy(); }); }); diff --git a/packages/server/tests/api/rpc.test.ts b/packages/server/tests/api/rpc.test.ts index 38af6d71a..5d7708745 100644 --- a/packages/server/tests/api/rpc.test.ts +++ b/packages/server/tests/api/rpc.test.ts @@ -11,12 +11,14 @@ import { schema } from '../utils'; describe('RPC API Handler Tests', () => { let prisma: any; let enhance: any; + let modelMeta: any; let zodSchemas: any; beforeAll(async () => { const params = await loadSchema(schema, { fullZod: true }); prisma = params.prisma; enhance = params.enhance; + modelMeta = params.modelMeta; zodSchemas = params.zodSchemas; }); @@ -393,18 +395,18 @@ describe('RPC API Handler Tests', () => { expect(r.status).toBe(200); expect(r.data).toBeNull(); }); -}); -function makeHandler(zodSchemas?: ZodSchemas) { - const _handler = RPCAPIHandler(); - return async (args: any) => { - const r = await _handler({ ...args, url: new URL(`http://localhost/${args.path}`), zodSchemas }); - return { - status: r.status, - body: r.body as any, - data: (r.body as any).data, - error: (r.body as any).error, - meta: (r.body as any).meta, + function makeHandler(zodSchemas?: ZodSchemas) { + const _handler = RPCAPIHandler(); + return async (args: any) => { + const r = await _handler({ ...args, url: new URL(`http://localhost/${args.path}`), modelMeta, zodSchemas }); + return { + status: r.status, + body: r.body as any, + data: (r.body as any).data, + error: (r.body as any).error, + meta: (r.body as any).meta, + }; }; - }; -} + } +}); 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(); + }); +});