From fb8b20a8386fd4ce4ec26813d81075ff2cd9acd4 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 19 May 2023 07:30:11 +0900 Subject: [PATCH] feat: copy nextjs adapter over to `server` package --- packages/server/package.json | 1 + packages/server/src/next/index.ts | 2 + packages/server/src/next/request-handler.ts | 177 ++++++++++++ .../server/tests/adapter/next-rpc.test.ts | 267 ++++++++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 450 insertions(+) create mode 100644 packages/server/src/next/index.ts create mode 100644 packages/server/src/next/request-handler.ts create mode 100644 packages/server/tests/adapter/next-rpc.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 65093273f..0ebb42924 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -52,6 +52,7 @@ "fastify-plugin": "^4.5.0", "isomorphic-fetch": "^3.0.0", "jest": "^29.5.0", + "next": "^12.3.1", "rimraf": "^3.0.2", "supertest": "^6.3.3", "ts-jest": "^29.0.5", diff --git a/packages/server/src/next/index.ts b/packages/server/src/next/index.ts new file mode 100644 index 000000000..c4a206552 --- /dev/null +++ b/packages/server/src/next/index.ts @@ -0,0 +1,2 @@ +export { default as NextRequestHandler } from './request-handler'; +export * from './request-handler'; diff --git a/packages/server/src/next/request-handler.ts b/packages/server/src/next/request-handler.ts new file mode 100644 index 000000000..4464b773a --- /dev/null +++ b/packages/server/src/next/request-handler.ts @@ -0,0 +1,177 @@ +import { + DbClientContract, + DbOperations, + isPrismaClientKnownRequestError, + isPrismaClientUnknownRequestError, + isPrismaClientValidationError, +} from '@zenstackhq/runtime'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { logError } from '../api/utils'; +import { AdapterBaseOptions } from '../types'; +import { marshalToObject, unmarshalFromObject, unmarshalFromString } from '../utils'; + +/** + * Options for initializing a Next.js API endpoint request handler. + * @see requestHandler + */ +export interface RequestHandlerOptions extends AdapterBaseOptions { + /** + * Callback method for getting a Prisma instance for the given request/response pair. + */ + getPrisma: (req: NextApiRequest, res: NextApiResponse) => Promise | unknown; +} + +/** + * Creates a Next.js API endpoint request handler which encapsulates Prisma CRUD operations. + * + * @param options Options for initialization + * @returns An API endpoint request handler + */ +export default function requestHandler( + options: RequestHandlerOptions +): (req: NextApiRequest, res: NextApiResponse) => Promise { + return async (req: NextApiRequest, res: NextApiResponse) => { + const prisma = await options.getPrisma(req, res); + if (!prisma) { + sendResponse( + res, + 500, + { + error: 'unable to get prisma from request context', + }, + options.useSuperJson === true + ); + return; + } + return handleRequest(req, res, prisma as DbClientContract, options); + }; +} + +async function handleRequest( + req: NextApiRequest, + res: NextApiResponse, + prisma: DbClientContract, + options: RequestHandlerOptions +): Promise { + const [model, op] = req.query.path as string[]; + + const dbOp = op as keyof DbOperations; + let args: unknown; + let resCode = 200; + const useSuperJson = options.useSuperJson === true; + + switch (dbOp) { + case 'create': + case 'createMany': + case 'upsert': + if (req.method !== 'POST') { + sendResponse(res, 400, { error: 'invalid http method' }, useSuperJson); + return; + } + args = unmarshalFromObject(req.body, options.useSuperJson); + // TODO: upsert's status code should be conditional + resCode = 201; + break; + + case 'findFirst': + case 'findUnique': + case 'findMany': + case 'aggregate': + case 'groupBy': + case 'count': + if (req.method !== 'GET') { + sendResponse(res, 400, { error: 'invalid http method' }, useSuperJson); + return; + } + args = req.query.q ? unmarshalFromString(req.query.q as string, options.useSuperJson) : {}; + break; + + case 'update': + case 'updateMany': + if (req.method !== 'PUT' && req.method !== 'PATCH') { + sendResponse(res, 400, { error: 'invalid http method' }, useSuperJson); + return; + } + args = unmarshalFromObject(req.body, options.useSuperJson); + break; + + case 'delete': + case 'deleteMany': + if (req.method !== 'DELETE') { + sendResponse(res, 400, { error: 'invalid http method' }, useSuperJson); + return; + } + args = req.query.q ? unmarshalFromString(req.query.q as string, options.useSuperJson) : {}; + break; + + default: + sendResponse(res, 400, { error: `unknown method name: ${op}` }, useSuperJson); + return; + } + + try { + if (!prisma[model]) { + sendResponse(res, 400, { error: `unknown model name: ${model}` }, useSuperJson); + return; + } + const result = await prisma[model][dbOp](args); + sendResponse(res, resCode, result, useSuperJson); + } catch (err) { + if (isPrismaClientKnownRequestError(err)) { + logError(options.logger, err.message, err.code); + if (err.code === 'P2004') { + // rejected by policy + sendResponse( + res, + 403, + { + prisma: true, + rejectedByPolicy: true, + code: err.code, + message: err.message, + reason: err.meta?.reason, + }, + useSuperJson + ); + } else { + sendResponse( + res, + 400, + { + prisma: true, + code: err.code, + message: err.message, + reason: err.meta?.reason, + }, + useSuperJson + ); + } + } else if (isPrismaClientUnknownRequestError(err) || isPrismaClientValidationError(err)) { + logError(options.logger, err.message); + sendResponse( + res, + 400, + { + prisma: true, + message: err.message, + }, + useSuperJson + ); + } else { + const _err = err as Error; + logError(options.logger, _err.message + (_err.stack ? '\n' + _err.stack : '')); + sendResponse( + res, + 500, + { + message: (err as Error).message, + }, + useSuperJson + ); + } + } +} + +function sendResponse(res: NextApiResponse, status: number, data: unknown, useSuperJson: boolean): void { + res.status(status).send(marshalToObject(data, useSuperJson)); +} diff --git a/packages/server/tests/adapter/next-rpc.test.ts b/packages/server/tests/adapter/next-rpc.test.ts new file mode 100644 index 000000000..3c6a3dc17 --- /dev/null +++ b/packages/server/tests/adapter/next-rpc.test.ts @@ -0,0 +1,267 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { loadSchema } from '@zenstackhq/testtools'; +import { createServer, RequestListener } from 'http'; +import { apiResolver } from 'next/dist/server/api-utils/node'; +import superjson from 'superjson'; +import request from 'supertest'; +import { requestHandler, RequestHandlerOptions } from '../../../next/src'; + +function makeTestClient(apiPath: string, options: RequestHandlerOptions, queryArgs?: unknown) { + const pathParts = apiPath.split('/').filter((p) => p); + + const query = { + path: pathParts, + ...(queryArgs ? { q: superjson.stringify(queryArgs) } : {}), + }; + + const handler = requestHandler(options); + + const listener: RequestListener = (req, res) => { + return apiResolver( + req, + res, + query, + handler, + { + previewModeEncryptionKey: '', + previewModeId: '', + previewModeSigningKey: '', + }, + false + ); + }; + + return request(createServer(listener)); +} + +describe('request handler tests', () => { + let origDir: string; + + beforeEach(() => { + origDir = process.cwd(); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('simple crud regular json', async () => { + const model = ` +model M { + id String @id @default(cuid()) + value Int +} + `; + + const { prisma } = await loadSchema(model); + + await makeTestClient('/m/create', { getPrisma: () => prisma }) + .post('/') + .send({ data: { id: '1', value: 1 } }) + .expect(201) + .expect((resp) => { + expect(resp.body.json.value).toBe(1); + }); + + await makeTestClient('/m/findUnique', { getPrisma: () => prisma }, { where: { id: '1' } }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json.value).toBe(1); + }); + + await makeTestClient('/m/findFirst', { getPrisma: () => prisma }, { where: { id: '1' } }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json.value).toBe(1); + }); + + await makeTestClient('/m/findMany', { getPrisma: () => prisma }, {}) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json).toHaveLength(1); + }); + + await makeTestClient('/m/update', { getPrisma: () => prisma }) + .put('/') + .send({ where: { id: '1' }, data: { value: 2 } }) + .expect(200) + .expect((resp) => { + expect(resp.body.json.value).toBe(2); + }); + + await makeTestClient('/m/updateMany', { getPrisma: () => prisma }) + .put('/') + .send({ data: { value: 4 } }) + .expect(200) + .expect((resp) => { + expect(resp.body.json.count).toBe(1); + }); + + await makeTestClient('/m/upsert', { getPrisma: () => prisma }) + .post('/') + .send({ where: { id: '2' }, create: { id: '2', value: 2 }, update: { value: 3 } }) + .expect(201) + .expect((resp) => { + expect(resp.body.json.value).toBe(2); + }); + + await makeTestClient('/m/upsert', { getPrisma: () => prisma }) + .post('/') + .send({ where: { id: '2' }, create: { id: '2', value: 2 }, update: { value: 3 } }) + .expect(201) + .expect((resp) => { + expect(resp.body.json.value).toBe(3); + }); + + await makeTestClient('/m/count', { getPrisma: () => prisma }, { where: { id: '1' } }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json).toBe(1); + }); + + await makeTestClient('/m/count', { getPrisma: () => prisma }, {}) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json).toBe(2); + }); + + await makeTestClient('/m/aggregate', { getPrisma: () => prisma }, { _sum: { value: true } }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json._sum.value).toBe(7); + }); + + await makeTestClient('/m/groupBy', { getPrisma: () => prisma }, { by: ['id'], _sum: { value: true } }) + .get('/') + .expect(200) + .expect((resp) => { + const data = resp.body.json; + expect(data).toHaveLength(2); + expect(data.find((item: any) => item.id === '1')._sum.value).toBe(4); + expect(data.find((item: any) => item.id === '2')._sum.value).toBe(3); + }); + + await makeTestClient('/m/delete', { getPrisma: () => prisma }, { where: { id: '1' } }) + .del('/') + .expect(200); + expect(await prisma.m.count()).toBe(1); + + await makeTestClient('/m/deleteMany', { getPrisma: () => prisma }, {}) + .del('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json.count).toBe(1); + }); + expect(await prisma.m.count()).toBe(0); + }); + + it('simple crud superjson', async () => { + const model = ` +model M { + id String @id @default(cuid()) + value Int +} + `; + + const { prisma } = await loadSchema(model); + + await makeTestClient('/m/create', { getPrisma: () => prisma, useSuperJson: true }) + .post('/') + .send(marshal({ data: { id: '1', value: 1 } })) + .expect(201) + .expect((resp) => { + expect(resp.body.json.value).toBe(1); + }); + + await makeTestClient('/m/findUnique', { getPrisma: () => prisma, useSuperJson: true }, { where: { id: '1' } }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json.value).toBe(1); + }); + + await makeTestClient('/m/findMany', { getPrisma: () => prisma, useSuperJson: true }, {}) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json).toHaveLength(1); + }); + + await makeTestClient('/m/update', { getPrisma: () => prisma, useSuperJson: true }) + .put('/') + .send(marshal({ where: { id: '1' }, data: { value: 2 } })) + .expect(200) + .expect((resp) => { + expect(resp.body.json.value).toBe(2); + }); + }); + + it('access policy crud', async () => { + const model = ` +model M { + id String @id @default(cuid()) + value Int + + @@allow('create', true) + @@allow('read', value > 0) + @@allow('update', future().value > 1) + @@allow('delete', value > 2) +} + `; + + const { withPresets } = await loadSchema(model); + + await makeTestClient('/m/create', { getPrisma: () => withPresets() }) + .post('/m/create') + .send({ data: { value: 0 } }) + .expect(403) + .expect((resp) => { + expect(resp.body.reason).toBe('RESULT_NOT_READABLE'); + }); + + await makeTestClient('/m/create', { getPrisma: () => withPresets() }) + .post('/') + .send({ data: { id: '1', value: 1 } }) + .expect(201); + + await makeTestClient('/m/findMany', { getPrisma: () => withPresets() }) + .get('/') + .expect(200) + .expect((resp) => { + expect(resp.body.json).toHaveLength(1); + }); + + await makeTestClient('/m/update', { getPrisma: () => withPresets() }) + .put('/') + .send({ where: { id: '1' }, data: { value: 0 } }) + .expect(403); + + await makeTestClient('/m/update', { getPrisma: () => withPresets() }) + .put('/') + .send({ where: { id: '1' }, data: { value: 2 } }) + .expect(200); + + await makeTestClient('/m/delete', { getPrisma: () => withPresets() }, { where: { id: '1' } }) + .del('/') + .expect(403); + + await makeTestClient('/m/update', { getPrisma: () => withPresets() }) + .put('/') + .send({ where: { id: '1' }, data: { value: 3 } }) + .expect(200); + + await makeTestClient('/m/delete', { getPrisma: () => withPresets() }, { where: { id: '1' } }) + .del('/') + .expect(200); + }); +}); + +function marshal(data: unknown) { + return JSON.parse(superjson.stringify(data)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d8815d82..16831868e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -820,6 +820,9 @@ importers: jest: specifier: ^29.5.0 version: 29.5.0 + next: + specifier: ^12.3.1 + version: 12.3.1(@babel/core@7.20.5)(react-dom@18.2.0)(react@18.2.0) rimraf: specifier: ^3.0.2 version: 3.0.2