bazis 0.98.5 → 0.98.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/http/HttpServer.ts +25 -6
- package/core/http/Middleware/accessLog.ts +2 -2
- package/core/http/Middleware/cors.ts +15 -3
- package/core/http/OpenApi/openApiDocument.ts +58 -8
- package/core/http/Routing/Router.ts +3 -0
- package/core/http/Routing/RouterBuilder.ts +15 -0
- package/core/http/correlation/createCorrelationIdMiddleware.ts +13 -1
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/scripts/di-generate.ts +1 -2
- package/library/openapi/codegen.ts +126 -17
- package/library/openapi/document.ts +4 -0
- package/library/openapi/types.ts +9 -0
- package/library/openapi/ui.ts +1 -1
- package/package.json +1 -1
package/core/http/HttpServer.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { HttpContext } from "./HttpContext/HttpContext";
|
|
|
6
6
|
import { holdResponseScope } from "./HttpContext/responseLifetime";
|
|
7
7
|
import { inspectableRedirectResponse } from "./HttpContext/inspectableRedirects";
|
|
8
8
|
import { accessLog } from "./Middleware/accessLog";
|
|
9
|
-
import { cors, isPreflight, preflightResponse } from "./Middleware/cors";
|
|
9
|
+
import { cors, corsOptionsOf, isPreflight, preflightResponse, type CorsOptions } from "./Middleware/cors";
|
|
10
|
+
import { isCorrelationIdMiddleware } from "./correlation/createCorrelationIdMiddleware";
|
|
10
11
|
import { errorHandler } from "./Middleware/errorHandler";
|
|
11
12
|
import { runPipeline } from "./Middleware/pipeline";
|
|
12
13
|
import { applySecurityHeaders, securityHeaders } from "./Middleware/securityHeaders";
|
|
@@ -75,6 +76,8 @@ export class HttpServer implements HostedService {
|
|
|
75
76
|
private server?: ReturnType<typeof Bun.serve>;
|
|
76
77
|
private router?: Router;
|
|
77
78
|
private readVersion: VersionReader = () => undefined;
|
|
79
|
+
/** CORS options for every preflight: `cors` or a `cors()` among the global middleware. */
|
|
80
|
+
private globalCors: CorsOptions | undefined;
|
|
78
81
|
/** Body cap in bytes; `undefined` when the limit is disabled (`maxBodyBytes: 0`). */
|
|
79
82
|
private readonly maxBodyBytes?: number;
|
|
80
83
|
private readonly requestScopeDisposeTimeoutMs?: number;
|
|
@@ -161,8 +164,13 @@ export class HttpServer implements HostedService {
|
|
|
161
164
|
onUnexpectedError: this.collectErrorHook(),
|
|
162
165
|
}),
|
|
163
166
|
);
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
const serverMiddleware = this.collectServerMiddleware();
|
|
168
|
+
// Responses produced before routing (404, 405, 413, preflight, docs,
|
|
169
|
+
// health) still carry the correlation id of a global correlation middleware.
|
|
170
|
+
this.shortCircuitChain = [...serverChain, ...serverMiddleware.filter(isCorrelationIdMiddleware)];
|
|
171
|
+
// A global `cors()` among the middleware answers preflights like `cors`.
|
|
172
|
+
this.globalCors = this.options.cors || serverMiddleware.map(corsOptionsOf).find((options) => options !== undefined);
|
|
173
|
+
serverChain.push(...serverMiddleware);
|
|
166
174
|
|
|
167
175
|
const builder = new RouterBuilder(
|
|
168
176
|
serverChain,
|
|
@@ -367,9 +375,11 @@ export class HttpServer implements HostedService {
|
|
|
367
375
|
}
|
|
368
376
|
}
|
|
369
377
|
|
|
370
|
-
if (
|
|
371
|
-
const corsOptions = this.
|
|
372
|
-
|
|
378
|
+
if (isPreflight(request)) {
|
|
379
|
+
const corsOptions = this.globalCors ?? this.routeCors(request, url);
|
|
380
|
+
if (corsOptions !== undefined) {
|
|
381
|
+
return this.runShortCircuit(request, url, clientIp, () => preflightResponse(corsOptions, request));
|
|
382
|
+
}
|
|
373
383
|
}
|
|
374
384
|
|
|
375
385
|
const segments = parseRequestPath(url.pathname);
|
|
@@ -430,6 +440,15 @@ export class HttpServer implements HostedService {
|
|
|
430
440
|
}
|
|
431
441
|
}
|
|
432
442
|
|
|
443
|
+
/** CORS options of the route a preflight asks about (its `Access-Control-Request-Method`). */
|
|
444
|
+
private routeCors(request: Request, url: URL): CorsOptions | undefined {
|
|
445
|
+
const segments = parseRequestPath(url.pathname);
|
|
446
|
+
const method = request.headers.get("access-control-request-method")?.trim().toUpperCase();
|
|
447
|
+
if (!segments || !method) return undefined;
|
|
448
|
+
const match = this.router!.match(method, segments, this.readVersion(request, url));
|
|
449
|
+
return match.kind === "matched" ? match.action.cors : undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
433
452
|
private async runShortCircuit(
|
|
434
453
|
request: Request,
|
|
435
454
|
url: URL,
|
|
@@ -7,7 +7,7 @@ export interface AccessLogEntry {
|
|
|
7
7
|
readonly method: string;
|
|
8
8
|
readonly path: string;
|
|
9
9
|
readonly status: number;
|
|
10
|
-
/** Wall-clock time spent in the pipeline, in milliseconds. */
|
|
10
|
+
/** Wall-clock time spent in the pipeline, in milliseconds, rounded to 0.01. */
|
|
11
11
|
readonly durationMs: number;
|
|
12
12
|
/** Correlation id from {@link createCorrelationIdMiddleware}, if present. */
|
|
13
13
|
readonly requestId?: string;
|
|
@@ -73,7 +73,7 @@ export function accessLog(options: AccessLogOptions = {}): HttpMiddleware {
|
|
|
73
73
|
method: ctx.method,
|
|
74
74
|
path: ctx.path,
|
|
75
75
|
status: ctx.response?.status ?? 0,
|
|
76
|
-
durationMs: performance.now() - startedAt,
|
|
76
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
77
77
|
...(typeof requestId === "string" ? { requestId } : {}),
|
|
78
78
|
});
|
|
79
79
|
}
|
|
@@ -22,6 +22,14 @@ export interface CorsOptions {
|
|
|
22
22
|
|
|
23
23
|
const DEFAULT_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] as const;
|
|
24
24
|
|
|
25
|
+
/** Options of a middleware created by {@link cors}; the server answers preflights with them. */
|
|
26
|
+
const CORS_OPTIONS = Symbol("bazis:http:cors-options");
|
|
27
|
+
|
|
28
|
+
/** Options of a {@link cors} middleware, or undefined for any other middleware. */
|
|
29
|
+
export function corsOptionsOf(middleware: HttpMiddleware): CorsOptions | undefined {
|
|
30
|
+
return (middleware as HttpMiddleware & { [CORS_OPTIONS]?: CorsOptions })[CORS_OPTIONS];
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
function assertSafeCorsOptions(options: CorsOptions): void {
|
|
26
34
|
if (!options.credentials) {
|
|
27
35
|
return;
|
|
@@ -119,12 +127,14 @@ export function preflightResponse(options: CorsOptions, request: Request): Respo
|
|
|
119
127
|
|
|
120
128
|
/**
|
|
121
129
|
* CORS middleware: adds response headers for cross-origin requests.
|
|
122
|
-
* Use globally via `httpModule({ cors })`
|
|
123
|
-
*
|
|
130
|
+
* Use globally via `httpModule({ cors })` or per controller/route via
|
|
131
|
+
* `@Middleware(cors({...}))`. Either way the server answers preflights
|
|
132
|
+
* (`OPTIONS` with `Access-Control-Request-Method`) with these options: a
|
|
133
|
+
* route's own `cors()` applies to preflights of that route.
|
|
124
134
|
*/
|
|
125
135
|
export function cors(options: CorsOptions = {}): HttpMiddleware {
|
|
126
136
|
assertSafeCorsOptions(options);
|
|
127
|
-
|
|
137
|
+
const middleware: HttpMiddleware = async (ctx, next) => {
|
|
128
138
|
await next();
|
|
129
139
|
const requestOrigin = ctx.header("origin");
|
|
130
140
|
if (!ctx.response) {
|
|
@@ -157,4 +167,6 @@ export function cors(options: CorsOptions = {}): HttpMiddleware {
|
|
|
157
167
|
}
|
|
158
168
|
}
|
|
159
169
|
};
|
|
170
|
+
Object.defineProperty(middleware, CORS_OPTIONS, { value: options });
|
|
171
|
+
return middleware;
|
|
160
172
|
}
|
|
@@ -25,12 +25,28 @@ export interface HttpOpenApiDocumentBuildInput {
|
|
|
25
25
|
readonly version: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Body of every error response: `{ "error": "...", "details": ... }` (see Middleware/errorHandler). */
|
|
29
|
+
const ERROR_SCHEMA_NAME = "HttpErrorResponse";
|
|
30
|
+
const ERROR_SCHEMA: OpenApiSchema = {
|
|
31
|
+
type: "object",
|
|
32
|
+
description: "Error response of the bazis HTTP server.",
|
|
33
|
+
properties: {
|
|
34
|
+
error: { type: "string", description: "Human-readable error message." },
|
|
35
|
+
details: { description: "Error details, for example the list of validation errors." },
|
|
36
|
+
},
|
|
37
|
+
required: ["error"],
|
|
38
|
+
};
|
|
39
|
+
const ERROR_DESCRIPTIONS: Readonly<Record<number, string>> = {
|
|
40
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 409: "Conflict",
|
|
41
|
+
413: "Payload Too Large", 415: "Unsupported Media Type", 429: "Too Many Requests",
|
|
42
|
+
};
|
|
43
|
+
|
|
28
44
|
export function buildHttpOpenApiDocument(input: HttpOpenApiDocumentBuildInput): OpenApiSchema {
|
|
29
45
|
const generated = getGeneratedOpenApiMetadata(input.controllers);
|
|
30
46
|
const documentInput: LibraryOpenApiDocumentBuildInput = {
|
|
31
47
|
title: input.title,
|
|
32
48
|
version: input.version,
|
|
33
|
-
schemas: generated.schemas,
|
|
49
|
+
schemas: { [ERROR_SCHEMA_NAME]: ERROR_SCHEMA, ...generated.schemas },
|
|
34
50
|
operations: collectOpenApiOperations(input, generated),
|
|
35
51
|
generatedBy: "bazis:di-generate",
|
|
36
52
|
};
|
|
@@ -89,16 +105,18 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
|
|
|
89
105
|
const version = input.action.version ?? input.meta.version;
|
|
90
106
|
const auth = resolveAuthorizeMeta(input.controllerClass, input.methodName);
|
|
91
107
|
const generatedOperation = input.generated.operations[input.controllerClass.name]?.[methodName];
|
|
92
|
-
const status = String(successStatus(input.httpMethod, input.action));
|
|
108
|
+
const status = String(successStatus(input.httpMethod, input.action, generatedOperation?.status));
|
|
93
109
|
const responseSchema = generatedOperation?.response;
|
|
94
110
|
const contentType = input.action.produces ?? "application/json";
|
|
111
|
+
const authorized = !auth.allowAnonymous && auth.authorize !== undefined;
|
|
95
112
|
|
|
96
113
|
return {
|
|
97
114
|
path: input.path,
|
|
98
115
|
httpMethod: input.httpMethod,
|
|
99
116
|
tag: tagName(input.meta, input.controllerClass),
|
|
100
117
|
operationId: stableHttpOperationId(input.httpMethod, input.path),
|
|
101
|
-
summary: `${normalizedMethod(input.httpMethod)} ${input.path}`,
|
|
118
|
+
summary: generatedOperation?.summary ?? `${normalizedMethod(input.httpMethod)} ${input.path}`,
|
|
119
|
+
...(generatedOperation?.description !== undefined ? { description: generatedOperation.description } : {}),
|
|
102
120
|
parameters: collectParameters(
|
|
103
121
|
input.bindings,
|
|
104
122
|
input.versioning,
|
|
@@ -117,8 +135,9 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
|
|
|
117
135
|
},
|
|
118
136
|
}
|
|
119
137
|
: { description: "Success" },
|
|
138
|
+
...errorResponses(errorStatuses(input.bindings, authorized, generatedOperation?.errors)),
|
|
120
139
|
},
|
|
121
|
-
authorized
|
|
140
|
+
authorized,
|
|
122
141
|
...(version !== undefined ? { versions: [version] } : {}),
|
|
123
142
|
};
|
|
124
143
|
}
|
|
@@ -305,11 +324,42 @@ function schemaForConstraint(constraint: string | undefined): OpenApiSchema {
|
|
|
305
324
|
}
|
|
306
325
|
}
|
|
307
326
|
|
|
308
|
-
function successStatus(method: string, action: ActionMeta): number {
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
function successStatus(method: string, action: ActionMeta, generated: number | undefined): number {
|
|
328
|
+
return action.httpCode ?? generated ?? (method === "DELETE" ? 204 : 200);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Errors the operation can answer with: 400 when the request carries values
|
|
333
|
+
* that are converted or validated (body, list query, query, typed route
|
|
334
|
+
* parameters), 401/403 under @Authorize, and the errors the method body
|
|
335
|
+
* returns or throws itself.
|
|
336
|
+
*/
|
|
337
|
+
function errorStatuses(
|
|
338
|
+
bindings: readonly ParameterBinding[],
|
|
339
|
+
authorized: boolean,
|
|
340
|
+
generated: readonly number[] | undefined,
|
|
341
|
+
): number[] {
|
|
342
|
+
const statuses = new Set(generated ?? []);
|
|
343
|
+
const validated = bindings.some((binding) =>
|
|
344
|
+
binding.source === "body" || binding.source === "list" || binding.source === "query"
|
|
345
|
+
|| (binding.source === "route" && binding.type !== undefined && binding.type !== "string"));
|
|
346
|
+
if (validated) statuses.add(400);
|
|
347
|
+
if (authorized) {
|
|
348
|
+
statuses.add(401);
|
|
349
|
+
statuses.add(403);
|
|
350
|
+
}
|
|
351
|
+
return [...statuses].sort((a, b) => a - b);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function errorResponses(statuses: readonly number[]): Record<string, OpenApiSchema> {
|
|
355
|
+
const responses: Record<string, OpenApiSchema> = {};
|
|
356
|
+
for (const status of statuses) {
|
|
357
|
+
responses[String(status)] = {
|
|
358
|
+
description: ERROR_DESCRIPTIONS[status] ?? "Error",
|
|
359
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${ERROR_SCHEMA_NAME}` } } },
|
|
360
|
+
};
|
|
311
361
|
}
|
|
312
|
-
return
|
|
362
|
+
return responses;
|
|
313
363
|
}
|
|
314
364
|
|
|
315
365
|
function tagName(meta: ControllerMeta, controllerClass: Class<object>): string {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HttpMiddleware } from "../Middleware/types";
|
|
2
|
+
import type { CorsOptions } from "../Middleware/cors";
|
|
2
3
|
import { HttpSetupError } from "../Errors/HttpError";
|
|
3
4
|
import { INVALID } from "../Binding/convert";
|
|
4
5
|
import type { SegmentConstraint, TemplateSegment } from "./template";
|
|
@@ -13,6 +14,8 @@ export interface RouteAction {
|
|
|
13
14
|
readonly name: string;
|
|
14
15
|
/** Action-specific body cap in bytes; undefined inherits the server cap. */
|
|
15
16
|
readonly maxBodyBytes?: number;
|
|
17
|
+
/** Options of the action's or controller's `cors()` middleware, for preflights. */
|
|
18
|
+
readonly cors?: CorsOptions;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
interface RouteEntry {
|
|
@@ -6,6 +6,7 @@ import { controllerMetaOf, type ActionMeta, type ControllerMeta } from "../Decor
|
|
|
6
6
|
import { HttpSetupError } from "../Errors/HttpError";
|
|
7
7
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
8
8
|
import type { ActionFilterHooks, HttpMiddleware } from "../Middleware/types";
|
|
9
|
+
import { corsOptionsOf } from "../Middleware/cors";
|
|
9
10
|
import { normalizeResult } from "../Results/normalize";
|
|
10
11
|
import type { ApiVersioningOptions, RouteMiddlewareComposer } from "../options";
|
|
11
12
|
import { createAuthorizeComposer } from "../Authorization/authorizeComposer";
|
|
@@ -84,6 +85,7 @@ export class RouterBuilder {
|
|
|
84
85
|
version,
|
|
85
86
|
name: `${controllerClass.name}.${String(methodName)}`,
|
|
86
87
|
maxBodyBytes: action.maxBodyBytes,
|
|
88
|
+
cors: routeCors(meta, action),
|
|
87
89
|
};
|
|
88
90
|
for (const declaration of action.routes) {
|
|
89
91
|
const versionSegment = useUrlVersioning && version !== undefined ? `v${version}` : undefined;
|
|
@@ -192,6 +194,19 @@ export class RouterBuilder {
|
|
|
192
194
|
* but has no bindings was most likely added or changed after the last codegen:
|
|
193
195
|
* its arguments would silently get wrong values, so the server refuses to start.
|
|
194
196
|
*/
|
|
197
|
+
/** The action's own `cors()` wins over the controller's. */
|
|
198
|
+
function routeCors(meta: ControllerMeta, action: ActionMeta): ReturnType<typeof corsOptionsOf> {
|
|
199
|
+
for (const middleware of [...action.middleware].reverse()) {
|
|
200
|
+
const options = corsOptionsOf(middleware);
|
|
201
|
+
if (options !== undefined) return options;
|
|
202
|
+
}
|
|
203
|
+
for (const middleware of [...meta.middleware].reverse()) {
|
|
204
|
+
const options = corsOptionsOf(middleware);
|
|
205
|
+
if (options !== undefined) return options;
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
195
210
|
function assertBindingsFresh(
|
|
196
211
|
controllerClass: Class<object>,
|
|
197
212
|
methodName: string | symbol,
|
|
@@ -14,6 +14,14 @@ export interface CorrelationIdMiddlewareOptions {
|
|
|
14
14
|
readonly validateIncomingId?: (value: string) => boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Marks the middleware so the server also applies it to responses produced before routing. */
|
|
18
|
+
const CORRELATION_ID_MIDDLEWARE = Symbol("bazis:http:correlation-id");
|
|
19
|
+
|
|
20
|
+
/** True for a middleware created by {@link createCorrelationIdMiddleware}. */
|
|
21
|
+
export function isCorrelationIdMiddleware(middleware: HttpMiddleware): boolean {
|
|
22
|
+
return (middleware as HttpMiddleware & { [CORRELATION_ID_MIDDLEWARE]?: true })[CORRELATION_ID_MIDDLEWARE] === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
const DEFAULT_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/+\-=]{0,127}$/;
|
|
18
26
|
const TRACEPARENT = /^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-[\da-f]{2}(?:-[\x21-\x7e]+)?$/i;
|
|
19
27
|
|
|
@@ -21,6 +29,8 @@ const TRACEPARENT = /^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-[\da-f]{2}(?:-[\x21
|
|
|
21
29
|
* Ensures one correlation ID per HTTP request: reads `x-request-id` or
|
|
22
30
|
* generates a UUID, stores it on {@link HttpContext.state}, echoes it on the
|
|
23
31
|
* response, and binds it to async local storage for application logs.
|
|
32
|
+
* Registered among the server's global middleware, it also applies to the
|
|
33
|
+
* responses the server produces before routing (404, 405, 413, preflight).
|
|
24
34
|
*/
|
|
25
35
|
export function createCorrelationIdMiddleware(
|
|
26
36
|
options: CorrelationIdMiddlewareOptions = {},
|
|
@@ -29,7 +39,7 @@ export function createCorrelationIdMiddleware(
|
|
|
29
39
|
const generateId = options.generateId ?? (() => crypto.randomUUID());
|
|
30
40
|
const validateIncomingId = options.validateIncomingId ?? ((value: string) => DEFAULT_REQUEST_ID.test(value));
|
|
31
41
|
|
|
32
|
-
|
|
42
|
+
const middleware: HttpMiddleware = async (ctx, next) => {
|
|
33
43
|
const incoming = ctx.header(headerName);
|
|
34
44
|
const trimmedIncoming = incoming?.trim();
|
|
35
45
|
const generated = (): string => {
|
|
@@ -64,6 +74,8 @@ export function createCorrelationIdMiddleware(
|
|
|
64
74
|
});
|
|
65
75
|
}
|
|
66
76
|
};
|
|
77
|
+
Object.defineProperty(middleware, CORRELATION_ID_MIDDLEWARE, { value: true });
|
|
78
|
+
return middleware;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
function normalizeTraceparent(value: string | undefined): string | undefined {
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.7" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -1360,8 +1360,7 @@ function collectOpenApiOperation(controllerName: string, method: ts.MethodDeclar
|
|
|
1360
1360
|
if (!ts.isIdentifier(method.name)) {
|
|
1361
1361
|
return;
|
|
1362
1362
|
}
|
|
1363
|
-
|
|
1364
|
-
(openApiOperations[controllerName] ??= {})[method.name.text] = response === undefined ? {} : { response };
|
|
1363
|
+
(openApiOperations[controllerName] ??= {})[method.name.text] = openApiCodegen.operationFromMethod(method);
|
|
1365
1364
|
}
|
|
1366
1365
|
|
|
1367
1366
|
/** True if the class has an `extends ListRequest` (with or without type args). */
|
|
@@ -5,6 +5,14 @@ export type OpenApiCodegenSchema = Record<string, unknown>;
|
|
|
5
5
|
|
|
6
6
|
export interface OpenApiCodegenOperationSpec {
|
|
7
7
|
readonly response?: OpenApiCodegenSchema;
|
|
8
|
+
/** Success status when every success return uses the same result helper (`Created` -> 201). */
|
|
9
|
+
readonly status?: number;
|
|
10
|
+
/** Error statuses returned (`NotFound(...)`) or thrown (`throw new NotFoundError()`) in the method body. */
|
|
11
|
+
readonly errors?: readonly number[];
|
|
12
|
+
/** First line of the method's JSDoc. */
|
|
13
|
+
readonly summary?: string;
|
|
14
|
+
/** The rest of the method's JSDoc. */
|
|
15
|
+
readonly description?: string;
|
|
8
16
|
}
|
|
9
17
|
|
|
10
18
|
export interface OpenApiCodegenAnalyzer {
|
|
@@ -15,6 +23,8 @@ export interface OpenApiCodegenAnalyzer {
|
|
|
15
23
|
schemaFromDeclaration(declaration: ts.ClassDeclaration | ts.InterfaceDeclaration): OpenApiCodegenSchema;
|
|
16
24
|
schemaNameForDeclaration(name: string, declaration: ts.Declaration | undefined): string;
|
|
17
25
|
responseSchemaFromMethod(method: ts.MethodDeclaration): OpenApiCodegenSchema | undefined;
|
|
26
|
+
/** Response schema, statuses and JSDoc of a controller method. */
|
|
27
|
+
operationFromMethod(method: ts.MethodDeclaration): OpenApiCodegenOperationSpec;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
export interface OpenApiCodegenAnalyzerInput {
|
|
@@ -32,6 +42,14 @@ const SUCCESS_RESULT_HELPERS = new Set(["Ok", "Accepted"]);
|
|
|
32
42
|
const CREATED_RESULT_HELPERS = new Set(["Created"]);
|
|
33
43
|
const EMPTY_SUCCESS_RESULT_HELPERS = new Set(["NoContent", "Redirect", "File"]);
|
|
34
44
|
const ERROR_RESULT_HELPERS = new Set(["BadRequest", "Unauthorized", "Forbidden", "NotFound", "Conflict", "InternalServerError"]);
|
|
45
|
+
const SUCCESS_HELPER_STATUS: Readonly<Record<string, number>> = { Ok: 200, Accepted: 202, Created: 201, NoContent: 204 };
|
|
46
|
+
const ERROR_HELPER_STATUS: Readonly<Record<string, number>> = {
|
|
47
|
+
BadRequest: 400, Unauthorized: 401, Forbidden: 403, NotFound: 404, Conflict: 409,
|
|
48
|
+
};
|
|
49
|
+
const ERROR_CLASS_STATUS: Readonly<Record<string, number>> = {
|
|
50
|
+
BadRequestError: 400, UnauthorizedError: 401, ForbiddenError: 403, NotFoundError: 404,
|
|
51
|
+
PayloadTooLargeError: 413, UnsupportedMediaTypeError: 415, TooManyRequestsError: 429,
|
|
52
|
+
};
|
|
35
53
|
const OPENAPI_EMPTY_TYPE_NAMES = new Set(["HttpResult", "Response", "Blob", "ReadableStream", "Uint8Array"]);
|
|
36
54
|
const OPENAPI_BUILTIN_TYPE_NAMES = new Set([
|
|
37
55
|
"Array",
|
|
@@ -77,7 +95,8 @@ export function createOpenApiCodegenAnalyzer(input: OpenApiCodegenAnalyzerInput)
|
|
|
77
95
|
const baseSchema = typeNode ? schemaFromTypeNode(typeNode) : schemaFromInitializer(schemaInitializer(member));
|
|
78
96
|
const validators = validatorOptionsOf(member);
|
|
79
97
|
const schema = validators.reduce((current, options) => applyValidatorOptions(current, options), baseSchema);
|
|
80
|
-
|
|
98
|
+
const description = documentationOf(member.name);
|
|
99
|
+
properties[name] = description === undefined ? schema : { ...schema, description };
|
|
81
100
|
if (isRequiredProperty(member, ownerKind, validators)) {
|
|
82
101
|
required.push(name);
|
|
83
102
|
}
|
|
@@ -89,12 +108,95 @@ export function createOpenApiCodegenAnalyzer(input: OpenApiCodegenAnalyzerInput)
|
|
|
89
108
|
return schema;
|
|
90
109
|
};
|
|
91
110
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
/** JSDoc text of a declaration name, without tags; undefined when there is none. */
|
|
112
|
+
const documentationOf = (name: ts.Node): string | undefined => {
|
|
113
|
+
const symbol = checker.getSymbolAtLocation(name);
|
|
114
|
+
const text = symbol === undefined ? "" : ts.displayPartsToString(symbol.getDocumentationComment(checker)).trim();
|
|
115
|
+
return text.length > 0 ? text : undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const operationFromMethod = (method: ts.MethodDeclaration): OpenApiCodegenOperationSpec => {
|
|
119
|
+
const response = responseSchemaFromMethod(method);
|
|
120
|
+
const { status, errors } = statusesFromMethod(method);
|
|
121
|
+
const documentation = documentationOf(method.name);
|
|
122
|
+
const [summary, ...rest] = documentation?.split(/\r?\n/) ?? [];
|
|
123
|
+
const description = rest.join("\n").trim();
|
|
124
|
+
return {
|
|
125
|
+
...(response !== undefined ? { response } : {}),
|
|
126
|
+
...(status !== undefined ? { status } : {}),
|
|
127
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
128
|
+
...(summary !== undefined && summary.trim().length > 0 ? { summary: summary.trim() } : {}),
|
|
129
|
+
...(description.length > 0 ? { description } : {}),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Success and error statuses visible in the method body: result helpers in
|
|
135
|
+
* `return` statements and `throw new <HttpError subclass>`. Errors thrown
|
|
136
|
+
* by called services are not visible here.
|
|
137
|
+
*/
|
|
138
|
+
const statusesFromMethod = (method: ts.MethodDeclaration): { status?: number; errors: number[] } => {
|
|
139
|
+
const success = new Set<number>();
|
|
140
|
+
const errors = new Set<number>();
|
|
141
|
+
const addReturn = (expression: ts.Expression): void => {
|
|
142
|
+
const unwrapped = unwrapExpression(expression);
|
|
143
|
+
if (ts.isConditionalExpression(unwrapped)) {
|
|
144
|
+
addReturn(unwrapped.whenTrue);
|
|
145
|
+
addReturn(unwrapped.whenFalse);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const name = ts.isCallExpression(unwrapped) ? callExpressionName(unwrapped) : undefined;
|
|
149
|
+
if (name !== undefined && SUCCESS_HELPER_STATUS[name] !== undefined) {
|
|
150
|
+
success.add(SUCCESS_HELPER_STATUS[name]);
|
|
151
|
+
} else if (name !== undefined && ERROR_HELPER_STATUS[name] !== undefined) {
|
|
152
|
+
errors.add(ERROR_HELPER_STATUS[name]);
|
|
153
|
+
} else if (name === "StatusCode" && ts.isCallExpression(unwrapped)) {
|
|
154
|
+
const status = numericLiteralValue(unwrapped.arguments[0]);
|
|
155
|
+
if (status === undefined) success.add(-1);
|
|
156
|
+
else if (status >= 400 && status < 500) errors.add(status);
|
|
157
|
+
else if (status >= 200 && status < 300) success.add(status);
|
|
158
|
+
} else if (name === undefined || !EMPTY_SUCCESS_RESULT_HELPERS.has(name) && !ERROR_RESULT_HELPERS.has(name)) {
|
|
159
|
+
success.add(200);
|
|
160
|
+
} else {
|
|
161
|
+
success.add(-1);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const visit = (node: ts.Node): void => {
|
|
165
|
+
if (node !== method.body && isNestedFunctionLike(node)) return;
|
|
166
|
+
if (ts.isReturnStatement(node)) {
|
|
167
|
+
if (node.expression !== undefined) addReturn(node.expression);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (ts.isThrowStatement(node) && ts.isNewExpression(unwrapExpression(node.expression))) {
|
|
171
|
+
const status = httpErrorStatus(checker.getTypeAtLocation(node.expression));
|
|
172
|
+
if (status !== undefined) errors.add(status);
|
|
173
|
+
}
|
|
174
|
+
ts.forEachChild(node, visit);
|
|
175
|
+
};
|
|
176
|
+
if (method.body !== undefined) ts.forEachChild(method.body, visit);
|
|
177
|
+
const [only] = success;
|
|
178
|
+
return {
|
|
179
|
+
...(success.size === 1 && only !== undefined && only !== 200 && only !== -1 ? { status: only } : {}),
|
|
180
|
+
errors: [...errors].sort((a, b) => a - b),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** Status of an HttpError subclass by its class chain (`class TaskNotFound extends NotFoundError`). */
|
|
185
|
+
const httpErrorStatus = (type: ts.Type): number | undefined => {
|
|
186
|
+
let current: ts.Type | undefined = type;
|
|
187
|
+
for (let depth = 0; current !== undefined && depth < 16; depth += 1) {
|
|
188
|
+
const name = current.getSymbol()?.getName();
|
|
189
|
+
if (name !== undefined && ERROR_CLASS_STATUS[name] !== undefined) return ERROR_CLASS_STATUS[name];
|
|
190
|
+
current = current.isClass() ? checker.getBaseTypes(current)[0] : undefined;
|
|
96
191
|
}
|
|
97
|
-
return
|
|
192
|
+
return undefined;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const responseSchemaFromMethod = (method: ts.MethodDeclaration): OpenApiCodegenSchema | undefined => {
|
|
196
|
+
// A declared return type is the contract; result-helper types
|
|
197
|
+
// (HttpResult, Response) carry no schema and fall back to the returns.
|
|
198
|
+
const declared = method.type ? responseSchemaFromType(method.type) : undefined;
|
|
199
|
+
return declared ?? responseSchemaFromReturnExpressions(method);
|
|
98
200
|
};
|
|
99
201
|
|
|
100
202
|
const schemaFromTypeNode = (node: ts.TypeNode): OpenApiCodegenSchema => {
|
|
@@ -418,21 +520,28 @@ export function createOpenApiCodegenAnalyzer(input: OpenApiCodegenAnalyzerInput)
|
|
|
418
520
|
return {
|
|
419
521
|
schemaFromMembers,
|
|
420
522
|
schemaFromDeclaration(declaration) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
424
|
-
// Effective instance properties include imported and indirect base classes.
|
|
425
|
-
// Keep original property declarations so their Validator rules remain visible.
|
|
426
|
-
const properties = checker.getPropertiesOfType(checker.getTypeAtLocation(declaration));
|
|
427
|
-
const members = properties.flatMap(property => {
|
|
428
|
-
const member = property.valueDeclaration ?? property.declarations?.[0];
|
|
429
|
-
return member !== undefined && (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) ? [member] : [];
|
|
430
|
-
});
|
|
431
|
-
return schemaFromMembers(members, ts.isClassDeclaration(declaration) ? "class" : "interface");
|
|
523
|
+
const schema = schemaFromDeclarationMembers(declaration);
|
|
524
|
+
const description = declaration.name === undefined ? undefined : documentationOf(declaration.name);
|
|
525
|
+
return description === undefined ? schema : { description, ...schema };
|
|
432
526
|
},
|
|
433
527
|
schemaNameForDeclaration,
|
|
434
528
|
responseSchemaFromMethod,
|
|
529
|
+
operationFromMethod,
|
|
435
530
|
};
|
|
531
|
+
|
|
532
|
+
function schemaFromDeclarationMembers(declaration: ts.ClassDeclaration | ts.InterfaceDeclaration): OpenApiCodegenSchema {
|
|
533
|
+
if (!declaration.heritageClauses?.some(clause => clause.token === ts.SyntaxKind.ExtendsKeyword)) {
|
|
534
|
+
return schemaFromMembers(declaration.members, ts.isClassDeclaration(declaration) ? "class" : "interface");
|
|
535
|
+
}
|
|
536
|
+
// Effective instance properties include imported and indirect base classes.
|
|
537
|
+
// Keep original property declarations so their Validator rules remain visible.
|
|
538
|
+
const properties = checker.getPropertiesOfType(checker.getTypeAtLocation(declaration));
|
|
539
|
+
const members = properties.flatMap(property => {
|
|
540
|
+
const member = property.valueDeclaration ?? property.declarations?.[0];
|
|
541
|
+
return member !== undefined && (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) ? [member] : [];
|
|
542
|
+
});
|
|
543
|
+
return schemaFromMembers(members, ts.isClassDeclaration(declaration) ? "class" : "interface");
|
|
544
|
+
}
|
|
436
545
|
}
|
|
437
546
|
|
|
438
547
|
function collectSchemaNameIndex(sourceFiles: readonly ts.SourceFile[]): Map<string, Set<string>> {
|
|
@@ -4,6 +4,7 @@ type OpenApiOperation = {
|
|
|
4
4
|
tags: string[];
|
|
5
5
|
operationId: string;
|
|
6
6
|
summary: string;
|
|
7
|
+
description?: string;
|
|
7
8
|
parameters?: OpenApiParameter[];
|
|
8
9
|
requestBody?: OpenApiSchema;
|
|
9
10
|
responses: Record<string, OpenApiSchema>;
|
|
@@ -152,6 +153,9 @@ function operationFromCatalog(item: OpenApiCatalogOperation): OpenApiOperation {
|
|
|
152
153
|
summary: item.summary ?? `${normalizedMethod(item.httpMethod)} ${item.path}`,
|
|
153
154
|
responses: { ...item.responses },
|
|
154
155
|
};
|
|
156
|
+
if (item.description !== undefined) {
|
|
157
|
+
operation.description = item.description;
|
|
158
|
+
}
|
|
155
159
|
const parameters = mergeParameters(item.parameters ?? []);
|
|
156
160
|
if (parameters.length > 0) {
|
|
157
161
|
operation.parameters = parameters;
|
package/library/openapi/types.ts
CHANGED
|
@@ -2,6 +2,14 @@ export type OpenApiSchema = Readonly<Record<string, unknown>>;
|
|
|
2
2
|
|
|
3
3
|
export interface GeneratedOpenApiOperationMetadata {
|
|
4
4
|
readonly response?: OpenApiSchema;
|
|
5
|
+
/** Success status from the method's result helpers (`Created` -> 201). */
|
|
6
|
+
readonly status?: number;
|
|
7
|
+
/** Error statuses returned or thrown in the method body. */
|
|
8
|
+
readonly errors?: readonly number[];
|
|
9
|
+
/** First line of the method's JSDoc. */
|
|
10
|
+
readonly summary?: string;
|
|
11
|
+
/** The rest of the method's JSDoc. */
|
|
12
|
+
readonly description?: string;
|
|
5
13
|
}
|
|
6
14
|
|
|
7
15
|
export interface GeneratedOpenApiMetadata {
|
|
@@ -53,6 +61,7 @@ export interface OpenApiCatalogOperation {
|
|
|
53
61
|
readonly tag: string;
|
|
54
62
|
readonly operationId: string;
|
|
55
63
|
readonly summary?: string;
|
|
64
|
+
readonly description?: string;
|
|
56
65
|
readonly parameters?: readonly OpenApiParameter[];
|
|
57
66
|
readonly requestBody?: OpenApiSchema;
|
|
58
67
|
readonly responses: Readonly<Record<string, OpenApiSchema>>;
|
package/library/openapi/ui.ts
CHANGED
|
@@ -148,7 +148,7 @@ export function renderOpenApiHtml(title: string, specPath: string): string {
|
|
|
148
148
|
method.textContent = row.method;
|
|
149
149
|
const summary = document.createElement("span");
|
|
150
150
|
summary.className = "summary";
|
|
151
|
-
summary.textContent = row.operation.
|
|
151
|
+
summary.textContent = row.operation.summary || row.operation.operationId || "";
|
|
152
152
|
line.append(method, summary);
|
|
153
153
|
node.appendChild(line);
|
|
154
154
|
const meta = document.createElement("div");
|
package/package.json
CHANGED