diff --git a/package.json b/package.json index f49dd06..779b65a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/proxy", - "version": "0.3.0", + "version": "0.4.0", "description": "A CLI tool to run an Express server that proxies CRUD requests to a ZenStack backend", "main": "index.js", "publishConfig": { @@ -40,6 +40,7 @@ "express": "^4.19.2", "mixpanel": "^0.19.1", "semver": "^7.7.3", + "superjson": "^2.2.6", "tsx": "^4.20.6", "uuid": "^13.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab8d6fd..aca638e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: semver: specifier: ^7.7.3 version: 7.7.3 + superjson: + specifier: ^2.2.6 + version: 2.2.6 tsx: specifier: ^4.20.6 version: 4.21.0 @@ -433,6 +436,10 @@ packages: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + copyfiles@2.4.1: resolution: {integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==} hasBin: true @@ -660,6 +667,10 @@ packages: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + isarray@0.0.1: resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} @@ -982,6 +993,10 @@ packages: resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} engines: {node: '>=10'} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -1419,6 +1434,10 @@ snapshots: dependencies: is-what: 4.1.16 + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + copyfiles@2.4.1: dependencies: glob: 7.2.3 @@ -1680,6 +1699,8 @@ snapshots: is-what@4.1.16: {} + is-what@5.5.0: {} + isarray@0.0.1: {} isarray@1.0.0: {} @@ -2012,6 +2033,10 @@ snapshots: dependencies: copy-anything: 3.0.5 + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + tar-fs@2.1.4: dependencies: chownr: 1.1.4 diff --git a/src/server.ts b/src/server.ts index 9166561..bbc977e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { getNodeModulesFolder, getPrismaVersion, getZenStackVersion } from './ut import { blue, grey, red } from 'colors' import semver from 'semver' import { CliError } from './cli-error' +import SuperJSON from 'superjson' export interface ServerOptions { zenstackPath: string | undefined @@ -20,6 +21,25 @@ type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate // enable all enhancements except policy const Enhancements: EnhancementKind[] = ['password', 'omit', 'validation', 'delegate', 'encryption'] +const VALID_OPS = new Set([ + 'findMany', + 'findUnique', + 'findFirst', + 'create', + 'createMany', + 'createManyAndReturn', + 'update', + 'updateMany', + 'updateManyAndReturn', + 'upsert', + 'delete', + 'deleteMany', + 'count', + 'aggregate', + 'groupBy', + 'exists', +]) + /** * Resolve the absolute path to the Prisma schema directory */ @@ -203,6 +223,96 @@ async function loadZenStackModules( return { PrismaClient, modelMeta, enums, zenstackVersion, enhanceFunc } } +function makeError(message: string, status = 400) { + return { status, body: { error: message } } +} + +function lowerCaseFirst(input: string) { + return input.charAt(0).toLowerCase() + input.slice(1) +} + +function isValidModel(modelMeta: any, modelName: string): boolean { + return lowerCaseFirst(modelName) in modelMeta.models +} + +function processRequestPayload(args: any) { + if (args === null || args === undefined) { + return args + } + const { meta, ...rest } = args + if (meta?.serialization) { + // superjson deserialization + return SuperJSON.deserialize({ json: rest, meta: meta.serialization }) + } else { + return args + } +} + +async function handleTransaction(modelMeta: any, client: any, requestBody: unknown) { + const processedOps: Array<{ model: string; op: string; args: unknown }> = [] + if (!requestBody || !Array.isArray(requestBody) || requestBody.length === 0) { + return makeError('request body must be a non-empty array of operations') + } + for (let i = 0; i < requestBody.length; i++) { + const item = requestBody[i] + if (!item || typeof item !== 'object') { + return makeError(`operation at index ${i} must be an object`) + } + const { model: itemModel, op: itemOp, args: itemArgs } = item as any + if (!itemModel || typeof itemModel !== 'string') { + return makeError(`operation at index ${i} is missing a valid "model" field`) + } + if (!itemOp || typeof itemOp !== 'string') { + return makeError(`operation at index ${i} is missing a valid "op" field`) + } + if (!VALID_OPS.has(itemOp)) { + return makeError(`operation at index ${i} has invalid op: ${itemOp}`) + } + if (!isValidModel(modelMeta, itemModel)) { + return makeError(`operation at index ${i} has unknown model: ${itemModel}`) + } + if ( + itemArgs !== undefined && + itemArgs !== null && + (typeof itemArgs !== 'object' || Array.isArray(itemArgs)) + ) { + return makeError(`operation at index ${i} has invalid "args" field`) + } + + processedOps.push({ + model: lowerCaseFirst(itemModel), + op: itemOp, + args: processRequestPayload(itemArgs), + }) + } + + try { + const clientResult = await client.$transaction(async (tx: any) => { + const result: any[] = [] + for (const { model, op, args } of processedOps) { + result.push(await (tx as any)[model][op](args)) + } + return result + }) + + const { json, meta } = SuperJSON.serialize(clientResult) + const responseBody: any = { data: json } + if (meta) { + responseBody.meta = { serialization: meta } + } + + const response = { status: 200, body: responseBody } + + return response + } catch (err) { + console.error('error occurred when handling "$transaction" request:', err) + return makeError( + 'Transaction failed: ' + (err instanceof Error ? err.message : String(err)), + 500 + ) + } +} + /** * Start the Express server with ZenStack proxy */ @@ -237,6 +347,22 @@ export async function startServer(options: ServerOptions) { app.use(express.urlencoded({ extended: true, limit: '5mb' })) // ZenStack API endpoint + + app.post('/api/model/\\$transaction/sequential', async (_req, res) => { + const response = await handleTransaction( + modelMeta, + enhanceFunc( + prisma, + {}, + { + kinds: Enhancements, + } + ), + _req.body + ) + res.status(response.status).json(response.body) + }) + app.use( '/api/model', ZenStackMiddleware({