Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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"
},
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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({
Expand Down
Loading