Skip to content

Commit cbebe0c

Browse files
ymc9claude
andauthored
feat(server): add specific 4xx error responses to REST OpenAPI spec (#2504)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1adf26c commit cbebe0c

File tree

4 files changed

+501
-131
lines changed

4 files changed

+501
-131
lines changed

packages/server/src/api/common/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export type OpenApiSpecOptions = {
1919

2020
/** Spec summary. */
2121
summary?: string;
22+
23+
/**
24+
* When true, assumes that the schema includes access policies, and adds
25+
* 403 responses to operations that can potentially be rejected.
26+
*/
27+
respectAccessPolicies?: boolean;
2228
};
2329

2430
/**

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

Lines changed: 97 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
2-
import type { EnumDef, FieldDef, ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/orm/schema';
2+
import type { AttributeApplication, 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 {
@@ -18,20 +18,29 @@ type SchemaObject = OpenAPIV3_1.SchemaObject;
1818
type ReferenceObject = OpenAPIV3_1.ReferenceObject;
1919
type ParameterObject = OpenAPIV3_1.ParameterObject;
2020

21-
const ERROR_RESPONSE = {
22-
description: 'Error',
23-
content: {
24-
'application/vnd.api+json': {
25-
schema: { $ref: '#/components/schemas/_errorResponse' },
21+
function errorResponse(description: string): OpenAPIV3_1.ResponseObject {
22+
return {
23+
description,
24+
content: {
25+
'application/vnd.api+json': {
26+
schema: { $ref: '#/components/schemas/_errorResponse' },
27+
},
2628
},
27-
},
28-
};
29+
};
30+
}
31+
32+
const ERROR_400 = errorResponse('Error occurred while processing the request');
33+
const ERROR_403 = errorResponse('Forbidden: insufficient permissions to perform this operation');
34+
const ERROR_404 = errorResponse('Resource not found');
35+
const ERROR_422 = errorResponse('Operation is unprocessable due to validation errors');
2936

3037
const SCALAR_STRING_OPS = ['$contains', '$icontains', '$search', '$startsWith', '$endsWith'];
3138
const SCALAR_COMPARABLE_OPS = ['$lt', '$lte', '$gt', '$gte'];
3239
const SCALAR_ARRAY_OPS = ['$has', '$hasEvery', '$hasSome', '$isEmpty'];
3340

3441
export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
42+
private specOptions?: OpenApiSpecOptions;
43+
3544
constructor(private readonly handlerOptions: RestApiHandlerOptions<Schema>) {}
3645

3746
private get schema(): SchemaDef {
@@ -53,6 +62,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
5362
}
5463

5564
generateSpec(options?: OpenApiSpecOptions): OpenAPIV3_1.Document {
65+
this.specOptions = options;
5666
return {
5767
openapi: '3.1.0',
5868
info: {
@@ -100,7 +110,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
100110
}
101111

102112
// Single resource: GET + PATCH + DELETE
103-
const singlePath = this.buildSinglePath(modelName, tag);
113+
const singlePath = this.buildSinglePath(modelDef, tag);
104114
if (Object.keys(singlePath).length > 0) {
105115
paths[`/${modelPath}/{id}`] = singlePath;
106116
}
@@ -124,7 +134,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
124134

125135
// Relationship management path
126136
paths[`/${modelPath}/{id}/relationships/${fieldName}`] = this.buildRelationshipPath(
127-
modelName,
137+
modelDef,
128138
fieldName,
129139
fieldDef,
130140
tag,
@@ -175,7 +185,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
175185
},
176186
},
177187
},
178-
'400': ERROR_RESPONSE,
188+
'400': ERROR_400,
179189
},
180190
};
181191

@@ -200,7 +210,9 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
200210
},
201211
},
202212
},
203-
'400': ERROR_RESPONSE,
213+
'400': ERROR_400,
214+
...(this.mayDenyAccess(modelDef, 'create') && { '403': ERROR_403 }),
215+
'422': ERROR_422,
204216
},
205217
};
206218

@@ -214,7 +226,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
214226
return result;
215227
}
216228

217-
private buildSinglePath(modelName: string, tag: string): Record<string, any> {
229+
private buildSinglePath(modelDef: ModelDef, tag: string): Record<string, any> {
230+
const modelName = modelDef.name;
218231
const idParam = { $ref: '#/components/parameters/id' };
219232
const result: Record<string, any> = {};
220233

@@ -233,7 +246,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
233246
},
234247
},
235248
},
236-
'404': ERROR_RESPONSE,
249+
'404': ERROR_404,
237250
},
238251
};
239252
}
@@ -261,8 +274,10 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
261274
},
262275
},
263276
},
264-
'400': ERROR_RESPONSE,
265-
'404': ERROR_RESPONSE,
277+
'400': ERROR_400,
278+
...(this.mayDenyAccess(modelDef, 'update') && { '403': ERROR_403 }),
279+
'404': ERROR_404,
280+
'422': ERROR_422,
266281
},
267282
};
268283
}
@@ -275,7 +290,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
275290
parameters: [idParam],
276291
responses: {
277292
'200': { description: 'Deleted successfully' },
278-
'404': ERROR_RESPONSE,
293+
...(this.mayDenyAccess(modelDef, 'delete') && { '403': ERROR_403 }),
294+
'404': ERROR_404,
279295
},
280296
};
281297
}
@@ -319,18 +335,19 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
319335
},
320336
},
321337
},
322-
'404': ERROR_RESPONSE,
338+
'404': ERROR_404,
323339
},
324340
},
325341
};
326342
}
327343

328344
private buildRelationshipPath(
329-
_modelName: string,
345+
modelDef: ModelDef,
330346
fieldName: string,
331347
fieldDef: FieldDef,
332348
tag: string,
333349
): Record<string, any> {
350+
const modelName = modelDef.name;
334351
const isCollection = !!fieldDef.array;
335352
const idParam = { $ref: '#/components/parameters/id' };
336353
const relSchemaRef = isCollection
@@ -341,46 +358,50 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
341358
? { $ref: '#/components/schemas/_toManyRelationshipRequest' }
342359
: { $ref: '#/components/schemas/_toOneRelationshipRequest' };
343360

361+
const mayDeny = this.mayDenyAccess(modelDef, 'update');
362+
344363
const pathItem: Record<string, any> = {
345364
get: {
346365
tags: [tag],
347366
summary: `Fetch ${fieldName} relationship`,
348-
operationId: `get${_modelName}_relationships_${fieldName}`,
367+
operationId: `get${modelName}_relationships_${fieldName}`,
349368
parameters: [idParam],
350369
responses: {
351370
'200': {
352371
description: `${fieldName} relationship`,
353372
content: { 'application/vnd.api+json': { schema: relSchemaRef } },
354373
},
355-
'404': ERROR_RESPONSE,
374+
'404': ERROR_404,
356375
},
357376
},
358377
put: {
359378
tags: [tag],
360379
summary: `Replace ${fieldName} relationship`,
361-
operationId: `put${_modelName}_relationships_${fieldName}`,
380+
operationId: `put${modelName}_relationships_${fieldName}`,
362381
parameters: [idParam],
363382
requestBody: {
364383
required: true,
365384
content: { 'application/vnd.api+json': { schema: relRequestRef } },
366385
},
367386
responses: {
368387
'200': { description: 'Relationship updated' },
369-
'400': ERROR_RESPONSE,
388+
'400': ERROR_400,
389+
...(mayDeny && { '403': ERROR_403 }),
370390
},
371391
},
372392
patch: {
373393
tags: [tag],
374394
summary: `Update ${fieldName} relationship`,
375-
operationId: `patch${_modelName}_relationships_${fieldName}`,
395+
operationId: `patch${modelName}_relationships_${fieldName}`,
376396
parameters: [idParam],
377397
requestBody: {
378398
required: true,
379399
content: { 'application/vnd.api+json': { schema: relRequestRef } },
380400
},
381401
responses: {
382402
'200': { description: 'Relationship updated' },
383-
'400': ERROR_RESPONSE,
403+
'400': ERROR_400,
404+
...(mayDeny && { '403': ERROR_403 }),
384405
},
385406
},
386407
};
@@ -389,7 +410,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
389410
pathItem['post'] = {
390411
tags: [tag],
391412
summary: `Add to ${fieldName} collection relationship`,
392-
operationId: `post${_modelName}_relationships_${fieldName}`,
413+
operationId: `post${modelName}_relationships_${fieldName}`,
393414
parameters: [idParam],
394415
requestBody: {
395416
required: true,
@@ -401,7 +422,8 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
401422
},
402423
responses: {
403424
'200': { description: 'Added to relationship collection' },
404-
'400': ERROR_RESPONSE,
425+
'400': ERROR_400,
426+
...(mayDeny && { '403': ERROR_403 }),
405427
},
406428
};
407429
}
@@ -416,7 +438,7 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
416438
operationId: `proc_${procName}`,
417439
responses: {
418440
'200': { description: `Result of ${procName}` },
419-
'400': ERROR_RESPONSE,
441+
'400': ERROR_400,
420442
},
421443
};
422444

@@ -1016,4 +1038,51 @@ export class RestApiSpecGenerator<Schema extends SchemaDef = SchemaDef> {
10161038
private getIdFields(modelDef: ModelDef): FieldDef[] {
10171039
return modelDef.idFields.map((name) => modelDef.fields[name]).filter((f): f is FieldDef => f !== undefined);
10181040
}
1041+
1042+
/**
1043+
* Checks if an operation on a model may be denied by access policies.
1044+
* Returns true when `respectAccessPolicies` is enabled and the model's
1045+
* policies for the given operation are NOT a constant allow (i.e., not
1046+
* simply `@@allow('...', true)` with no `@@deny` rules).
1047+
*/
1048+
private mayDenyAccess(modelDef: ModelDef, operation: string): boolean {
1049+
if (!this.specOptions?.respectAccessPolicies) return false;
1050+
1051+
const policyAttrs = (modelDef.attributes ?? []).filter(
1052+
(attr) => attr.name === '@@allow' || attr.name === '@@deny',
1053+
);
1054+
1055+
// No policy rules at all means default-deny
1056+
if (policyAttrs.length === 0) return true;
1057+
1058+
const getArgByName = (args: AttributeApplication['args'], name: string) =>
1059+
args?.find((a) => a.name === name)?.value;
1060+
1061+
const matchesOperation = (args: AttributeApplication['args']) => {
1062+
const val = getArgByName(args, 'operation');
1063+
if (!val || val.kind !== 'literal' || typeof val.value !== 'string') return false;
1064+
const ops = val.value.split(',').map((s) => s.trim());
1065+
return ops.includes(operation) || ops.includes('all');
1066+
};
1067+
1068+
const hasEffectiveDeny = policyAttrs.some((attr) => {
1069+
if (attr.name !== '@@deny' || !matchesOperation(attr.args)) return false;
1070+
const condition = getArgByName(attr.args, 'condition');
1071+
// @@deny('op', false) is a no-op — skip it
1072+
return !(condition?.kind === 'literal' && condition.value === false);
1073+
});
1074+
if (hasEffectiveDeny) return true;
1075+
1076+
const relevantAllow = policyAttrs.filter(
1077+
(attr) => attr.name === '@@allow' && matchesOperation(attr.args),
1078+
);
1079+
1080+
// If any allow rule has a constant `true` condition (and no deny), access is unconditional
1081+
const hasConstantAllow = relevantAllow.some((attr) => {
1082+
const condition = getArgByName(attr.args, 'condition');
1083+
return condition?.kind === 'literal' && condition.value === true;
1084+
});
1085+
1086+
return !hasConstantAllow;
1087+
}
10191088
}

0 commit comments

Comments
 (0)