Skip to content

Commit 7012ef5

Browse files
authored
fix: wrap generated trpc routes with error handling (#338)
1 parent 4e27a00 commit 7012ef5

File tree

8 files changed

+156
-42
lines changed

8 files changed

+156
-42
lines changed

packages/plugins/trpc/src/generator.ts

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { DMMF } from '@prisma/generator-helper';
2-
import { PluginError, PluginOptions } from '@zenstackhq/sdk';
2+
import { CrudFailureReason, PluginError, PluginOptions, RUNTIME_PACKAGE } from '@zenstackhq/sdk';
33
import { Model } from '@zenstackhq/sdk/ast';
4+
import { camelCase } from 'change-case';
45
import { promises as fs } from 'fs';
56
import path from 'path';
6-
import { generate as PrismaZodGenerator } from './zod/generator';
7-
import { generateProcedure, generateRouterSchemaImports, getInputTypeByOpName, resolveModelsComments } from './helpers';
7+
import { Project } from 'ts-morph';
8+
import {
9+
generateHelperImport,
10+
generateProcedure,
11+
generateRouterSchemaImports,
12+
getInputTypeByOpName,
13+
resolveModelsComments,
14+
} from './helpers';
815
import { project } from './project';
916
import removeDir from './utils/removeDir';
10-
import { camelCase } from 'change-case';
11-
import { Project } from 'ts-morph';
17+
import { generate as PrismaZodGenerator } from './zod/generator';
1218

1319
export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.Document) {
1420
let outDir = options.output as string;
@@ -33,6 +39,13 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.
3339
const hiddenModels: string[] = [];
3440
resolveModelsComments(models, hiddenModels);
3541

42+
createAppRouter(outDir, modelOperations, hiddenModels);
43+
createHelper(outDir);
44+
45+
await project.save();
46+
}
47+
48+
function createAppRouter(outDir: string, modelOperations: DMMF.ModelMapping[], hiddenModels: string[]) {
3649
const appRouter = project.createSourceFile(path.resolve(outDir, 'routers', `index.ts`), undefined, {
3750
overwrite: true,
3851
});
@@ -110,7 +123,6 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.
110123
});
111124

112125
appRouter.formatText();
113-
await project.save();
114126
}
115127

116128
function generateModelCreateRouter(
@@ -133,6 +145,7 @@ function generateModelCreateRouter(
133145
]);
134146

135147
generateRouterSchemaImports(modelRouter, model);
148+
generateHelperImport(modelRouter);
136149

137150
modelRouter
138151
.addFunction({
@@ -162,3 +175,108 @@ function generateModelCreateRouter(
162175

163176
modelRouter.formatText();
164177
}
178+
179+
function createHelper(outDir: string) {
180+
const sf = project.createSourceFile(path.resolve(outDir, 'helper.ts'), undefined, {
181+
overwrite: true,
182+
});
183+
184+
sf.addStatements(`import { TRPCError } from '@trpc/server';`);
185+
sf.addStatements(`import { isPrismaClientKnownRequestError } from '${RUNTIME_PACKAGE}';`);
186+
187+
const checkMutate = sf.addFunction({
188+
name: 'checkMutate',
189+
typeParameters: [{ name: 'T' }],
190+
parameters: [
191+
{
192+
name: 'promise',
193+
type: 'Promise<T>',
194+
},
195+
],
196+
isAsync: true,
197+
isExported: true,
198+
returnType: 'Promise<T | undefined>',
199+
});
200+
201+
checkMutate.setBodyText(
202+
`try {
203+
return await promise;
204+
} catch (err: any) {
205+
if (isPrismaClientKnownRequestError(err)) {
206+
if (err.code === 'P2004') {
207+
if (err.meta?.reason === '${CrudFailureReason.RESULT_NOT_READABLE}') {
208+
// unable to readback data
209+
return undefined;
210+
} else {
211+
// rejected by policy
212+
throw new TRPCError({
213+
code: 'FORBIDDEN',
214+
message: err.message,
215+
cause: err,
216+
});
217+
}
218+
} else {
219+
// request error
220+
throw new TRPCError({
221+
code: 'BAD_REQUEST',
222+
message: err.message,
223+
cause: err,
224+
});
225+
}
226+
} else {
227+
throw err;
228+
}
229+
}
230+
`
231+
);
232+
checkMutate.formatText();
233+
234+
const checkRead = sf.addFunction({
235+
name: 'checkRead',
236+
typeParameters: [{ name: 'T' }],
237+
parameters: [
238+
{
239+
name: 'promise',
240+
type: 'Promise<T>',
241+
},
242+
],
243+
isAsync: true,
244+
isExported: true,
245+
returnType: 'Promise<T>',
246+
});
247+
248+
checkRead.setBodyText(
249+
`try {
250+
return await promise;
251+
} catch (err: any) {
252+
if (isPrismaClientKnownRequestError(err)) {
253+
if (err.code === 'P2004') {
254+
// rejected by policy
255+
throw new TRPCError({
256+
code: 'FORBIDDEN',
257+
message: err.message,
258+
cause: err,
259+
});
260+
} else if (err.code === 'P2025') {
261+
// not found
262+
throw new TRPCError({
263+
code: 'NOT_FOUND',
264+
message: err.message,
265+
cause: err,
266+
});
267+
} else {
268+
// request error
269+
throw new TRPCError({
270+
code: 'BAD_REQUEST',
271+
message: err.message,
272+
cause: err,
273+
})
274+
}
275+
} else {
276+
throw err;
277+
}
278+
}
279+
`
280+
);
281+
checkRead.formatText();
282+
}

packages/plugins/trpc/src/helpers.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,7 @@
11
import { DMMF } from '@prisma/generator-helper';
2-
import { CrudFailureReason } from '@zenstackhq/sdk';
32
import { CodeBlockWriter, SourceFile } from 'ts-morph';
43
import { uncapitalizeFirstLetter } from './utils/uncapitalizeFirstLetter';
54

6-
export const generatetRPCImport = (sourceFile: SourceFile) => {
7-
sourceFile.addImportDeclaration({
8-
moduleSpecifier: '@trpc/server',
9-
namespaceImport: 'trpc',
10-
});
11-
};
12-
13-
export const generateRouterImport = (sourceFile: SourceFile, modelNamePlural: string, modelNameCamelCase: string) => {
14-
sourceFile.addImportDeclaration({
15-
moduleSpecifier: `./${modelNameCamelCase}.router`,
16-
namedImports: [`${modelNamePlural}Router`],
17-
});
18-
};
19-
205
export function generateProcedure(
216
writer: CodeBlockWriter,
227
opType: string,
@@ -29,24 +14,15 @@ export function generateProcedure(
2914

3015
if (procType === 'query') {
3116
writer.write(`
32-
${opType}: procedure.input(${typeName}).query(({ctx, input}) => db(ctx).${uncapitalizeFirstLetter(
17+
${opType}: procedure.input(${typeName}).query(({ctx, input}) => checkRead(db(ctx).${uncapitalizeFirstLetter(
3318
modelName
34-
)}.${prismaMethod}(input)),
19+
)}.${prismaMethod}(input))),
3520
`);
3621
} else if (procType === 'mutation') {
3722
writer.write(`
38-
${opType}: procedure.input(${typeName}).mutation(async ({ctx, input}) => {
39-
try {
40-
return await db(ctx).${uncapitalizeFirstLetter(modelName)}.${prismaMethod}(input);
41-
} catch (err: any) {
42-
if (err.code === 'P2004' && err.meta?.reason === '${CrudFailureReason.RESULT_NOT_READABLE}') {
43-
// unable to readback data
44-
return undefined;
45-
} else {
46-
throw err;
47-
}
48-
}
49-
}),
23+
${opType}: procedure.input(${typeName}).mutation(async ({ctx, input}) => checkMutate(db(ctx).${uncapitalizeFirstLetter(
24+
modelName
25+
)}.${prismaMethod}(input))),
5026
`);
5127
}
5228
}
@@ -55,6 +31,10 @@ export function generateRouterSchemaImports(sourceFile: SourceFile, name: string
5531
sourceFile.addStatements(`import { ${name}Schema } from '../schemas/${name}.schema';`);
5632
}
5733

34+
export function generateHelperImport(sourceFile: SourceFile) {
35+
sourceFile.addStatements(`import { checkRead, checkMutate } from '../helper';`);
36+
}
37+
5838
export const getInputTypeByOpName = (opName: string, modelName: string) => {
5939
let inputType;
6040
switch (opName) {

packages/schema/src/plugins/access-policy/policy-guard-generator.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,23 @@ import {
1414
Model,
1515
} from '@zenstackhq/language/ast';
1616
import type { PolicyKind, PolicyOperationKind } from '@zenstackhq/runtime';
17-
import { getDataModels, getLiteral, GUARD_FIELD_NAME, PluginError, PluginOptions, resolved } from '@zenstackhq/sdk';
17+
import {
18+
getDataModels,
19+
getLiteral,
20+
GUARD_FIELD_NAME,
21+
PluginError,
22+
PluginOptions,
23+
resolved,
24+
RUNTIME_PACKAGE,
25+
} from '@zenstackhq/sdk';
1826
import { camelCase } from 'change-case';
1927
import { streamAllContents } from 'langium';
2028
import path from 'path';
2129
import { FunctionDeclaration, Project, SourceFile, VariableDeclarationKind } from 'ts-morph';
2230
import { name } from '.';
2331
import { isFromStdlib } from '../../language-server/utils';
2432
import { analyzePolicies, getIdFields } from '../../utils/ast-utils';
25-
import { ALL_OPERATION_KINDS, getDefaultOutputFolder, RUNTIME_PACKAGE } from '../plugin-utils';
33+
import { ALL_OPERATION_KINDS, getDefaultOutputFolder } from '../plugin-utils';
2634
import { ExpressionWriter } from './expression-writer';
2735
import { isFutureExpr } from './utils';
2836
import { ZodSchemaGenerator } from './zod-schema-generator';

packages/schema/src/plugins/plugin-utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { PolicyOperationKind } from '@zenstackhq/runtime';
22
import fs from 'fs';
33
import path from 'path';
44

5-
export const RUNTIME_PACKAGE = '@zenstackhq/runtime';
65
export const ALL_OPERATION_KINDS: PolicyOperationKind[] = ['create', 'update', 'postUpdate', 'read', 'delete'];
76

87
/**

packages/sdk/src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ export enum CrudFailureReason {
2222
*/
2323
RESULT_NOT_READABLE = 'RESULT_NOT_READABLE',
2424
}
25+
26+
/**
27+
* @zenstackhq/runtime package name
28+
*/
29+
export const RUNTIME_PACKAGE = '@zenstackhq/runtime';

packages/server/src/express/middleware.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export interface MiddlewareOptions {
1919
logger?: LoggerConfig;
2020

2121
/**
22-
* Zod schemas for validating request input. Pass `true` to load from standard location (need to enable `@core/zod` plugin in schema.zmodel).
22+
* Zod schemas for validating request input. Pass `true` to load from standard location
23+
* (need to enable `@core/zod` plugin in schema.zmodel) or omit to disable input validation.
2324
*/
2425
zodSchemas?: ModelZodSchema | boolean;
2526
}

packages/server/src/fastify/plugin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export interface PluginOptions {
2525
logger?: LoggerConfig;
2626

2727
/**
28-
* Zod schemas for validating request input. Pass `true` to load from standard location (need to enable `@core/zod` plugin in schema.zmodel).
28+
* Zod schemas for validating request input. Pass `true` to load from standard location
29+
* (need to enable `@core/zod` plugin in schema.zmodel) or omit to disable input validation.
2930
*/
3031
zodSchemas?: ModelZodSchema | boolean;
3132
}

tests/integration/test-run/package-lock.json

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)