Skip to content

Commit f361d32

Browse files
ymc9claude
andauthored
feat(server): add OpenAPI spec generation for RESTful API (#2498)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 00768de commit f361d32

File tree

11 files changed

+6068
-19
lines changed

11 files changed

+6068
-19
lines changed

packages/server/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"watch": "tsup-node --watch",
99
"lint": "eslint src --ext ts",
1010
"test": "vitest run",
11+
"update-baseline": "UPDATE_BASELINE=1 vitest run test/openapi",
1112
"pack": "pnpm pack"
1213
},
1314
"keywords": [
@@ -124,13 +125,15 @@
124125
"@zenstackhq/common-helpers": "workspace:*",
125126
"@zenstackhq/orm": "workspace:*",
126127
"decimal.js": "catalog:",
128+
"openapi-types": "^12.1.3",
127129
"superjson": "^2.2.3",
128130
"ts-japi": "^1.12.1",
129131
"ts-pattern": "catalog:",
130132
"url-pattern": "^1.0.3",
131133
"zod-validation-error": "catalog:"
132134
},
133135
"devDependencies": {
136+
"@readme/openapi-parser": "^6.0.0",
134137
"@sveltejs/kit": "catalog:",
135138
"@types/body-parser": "^1.19.6",
136139
"@types/express": "^5.0.0",
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+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { lowerCaseFirst } from '@zenstackhq/common-helpers';
2+
import type { QueryOptions } from '@zenstackhq/orm';
3+
import { ExpressionUtils, type AttributeApplication, type SchemaDef } from '@zenstackhq/orm/schema';
4+
5+
/**
6+
* Checks if a model is included based on slicing options.
7+
*/
8+
export function isModelIncluded(modelName: string, queryOptions?: QueryOptions<any>): boolean {
9+
const slicing = queryOptions?.slicing;
10+
if (!slicing) return true;
11+
12+
const excluded = slicing.excludedModels as readonly string[] | undefined;
13+
if (excluded?.includes(modelName)) return false;
14+
15+
const included = slicing.includedModels as readonly string[] | undefined;
16+
if (included && !included.includes(modelName)) return false;
17+
18+
return true;
19+
}
20+
21+
/**
22+
* Checks if a CRUD operation is included for a model based on slicing options.
23+
*/
24+
export function isOperationIncluded(modelName: string, op: string, queryOptions?: QueryOptions<any>): boolean {
25+
const slicing = queryOptions?.slicing;
26+
if (!slicing?.models) return true;
27+
28+
const modelKey = lowerCaseFirst(modelName);
29+
const modelSlicing = (slicing.models as Record<string, any>)[modelKey] ?? (slicing.models as any).$all;
30+
if (!modelSlicing) return true;
31+
32+
const excluded = modelSlicing.excludedOperations as readonly string[] | undefined;
33+
if (excluded?.includes(op)) return false;
34+
35+
const included = modelSlicing.includedOperations as readonly string[] | undefined;
36+
if (included && !included.includes(op)) return false;
37+
38+
return true;
39+
}
40+
41+
/**
42+
* Checks if a procedure is included based on slicing options.
43+
*/
44+
export function isProcedureIncluded(procName: string, queryOptions?: QueryOptions<any>): boolean {
45+
const slicing = queryOptions?.slicing;
46+
if (!slicing) return true;
47+
48+
const excluded = slicing.excludedProcedures as readonly string[] | undefined;
49+
if (excluded?.includes(procName)) return false;
50+
51+
const included = slicing.includedProcedures as readonly string[] | undefined;
52+
if (included && !included.includes(procName)) return false;
53+
54+
return true;
55+
}
56+
57+
/**
58+
* Checks if a field should be omitted from the output schema based on queryOptions.omit.
59+
*/
60+
export function isFieldOmitted(modelName: string, fieldName: string, queryOptions?: QueryOptions<any>): boolean {
61+
const omit = queryOptions?.omit as Record<string, Record<string, boolean>> | undefined;
62+
return omit?.[modelName]?.[fieldName] === true;
63+
}
64+
65+
/**
66+
* Returns the list of model names from the schema that pass the slicing filter.
67+
*/
68+
export function getIncludedModels(schema: SchemaDef, queryOptions?: QueryOptions<any>): string[] {
69+
return Object.keys(schema.models).filter((name) => isModelIncluded(name, queryOptions));
70+
}
71+
72+
/**
73+
* Checks if a filter kind is allowed for a specific field based on slicing options.
74+
*/
75+
export function isFilterKindIncluded(
76+
modelName: string,
77+
fieldName: string,
78+
filterKind: string,
79+
queryOptions?: QueryOptions<any>,
80+
): boolean {
81+
const slicing = queryOptions?.slicing;
82+
if (!slicing?.models) return true;
83+
84+
const modelKey = lowerCaseFirst(modelName);
85+
const modelSlicing = (slicing.models as Record<string, any>)[modelKey] ?? (slicing.models as any).$all;
86+
if (!modelSlicing?.fields) return true;
87+
88+
const fieldSlicing = modelSlicing.fields[fieldName] ?? modelSlicing.fields.$all;
89+
if (!fieldSlicing) return true;
90+
91+
const excluded = fieldSlicing.excludedFilterKinds as readonly string[] | undefined;
92+
if (excluded?.includes(filterKind)) return false;
93+
94+
const included = fieldSlicing.includedFilterKinds as readonly string[] | undefined;
95+
if (included && !included.includes(filterKind)) return false;
96+
97+
return true;
98+
}
99+
100+
/**
101+
* Extracts a "description" from `@@meta("description", "...")` or `@meta("description", "...")` attributes.
102+
*/
103+
export function getMetaDescription(attributes: readonly AttributeApplication[] | undefined): string | undefined {
104+
if (!attributes) return undefined;
105+
for (const attr of attributes) {
106+
if (attr.name !== '@meta' && attr.name !== '@@meta') continue;
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;
112+
}
113+
}
114+
return undefined;
115+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { QueryOptions } from '@zenstackhq/orm';
2+
import type { SchemaDef } from '@zenstackhq/orm/schema';
3+
import type { OpenAPIV3_1 } from 'openapi-types';
4+
5+
export type CommonHandlerOptions<Schema extends SchemaDef> = {
6+
/** Query options that affect the behavior of the OpenAPI provider. */
7+
queryOptions?: QueryOptions<Schema>;
8+
};
9+
10+
export type OpenApiSpecOptions = {
11+
/** Spec title. Defaults to 'ZenStack Generated API' */
12+
title?: string;
13+
14+
/** Spec version. Defaults to '1.0.0' */
15+
version?: string;
16+
17+
/** Spec description. */
18+
description?: string;
19+
20+
/** Spec summary. */
21+
summary?: string;
22+
};
23+
24+
/**
25+
* Interface for generating OpenAPI specifications.
26+
*/
27+
export interface OpenApiSpecGenerator {
28+
/**
29+
* Generates an OpenAPI v3.1 specification document.
30+
*/
31+
generateSpec(options?: OpenApiSpecOptions): Promise<OpenAPIV3_1.Document>;
32+
}

packages/server/src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export type { OpenApiSpecGenerator, OpenApiSpecOptions } from './common/types';
12
export { RestApiHandler, type RestApiHandlerOptions } from './rest';
23
export { RPCApiHandler, type RPCApiHandlerOptions } from './rpc';

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ 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';
14+
import type { CommonHandlerOptions, OpenApiSpecGenerator, OpenApiSpecOptions } from '../common/types';
1415
import { processSuperJsonRequestPayload } from '../common/utils';
1516
import { getZodErrorMessage, log, registerCustomSerializers } from '../utils';
17+
import { RestApiSpecGenerator } from './openapi';
1618

1719
/**
1820
* Options for {@link RestApiHandler}
@@ -64,7 +66,7 @@ export type RestApiHandlerOptions<Schema extends SchemaDef = SchemaDef> = {
6466
* Mapping from model names to unique field name to be used as resource's ID.
6567
*/
6668
externalIdMapping?: Record<string, string>;
67-
};
69+
} & CommonHandlerOptions<Schema>;
6870

6971
type RelationshipInfo = {
7072
type: string;
@@ -127,7 +129,7 @@ registerCustomSerializers();
127129
/**
128130
* RESTful-style API request handler (compliant with JSON:API)
129131
*/
130-
export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema> {
132+
export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements ApiHandler<Schema>, OpenApiSpecGenerator {
131133
// resource serializers
132134
private serializers = new Map<string, Serializer>();
133135

@@ -298,6 +300,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
298300
urlSegmentCharset: z.string().min(1).optional(),
299301
modelNameMapping: z.record(z.string(), z.string()).optional(),
300302
externalIdMapping: z.record(z.string(), z.string()).optional(),
303+
queryOptions: queryOptionsSchema.optional(),
301304
});
302305
const parseResult = schema.safeParse(options);
303306
if (!parseResult.success) {
@@ -2060,9 +2063,7 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
20602063
}
20612064
} else {
20622065
if (op === 'between') {
2063-
const parts = value
2064-
.split(',')
2065-
.map((v) => this.coerce(fieldDef, v));
2066+
const parts = value.split(',').map((v) => this.coerce(fieldDef, v));
20662067
if (parts.length !== 2) {
20672068
throw new InvalidValueError(`"between" expects exactly 2 comma-separated values`);
20682069
}
@@ -2201,4 +2202,11 @@ export class RestApiHandler<Schema extends SchemaDef = SchemaDef> implements Api
22012202
}
22022203

22032204
//#endregion
2205+
2206+
async generateSpec(options?: OpenApiSpecOptions) {
2207+
const generator = new RestApiSpecGenerator(this.options);
2208+
return generator.generateSpec(options);
2209+
}
22042210
}
2211+
2212+
export { RestApiSpecGenerator } from './openapi';

0 commit comments

Comments
 (0)