Skip to content

Commit 76b634c

Browse files
ymc9claude
andcommitted
feat(server): add operation slicing, meta descriptions, and queryOptions validation to OpenAPI generators
- REST OpenAPI generator now respects `slicing.excludedOperations`/`includedOperations` per model, mapping REST verbs to ORM operations (findMany, create, findUnique, update, delete) - Add proper zod validation schema for `queryOptions` in both REST and RPC handlers - Support `@@meta("description")` and `@meta("description")` for model/field schema descriptions - Use named arg lookup in `getMetaDescription` for reliability Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ba315ea commit 76b634c

8 files changed

Lines changed: 245 additions & 67 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
11
import z from 'zod';
22

33
export const loggerSchema = z.union([z.enum(['debug', 'info', 'warn', 'error']).array(), z.function()]);
4+
5+
const fieldSlicingSchema = z.looseObject({
6+
includedFilterKinds: z.string().array().optional(),
7+
excludedFilterKinds: z.string().array().optional(),
8+
});
9+
10+
const modelSlicingSchema = z.looseObject({
11+
includedOperations: z.array(z.string()).optional(),
12+
excludedOperations: z.array(z.string()).optional(),
13+
fields: z.record(z.string(), fieldSlicingSchema).optional(),
14+
});
15+
16+
const slicingSchema = z.looseObject({
17+
includedModels: z.array(z.string()).optional(),
18+
excludedModels: z.array(z.string()).optional(),
19+
models: z.record(z.string(), modelSlicingSchema).optional(),
20+
includedProcedures: z.array(z.string()).optional(),
21+
excludedProcedures: z.array(z.string()).optional(),
22+
});
23+
24+
export const queryOptionsSchema = z.looseObject({
25+
omit: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
26+
slicing: slicingSchema.optional(),
27+
});

packages/server/src/api/common/spec-utils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,11 @@ export function getMetaDescription(attributes: readonly AttributeApplication[] |
104104
if (!attributes) return undefined;
105105
for (const attr of attributes) {
106106
if (attr.name !== '@meta' && attr.name !== '@@meta') continue;
107-
const nameExpr = attr.args?.[0]?.value;
108-
if (!nameExpr || ExpressionUtils.getLiteralValue(nameExpr) !== 'description') continue;
109-
const valueExpr = attr.args?.[1]?.value;
110-
if (valueExpr) {
111-
return ExpressionUtils.getLiteralValue(valueExpr) as string | undefined;
107+
const nameArg = attr.args?.find((a) => a.name === 'name');
108+
if (!nameArg || ExpressionUtils.getLiteralValue(nameArg.value) !== 'description') continue;
109+
const valueArg = attr.args?.find((a) => a.name === 'value');
110+
if (valueArg) {
111+
return ExpressionUtils.getLiteralValue(valueArg.value) as string | undefined;
112112
}
113113
}
114114
return undefined;

packages/server/src/api/rest/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import z from 'zod';
1010
import { fromError } from 'zod-validation-error/v4';
1111
import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types';
1212
import { getProcedureDef, mapProcedureArgs } from '../common/procedures';
13-
import { loggerSchema } from '../common/schemas';
13+
import { loggerSchema, queryOptionsSchema } from '../common/schemas';
1414
import type { CommonHandlerOptions, OpenApiSpecGenerator, OpenApiSpecOptions } from '../common/types';
1515
import { processSuperJsonRequestPayload } from '../common/utils';
1616
import { getZodErrorMessage, log, registerCustomSerializers } from '../utils';
@@ -300,7 +300,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
300300
urlSegmentCharset: z.string().min(1).optional(),
301301
modelNameMapping: z.record(z.string(), z.string()).optional(),
302302
externalIdMapping: z.record(z.string(), z.string()).optional(),
303-
queryOptions: z.object().optional(),
303+
queryOptions: queryOptionsSchema.optional(),
304304
});
305305
const parseResult = schema.safeParse(options);
306306
if (!parseResult.success) {

packages/server/src/api/rest/openapi.ts

Lines changed: 74 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getMetaDescription,
88
isFieldOmitted,
99
isFilterKindIncluded,
10+
isOperationIncluded,
1011
isProcedureIncluded,
1112
} from '../common/spec-utils';
1213
import type { OpenApiSpecOptions } from '../common/types';
@@ -88,10 +89,16 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
8889
const tag = lowerCaseFirst(modelName);
8990

9091
// Collection: GET (list) + POST (create)
91-
paths[`/${modelPath}`] = this.buildCollectionPath(modelName, modelDef, tag) as any;
92+
const collectionPath = this.buildCollectionPath(modelName, modelDef, tag);
93+
if (Object.keys(collectionPath).length > 0) {
94+
paths[`/${modelPath}`] = collectionPath;
95+
}
9296

9397
// Single resource: GET + PATCH + DELETE
94-
paths[`/${modelPath}/{id}`] = this.buildSinglePath(modelName, tag) as any;
98+
const singlePath = this.buildSinglePath(modelName, tag);
99+
if (Object.keys(singlePath).length > 0) {
100+
paths[`/${modelPath}/{id}`] = singlePath;
101+
}
95102

96103
// Relation paths
97104
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
@@ -191,69 +198,83 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
191198
},
192199
};
193200

194-
return { get: listOp, post: createOp };
201+
const result: Record<string, any> = {};
202+
if (isOperationIncluded(modelName, 'findMany', this.queryOptions)) {
203+
result['get'] = listOp;
204+
}
205+
if (isOperationIncluded(modelName, 'create', this.queryOptions)) {
206+
result['post'] = createOp;
207+
}
208+
return result;
195209
}
196210

197211
private buildSinglePath(modelName: string, tag: string): Record<string, any> {
198212
const idParam = { $ref: '#/components/parameters/id' };
213+
const result: Record<string, any> = {};
199214

200-
const getOp = {
201-
tags: [tag],
202-
summary: `Get a ${modelName} resource by ID`,
203-
operationId: `get${modelName}`,
204-
parameters: [idParam, { $ref: '#/components/parameters/include' }],
205-
responses: {
206-
'200': {
207-
description: `${modelName} resource`,
208-
content: {
209-
'application/vnd.api+json': {
210-
schema: { $ref: `#/components/schemas/${modelName}Response` },
215+
if (isOperationIncluded(modelName, 'findUnique', this.queryOptions)) {
216+
result['get'] = {
217+
tags: [tag],
218+
summary: `Get a ${modelName} resource by ID`,
219+
operationId: `get${modelName}`,
220+
parameters: [idParam, { $ref: '#/components/parameters/include' }],
221+
responses: {
222+
'200': {
223+
description: `${modelName} resource`,
224+
content: {
225+
'application/vnd.api+json': {
226+
schema: { $ref: `#/components/schemas/${modelName}Response` },
227+
},
211228
},
212229
},
230+
'404': { $ref: '#/components/schemas/_errorResponse' },
213231
},
214-
'404': { $ref: '#/components/schemas/_errorResponse' },
215-
},
216-
};
232+
};
233+
}
217234

218-
const patchOp = {
219-
tags: [tag],
220-
summary: `Update a ${modelName} resource`,
221-
operationId: `update${modelName}`,
222-
parameters: [idParam],
223-
requestBody: {
224-
required: true,
225-
content: {
226-
'application/vnd.api+json': {
227-
schema: { $ref: `#/components/schemas/${modelName}UpdateRequest` },
228-
},
229-
},
230-
},
231-
responses: {
232-
'200': {
233-
description: `Updated ${modelName} resource`,
235+
if (isOperationIncluded(modelName, 'update', this.queryOptions)) {
236+
result['patch'] = {
237+
tags: [tag],
238+
summary: `Update a ${modelName} resource`,
239+
operationId: `update${modelName}`,
240+
parameters: [idParam],
241+
requestBody: {
242+
required: true,
234243
content: {
235244
'application/vnd.api+json': {
236-
schema: { $ref: `#/components/schemas/${modelName}Response` },
245+
schema: { $ref: `#/components/schemas/${modelName}UpdateRequest` },
237246
},
238247
},
239248
},
240-
'400': { $ref: '#/components/schemas/_errorResponse' },
241-
'404': { $ref: '#/components/schemas/_errorResponse' },
242-
},
243-
};
249+
responses: {
250+
'200': {
251+
description: `Updated ${modelName} resource`,
252+
content: {
253+
'application/vnd.api+json': {
254+
schema: { $ref: `#/components/schemas/${modelName}Response` },
255+
},
256+
},
257+
},
258+
'400': { $ref: '#/components/schemas/_errorResponse' },
259+
'404': { $ref: '#/components/schemas/_errorResponse' },
260+
},
261+
};
262+
}
244263

245-
const deleteOp = {
246-
tags: [tag],
247-
summary: `Delete a ${modelName} resource`,
248-
operationId: `delete${modelName}`,
249-
parameters: [idParam],
250-
responses: {
251-
'200': { description: 'Deleted successfully' },
252-
'404': { $ref: '#/components/schemas/_errorResponse' },
253-
},
254-
};
264+
if (isOperationIncluded(modelName, 'delete', this.queryOptions)) {
265+
result['delete'] = {
266+
tags: [tag],
267+
summary: `Delete a ${modelName} resource`,
268+
operationId: `delete${modelName}`,
269+
parameters: [idParam],
270+
responses: {
271+
'200': { description: 'Deleted successfully' },
272+
'404': { $ref: '#/components/schemas/_errorResponse' },
273+
},
274+
};
275+
}
255276

256-
return { get: getOp, patch: patchOp, delete: deleteOp };
277+
return result;
257278
}
258279

259280
private buildFetchRelatedPath(
@@ -683,6 +704,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
683704
if (isFieldOmitted(modelName, fieldName, this.queryOptions)) continue;
684705

685706
const schema = this.fieldToSchema(fieldDef);
707+
const fieldDescription = getMetaDescription(fieldDef.attributes);
708+
if (fieldDescription && !('$ref' in schema)) {
709+
schema.description = fieldDescription;
710+
}
686711
properties[fieldName] = schema;
687712

688713
if (!fieldDef.optional && !fieldDef.array) {

packages/server/src/api/rpc/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import z from 'zod';
77
import { fromError } from 'zod-validation-error/v4';
88
import type { ApiHandler, LogConfig, RequestContext, Response } from '../../types';
99
import { getProcedureDef, mapProcedureArgs, PROCEDURE_ROUTE_PREFIXES } from '../common/procedures';
10-
import { loggerSchema } from '../common/schemas';
10+
import { loggerSchema, queryOptionsSchema } from '../common/schemas';
1111
import type { CommonHandlerOptions, OpenApiSpecGenerator } from '../common/types';
1212
import { processSuperJsonRequestPayload, unmarshalQ } from '../common/utils';
1313
import { log, registerCustomSerializers } from '../utils';
@@ -45,7 +45,7 @@ export class RPCApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiH
4545
const schema = z.strictObject({
4646
schema: z.object(),
4747
log: loggerSchema.optional(),
48-
queryOptions: z.object().optional(),
48+
queryOptions: queryOptionsSchema.optional(),
4949
});
5050
const parseResult = schema.safeParse(options);
5151
if (!parseResult.success) {

packages/server/src/api/rpc/openapi.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,12 @@ export class RPCApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
381381
for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) {
382382
if (fieldDef.omit) continue;
383383
if (isFieldOmitted(modelName, fieldName, this.queryOptions)) continue;
384-
properties[fieldName] = this.fieldToSchema(fieldDef);
384+
const schema = this.fieldToSchema(fieldDef);
385+
const fieldDescription = getMetaDescription(fieldDef.attributes);
386+
if (fieldDescription && !('$ref' in schema)) {
387+
schema.description = fieldDescription;
388+
}
389+
properties[fieldName] = schema;
385390
if (!fieldDef.optional && !fieldDef.array) {
386391
required.push(fieldName);
387392
}

0 commit comments

Comments
 (0)