Skip to content

Commit dc26257

Browse files
ymc9claude
andauthored
feat(server): add OpenAPI spec generation for RPC API (#2574)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3dc922d commit dc26257

File tree

10 files changed

+17186
-271
lines changed

10 files changed

+17186
-271
lines changed

packages/orm/src/client/zod/factory.ts

Lines changed: 388 additions & 205 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { createQuerySchemaFactory } from './factory';
1+
export { createQuerySchemaFactory, type ZodSchemaFactory } from './factory';

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
22
import type { QueryOptions } from '@zenstackhq/orm';
3-
import { ExpressionUtils, type AttributeApplication, type SchemaDef } from '@zenstackhq/orm/schema';
3+
import { ExpressionUtils, type AttributeApplication, type ModelDef, type SchemaDef } from '@zenstackhq/orm/schema';
4+
5+
export const DEFAULT_SPEC_TITLE = 'ZenStack Generated API';
6+
export const DEFAULT_SPEC_VERSION = '1.0.0';
47

58
/**
69
* Checks if a model is included based on slicing options.
@@ -97,6 +100,49 @@ export function isFilterKindIncluded(
97100
return true;
98101
}
99102

103+
/**
104+
* Checks if an operation on a model may be denied by access policies.
105+
* Returns true when `respectAccessPolicies` is enabled and the model's policies
106+
* for the given operation are NOT a constant allow.
107+
*/
108+
export function mayDenyAccess(modelDef: ModelDef, operation: string, respectAccessPolicies?: boolean): boolean {
109+
if (!respectAccessPolicies) return false;
110+
111+
const policyAttrs = (modelDef.attributes ?? []).filter(
112+
(attr) => attr.name === '@@allow' || attr.name === '@@deny',
113+
);
114+
115+
// No policy rules at all means default-deny
116+
if (policyAttrs.length === 0) return true;
117+
118+
const getArgByName = (args: AttributeApplication['args'], name: string) =>
119+
args?.find((a) => a.name === name)?.value;
120+
121+
const matchesOperation = (args: AttributeApplication['args']) => {
122+
const val = getArgByName(args, 'operation');
123+
if (!val || val.kind !== 'literal' || typeof val.value !== 'string') return false;
124+
const ops = val.value.split(',').map((s) => s.trim());
125+
return ops.includes(operation) || ops.includes('all');
126+
};
127+
128+
const hasEffectiveDeny = policyAttrs.some((attr) => {
129+
if (attr.name !== '@@deny' || !matchesOperation(attr.args)) return false;
130+
const condition = getArgByName(attr.args, 'condition');
131+
// @@deny('op', false) is a no-op — skip it
132+
return !(condition?.kind === 'literal' && condition.value === false);
133+
});
134+
if (hasEffectiveDeny) return true;
135+
136+
const relevantAllow = policyAttrs.filter((attr) => attr.name === '@@allow' && matchesOperation(attr.args));
137+
138+
const hasConstantAllow = relevantAllow.some((attr) => {
139+
const condition = getArgByName(attr.args, 'condition');
140+
return condition?.kind === 'literal' && condition.value === true;
141+
});
142+
143+
return !hasConstantAllow;
144+
}
145+
100146
/**
101147
* Extracts a "description" from `@@meta("description", "...")` or `@meta("description", "...")` attributes.
102148
*/

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

Lines changed: 13 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
2-
import type { AttributeApplication, EnumDef, FieldDef, ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema';
2+
import type { EnumDef, FieldDef, ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema';
33
import type { OpenAPIV3_1 } from 'openapi-types';
44
import { PROCEDURE_ROUTE_PREFIXES } from '../common/procedures';
55
import {
6+
DEFAULT_SPEC_TITLE,
7+
DEFAULT_SPEC_VERSION,
68
getIncludedModels,
79
getMetaDescription,
810
isFieldOmitted,
911
isFilterKindIncluded,
1012
isModelIncluded,
1113
isOperationIncluded,
1214
isProcedureIncluded,
15+
mayDenyAccess,
1316
} from '../common/spec-utils';
1417
import type { OpenApiSpecOptions } from '../common/types';
1518
import type { RestApiHandlerOptions } from '.';
@@ -70,8 +73,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
7073
return {
7174
openapi: '3.1.0',
7275
info: {
73-
title: options?.title ?? 'ZenStack Generated API',
74-
version: options?.version ?? '1.0.0',
76+
title: options?.title ?? DEFAULT_SPEC_TITLE,
77+
version: options?.version ?? DEFAULT_SPEC_VERSION,
7578
...(options?.description && { description: options.description }),
7679
...(options?.summary && { summary: options.summary }),
7780
},
@@ -229,7 +232,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
229232
},
230233
},
231234
'400': ERROR_400,
232-
...(this.mayDenyAccess(modelDef, 'create') && { '403': ERROR_403 }),
235+
...(mayDenyAccess(modelDef, 'create', this.specOptions?.respectAccessPolicies) && { '403': ERROR_403 }),
233236
'422': ERROR_422,
234237
},
235238
};
@@ -293,7 +296,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
293296
},
294297
},
295298
'400': ERROR_400,
296-
...(this.mayDenyAccess(modelDef, 'update') && { '403': ERROR_403 }),
299+
...(mayDenyAccess(modelDef, 'update', this.specOptions?.respectAccessPolicies) && { '403': ERROR_403 }),
297300
'404': ERROR_404,
298301
'422': ERROR_422,
299302
},
@@ -308,7 +311,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
308311
parameters: [idParam],
309312
responses: {
310313
'200': { description: 'Deleted successfully' },
311-
...(this.mayDenyAccess(modelDef, 'delete') && { '403': ERROR_403 }),
314+
...(mayDenyAccess(modelDef, 'delete', this.specOptions?.respectAccessPolicies) && { '403': ERROR_403 }),
312315
'404': ERROR_404,
313316
},
314317
};
@@ -359,7 +362,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
359362
};
360363

361364
if (this.nestedRoutes && relModelDef) {
362-
const mayDeny = this.mayDenyAccess(relModelDef, isCollection ? 'create' : 'update');
365+
const mayDeny = mayDenyAccess(relModelDef, isCollection ? 'create' : 'update', this.specOptions?.respectAccessPolicies);
363366
if (isCollection && isOperationIncluded(fieldDef.type, 'create', this.queryOptions)) {
364367
// POST /{model}/{id}/{field} — nested create
365368
pathItem['post'] = {
@@ -434,8 +437,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
434437
): Record<string, any> {
435438
const childIdParam = { name: 'childId', in: 'path', required: true, schema: { type: 'string' } };
436439
const idParam = { $ref: '#/components/parameters/id' };
437-
const mayDenyUpdate = this.mayDenyAccess(relModelDef, 'update');
438-
const mayDenyDelete = this.mayDenyAccess(relModelDef, 'delete');
440+
const mayDenyUpdate = mayDenyAccess(relModelDef, 'update', this.specOptions?.respectAccessPolicies);
441+
const mayDenyDelete = mayDenyAccess(relModelDef, 'delete', this.specOptions?.respectAccessPolicies);
439442
const result: Record<string, any> = {};
440443

441444
if (isOperationIncluded(fieldDef.type, 'findUnique', this.queryOptions)) {
@@ -523,7 +526,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
523526
? { $ref: '#/components/schemas/_toManyRelationshipRequest' }
524527
: { $ref: '#/components/schemas/_toOneRelationshipRequest' };
525528

526-
const mayDeny = this.mayDenyAccess(modelDef, 'update');
529+
const mayDeny = mayDenyAccess(modelDef, 'update', this.specOptions?.respectAccessPolicies);
527530

528531
const pathItem: Record<string, any> = {
529532
get: {
@@ -1204,50 +1207,4 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
12041207
return modelDef.idFields.map((name) => modelDef.fields[name]).filter((f): f is FieldDef => f !== undefined);
12051208
}
12061209

1207-
/**
1208-
* Checks if an operation on a model may be denied by access policies.
1209-
* Returns true when `respectAccessPolicies` is enabled and the model's
1210-
* policies for the given operation are NOT a constant allow (i.e., not
1211-
* simply `@@allow('...', true)` with no `@@deny` rules).
1212-
*/
1213-
private mayDenyAccess(modelDef: ModelDef, operation: string): boolean {
1214-
if (!this.specOptions?.respectAccessPolicies) return false;
1215-
1216-
const policyAttrs = (modelDef.attributes ?? []).filter(
1217-
(attr) => attr.name === '@@allow' || attr.name === '@@deny',
1218-
);
1219-
1220-
// No policy rules at all means default-deny
1221-
if (policyAttrs.length === 0) return true;
1222-
1223-
const getArgByName = (args: AttributeApplication['args'], name: string) =>
1224-
args?.find((a) => a.name === name)?.value;
1225-
1226-
const matchesOperation = (args: AttributeApplication['args']) => {
1227-
const val = getArgByName(args, 'operation');
1228-
if (!val || val.kind !== 'literal' || typeof val.value !== 'string') return false;
1229-
const ops = val.value.split(',').map((s) => s.trim());
1230-
return ops.includes(operation) || ops.includes('all');
1231-
};
1232-
1233-
const hasEffectiveDeny = policyAttrs.some((attr) => {
1234-
if (attr.name !== '@@deny' || !matchesOperation(attr.args)) return false;
1235-
const condition = getArgByName(attr.args, 'condition');
1236-
// @@deny('op', false) is a no-op — skip it
1237-
return !(condition?.kind === 'literal' && condition.value === false);
1238-
});
1239-
if (hasEffectiveDeny) return true;
1240-
1241-
const relevantAllow = policyAttrs.filter(
1242-
(attr) => attr.name === '@@allow' && matchesOperation(attr.args),
1243-
);
1244-
1245-
// If any allow rule has a constant `true` condition (and no deny), access is unconditional
1246-
const hasConstantAllow = relevantAllow.some((attr) => {
1247-
const condition = getArgByName(attr.args, 'condition');
1248-
return condition?.kind === 'literal' && condition.value === true;
1249-
});
1250-
1251-
return !hasConstantAllow;
1252-
}
12531210
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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';
1010
import { loggerSchema, queryOptionsSchema } from '../common/schemas';
11-
import type { CommonHandlerOptions } from '../common/types';
11+
import type { CommonHandlerOptions, OpenApiSpecGenerator, OpenApiSpecOptions } from '../common/types';
1212
import { processSuperJsonRequestPayload, unmarshalQ } from '../common/utils';
1313
import { log, registerCustomSerializers } from '../utils';
1414

@@ -35,7 +35,7 @@ export type RPCApiHandlerOptions<Schema extends SchemaDef = SchemaDef> = {
3535
/**
3636
* RPC style API request handler that mirrors the ZenStackClient API
3737
*/
38-
export class RPCApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema> {
38+
export class RPCApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema>, OpenApiSpecGenerator {
3939
constructor(private readonly options: RPCApiHandlerOptions<Schema>) {
4040
this.validateOptions(options);
4141
}
@@ -434,6 +434,12 @@ export class RPCApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiH
434434
return resp;
435435
}
436436

437+
async generateSpec(options?: OpenApiSpecOptions) {
438+
const { RPCApiSpecGenerator } = await import('./openapi');
439+
const generator = new RPCApiSpecGenerator(this.options);
440+
return generator.generateSpec(options);
441+
}
442+
437443
private async processRequestPayload(args: any) {
438444
const { meta, ...rest } = args ?? {};
439445
if (meta?.serialization) {

0 commit comments

Comments
 (0)