@znight/openapi 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +572 -2
- package/dist/build-BTUC96mY.d.mts +174 -0
- package/dist/build-DXunOzlI.mjs +733 -0
- package/dist/build-DXunOzlI.mjs.map +1 -0
- package/dist/compiler.d.mts +16 -0
- package/dist/compiler.mjs +2639 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/emit-child.d.mts +1 -0
- package/dist/emit-child.mjs +124 -0
- package/dist/emit-child.mjs.map +1 -0
- package/dist/emit.d.mts +2 -0
- package/dist/emit.mjs +252 -0
- package/dist/emit.mjs.map +1 -0
- package/dist/index-CqQ6IRnN.d.mts +60 -0
- package/dist/index.d.mts +260 -0
- package/dist/index.mjs +777 -0
- package/dist/index.mjs.map +1 -0
- package/dist/openapi-CQk5dHqc.mjs +39 -0
- package/dist/openapi-CQk5dHqc.mjs.map +1 -0
- package/dist/shared-BZxtyaB0.mjs +147 -0
- package/dist/shared-BZxtyaB0.mjs.map +1 -0
- package/dist/testing.d.mts +39 -0
- package/dist/testing.mjs +155 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-VzBexMKj.d.mts +213 -0
- package/dist/vite.d.mts +24 -0
- package/dist/vite.mjs +64 -0
- package/dist/vite.mjs.map +1 -0
- package/package.json +115 -4
- package/src/build.ts +898 -0
- package/src/compiler/analyze.ts +81 -0
- package/src/compiler/document.ts +612 -0
- package/src/compiler/generate.ts +79 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/model.ts +141 -0
- package/src/compiler/operations.ts +1475 -0
- package/src/compiler/schemas.ts +706 -0
- package/src/compiler/validate.ts +102 -0
- package/src/compiler/values.ts +81 -0
- package/src/converters.ts +137 -0
- package/src/decorators.ts +187 -0
- package/src/emit/child.ts +144 -0
- package/src/emit/index.ts +377 -0
- package/src/emit/protocol.ts +35 -0
- package/src/index.ts +94 -0
- package/src/metadata.ts +112 -0
- package/src/openapi.ts +55 -0
- package/src/plugin.ts +810 -0
- package/src/shared.ts +154 -0
- package/src/testing.ts +224 -0
- package/src/types.ts +240 -0
- package/src/ui.ts +215 -0
- package/src/vite.ts +126 -0
|
@@ -0,0 +1,2639 @@
|
|
|
1
|
+
import { a as METHOD_ORDER, c as canonicalJson, d as isResponseStatus, i as MEDIA_TYPE, l as compareStrings, m as schemaRef, n as ERROR_RESPONSES, o as OPENAPI_VERSION, p as reasonPhrase, r as HEADER_NAME, s as OPERATION_ID, t as ERROR_COMPONENTS, u as componentName } from "./shared-BZxtyaB0.mjs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
// src/compiler/model.ts
|
|
4
|
+
/** Name of the extension (key of `metadata.extensions`). */
|
|
5
|
+
const EXTENSION_NAME = "openapi";
|
|
6
|
+
/** Canonical identities of the decorators and functions of `@znight/openapi`. */
|
|
7
|
+
const Ids = Object.freeze({
|
|
8
|
+
OpenApiDocument: "@znight/openapi#OpenApiDocument",
|
|
9
|
+
ApiOperation: "@znight/openapi#ApiOperation",
|
|
10
|
+
ApiTags: "@znight/openapi#ApiTags",
|
|
11
|
+
ApiResponse: "@znight/openapi#ApiResponse",
|
|
12
|
+
ApiBody: "@znight/openapi#ApiBody",
|
|
13
|
+
ApiQuery: "@znight/openapi#ApiQuery",
|
|
14
|
+
ApiHeader: "@znight/openapi#ApiHeader",
|
|
15
|
+
ApiSecurity: "@znight/openapi#ApiSecurity",
|
|
16
|
+
ApiExclude: "@znight/openapi#ApiExclude",
|
|
17
|
+
securityScheme: "@znight/openapi#securityScheme"
|
|
18
|
+
});
|
|
19
|
+
/** Identities of `@znight/http` read by the extension. */
|
|
20
|
+
const HttpIds = Object.freeze({
|
|
21
|
+
Controller: "@znight/http#Controller",
|
|
22
|
+
createRouteDecorator: "@znight/http#createRouteDecorator",
|
|
23
|
+
HttpResponse: "@znight/http#HttpResponse",
|
|
24
|
+
FileBody: "@znight/http#FileBody",
|
|
25
|
+
SseEvent: "@znight/http#SseEvent",
|
|
26
|
+
SseBody: "@znight/http#SseBody"
|
|
27
|
+
});
|
|
28
|
+
/** Route method decorators of `@znight/http` (a method carrying one is a route handler). */
|
|
29
|
+
const ROUTE_DECORATORS = /* @__PURE__ */ new Set([
|
|
30
|
+
"@znight/http#Get",
|
|
31
|
+
"@znight/http#Post",
|
|
32
|
+
"@znight/http#Put",
|
|
33
|
+
"@znight/http#Patch",
|
|
34
|
+
"@znight/http#Delete",
|
|
35
|
+
"@znight/http#Head",
|
|
36
|
+
"@znight/http#Options",
|
|
37
|
+
"@znight/http#All",
|
|
38
|
+
"@znight/http#Sse"
|
|
39
|
+
]);
|
|
40
|
+
/** Error codes used by the extension (see `ErrorCode` in `@znight/core`). */
|
|
41
|
+
const Codes = Object.freeze({
|
|
42
|
+
InvalidDecorator: "ZNIGHT005",
|
|
43
|
+
InvalidOpenApiDefinition: "ZNIGHT030",
|
|
44
|
+
UndocumentedResponse: "ZNIGHT031",
|
|
45
|
+
UnrepresentableType: "ZNIGHT032",
|
|
46
|
+
DuplicateOperationId: "ZNIGHT033",
|
|
47
|
+
SchemaNameConflict: "ZNIGHT034",
|
|
48
|
+
InconsistentOperation: "ZNIGHT035",
|
|
49
|
+
InvalidSecurityRequirement: "ZNIGHT036"
|
|
50
|
+
});
|
|
51
|
+
/** Generic aliases meaning "the Standard Schema S" (`z.output<typeof S>`), checked against the installed libraries. */
|
|
52
|
+
const SCHEMA_ALIASES = Object.freeze({
|
|
53
|
+
"zod#output": "output",
|
|
54
|
+
"zod#infer": "output",
|
|
55
|
+
"zod#input": "input",
|
|
56
|
+
"zod/v4#output": "output",
|
|
57
|
+
"zod/v4#infer": "output",
|
|
58
|
+
"zod/v4#input": "input",
|
|
59
|
+
"valibot#InferOutput": "output",
|
|
60
|
+
"valibot#InferInput": "input",
|
|
61
|
+
"@znight/http#InferSchemaOutput": "output",
|
|
62
|
+
"@znight/http#InferSchemaInput": "input",
|
|
63
|
+
"@standard-schema/spec#StandardSchemaV1.InferOutput": "output",
|
|
64
|
+
"@standard-schema/spec#StandardSchemaV1.InferInput": "input"
|
|
65
|
+
});
|
|
66
|
+
/** ArkType: `typeof S.infer` / `typeof S.inferIn`. */
|
|
67
|
+
const SCHEMA_MEMBERS = Object.freeze({
|
|
68
|
+
infer: "output",
|
|
69
|
+
inferIn: "input"
|
|
70
|
+
});
|
|
71
|
+
/** Types kept as references by the shape resolver (bodies of special responses). */
|
|
72
|
+
const KEEP = Object.freeze([
|
|
73
|
+
HttpIds.HttpResponse,
|
|
74
|
+
HttpIds.FileBody,
|
|
75
|
+
HttpIds.SseEvent,
|
|
76
|
+
HttpIds.SseBody,
|
|
77
|
+
"globalThis#AsyncIterable",
|
|
78
|
+
"globalThis#AsyncIterableIterator",
|
|
79
|
+
"globalThis#AsyncGenerator",
|
|
80
|
+
"globalThis#Response"
|
|
81
|
+
]);
|
|
82
|
+
// src/compiler/analyze.ts
|
|
83
|
+
/** `analyzeClass` of the extension. */
|
|
84
|
+
function analyzeOpenApiClass(cls, ctx) {
|
|
85
|
+
const owned = (decorators) => decorators.filter((decorator) => decorator.owner === EXTENSION_NAME);
|
|
86
|
+
const document = cls.decorators.find((decorator) => decorator.identity === Ids.OpenApiDocument);
|
|
87
|
+
const controller = cls.decorators.some((decorator) => decorator.identity === HttpIds.Controller);
|
|
88
|
+
const members = [...cls.methods.map((method) => ({
|
|
89
|
+
decorators: owned(method.decorators),
|
|
90
|
+
method
|
|
91
|
+
})), ...cls.properties.map((property) => ({
|
|
92
|
+
decorators: owned(property.decorators),
|
|
93
|
+
method: void 0
|
|
94
|
+
}))];
|
|
95
|
+
const misplaced = (decorator, message, hints) => {
|
|
96
|
+
ctx.report({
|
|
97
|
+
code: Codes.InvalidOpenApiDefinition,
|
|
98
|
+
severity: "error",
|
|
99
|
+
message,
|
|
100
|
+
location: decorator.location,
|
|
101
|
+
hints
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
if (document !== void 0) {
|
|
105
|
+
if (controller) misplaced(document, `@OpenApiDocument goes on a class of its own, not on the controller ${cls.displayName}`, ["Declare it on an empty class: @OpenApiDocument({...}) export class Api {}"]);
|
|
106
|
+
for (const decorator of owned(cls.decorators)) if (decorator !== document && !controller) misplaced(decorator, `@${decorator.name} applies to controllers and route handlers, not to the @OpenApiDocument class`, []);
|
|
107
|
+
ctx.store({
|
|
108
|
+
kind: "document",
|
|
109
|
+
identity: cls.identity,
|
|
110
|
+
displayName: cls.displayName,
|
|
111
|
+
location: document.location
|
|
112
|
+
});
|
|
113
|
+
if (!controller) return;
|
|
114
|
+
}
|
|
115
|
+
if (!controller) {
|
|
116
|
+
const hints = cls.decorators.some((decorator) => decorator.identity === "@znight/messaging#Consumer") ? ["Message consumers are not documented by OpenAPI (AsyncAPI is not supported in v0.1)"] : ["Add @Controller() to the class, or remove the decorator"];
|
|
117
|
+
for (const decorator of owned(cls.decorators)) misplaced(decorator, `@${decorator.name} applies to controllers: ${cls.displayName} is not a @Controller`, hints);
|
|
118
|
+
for (const member of members) for (const decorator of member.decorators) misplaced(decorator, `@${decorator.name} applies to route handlers: ${cls.displayName} is not a @Controller`, hints);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const member of members) {
|
|
122
|
+
if (member.method?.decorators.some((decorator) => decorator.identity !== void 0 && ROUTE_DECORATORS.has(decorator.identity)) === true) continue;
|
|
123
|
+
for (const decorator of member.decorators) misplaced(decorator, `@${decorator.name} applies to route handlers: ${cls.displayName}.${member.method?.name ?? "this member"} has no route decorator (@Get, @Post...)`, ["Add a route decorator, or move the decorator to a route handler"]);
|
|
124
|
+
}
|
|
125
|
+
ctx.store({
|
|
126
|
+
kind: "controller",
|
|
127
|
+
identity: cls.identity,
|
|
128
|
+
displayName: cls.displayName
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// src/compiler/values.ts
|
|
132
|
+
/** `true` for a `ValueRef` (a reference to an exported binding, nested in an evaluated value). */
|
|
133
|
+
function isRef(value) {
|
|
134
|
+
if (typeof value !== "object" || value === null) return false;
|
|
135
|
+
const prototype = Object.getPrototypeOf(value);
|
|
136
|
+
return prototype !== Object.prototype && prototype !== null && value.kind === "ref";
|
|
137
|
+
}
|
|
138
|
+
/** `true` for an evaluated object literal (not an array, not a reference). */
|
|
139
|
+
function isRecord(value) {
|
|
140
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !isRef(value);
|
|
141
|
+
}
|
|
142
|
+
/** The value when it is JSON (no `undefined` inside objects is kept, no bigint, no reference). */
|
|
143
|
+
function toJson(value) {
|
|
144
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
145
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
146
|
+
if (value === void 0 || typeof value === "bigint" || isRef(value)) return void 0;
|
|
147
|
+
if (Array.isArray(value)) {
|
|
148
|
+
const items = [];
|
|
149
|
+
for (const item of value) {
|
|
150
|
+
const json = toJson(item);
|
|
151
|
+
if (json === void 0) return void 0;
|
|
152
|
+
items.push(json);
|
|
153
|
+
}
|
|
154
|
+
return items;
|
|
155
|
+
}
|
|
156
|
+
const result = {};
|
|
157
|
+
for (const [key, item] of Object.entries(value)) {
|
|
158
|
+
if (item === void 0) continue;
|
|
159
|
+
const json = toJson(item);
|
|
160
|
+
if (json === void 0) return void 0;
|
|
161
|
+
result[key] = json;
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
/** The first `$znight` key found in a value (reserved), as a path (`info.x.$znight`). */
|
|
166
|
+
function findReservedKey(value, path = "") {
|
|
167
|
+
if (Array.isArray(value)) {
|
|
168
|
+
for (const [index, item] of value.entries()) {
|
|
169
|
+
const found = findReservedKey(item, `${path}[${index}]`);
|
|
170
|
+
if (found !== void 0) return found;
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!isRecord(value)) return void 0;
|
|
175
|
+
for (const [key, item] of Object.entries(value)) {
|
|
176
|
+
const where = path === "" ? key : `${path}.${key}`;
|
|
177
|
+
if (key === "$znight") return where;
|
|
178
|
+
const found = findReservedKey(item, where);
|
|
179
|
+
if (found !== void 0) return found;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** A short description for messages. */
|
|
183
|
+
function describe(value) {
|
|
184
|
+
if (value === null) return "null";
|
|
185
|
+
if (Array.isArray(value)) return "an array";
|
|
186
|
+
if (isRef(value)) return `a reference to '${value.text}'`;
|
|
187
|
+
if (typeof value === "object") return "an object";
|
|
188
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
189
|
+
return String(value);
|
|
190
|
+
}
|
|
191
|
+
/** Absolute URL (`https://...`, `mailto:`...). */
|
|
192
|
+
function isAbsoluteUrl(value) {
|
|
193
|
+
return URL.canParse(value);
|
|
194
|
+
}
|
|
195
|
+
/** Absolute or relative URL (servers: `/api`, `https://api.example.com/v1`). */
|
|
196
|
+
function isUrlReference(value) {
|
|
197
|
+
return value !== "" && !/\s/.test(value) && (URL.canParse(value) || URL.canParse(value, "https://example.com"));
|
|
198
|
+
}
|
|
199
|
+
// src/compiler/document.ts
|
|
200
|
+
const OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
201
|
+
"info",
|
|
202
|
+
"servers",
|
|
203
|
+
"tags",
|
|
204
|
+
"externalDocs",
|
|
205
|
+
"security",
|
|
206
|
+
"decisions",
|
|
207
|
+
"operationIds",
|
|
208
|
+
"errors",
|
|
209
|
+
"strict",
|
|
210
|
+
"jsdoc",
|
|
211
|
+
"extensions"
|
|
212
|
+
]);
|
|
213
|
+
const INFO_KEYS = /* @__PURE__ */ new Set([
|
|
214
|
+
"title",
|
|
215
|
+
"version",
|
|
216
|
+
"summary",
|
|
217
|
+
"description",
|
|
218
|
+
"termsOfService",
|
|
219
|
+
"contact",
|
|
220
|
+
"license"
|
|
221
|
+
]);
|
|
222
|
+
const SPDX = /^[A-Za-z0-9.+-]+(?:\s+(?:AND|OR|WITH)\s+[A-Za-z0-9.+-]+)*$/;
|
|
223
|
+
/** Security schemes declared with `securityScheme(name, definition)`, by identity of their `const`. */
|
|
224
|
+
var SchemeRegistry = class {
|
|
225
|
+
#ctx;
|
|
226
|
+
#schemes = /* @__PURE__ */ new Map();
|
|
227
|
+
#byName = /* @__PURE__ */ new Map();
|
|
228
|
+
#definitions;
|
|
229
|
+
/** Schemes referenced by the document, by name. */
|
|
230
|
+
used = /* @__PURE__ */ new Map();
|
|
231
|
+
constructor(ctx) {
|
|
232
|
+
this.#ctx = ctx;
|
|
233
|
+
this.#definitions = new Map(ctx.definitionsOf(Ids.securityScheme).map((definition) => [definition.identity, definition]));
|
|
234
|
+
}
|
|
235
|
+
/** The scheme a reference points to (`undefined` and a ZNIGHT036 when it is not a scheme). */
|
|
236
|
+
resolve(ref) {
|
|
237
|
+
const identity = ref.identity;
|
|
238
|
+
if (identity !== void 0 && this.#schemes.has(identity)) return this.#schemes.get(identity) ?? void 0;
|
|
239
|
+
const definition = identity === void 0 ? void 0 : this.#definitions.get(identity);
|
|
240
|
+
if (definition === void 0 || identity === void 0) {
|
|
241
|
+
this.#ctx.report({
|
|
242
|
+
code: Codes.InvalidSecurityRequirement,
|
|
243
|
+
severity: "error",
|
|
244
|
+
message: `${ref.text} is not a security scheme declared with securityScheme()`,
|
|
245
|
+
location: ref.location,
|
|
246
|
+
hints: ["Declare it: export const ApiKey = securityScheme('apiKey', { type: 'apiKey', in: 'header', name: 'x-api-key' })"]
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const scheme = this.#parse(definition);
|
|
251
|
+
this.#schemes.set(identity, scheme ?? null);
|
|
252
|
+
return scheme;
|
|
253
|
+
}
|
|
254
|
+
#parse(definition) {
|
|
255
|
+
const [nameArg, definitionArg] = definition.args;
|
|
256
|
+
const name = nameArg?.evaluated ? nameArg.value : void 0;
|
|
257
|
+
const value = definitionArg?.evaluated ? definitionArg.value : void 0;
|
|
258
|
+
const malformed = (message, location = definition.call) => {
|
|
259
|
+
this.#ctx.report({
|
|
260
|
+
code: Codes.InvalidOpenApiDefinition,
|
|
261
|
+
severity: "error",
|
|
262
|
+
message: `Invalid security scheme ${definition.displayName}: ${message}`,
|
|
263
|
+
location,
|
|
264
|
+
hints: ["securityScheme(name, definition) takes a static name and an OpenAPI Security Scheme Object"]
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
if (typeof name !== "string" || !/^[A-Za-z0-9._-]+$/.test(name)) return malformed("the name must be a static string of [A-Za-z0-9._-]", nameArg?.location);
|
|
268
|
+
if (!isRecord(value)) return malformed("the definition must be a static object", definitionArg?.location);
|
|
269
|
+
const reserved = findReservedKey(value);
|
|
270
|
+
if (reserved !== void 0) return malformed(`"$znight" is a reserved key (${reserved})`, definitionArg?.location);
|
|
271
|
+
const json = toJson(value);
|
|
272
|
+
if (!isRecord(json)) return malformed("the definition must be JSON", definitionArg?.location);
|
|
273
|
+
const where = definitionArg?.location;
|
|
274
|
+
const text = (key, required) => {
|
|
275
|
+
const field = json[key];
|
|
276
|
+
if (field === void 0 && !required) return void 0;
|
|
277
|
+
if (typeof field !== "string" || field === "") throw new SchemeError(`"${key}" must be a non-empty string`);
|
|
278
|
+
return field;
|
|
279
|
+
};
|
|
280
|
+
let scopes;
|
|
281
|
+
try {
|
|
282
|
+
for (const key of Object.keys(json)) if (!ALLOWED_SCHEME_KEYS[String(json["type"])]?.has(key) && !key.startsWith("x-") && key !== "description") throw new SchemeError(`unknown field "${key}" for a ${String(json["type"])} scheme`);
|
|
283
|
+
switch (json["type"]) {
|
|
284
|
+
case "apiKey": {
|
|
285
|
+
text("name", true);
|
|
286
|
+
const location = text("in", true);
|
|
287
|
+
if (location !== "query" && location !== "header" && location !== "cookie") throw new SchemeError("\"in\" must be \"query\", \"header\" or \"cookie\"");
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case "http":
|
|
291
|
+
text("scheme", true);
|
|
292
|
+
text("bearerFormat", false);
|
|
293
|
+
break;
|
|
294
|
+
case "oauth2": {
|
|
295
|
+
const flows = json["flows"];
|
|
296
|
+
if (!isRecord(flows) || Object.keys(flows).length === 0) throw new SchemeError("\"flows\" must declare a flow");
|
|
297
|
+
scopes = /* @__PURE__ */ new Set();
|
|
298
|
+
for (const [flow, body] of Object.entries(flows)) {
|
|
299
|
+
if (!FLOW_URLS[flow]) throw new SchemeError(`unknown flow "${flow}"`);
|
|
300
|
+
if (!isRecord(body) || !isRecord(body["scopes"])) throw new SchemeError(`the ${flow} flow needs "scopes"`);
|
|
301
|
+
for (const url of FLOW_URLS[flow] ?? []) {
|
|
302
|
+
const field = body[url];
|
|
303
|
+
if (typeof field !== "string" || !isUrlReference(field)) throw new SchemeError(`the ${flow} flow needs a "${url}" URL`);
|
|
304
|
+
}
|
|
305
|
+
for (const scope of Object.keys(body["scopes"])) scopes.add(scope);
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case "openIdConnect":
|
|
310
|
+
if (!isUrlReference(text("openIdConnectUrl", true))) throw new SchemeError("\"openIdConnectUrl\" must be a URL");
|
|
311
|
+
break;
|
|
312
|
+
case "mutualTLS": break;
|
|
313
|
+
default: throw new SchemeError("\"type\" must be \"apiKey\", \"http\", \"oauth2\", \"openIdConnect\" or \"mutualTLS\"");
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error instanceof SchemeError) return malformed(error.message, where);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
const scheme = {
|
|
320
|
+
identity: definition.identity,
|
|
321
|
+
name,
|
|
322
|
+
definition: json,
|
|
323
|
+
location: definition.location,
|
|
324
|
+
scopes,
|
|
325
|
+
scoped: json["type"] === "oauth2" || json["type"] === "openIdConnect"
|
|
326
|
+
};
|
|
327
|
+
const other = this.#byName.get(name);
|
|
328
|
+
if (other !== void 0 && JSON.stringify(other.definition) !== JSON.stringify(scheme.definition)) {
|
|
329
|
+
this.#ctx.report({
|
|
330
|
+
code: Codes.InvalidSecurityRequirement,
|
|
331
|
+
severity: "error",
|
|
332
|
+
message: `Two security schemes are named "${name}" with different definitions: ${definition.displayName} and ${other.identity}`,
|
|
333
|
+
location: definition.location,
|
|
334
|
+
related: [{
|
|
335
|
+
message: "the other scheme",
|
|
336
|
+
location: other.location
|
|
337
|
+
}],
|
|
338
|
+
hints: ["Give each scheme its own name, or reuse one declaration"]
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.#byName.set(name, scheme);
|
|
343
|
+
return scheme;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Resolves a requirement input (`ApiKey`, `[Bearer, Tenant]`, `{ scheme, scopes }`) to schemes.
|
|
347
|
+
* Reports ZNIGHT036 and returns `undefined` when it is invalid.
|
|
348
|
+
*/
|
|
349
|
+
requirement(value, location) {
|
|
350
|
+
const report = (message, hints = REQUIREMENT_HINTS) => {
|
|
351
|
+
this.#ctx.report({
|
|
352
|
+
code: Codes.InvalidSecurityRequirement,
|
|
353
|
+
severity: "error",
|
|
354
|
+
message,
|
|
355
|
+
location,
|
|
356
|
+
hints
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
const one = (item, scopes) => {
|
|
360
|
+
if (!isRef(item)) return report(`${describe(item)} is not a security scheme declared with securityScheme()`);
|
|
361
|
+
const scheme = this.resolve(item);
|
|
362
|
+
if (scheme === void 0) return void 0;
|
|
363
|
+
if (scopes !== void 0 && scopes.length > 0) {
|
|
364
|
+
if (!scheme.scoped) return report(`Scopes are only allowed on oauth2 and openIdConnect schemes, not on "${scheme.name}" (${String(scheme.definition.type)})`, ["Remove the scopes, or use an oauth2 / openIdConnect scheme"]);
|
|
365
|
+
const unknown = scheme.scopes === void 0 ? [] : scopes.filter((scope) => !scheme.scopes?.has(scope));
|
|
366
|
+
if (unknown.length > 0) return report(`The scope${unknown.length > 1 ? "s" : ""} ${unknown.map((scope) => `"${scope}"`).join(", ")} ${unknown.length > 1 ? "are" : "is"} not declared by the flows of "${scheme.name}"`, [`Declared scopes: ${[...scheme.scopes ?? []].join(", ") || "none"}`]);
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
scheme,
|
|
370
|
+
scopes
|
|
371
|
+
};
|
|
372
|
+
};
|
|
373
|
+
if (Array.isArray(value)) {
|
|
374
|
+
const items = [];
|
|
375
|
+
for (const item of value) {
|
|
376
|
+
const resolved = one(item, void 0);
|
|
377
|
+
if (resolved === void 0) return void 0;
|
|
378
|
+
items.push(resolved);
|
|
379
|
+
}
|
|
380
|
+
return items;
|
|
381
|
+
}
|
|
382
|
+
if (isRecord(value)) {
|
|
383
|
+
const scopes = value["scopes"];
|
|
384
|
+
if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) return report("{ scheme, scopes } needs a static array of scope names");
|
|
385
|
+
const resolved = one(value["scheme"], scopes);
|
|
386
|
+
return resolved === void 0 ? void 0 : [resolved];
|
|
387
|
+
}
|
|
388
|
+
const resolved = one(value, void 0);
|
|
389
|
+
return resolved === void 0 ? void 0 : [resolved];
|
|
390
|
+
}
|
|
391
|
+
/** Requirement objects of resolved requirements (`scopes` overrides every scheme's scopes). */
|
|
392
|
+
objects(requirements, scopes) {
|
|
393
|
+
return requirements.map((requirement) => {
|
|
394
|
+
const object = {};
|
|
395
|
+
for (const { scheme, scopes: own } of requirement) {
|
|
396
|
+
this.used.set(scheme.name, scheme);
|
|
397
|
+
object[scheme.name] = [...scopes ?? own ?? []];
|
|
398
|
+
}
|
|
399
|
+
return object;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
var SchemeError = class extends Error {};
|
|
404
|
+
const REQUIREMENT_HINTS = ["A requirement is a scheme (ApiKey), several schemes that all apply ([Bearer, Tenant]) or { scheme: OAuth, scopes: [...] }"];
|
|
405
|
+
const ALLOWED_SCHEME_KEYS = {
|
|
406
|
+
apiKey: /* @__PURE__ */ new Set([
|
|
407
|
+
"type",
|
|
408
|
+
"name",
|
|
409
|
+
"in"
|
|
410
|
+
]),
|
|
411
|
+
http: /* @__PURE__ */ new Set([
|
|
412
|
+
"type",
|
|
413
|
+
"scheme",
|
|
414
|
+
"bearerFormat"
|
|
415
|
+
]),
|
|
416
|
+
oauth2: /* @__PURE__ */ new Set(["type", "flows"]),
|
|
417
|
+
openIdConnect: /* @__PURE__ */ new Set(["type", "openIdConnectUrl"]),
|
|
418
|
+
mutualTLS: /* @__PURE__ */ new Set(["type"])
|
|
419
|
+
};
|
|
420
|
+
const FLOW_URLS = {
|
|
421
|
+
implicit: ["authorizationUrl"],
|
|
422
|
+
password: ["tokenUrl"],
|
|
423
|
+
clientCredentials: ["tokenUrl"],
|
|
424
|
+
authorizationCode: ["authorizationUrl", "tokenUrl"]
|
|
425
|
+
};
|
|
426
|
+
/** Reads and checks the `@OpenApiDocument` options (and the defaults of `package.json`). */
|
|
427
|
+
function readDocument(ctx, schemes) {
|
|
428
|
+
const documents = ctx.stored.filter((data) => data.kind === "document");
|
|
429
|
+
for (const extra of documents.slice(1)) {
|
|
430
|
+
const first = documents[0];
|
|
431
|
+
ctx.report({
|
|
432
|
+
code: Codes.InvalidOpenApiDefinition,
|
|
433
|
+
severity: "error",
|
|
434
|
+
message: `An application has one @OpenApiDocument: ${extra.displayName} and ${first?.displayName ?? "?"} both declare one`,
|
|
435
|
+
location: extra.location,
|
|
436
|
+
...first === void 0 ? {} : { related: [{
|
|
437
|
+
message: "the other @OpenApiDocument",
|
|
438
|
+
location: first.location
|
|
439
|
+
}] },
|
|
440
|
+
hints: ["Keep one class with @OpenApiDocument; filter operations with OpenApiPlugin.configure({ filter })"]
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const stored = documents[0];
|
|
444
|
+
const cls = stored === void 0 ? void 0 : ctx.classInfo(stored.identity);
|
|
445
|
+
const decorator = cls?.decorators.find((candidate) => candidate.identity === Ids.OpenApiDocument);
|
|
446
|
+
const arg = decorator?.args[0];
|
|
447
|
+
const value = arg?.evaluated === true ? arg.value : void 0;
|
|
448
|
+
const location = arg?.location ?? decorator?.location;
|
|
449
|
+
const invalid = (message, hints = []) => {
|
|
450
|
+
ctx.report({
|
|
451
|
+
code: Codes.InvalidOpenApiDefinition,
|
|
452
|
+
severity: "error",
|
|
453
|
+
message: `Invalid @OpenApiDocument options: ${message}`,
|
|
454
|
+
...location === void 0 ? {} : { location },
|
|
455
|
+
hints
|
|
456
|
+
});
|
|
457
|
+
};
|
|
458
|
+
const options = isRecord(value) ? value : {};
|
|
459
|
+
if (value !== void 0 && !isRecord(value)) invalid(`expected an object, received ${describe(value)}`);
|
|
460
|
+
for (const key of Object.keys(options)) if (!OPTION_KEYS.has(key)) invalid(`unknown option "${key}"`, [`Options: ${[...OPTION_KEYS].join(", ")}`]);
|
|
461
|
+
const reserved = findReservedKey(options);
|
|
462
|
+
if (reserved !== void 0) invalid(`"$znight" is a reserved key (${reserved})`);
|
|
463
|
+
const bool = (key, fallback) => {
|
|
464
|
+
const option = options[key];
|
|
465
|
+
if (option === void 0) return fallback;
|
|
466
|
+
if (typeof option !== "boolean") {
|
|
467
|
+
invalid(`"${key}" must be true or false`);
|
|
468
|
+
return fallback;
|
|
469
|
+
}
|
|
470
|
+
return option;
|
|
471
|
+
};
|
|
472
|
+
const json = (key) => {
|
|
473
|
+
const option = options[key];
|
|
474
|
+
if (option === void 0) return void 0;
|
|
475
|
+
const converted = toJson(option);
|
|
476
|
+
if (converted === void 0) invalid(`"${key}" must be a static JSON value`);
|
|
477
|
+
return converted;
|
|
478
|
+
};
|
|
479
|
+
const doc = bool("jsdoc", true) ? cls?.doc : void 0;
|
|
480
|
+
const manifest = ctx.project.rootPackage?.json ?? {};
|
|
481
|
+
const infoOption = json("info");
|
|
482
|
+
const info = {};
|
|
483
|
+
if (infoOption !== void 0 && !isRecord(infoOption)) invalid("\"info\" must be an object");
|
|
484
|
+
const given = isRecord(infoOption) ? infoOption : {};
|
|
485
|
+
for (const key of Object.keys(given)) if (!INFO_KEYS.has(key) && !key.startsWith("x-")) invalid(`unknown field "info.${key}"`);
|
|
486
|
+
const manifestString = (key) => {
|
|
487
|
+
const field = manifest[key];
|
|
488
|
+
return typeof field === "string" && field !== "" ? field : void 0;
|
|
489
|
+
};
|
|
490
|
+
const title = given["title"] ?? doc?.summary ?? manifestString("name") ?? "API";
|
|
491
|
+
const version = given["version"] ?? manifestString("version") ?? "0.0.0";
|
|
492
|
+
if (typeof title !== "string" || title === "") invalid("\"info.title\" must be a non-empty string");
|
|
493
|
+
if (typeof version !== "string" || version === "") invalid("\"info.version\" must be a non-empty string");
|
|
494
|
+
info["title"] = typeof title === "string" ? title : "API";
|
|
495
|
+
info["version"] = typeof version === "string" ? version : "0.0.0";
|
|
496
|
+
for (const key of [
|
|
497
|
+
"summary",
|
|
498
|
+
"termsOfService",
|
|
499
|
+
"contact",
|
|
500
|
+
"license"
|
|
501
|
+
]) if (given[key] !== void 0) info[key] = given[key];
|
|
502
|
+
const description = given["description"] ?? doc?.description ?? manifestString("description");
|
|
503
|
+
if (description !== void 0) info["description"] = description;
|
|
504
|
+
if (info["license"] === void 0) {
|
|
505
|
+
const license = manifestString("license");
|
|
506
|
+
if (license !== void 0 && SPDX.test(license) && license !== "UNLICENSED") info["license"] = {
|
|
507
|
+
name: license,
|
|
508
|
+
identifier: license
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
for (const [key, field] of Object.entries(given)) if (key.startsWith("x-")) info[key] = field;
|
|
512
|
+
checkInfo(info, invalid);
|
|
513
|
+
const servers = json("servers");
|
|
514
|
+
if (servers !== void 0) {
|
|
515
|
+
if (!Array.isArray(servers) || servers.some((server) => !isRecord(server) || !isUrlReference(String(server["url"])))) invalid("\"servers\" must be an array of { url, description?, variables? } with valid URLs");
|
|
516
|
+
}
|
|
517
|
+
const tagsOption = json("tags");
|
|
518
|
+
const tags = [];
|
|
519
|
+
if (tagsOption !== void 0) {
|
|
520
|
+
if (Array.isArray(tagsOption)) {
|
|
521
|
+
const seen = /* @__PURE__ */ new Set();
|
|
522
|
+
for (const tag of tagsOption) {
|
|
523
|
+
if (!isRecord(tag) || typeof tag["name"] !== "string" || tag["name"] === "") {
|
|
524
|
+
invalid("every tag needs a non-empty \"name\"");
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (seen.has(tag["name"])) invalid(`the tag "${tag["name"]}" is declared twice`);
|
|
528
|
+
seen.add(tag["name"]);
|
|
529
|
+
tags.push(tag);
|
|
530
|
+
}
|
|
531
|
+
} else invalid("\"tags\" must be an array of { name, description? }");
|
|
532
|
+
}
|
|
533
|
+
const externalDocs = json("externalDocs");
|
|
534
|
+
if (externalDocs !== void 0 && (!isRecord(externalDocs) || !isAbsoluteUrl(String(externalDocs["url"])))) invalid("\"externalDocs\" must be { url, description? } with an absolute URL");
|
|
535
|
+
const extensionsOption = json("extensions");
|
|
536
|
+
const extensions = {};
|
|
537
|
+
if (extensionsOption !== void 0) {
|
|
538
|
+
if (isRecord(extensionsOption)) for (const [key, field] of Object.entries(extensionsOption)) if (key.startsWith("x-")) extensions[key] = field;
|
|
539
|
+
else invalid(`the extension "${key}" must start with "x-"`);
|
|
540
|
+
else invalid("\"extensions\" must be an object of x-* fields");
|
|
541
|
+
}
|
|
542
|
+
const style = options["operationIds"] ?? "controller.handler";
|
|
543
|
+
if (style !== "controller.handler" && style !== "handler" && style !== "Controller_handler") invalid("\"operationIds\" must be \"controller.handler\", \"handler\" or \"Controller_handler\"");
|
|
544
|
+
let errors = {
|
|
545
|
+
contentType: "application/json",
|
|
546
|
+
schema: void 0
|
|
547
|
+
};
|
|
548
|
+
const errorsOption = options["errors"];
|
|
549
|
+
if (errorsOption === false) errors = false;
|
|
550
|
+
else if (isRecord(errorsOption)) {
|
|
551
|
+
const schema = errorsOption["schema"];
|
|
552
|
+
const contentType = errorsOption["contentType"] ?? "application/json";
|
|
553
|
+
if (!isRef(schema)) invalid("\"errors.schema\" must be a Standard Schema (an exported schema)");
|
|
554
|
+
if (typeof contentType !== "string") invalid("\"errors.contentType\" must be a media type");
|
|
555
|
+
errors = {
|
|
556
|
+
contentType: typeof contentType === "string" ? contentType : "application/json",
|
|
557
|
+
schema: isRef(schema) ? schema : void 0
|
|
558
|
+
};
|
|
559
|
+
} else if (errorsOption !== void 0 && errorsOption !== true) invalid("\"errors\" must be true, false or { schema, contentType? }");
|
|
560
|
+
const where = location ?? decorator?.location;
|
|
561
|
+
let security;
|
|
562
|
+
const securityOption = options["security"];
|
|
563
|
+
if (securityOption !== void 0 && where !== void 0) {
|
|
564
|
+
if (Array.isArray(securityOption)) {
|
|
565
|
+
security = [];
|
|
566
|
+
for (const item of securityOption) {
|
|
567
|
+
const requirement = schemes.requirement(item, where);
|
|
568
|
+
if (requirement !== void 0) security.push(requirement);
|
|
569
|
+
}
|
|
570
|
+
} else invalid("\"security\" must be an array of requirements");
|
|
571
|
+
}
|
|
572
|
+
const decisions = readDecisions(ctx, schemes, options["decisions"], where, invalid);
|
|
573
|
+
return {
|
|
574
|
+
identity: stored?.identity,
|
|
575
|
+
location: decorator?.location,
|
|
576
|
+
info,
|
|
577
|
+
servers: Array.isArray(servers) ? servers : void 0,
|
|
578
|
+
tags,
|
|
579
|
+
externalDocs,
|
|
580
|
+
security,
|
|
581
|
+
decisions,
|
|
582
|
+
operationIds: typeof style === "string" ? style : "controller.handler",
|
|
583
|
+
errors,
|
|
584
|
+
strict: bool("strict", false),
|
|
585
|
+
jsdoc: bool("jsdoc", true),
|
|
586
|
+
extensions
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
function checkInfo(info, invalid) {
|
|
590
|
+
const terms = info["termsOfService"];
|
|
591
|
+
if (terms !== void 0 && (typeof terms !== "string" || !isAbsoluteUrl(terms))) invalid("\"info.termsOfService\" must be an absolute URL");
|
|
592
|
+
const contact = info["contact"];
|
|
593
|
+
if (contact !== void 0) {
|
|
594
|
+
if (isRecord(contact)) {
|
|
595
|
+
if (contact["url"] !== void 0 && !isAbsoluteUrl(String(contact["url"]))) invalid("\"info.contact.url\" must be an absolute URL");
|
|
596
|
+
if (contact["email"] !== void 0 && !/^[^\s@]+@[^\s@]+$/.test(String(contact["email"]))) invalid("\"info.contact.email\" must be an email address");
|
|
597
|
+
} else invalid("\"info.contact\" must be { name?, url?, email? }");
|
|
598
|
+
}
|
|
599
|
+
const license = info["license"];
|
|
600
|
+
if (license !== void 0) {
|
|
601
|
+
if (!isRecord(license) || typeof license["name"] !== "string" || license["name"] === "") invalid("\"info.license\" must be { name, identifier? } or { name, url? }");
|
|
602
|
+
else {
|
|
603
|
+
if (license["identifier"] !== void 0 && license["url"] !== void 0) invalid("\"info.license\" takes \"identifier\" or \"url\", not both");
|
|
604
|
+
if (license["identifier"] !== void 0 && !SPDX.test(String(license["identifier"]))) invalid("\"info.license.identifier\" must be an SPDX expression (MIT, Apache-2.0...)");
|
|
605
|
+
if (license["url"] !== void 0 && !isAbsoluteUrl(String(license["url"]))) invalid("\"info.license.url\" must be an absolute URL");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function readDecisions(ctx, schemes, value, location, invalid) {
|
|
610
|
+
if (value === void 0 || location === void 0) return [];
|
|
611
|
+
if (!Array.isArray(value)) {
|
|
612
|
+
invalid("\"decisions\" must be an array of { when, security, scopes? }");
|
|
613
|
+
return [];
|
|
614
|
+
}
|
|
615
|
+
const routeDecorators = new Set(ctx.definitionsOf(HttpIds.createRouteDecorator).map((definition) => definition.identity));
|
|
616
|
+
const decisions = [];
|
|
617
|
+
for (const entry of value) {
|
|
618
|
+
if (!isRecord(entry)) {
|
|
619
|
+
invalid("every decision is { when, security, scopes? }");
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const when = entry["when"];
|
|
623
|
+
if (!isRef(when) || when.identity === void 0 || !routeDecorators.has(when.identity)) {
|
|
624
|
+
ctx.report({
|
|
625
|
+
code: Codes.InvalidSecurityRequirement,
|
|
626
|
+
severity: "error",
|
|
627
|
+
message: `decisions[].when must be a route decorator created with createRouteDecorator(), not ${describe(when)}`,
|
|
628
|
+
location: isRef(when) ? when.location : location,
|
|
629
|
+
hints: ["Declare it: export const Public = createRouteDecorator('public')"]
|
|
630
|
+
});
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const scopes = entry["scopes"];
|
|
634
|
+
if (scopes !== void 0 && scopes !== "value") {
|
|
635
|
+
ctx.report({
|
|
636
|
+
code: Codes.InvalidSecurityRequirement,
|
|
637
|
+
severity: "error",
|
|
638
|
+
message: `decisions[].scopes must be 'value' (received ${describe(scopes)})`,
|
|
639
|
+
location: when.location,
|
|
640
|
+
hints: ["{ when: Roles, security: [OAuth], scopes: 'value' } turns @Roles(['admin']) into the scopes ['admin']"]
|
|
641
|
+
});
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const security = entry["security"];
|
|
645
|
+
if (!Array.isArray(security)) {
|
|
646
|
+
ctx.report({
|
|
647
|
+
code: Codes.InvalidSecurityRequirement,
|
|
648
|
+
severity: "error",
|
|
649
|
+
message: `decisions[].security of ${when.text} must be an array of requirements ([] means public)`,
|
|
650
|
+
location: when.location
|
|
651
|
+
});
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const requirements = [];
|
|
655
|
+
let valid = true;
|
|
656
|
+
for (const item of security) {
|
|
657
|
+
const requirement = schemes.requirement(item, when.location);
|
|
658
|
+
if (requirement === void 0) valid = false;
|
|
659
|
+
else requirements.push(requirement);
|
|
660
|
+
}
|
|
661
|
+
if (!valid) continue;
|
|
662
|
+
decisions.push({
|
|
663
|
+
identity: when.identity,
|
|
664
|
+
text: when.text,
|
|
665
|
+
location: when.location,
|
|
666
|
+
security: requirements,
|
|
667
|
+
scopesFromValue: scopes === "value",
|
|
668
|
+
used: false
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return decisions;
|
|
672
|
+
}
|
|
673
|
+
/** The document template: `openapi`, `info`, declared `servers`, `tags`... and the default security. */
|
|
674
|
+
function documentTemplate(model, security) {
|
|
675
|
+
const template = {
|
|
676
|
+
openapi: OPENAPI_VERSION,
|
|
677
|
+
info: model.info
|
|
678
|
+
};
|
|
679
|
+
if (model.servers !== void 0) template["servers"] = model.servers;
|
|
680
|
+
if (model.tags.length > 0) template["tags"] = model.tags;
|
|
681
|
+
if (model.externalDocs !== void 0) template["externalDocs"] = model.externalDocs;
|
|
682
|
+
if (security !== void 0) template["security"] = security;
|
|
683
|
+
for (const [key, value] of Object.entries(model.extensions)) template[key] = value;
|
|
684
|
+
return template;
|
|
685
|
+
}
|
|
686
|
+
// src/compiler/operations.ts
|
|
687
|
+
const ALL_METHODS = [
|
|
688
|
+
"GET",
|
|
689
|
+
"POST",
|
|
690
|
+
"PUT",
|
|
691
|
+
"PATCH",
|
|
692
|
+
"DELETE"
|
|
693
|
+
];
|
|
694
|
+
const IGNORED_HEADERS = /* @__PURE__ */ new Set([
|
|
695
|
+
"accept",
|
|
696
|
+
"content-type",
|
|
697
|
+
"authorization"
|
|
698
|
+
]);
|
|
699
|
+
/** Builds every operation of the application (sorted by path, then method) and the tag descriptions. */
|
|
700
|
+
function buildOperations(input) {
|
|
701
|
+
const { ctx } = input;
|
|
702
|
+
const controllers = [...ctx.graph.extensions.find((extension) => extension.name === "http")?.stored ?? []].sort((a, b) => compareStrings(a.token, b.token));
|
|
703
|
+
const explicit = /* @__PURE__ */ new Set();
|
|
704
|
+
for (const controller of controllers) for (const route of controller.routes) if (route.method !== "ALL") explicit.add(`${route.method} ${pathShape(route.fullPath)}`);
|
|
705
|
+
const operations = [];
|
|
706
|
+
const tagDescriptions = {};
|
|
707
|
+
for (const controller of controllers) {
|
|
708
|
+
const cls = ctx.classInfo(controller.identity);
|
|
709
|
+
const classDecorators = owned(cls?.decorators);
|
|
710
|
+
if (excluded(ctx, classDecorators, controller.displayName)) continue;
|
|
711
|
+
const defaultTag = controller.displayName.replace(/Controller$/, "") || controller.displayName;
|
|
712
|
+
const doc = input.document.jsdoc ? cls?.doc : void 0;
|
|
713
|
+
const classTags = tagsOf(ctx, classDecorators);
|
|
714
|
+
if (classTags.length === 0 && doc !== void 0 && doc.text !== "") tagDescriptions[defaultTag] = doc.text;
|
|
715
|
+
for (const route of controller.routes) {
|
|
716
|
+
if (!route.complete) continue;
|
|
717
|
+
const method = cls?.methods.find((candidate) => candidate.name === route.handler && !candidate.static);
|
|
718
|
+
const methodDecorators = owned(method?.decorators);
|
|
719
|
+
if (excluded(ctx, methodDecorators, `${controller.displayName}.${route.handler}`)) continue;
|
|
720
|
+
const methods = route.method === "ALL" ? ALL_METHODS.filter((m) => !explicit.has(`${m} ${pathShape(route.fullPath)}`)) : [route.method];
|
|
721
|
+
for (const httpMethod of methods) operations.push(new OperationBuilder(input, {
|
|
722
|
+
controller,
|
|
723
|
+
route,
|
|
724
|
+
httpMethod,
|
|
725
|
+
cls,
|
|
726
|
+
method,
|
|
727
|
+
classDecorators,
|
|
728
|
+
methodDecorators,
|
|
729
|
+
classTags,
|
|
730
|
+
defaultTag
|
|
731
|
+
}).build());
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
checkDuplicateIds(ctx, operations);
|
|
735
|
+
unifyPathTemplates(ctx, operations);
|
|
736
|
+
for (const decision of input.document.decisions) {
|
|
737
|
+
if (decision.used) continue;
|
|
738
|
+
ctx.report({
|
|
739
|
+
code: Codes.InvalidSecurityRequirement,
|
|
740
|
+
severity: "warning",
|
|
741
|
+
message: `The security decision on ${decision.text} applies to no route`,
|
|
742
|
+
location: decision.location,
|
|
743
|
+
hints: ["Remove the decision, or apply the decorator to the routes it describes"]
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
operations.sort((a, b) => compareStrings(a.path, b.path) || METHOD_ORDER.indexOf(a.method) - METHOD_ORDER.indexOf(b.method));
|
|
747
|
+
return {
|
|
748
|
+
operations,
|
|
749
|
+
tagDescriptions
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* `/books/{id}` from `/books/:id`; the trailing wildcard is `{wildcard}` (`{wildcard_1}`... when a
|
|
754
|
+
* parameter of the path is named `wildcard`).
|
|
755
|
+
*/
|
|
756
|
+
function pathTemplate(fullPath) {
|
|
757
|
+
const wildcard = wildcardName(fullPath);
|
|
758
|
+
return fullPath.split("/").map((segment) => {
|
|
759
|
+
if (segment.startsWith(":")) return `{${segment.slice(1)}}`;
|
|
760
|
+
return segment === "*" ? `{${wildcard}}` : segment;
|
|
761
|
+
}).join("/");
|
|
762
|
+
}
|
|
763
|
+
/** The name of the wildcard parameter of a path: `wildcard`, unless a `:wildcard` parameter exists. */
|
|
764
|
+
function wildcardName(fullPath) {
|
|
765
|
+
const names = new Set(fullPath.split("/").filter((segment) => segment.startsWith(":")).map((segment) => segment.slice(1)));
|
|
766
|
+
let name = "wildcard";
|
|
767
|
+
for (let index = 1; names.has(name); index++) name = `wildcard_${index}`;
|
|
768
|
+
return name;
|
|
769
|
+
}
|
|
770
|
+
/** Names of the parameters of a path template, in order. */
|
|
771
|
+
function templateNames(template) {
|
|
772
|
+
return [...template.matchAll(/\{([^}]*)\}/g)].map((match) => match[1]);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* OpenAPI forbids two templates of one path (`/books/{id}` and `/books/{bookId}`): the operations of
|
|
776
|
+
* such a path are documented under one template (the first one, or the one a whole-path schema
|
|
777
|
+
* names), with a ZNIGHT035 warning.
|
|
778
|
+
*/
|
|
779
|
+
function unifyPathTemplates(ctx, operations) {
|
|
780
|
+
const groups = /* @__PURE__ */ new Map();
|
|
781
|
+
operations.forEach((operation, index) => {
|
|
782
|
+
const shape = operation.path.replace(/\{[^}]*\}/g, "{}");
|
|
783
|
+
const group = groups.get(shape);
|
|
784
|
+
if (group === void 0) groups.set(shape, [index]);
|
|
785
|
+
else group.push(index);
|
|
786
|
+
});
|
|
787
|
+
for (const indexes of groups.values()) {
|
|
788
|
+
const group = indexes.map((index) => operations[index]);
|
|
789
|
+
const templates = [...new Set(group.map((operation) => operation.path))].sort(compareStrings);
|
|
790
|
+
if (templates.length < 2) continue;
|
|
791
|
+
const canonical = [...group.filter(hasPathPlaceholder).map((operation) => operation.path)].sort(compareStrings)[0] ?? templates[0];
|
|
792
|
+
const target = templateNames(canonical);
|
|
793
|
+
const renamed = [];
|
|
794
|
+
for (const index of indexes) {
|
|
795
|
+
const operation = operations[index];
|
|
796
|
+
if (operation.path === canonical || hasPathPlaceholder(operation)) continue;
|
|
797
|
+
const names = new Map(templateNames(operation.path).map((name, position) => [name, target[position]]));
|
|
798
|
+
for (const parameter of operation.operation["parameters"] ?? []) {
|
|
799
|
+
const name = parameter["name"];
|
|
800
|
+
if (parameter["in"] === "path" && typeof name === "string" && names.has(name)) parameter["name"] = names.get(name);
|
|
801
|
+
}
|
|
802
|
+
operations[index] = {
|
|
803
|
+
...operation,
|
|
804
|
+
path: canonical
|
|
805
|
+
};
|
|
806
|
+
renamed.push(operation);
|
|
807
|
+
}
|
|
808
|
+
const first = renamed[0] ?? group[0];
|
|
809
|
+
const kept = group.filter((operation) => operation.path !== canonical && hasPathPlaceholder(operation));
|
|
810
|
+
ctx.report({
|
|
811
|
+
code: Codes.InconsistentOperation,
|
|
812
|
+
severity: "warning",
|
|
813
|
+
message: `The routes of one path name its parameters differently (${group.map((operation) => operation.label).join(", ")}): OpenAPI forbids two templates of one path, ${kept.length === 0 ? `documented as ${canonical}` : `the whole-path schemas keep ${kept.map((operation) => operation.path).join(", ")}`}`,
|
|
814
|
+
location: first.location,
|
|
815
|
+
related: group.filter((operation) => operation !== first).map((operation) => ({
|
|
816
|
+
message: operation.label,
|
|
817
|
+
location: operation.location
|
|
818
|
+
})),
|
|
819
|
+
hints: ["Give the parameters the same names on every route of the path"]
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
/** Whether the path parameters of an operation come from a whole-path schema (`@Param(Schema)`). */
|
|
824
|
+
function hasPathPlaceholder(operation) {
|
|
825
|
+
return (operation.operation["parameters"] ?? []).some((parameter) => parameter["$znight"] === "parameters" && parameter["in"] === "path");
|
|
826
|
+
}
|
|
827
|
+
function pathShape(fullPath) {
|
|
828
|
+
return fullPath.split("/").map((segment) => segment.startsWith(":") ? ":" : segment).join("/");
|
|
829
|
+
}
|
|
830
|
+
function owned(decorators) {
|
|
831
|
+
return (decorators ?? []).filter((decorator) => decorator.owner === EXTENSION_NAME);
|
|
832
|
+
}
|
|
833
|
+
function excluded(ctx, decorators, target) {
|
|
834
|
+
const exclude = decorators.find((decorator) => decorator.identity === Ids.ApiExclude);
|
|
835
|
+
if (exclude === void 0) return false;
|
|
836
|
+
const others = decorators.filter((decorator) => decorator !== exclude);
|
|
837
|
+
if (others.length > 0) ctx.report({
|
|
838
|
+
code: Codes.InconsistentOperation,
|
|
839
|
+
severity: "warning",
|
|
840
|
+
message: `${target} is excluded from the OpenAPI document (@ApiExclude): ${others.map((decorator) => `@${decorator.name}`).join(", ")} ${others.length > 1 ? "have" : "has"} no effect`,
|
|
841
|
+
location: exclude.location,
|
|
842
|
+
hints: ["Remove @ApiExclude to document it, or remove the other @Api* decorators"]
|
|
843
|
+
});
|
|
844
|
+
return true;
|
|
845
|
+
}
|
|
846
|
+
function tagsOf(ctx, decorators) {
|
|
847
|
+
const tags = [];
|
|
848
|
+
for (const decorator of decorators.filter((candidate) => candidate.identity === Ids.ApiTags)) for (const arg of decorator.args) if (typeof arg.value === "string" && arg.value.trim() !== "") tags.push(arg.value);
|
|
849
|
+
else invalidValue(ctx, arg, "a tag must be a non-empty string");
|
|
850
|
+
return [...new Set(tags)];
|
|
851
|
+
}
|
|
852
|
+
function invalidValue(ctx, arg, message, hints = []) {
|
|
853
|
+
ctx.report({
|
|
854
|
+
code: Codes.InvalidOpenApiDefinition,
|
|
855
|
+
severity: "error",
|
|
856
|
+
message,
|
|
857
|
+
location: arg.location,
|
|
858
|
+
hints
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
function checkDuplicateIds(ctx, operations) {
|
|
862
|
+
const byId = /* @__PURE__ */ new Map();
|
|
863
|
+
for (const operation of operations) {
|
|
864
|
+
const other = byId.get(operation.operationId);
|
|
865
|
+
if (other === void 0) {
|
|
866
|
+
byId.set(operation.operationId, operation);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
ctx.report({
|
|
870
|
+
code: Codes.DuplicateOperationId,
|
|
871
|
+
severity: "error",
|
|
872
|
+
message: `Duplicate operation id "${operation.operationId}": ${other.label} and ${operation.label}`,
|
|
873
|
+
location: operation.location,
|
|
874
|
+
related: [{
|
|
875
|
+
message: "the other operation",
|
|
876
|
+
location: other.location
|
|
877
|
+
}],
|
|
878
|
+
hints: [`Rename one controller, or set an id: @ApiOperation({ operationId: '...' })`]
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
var OperationBuilder = class {
|
|
883
|
+
#input;
|
|
884
|
+
#ctx;
|
|
885
|
+
#source;
|
|
886
|
+
#label;
|
|
887
|
+
#location;
|
|
888
|
+
#doc;
|
|
889
|
+
#paramDocs;
|
|
890
|
+
#strict;
|
|
891
|
+
constructor(input, source) {
|
|
892
|
+
this.#input = input;
|
|
893
|
+
this.#ctx = input.ctx;
|
|
894
|
+
this.#source = source;
|
|
895
|
+
this.#label = `${source.httpMethod} ${source.route.fullPath} (${source.controller.displayName}.${source.route.handler})`;
|
|
896
|
+
this.#location = source.method?.location ?? source.route.location;
|
|
897
|
+
this.#doc = input.document.jsdoc ? source.method?.doc : void 0;
|
|
898
|
+
this.#paramDocs = /* @__PURE__ */ new Map();
|
|
899
|
+
for (const tag of this.#doc?.tags ?? []) {
|
|
900
|
+
if (tag.name !== "param") continue;
|
|
901
|
+
const match = /^(?:\{[^}]*\}\s*)?\[?([\w$]+)[^\s]*\s*(?:-\s*)?([\s\S]*)$/.exec(tag.text);
|
|
902
|
+
if (match?.[1] !== void 0 && match[2] !== void 0 && match[2].trim() !== "") this.#paramDocs.set(match[1], match[2].trim());
|
|
903
|
+
}
|
|
904
|
+
this.#strict = input.document.strict;
|
|
905
|
+
}
|
|
906
|
+
build() {
|
|
907
|
+
const { route, httpMethod, controller } = this.#source;
|
|
908
|
+
const method = httpMethod.toLowerCase();
|
|
909
|
+
const options = this.#apiOperation();
|
|
910
|
+
const operation = {};
|
|
911
|
+
const operationId = this.#operationId(options);
|
|
912
|
+
operation["operationId"] = operationId;
|
|
913
|
+
const summary = stringOption(options, "summary") ?? this.#doc?.summary;
|
|
914
|
+
const description = stringOption(options, "description") ?? this.#doc?.description;
|
|
915
|
+
if (summary !== void 0) operation["summary"] = summary;
|
|
916
|
+
if (description !== void 0) operation["description"] = description;
|
|
917
|
+
const methodTags = tagsOf(this.#ctx, this.#source.methodDecorators);
|
|
918
|
+
const tags = [.../* @__PURE__ */ new Set([...this.#source.classTags, ...methodTags])];
|
|
919
|
+
operation["tags"] = tags.length > 0 ? tags : [this.#source.defaultTag];
|
|
920
|
+
if (typeof options?.["deprecated"] === "boolean" ? options["deprecated"] : this.#doc?.tags.some((tag) => tag.name === "deprecated") === true) operation["deprecated"] = true;
|
|
921
|
+
if (isRecord(options?.["externalDocs"])) operation["externalDocs"] = toJson(options["externalDocs"]);
|
|
922
|
+
const parameters = this.#parameters();
|
|
923
|
+
if (parameters.length > 0) operation["parameters"] = parameters;
|
|
924
|
+
const body = this.#requestBody(httpMethod);
|
|
925
|
+
if (body !== void 0) operation["requestBody"] = body;
|
|
926
|
+
operation["responses"] = this.#responses(httpMethod);
|
|
927
|
+
const security = this.#security();
|
|
928
|
+
if (security !== void 0) operation["security"] = security;
|
|
929
|
+
if (isRecord(options?.["extensions"])) {
|
|
930
|
+
for (const [key, value] of Object.entries(options["extensions"])) if (key.startsWith("x-")) operation[key] = toJson(value);
|
|
931
|
+
}
|
|
932
|
+
if (route.fullPath.endsWith("/*")) operation["x-znight-wildcard"] = true;
|
|
933
|
+
return {
|
|
934
|
+
controller: controller.token,
|
|
935
|
+
handler: route.handler,
|
|
936
|
+
route: route.method,
|
|
937
|
+
method,
|
|
938
|
+
path: pathTemplate(route.fullPath),
|
|
939
|
+
operation,
|
|
940
|
+
operationId,
|
|
941
|
+
label: this.#label,
|
|
942
|
+
location: this.#location
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
#apiOperation() {
|
|
946
|
+
const arg = this.#source.methodDecorators.find((candidate) => candidate.identity === Ids.ApiOperation)?.args[0];
|
|
947
|
+
if (arg === void 0) return void 0;
|
|
948
|
+
if (!isRecord(arg.value)) {
|
|
949
|
+
invalidValue(this.#ctx, arg, `@ApiOperation takes { summary?, description?, operationId?, deprecated?, externalDocs?, extensions? }, received ${describe(arg.value)}`);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const reserved = findReservedKey(arg.value);
|
|
953
|
+
if (reserved !== void 0) invalidValue(this.#ctx, arg, `"$znight" is a reserved key (${reserved})`);
|
|
954
|
+
for (const key of Object.keys(arg.value)) if (![
|
|
955
|
+
"summary",
|
|
956
|
+
"description",
|
|
957
|
+
"operationId",
|
|
958
|
+
"deprecated",
|
|
959
|
+
"externalDocs",
|
|
960
|
+
"extensions"
|
|
961
|
+
].includes(key)) invalidValue(this.#ctx, arg, `@ApiOperation: unknown option "${key}"`);
|
|
962
|
+
const extensions = arg.value["extensions"];
|
|
963
|
+
if (isRecord(extensions)) {
|
|
964
|
+
for (const key of Object.keys(extensions)) if (!key.startsWith("x-")) invalidValue(this.#ctx, arg, `@ApiOperation: the extension "${key}" must start with "x-"`);
|
|
965
|
+
}
|
|
966
|
+
return arg.value;
|
|
967
|
+
}
|
|
968
|
+
#operationId(options) {
|
|
969
|
+
const { controller, route, httpMethod } = this.#source;
|
|
970
|
+
const explicit = options?.["operationId"];
|
|
971
|
+
if (explicit !== void 0) {
|
|
972
|
+
if (typeof explicit !== "string" || !OPERATION_ID.test(explicit)) {
|
|
973
|
+
const decorator = this.#source.methodDecorators.find((candidate) => candidate.identity === Ids.ApiOperation);
|
|
974
|
+
invalidValue(this.#ctx, decorator ?? { location: this.#location }, `Invalid operation id ${describe(explicit)}: it must match ${String(OPERATION_ID)}`);
|
|
975
|
+
} else return route.method === "ALL" ? `${explicit}.${httpMethod.toLowerCase()}` : explicit;
|
|
976
|
+
}
|
|
977
|
+
const base = controller.displayName.replace(/Controller$/, "") || controller.displayName;
|
|
978
|
+
let id;
|
|
979
|
+
switch (this.#input.document.operationIds) {
|
|
980
|
+
case "handler":
|
|
981
|
+
id = route.handler;
|
|
982
|
+
break;
|
|
983
|
+
case "Controller_handler":
|
|
984
|
+
id = `${controller.displayName}_${route.handler}`;
|
|
985
|
+
break;
|
|
986
|
+
default: id = `${base.charAt(0).toLowerCase()}${base.slice(1)}.${route.handler}`;
|
|
987
|
+
}
|
|
988
|
+
if (route.method === "ALL") id = `${id}.${httpMethod.toLowerCase()}`;
|
|
989
|
+
if (!OPERATION_ID.test(id)) id = id.replace(/[^\w.-]/g, "_").replace(/^[^A-Za-z_]/, "_");
|
|
990
|
+
return id;
|
|
991
|
+
}
|
|
992
|
+
#use(part, io, location) {
|
|
993
|
+
return {
|
|
994
|
+
io,
|
|
995
|
+
operation: this.#label,
|
|
996
|
+
part,
|
|
997
|
+
location: location ?? this.#location
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
#paramInfo(data) {
|
|
1001
|
+
return this.#source.method?.params.find((param) => param.index === data.index);
|
|
1002
|
+
}
|
|
1003
|
+
#paramDoc(data) {
|
|
1004
|
+
return this.#paramDocs.get(this.#paramInfo(data)?.name ?? data.name);
|
|
1005
|
+
}
|
|
1006
|
+
#parameters() {
|
|
1007
|
+
const { route } = this.#source;
|
|
1008
|
+
const parameters = [];
|
|
1009
|
+
const declared = /* @__PURE__ */ new Set();
|
|
1010
|
+
const params = route.params;
|
|
1011
|
+
const wholePath = params.find((param) => param.source === "param" && param.key === void 0 && param.schema !== void 0);
|
|
1012
|
+
if (wholePath?.schema === void 0) for (const segment of route.fullPath.split("/")) {
|
|
1013
|
+
if (!segment.startsWith(":") && segment !== "*") continue;
|
|
1014
|
+
const key = segment === "*" ? "*" : segment.slice(1);
|
|
1015
|
+
const data = params.find((param) => param.source === "param" && param.key === key);
|
|
1016
|
+
const parameter = {
|
|
1017
|
+
name: key === "*" ? wildcardName(route.fullPath) : key,
|
|
1018
|
+
in: "path"
|
|
1019
|
+
};
|
|
1020
|
+
const doc = data === void 0 ? void 0 : this.#paramDoc(data);
|
|
1021
|
+
if (doc !== void 0) parameter["description"] = doc;
|
|
1022
|
+
else if (key === "*") parameter["description"] = "The rest of the path, `/` included";
|
|
1023
|
+
parameter["required"] = true;
|
|
1024
|
+
parameter["schema"] = data === void 0 ? { type: "string" } : this.#paramSchema(data, `path parameter "${key}"`);
|
|
1025
|
+
if (key === "*") parameter["x-znight-wildcard"] = true;
|
|
1026
|
+
parameters.push(parameter);
|
|
1027
|
+
declared.add(`path ${parameter["name"]}`);
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
const name = this.#input.slots.use(wholePath.schema, "input")?.name;
|
|
1031
|
+
if (name !== void 0) parameters.push(placeholder("path", name));
|
|
1032
|
+
}
|
|
1033
|
+
for (const data of params) if (data.source === "query") this.#signatureParameter(data, "query", parameters, declared);
|
|
1034
|
+
else if (data.source === "headers") this.#signatureParameter(data, "header", parameters, declared);
|
|
1035
|
+
else if (data.source === "cookie" || data.source === "signedCookie") this.#signatureParameter(data, "cookie", parameters, declared);
|
|
1036
|
+
const decorated = [...this.#source.classDecorators.filter((decorator) => decorator.identity === Ids.ApiHeader), ...this.#source.methodDecorators.filter((decorator) => decorator.identity === Ids.ApiQuery || decorator.identity === Ids.ApiHeader)];
|
|
1037
|
+
for (const decorator of decorated) {
|
|
1038
|
+
const parameter = this.#decoratedParameter(decorator, declared);
|
|
1039
|
+
if (parameter !== void 0) parameters.push(parameter);
|
|
1040
|
+
}
|
|
1041
|
+
return parameters;
|
|
1042
|
+
}
|
|
1043
|
+
#signatureParameter(data, location, parameters, declared) {
|
|
1044
|
+
if (data.key === void 0) {
|
|
1045
|
+
if (data.schema !== void 0) {
|
|
1046
|
+
const name = this.#input.slots.use(data.schema, "input")?.name;
|
|
1047
|
+
if (name !== void 0) parameters.push(placeholder(location, name));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const info = this.#paramInfo(data);
|
|
1051
|
+
if (info === void 0 || location !== "query") return;
|
|
1052
|
+
const result = this.#input.schemas.shape({ parameter: info });
|
|
1053
|
+
const use = this.#use(`the ${location} parameters`, "input", info.typeLocation);
|
|
1054
|
+
this.#input.schemas.register(result, use);
|
|
1055
|
+
const object = this.#objectProperties(result.root);
|
|
1056
|
+
for (const property of object ?? []) {
|
|
1057
|
+
const parameter = {
|
|
1058
|
+
name: property.name,
|
|
1059
|
+
in: location
|
|
1060
|
+
};
|
|
1061
|
+
if (property.description !== void 0) parameter["description"] = property.description;
|
|
1062
|
+
if (!property.optional) parameter["required"] = true;
|
|
1063
|
+
parameter["schema"] = this.#input.schemas.convert(property.shape, use, property.name);
|
|
1064
|
+
if (property.shape.kind === "array") {
|
|
1065
|
+
parameter["style"] = "form";
|
|
1066
|
+
parameter["explode"] = true;
|
|
1067
|
+
}
|
|
1068
|
+
parameters.push(parameter);
|
|
1069
|
+
declared.add(`${location} ${property.name}`);
|
|
1070
|
+
}
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (location === "header" && IGNORED_HEADERS.has(data.key)) {
|
|
1074
|
+
this.#ctx.report({
|
|
1075
|
+
code: Codes.InconsistentOperation,
|
|
1076
|
+
severity: "warning",
|
|
1077
|
+
message: `The "${data.key}" header of ${this.#label} is not documented as a parameter: OpenAPI ignores Accept, Content-Type and Authorization parameters`,
|
|
1078
|
+
location: data.location,
|
|
1079
|
+
hints: ["Describe credentials with a security scheme (securityScheme()) and @ApiSecurity or decisions"]
|
|
1080
|
+
});
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const parameter = {
|
|
1084
|
+
name: data.key,
|
|
1085
|
+
in: location
|
|
1086
|
+
};
|
|
1087
|
+
const doc = this.#paramDoc(data);
|
|
1088
|
+
if (doc !== void 0) parameter["description"] = doc;
|
|
1089
|
+
if (!data.optional) parameter["required"] = true;
|
|
1090
|
+
parameter["schema"] = this.#paramSchema(data, `${location} parameter "${data.key}"`);
|
|
1091
|
+
if (location === "query" && isArrayPlan(data.coercion?.plan)) {
|
|
1092
|
+
parameter["style"] = "form";
|
|
1093
|
+
parameter["explode"] = true;
|
|
1094
|
+
}
|
|
1095
|
+
parameters.push(parameter);
|
|
1096
|
+
declared.add(`${location} ${data.key}`);
|
|
1097
|
+
}
|
|
1098
|
+
#objectProperties(shape) {
|
|
1099
|
+
if (shape.kind === "named") {
|
|
1100
|
+
const named = this.#input.schemas.named(shape.key);
|
|
1101
|
+
return named === void 0 ? void 0 : this.#objectProperties(named.shape);
|
|
1102
|
+
}
|
|
1103
|
+
if (shape.kind !== "object") return void 0;
|
|
1104
|
+
return shape.properties.map((property) => ({
|
|
1105
|
+
name: property.name,
|
|
1106
|
+
shape: property.shape,
|
|
1107
|
+
optional: property.optional,
|
|
1108
|
+
description: property.doc?.text === "" ? void 0 : property.doc?.text
|
|
1109
|
+
}));
|
|
1110
|
+
}
|
|
1111
|
+
#paramSchema(data, part) {
|
|
1112
|
+
if (data.schema !== void 0) return this.#input.slots.use(data.schema, "input") ?? {};
|
|
1113
|
+
const schema = data.coercion === void 0 ? {} : { ...planSchema(data.coercion.plan) };
|
|
1114
|
+
if (data.defaultValue !== void 0) {
|
|
1115
|
+
const value = toJson(data.defaultValue);
|
|
1116
|
+
if (value !== void 0) schema["default"] = value;
|
|
1117
|
+
}
|
|
1118
|
+
return schema;
|
|
1119
|
+
}
|
|
1120
|
+
#decoratedParameter(decorator, declared) {
|
|
1121
|
+
const location = decorator.identity === Ids.ApiQuery ? "query" : "header";
|
|
1122
|
+
const [nameArg, optionsArg] = decorator.args;
|
|
1123
|
+
const name = nameArg?.value;
|
|
1124
|
+
if (typeof name !== "string" || name === "") {
|
|
1125
|
+
invalidValue(this.#ctx, nameArg ?? decorator, `@${decorator.name} needs a parameter name`);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const key = location === "header" ? name.toLowerCase() : name;
|
|
1129
|
+
if (location === "header" && !HEADER_NAME.test(name)) {
|
|
1130
|
+
invalidValue(this.#ctx, nameArg ?? decorator, `Invalid header name ${JSON.stringify(name)}`);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (location === "header" && IGNORED_HEADERS.has(key)) {
|
|
1134
|
+
this.#ctx.report({
|
|
1135
|
+
code: Codes.InconsistentOperation,
|
|
1136
|
+
severity: "error",
|
|
1137
|
+
message: `@ApiHeader('${name}'): OpenAPI ignores Accept, Content-Type and Authorization parameters`,
|
|
1138
|
+
location: decorator.location,
|
|
1139
|
+
hints: ["Describe credentials with a security scheme: securityScheme() and @ApiSecurity"]
|
|
1140
|
+
});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (declared.has(`${location} ${key}`)) {
|
|
1144
|
+
this.#ctx.report({
|
|
1145
|
+
code: Codes.InconsistentOperation,
|
|
1146
|
+
severity: "error",
|
|
1147
|
+
message: `@${decorator.name}('${name}') documents the ${location} parameter "${key}" of ${this.#label}, which the signature already reads`,
|
|
1148
|
+
location: decorator.location,
|
|
1149
|
+
hints: [`Remove @${decorator.name}: the parameter is documented from the signature`]
|
|
1150
|
+
});
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
declared.add(`${location} ${key}`);
|
|
1154
|
+
const options = optionsArg?.value;
|
|
1155
|
+
if (options !== void 0 && !isRecord(options)) invalidValue(this.#ctx, optionsArg ?? decorator, `@${decorator.name} options must be { description?, required?, deprecated?, example? }`);
|
|
1156
|
+
const record = isRecord(options) ? options : {};
|
|
1157
|
+
for (const option of Object.keys(record)) if (![
|
|
1158
|
+
"description",
|
|
1159
|
+
"required",
|
|
1160
|
+
"deprecated",
|
|
1161
|
+
"example"
|
|
1162
|
+
].includes(option)) invalidValue(this.#ctx, optionsArg ?? decorator, `@${decorator.name}: unknown option "${option}"`);
|
|
1163
|
+
const parameter = {
|
|
1164
|
+
name: key,
|
|
1165
|
+
in: location
|
|
1166
|
+
};
|
|
1167
|
+
if (typeof record["description"] === "string") parameter["description"] = record["description"];
|
|
1168
|
+
if (record["required"] === true) parameter["required"] = true;
|
|
1169
|
+
if (record["deprecated"] === true) parameter["deprecated"] = true;
|
|
1170
|
+
if (decorator.typeArgs.length > 0) {
|
|
1171
|
+
const result = this.#input.schemas.shape({
|
|
1172
|
+
decorator,
|
|
1173
|
+
typeArgument: 0
|
|
1174
|
+
});
|
|
1175
|
+
parameter["schema"] = this.#input.schemas.fromResult(result, this.#use(`the ${location} parameter "${key}"`, "input", decorator.location));
|
|
1176
|
+
} else parameter["schema"] = { type: "string" };
|
|
1177
|
+
if (record["example"] !== void 0) parameter["example"] = toJson(record["example"]);
|
|
1178
|
+
return parameter;
|
|
1179
|
+
}
|
|
1180
|
+
#requestBody(httpMethod) {
|
|
1181
|
+
const { route } = this.#source;
|
|
1182
|
+
const body = route.params.find((param) => param.source === "body");
|
|
1183
|
+
const uploads = route.params.filter((param) => param.upload !== void 0);
|
|
1184
|
+
const apiBody = this.#source.methodDecorators.find((decorator) => decorator.identity === Ids.ApiBody);
|
|
1185
|
+
if (apiBody !== void 0 && (httpMethod === "GET" || httpMethod === "HEAD")) this.#ctx.report({
|
|
1186
|
+
code: Codes.InconsistentOperation,
|
|
1187
|
+
severity: "warning",
|
|
1188
|
+
message: `@ApiBody on ${this.#label}: ${httpMethod} requests have no body in OpenAPI tools`,
|
|
1189
|
+
location: apiBody.location,
|
|
1190
|
+
hints: ["Use another method, or remove @ApiBody"]
|
|
1191
|
+
});
|
|
1192
|
+
if (apiBody !== void 0 && (body?.schema !== void 0 || route.bodySchema !== void 0)) {
|
|
1193
|
+
this.#ctx.report({
|
|
1194
|
+
code: Codes.InconsistentOperation,
|
|
1195
|
+
severity: "error",
|
|
1196
|
+
message: `@ApiBody on ${this.#label}, whose body is already documented by its schema`,
|
|
1197
|
+
location: apiBody.location,
|
|
1198
|
+
hints: ["Remove @ApiBody: the body schema documents the request"]
|
|
1199
|
+
});
|
|
1200
|
+
return this.#bodyFromSignature(body, uploads);
|
|
1201
|
+
}
|
|
1202
|
+
if (apiBody !== void 0 && uploads.length === 0) return this.#declaredBody(apiBody, body);
|
|
1203
|
+
return this.#bodyFromSignature(body, uploads);
|
|
1204
|
+
}
|
|
1205
|
+
#bodyFromSignature(body, uploads) {
|
|
1206
|
+
const { route } = this.#source;
|
|
1207
|
+
if (uploads.length > 0) return this.#multipart(body, uploads);
|
|
1208
|
+
if (body !== void 0) {
|
|
1209
|
+
const request = {};
|
|
1210
|
+
const doc = this.#paramDoc(body);
|
|
1211
|
+
if (doc !== void 0) request["description"] = doc;
|
|
1212
|
+
let content;
|
|
1213
|
+
if (body.schema === void 0) {
|
|
1214
|
+
const info = this.#paramInfo(body);
|
|
1215
|
+
const result = info === void 0 ? void 0 : this.#input.schemas.shape({ parameter: info });
|
|
1216
|
+
const root = result?.root;
|
|
1217
|
+
if (result === void 0 || root === void 0) content = { "application/json": { schema: {} } };
|
|
1218
|
+
else if (root.kind === "string") content = { "text/plain": { schema: { type: "string" } } };
|
|
1219
|
+
else if (root.kind === "binary") content = { "application/octet-stream": {} };
|
|
1220
|
+
else content = { "application/json": { schema: this.#input.schemas.fromResult(result, this.#use("the request body", "input", info?.typeLocation)) } };
|
|
1221
|
+
} else content = { "application/json": { schema: this.#input.slots.use(body.schema, "input") ?? {} } };
|
|
1222
|
+
request["content"] = content;
|
|
1223
|
+
if (!body.optional) request["required"] = true;
|
|
1224
|
+
return request;
|
|
1225
|
+
}
|
|
1226
|
+
if (route.bodySchema !== void 0) return {
|
|
1227
|
+
content: { "application/json": { schema: this.#input.slots.use(route.bodySchema, "input") ?? {} } },
|
|
1228
|
+
required: true
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
#multipart(form, uploads) {
|
|
1232
|
+
const properties = {};
|
|
1233
|
+
const required = [];
|
|
1234
|
+
const encoding = {};
|
|
1235
|
+
let additional;
|
|
1236
|
+
for (const param of uploads) {
|
|
1237
|
+
const upload = param.upload;
|
|
1238
|
+
const file = {
|
|
1239
|
+
type: "string",
|
|
1240
|
+
contentMediaType: "application/octet-stream"
|
|
1241
|
+
};
|
|
1242
|
+
const doc = this.#paramDoc(param);
|
|
1243
|
+
const size = upload.maxSize === void 0 || upload.maxSize === Number.POSITIVE_INFINITY ? void 0 : upload.maxSize;
|
|
1244
|
+
const descriptions = [doc, size === void 0 ? void 0 : `At most ${formatSize(size)}`].filter((text) => text !== void 0);
|
|
1245
|
+
if (descriptions.length > 0) file["description"] = descriptions.join(". ");
|
|
1246
|
+
if (size !== void 0) file["x-znight-max-size"] = size;
|
|
1247
|
+
const schema = upload.multiple ? {
|
|
1248
|
+
type: "array",
|
|
1249
|
+
items: file,
|
|
1250
|
+
...upload.maxFiles === void 0 || upload.maxFiles === Number.POSITIVE_INFINITY ? {} : { maxItems: upload.maxFiles }
|
|
1251
|
+
} : file;
|
|
1252
|
+
if (upload.field === void 0) {
|
|
1253
|
+
additional = schema;
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
properties[upload.field] = schema;
|
|
1257
|
+
if (upload.required) required.push(upload.field);
|
|
1258
|
+
if (upload.mimeTypes !== void 0 && upload.mimeTypes.length > 0) encoding[upload.field] = { contentType: upload.mimeTypes.join(", ") };
|
|
1259
|
+
}
|
|
1260
|
+
const files = { type: "object" };
|
|
1261
|
+
if (Object.keys(properties).length > 0) files["properties"] = properties;
|
|
1262
|
+
if (required.length > 0) files["required"] = required.sort(compareStrings);
|
|
1263
|
+
if (additional !== void 0) files["additionalProperties"] = additional;
|
|
1264
|
+
const textFields = form?.schema === void 0 ? void 0 : this.#input.slots.use(form.schema, "input");
|
|
1265
|
+
const media = { schema: textFields === void 0 ? files : { allOf: [textFields, files] } };
|
|
1266
|
+
if (Object.keys(encoding).length > 0) media["encoding"] = encoding;
|
|
1267
|
+
const request = { content: { "multipart/form-data": media } };
|
|
1268
|
+
if (required.length > 0 || form !== void 0 && !form.optional) request["required"] = true;
|
|
1269
|
+
return request;
|
|
1270
|
+
}
|
|
1271
|
+
#declaredBody(decorator, body) {
|
|
1272
|
+
const arg = decorator.args[0];
|
|
1273
|
+
const options = arg?.value;
|
|
1274
|
+
if (options !== void 0 && !isRecord(options)) {
|
|
1275
|
+
invalidValue(this.#ctx, arg ?? decorator, "@ApiBody options must be { description?, required?, contentType?, schema? }");
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
const record = isRecord(options) ? options : {};
|
|
1279
|
+
for (const key of Object.keys(record)) if (![
|
|
1280
|
+
"description",
|
|
1281
|
+
"required",
|
|
1282
|
+
"contentType",
|
|
1283
|
+
"schema"
|
|
1284
|
+
].includes(key)) invalidValue(this.#ctx, arg ?? decorator, `@ApiBody: unknown option "${key}"`);
|
|
1285
|
+
const contentTypes = this.#mediaTypes(record["contentType"], arg ?? decorator) ?? ["application/json"];
|
|
1286
|
+
let schema;
|
|
1287
|
+
const schemaOption = record["schema"];
|
|
1288
|
+
if (isRef(schemaOption)) schema = this.#input.slots.use(schemaOption, "input");
|
|
1289
|
+
else if (schemaOption !== void 0) invalidValue(this.#ctx, arg ?? decorator, "@ApiBody schema must be a Standard Schema (an exported schema)");
|
|
1290
|
+
else if (decorator.typeArgs.length > 0) {
|
|
1291
|
+
const result = this.#input.schemas.shape({
|
|
1292
|
+
decorator,
|
|
1293
|
+
typeArgument: 0
|
|
1294
|
+
});
|
|
1295
|
+
schema = this.#input.schemas.fromResult(result, this.#use("the request body", "input", decorator.location));
|
|
1296
|
+
}
|
|
1297
|
+
const content = {};
|
|
1298
|
+
for (const type of contentTypes) content[type] = schema === void 0 ? defaultMedia(type) : { schema };
|
|
1299
|
+
const request = {};
|
|
1300
|
+
if (typeof record["description"] === "string") request["description"] = record["description"];
|
|
1301
|
+
request["content"] = content;
|
|
1302
|
+
if (record["required"] !== false) request["required"] = true;
|
|
1303
|
+
return request;
|
|
1304
|
+
}
|
|
1305
|
+
#mediaTypes(value, where) {
|
|
1306
|
+
if (value === void 0) return void 0;
|
|
1307
|
+
let list;
|
|
1308
|
+
if (typeof value === "string") list = [value];
|
|
1309
|
+
else if (Array.isArray(value)) list = value;
|
|
1310
|
+
if (list === void 0 || list.length === 0 || list.some((item) => typeof item !== "string" || !MEDIA_TYPE.test(item))) {
|
|
1311
|
+
invalidValue(this.#ctx, where, `Invalid media type ${describe(value)}: expected "type/subtype" or a list of them`);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
return list;
|
|
1315
|
+
}
|
|
1316
|
+
#responses(httpMethod) {
|
|
1317
|
+
const { route, method } = this.#source;
|
|
1318
|
+
const errors = this.#input.document.errors;
|
|
1319
|
+
const declared = this.#source.methodDecorators.filter((decorator) => decorator.identity === Ids.ApiResponse);
|
|
1320
|
+
const declaredStatuses = declared.map((decorator) => decorator.args[0]?.value).map((status) => typeof status === "number" ? String(status) : status);
|
|
1321
|
+
const success = this.#success(httpMethod);
|
|
1322
|
+
const responses = /* @__PURE__ */ new Map();
|
|
1323
|
+
const covered = declaredStatuses.includes(String(success.status)) || declaredStatuses.includes("2XX") || success.dynamic && declaredStatuses.some((status) => typeof status === "string" && /^2\d\d$/.test(status));
|
|
1324
|
+
if (success.problem !== void 0 && !covered) this.#ctx.report({
|
|
1325
|
+
code: Codes.UndocumentedResponse,
|
|
1326
|
+
severity: this.#strict ? "error" : "warning",
|
|
1327
|
+
message: `The ${success.status} response of ${this.#label} cannot be documented: ${success.problem}`,
|
|
1328
|
+
location: success.location ?? this.#location,
|
|
1329
|
+
hints: success.hints
|
|
1330
|
+
});
|
|
1331
|
+
const returns = this.#doc?.tags.find((tag) => tag.name === "returns" || tag.name === "return")?.text;
|
|
1332
|
+
if (success.problem === void 0 || !covered) {
|
|
1333
|
+
const headers = {};
|
|
1334
|
+
for (const [name, value] of route.headers) headers[name] = {
|
|
1335
|
+
schema: { type: "string" },
|
|
1336
|
+
example: value
|
|
1337
|
+
};
|
|
1338
|
+
responses.set(String(success.status), {
|
|
1339
|
+
description: returns !== void 0 && returns !== "" ? returns : void 0,
|
|
1340
|
+
headers,
|
|
1341
|
+
content: success.content
|
|
1342
|
+
});
|
|
1343
|
+
if (success.empty !== void 0) responses.set(String(success.empty), {
|
|
1344
|
+
description: void 0,
|
|
1345
|
+
headers: { ...headers },
|
|
1346
|
+
content: void 0
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
for (const decorator of declared) this.#declaredResponse(decorator, responses, success);
|
|
1350
|
+
const result = {};
|
|
1351
|
+
const reads = route.params.some((param) => param.source === "body" || param.upload !== void 0) || route.bodySchema !== void 0;
|
|
1352
|
+
const validates = reads || route.params.some((param) => param.schema !== void 0 || param.coercion !== void 0 && param.coercion.plan.kind !== "raw");
|
|
1353
|
+
const uploads = route.params.some((param) => param.upload !== void 0);
|
|
1354
|
+
if (errors !== false) {
|
|
1355
|
+
const standard = (status, name) => {
|
|
1356
|
+
if (!responses.has(status)) result[status] = { $ref: `#/components/responses/${name}` };
|
|
1357
|
+
};
|
|
1358
|
+
if (validates) standard("400", ERROR_RESPONSES.badRequest);
|
|
1359
|
+
if (reads) standard("413", ERROR_RESPONSES.payloadTooLarge);
|
|
1360
|
+
if (uploads) standard("415", ERROR_RESPONSES.unsupportedMediaType);
|
|
1361
|
+
}
|
|
1362
|
+
for (const [status, draft] of responses) {
|
|
1363
|
+
const response = { description: draft.description ?? reasonPhrase(status) };
|
|
1364
|
+
if (Object.keys(draft.headers).length > 0) response["headers"] = draft.headers;
|
|
1365
|
+
let content = draft.content;
|
|
1366
|
+
if (content === void 0 && errors !== false && /^[45]/.test(status)) {
|
|
1367
|
+
const schema = errors.schema === void 0 ? { $ref: schemaRef(ERROR_COMPONENTS.body) } : this.#input.slots.use(errors.schema, "output") ?? {};
|
|
1368
|
+
content = { [errors.contentType]: { schema } };
|
|
1369
|
+
}
|
|
1370
|
+
if (content !== void 0) response["content"] = content;
|
|
1371
|
+
result[status] = response;
|
|
1372
|
+
}
|
|
1373
|
+
const sorted = {};
|
|
1374
|
+
for (const status of Object.keys(result).sort(compareStatuses)) sorted[status] = result[status];
|
|
1375
|
+
return sorted;
|
|
1376
|
+
}
|
|
1377
|
+
#success(httpMethod) {
|
|
1378
|
+
const { route, method } = this.#source;
|
|
1379
|
+
const example = exampleBodyType(this.#source.controller.displayName);
|
|
1380
|
+
const post = httpMethod === "POST";
|
|
1381
|
+
const statusFor = (empty) => {
|
|
1382
|
+
if (route.status !== void 0) return route.status;
|
|
1383
|
+
if (post) return 201;
|
|
1384
|
+
return empty ? 204 : 200;
|
|
1385
|
+
};
|
|
1386
|
+
const undocumentable = (problem, hints, dynamic = false) => ({
|
|
1387
|
+
status: statusFor(false),
|
|
1388
|
+
dynamic,
|
|
1389
|
+
empty: void 0,
|
|
1390
|
+
content: void 0,
|
|
1391
|
+
problem,
|
|
1392
|
+
hints,
|
|
1393
|
+
location: method?.location
|
|
1394
|
+
});
|
|
1395
|
+
if (route.sse !== void 0) return this.#sse();
|
|
1396
|
+
if (route.rawResponse) return undocumentable("the handler writes the response itself (@Res())", [`Declare it: @ApiResponse<${example}>(${statusFor(false)})`], true);
|
|
1397
|
+
if (method?.returnType === void 0) return undocumentable("the handler has no return type annotation", [`Annotate the return type with the body type: ${handlerKey(route.handler)}(): ${method?.async === true ? `Promise<${example}>` : example}`, `Or declare it: @ApiResponse<${example}>(${statusFor(false)})`]);
|
|
1398
|
+
const result = this.#input.schemas.shape({ returnOf: method });
|
|
1399
|
+
const use = this.#use("the response", "output");
|
|
1400
|
+
this.#input.schemas.register(result, use);
|
|
1401
|
+
let dynamic = false;
|
|
1402
|
+
let returnsUndefined = false;
|
|
1403
|
+
const bodies = [];
|
|
1404
|
+
for (const member of unionMembers(result.root)) if (member.kind === "reference" && member.identity === HttpIds.HttpResponse) {
|
|
1405
|
+
dynamic = true;
|
|
1406
|
+
const body = member.typeArgs[0];
|
|
1407
|
+
if (member.explicitTypeArgs === 0 || body === void 0) return undocumentable("HttpResponse has no body type", [`Give the body type: Promise<HttpResponse<${example}>>`, `Or declare it: @ApiResponse<${example}>(${statusFor(false)})`], true);
|
|
1408
|
+
bodies.push(...unionMembers(body));
|
|
1409
|
+
} else {
|
|
1410
|
+
if (member.kind === "undefined") returnsUndefined = true;
|
|
1411
|
+
bodies.push(member);
|
|
1412
|
+
}
|
|
1413
|
+
const defined = distinct(bodies.filter((member) => member.kind !== "undefined" && member.kind !== "never"));
|
|
1414
|
+
if (defined.some((member) => member.kind === "reference" && member.identity === "globalThis#Response")) return undocumentable("a Web Response has no body type", [`Declare it: @ApiResponse<${example}>(${statusFor(false)})`], true);
|
|
1415
|
+
if (defined.some((member) => member.kind === "unknown")) return undocumentable(`the return type is ${method.returnType.kind === "primitive" ? method.returnType.name : "unknown"}`, ["Give a precise return type", `Or declare it: @ApiResponse<${example}>(${statusFor(false)})`], dynamic);
|
|
1416
|
+
if (defined.length === 0) return {
|
|
1417
|
+
status: statusFor(true),
|
|
1418
|
+
dynamic,
|
|
1419
|
+
empty: void 0,
|
|
1420
|
+
content: void 0,
|
|
1421
|
+
problem: void 0,
|
|
1422
|
+
hints: [],
|
|
1423
|
+
location: void 0
|
|
1424
|
+
};
|
|
1425
|
+
const events = defined.filter((member) => member.kind === "reference" && member.identity === HttpIds.SseBody);
|
|
1426
|
+
if (events.length > 0) {
|
|
1427
|
+
if (events.length < defined.length) return undocumentable("the handler returns an event stream or another body", [`Declare the responses: @ApiResponse(${route.status ?? 200}, { contentType: 'text/event-stream' })`], true);
|
|
1428
|
+
return {
|
|
1429
|
+
status: route.status ?? 200,
|
|
1430
|
+
dynamic: true,
|
|
1431
|
+
empty: void 0,
|
|
1432
|
+
content: httpMethod === "HEAD" ? void 0 : { "text/event-stream": { schema: { type: "string" } } },
|
|
1433
|
+
problem: void 0,
|
|
1434
|
+
hints: [],
|
|
1435
|
+
location: void 0
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
const status = statusFor(false);
|
|
1439
|
+
let content = this.#successContent(defined, use);
|
|
1440
|
+
if (content !== void 0 && isBodylessStatus(status)) {
|
|
1441
|
+
this.#ctx.report({
|
|
1442
|
+
code: Codes.InconsistentOperation,
|
|
1443
|
+
severity: "error",
|
|
1444
|
+
message: `${this.#label} returns a body, but its status is always ${status} (@HttpCode(${status})): ${status} responses have none, the body is never sent`,
|
|
1445
|
+
location: method.location,
|
|
1446
|
+
hints: ["Return nothing (void), or use another status"]
|
|
1447
|
+
});
|
|
1448
|
+
content = void 0;
|
|
1449
|
+
}
|
|
1450
|
+
return {
|
|
1451
|
+
status,
|
|
1452
|
+
dynamic,
|
|
1453
|
+
empty: returnsUndefined && route.status === void 0 && !post && status !== 204 ? 204 : void 0,
|
|
1454
|
+
content: httpMethod === "HEAD" ? void 0 : content,
|
|
1455
|
+
problem: void 0,
|
|
1456
|
+
hints: [],
|
|
1457
|
+
location: void 0
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
/** Content of the success response: media types of the serialiser (§9.1 of the architecture). */
|
|
1461
|
+
#successContent(members, use) {
|
|
1462
|
+
const single = members.length === 1 ? members[0] : void 0;
|
|
1463
|
+
if (single !== void 0) {
|
|
1464
|
+
if (single.kind === "binary" || single.kind === "reference" && single.identity === HttpIds.FileBody) return { "application/octet-stream": {} };
|
|
1465
|
+
if (single.kind === "reference" && single.identity === "globalThis#AsyncIterable") {
|
|
1466
|
+
const event = single.typeArgs[0];
|
|
1467
|
+
const data = event?.kind === "reference" && event.identity === HttpIds.SseEvent ? event.typeArgs[0] : event;
|
|
1468
|
+
const media = { schema: { type: "string" } };
|
|
1469
|
+
if (data !== void 0) media["x-znight-event-schema"] = this.#input.schemas.convert(data, use, "");
|
|
1470
|
+
return { "text/event-stream": media };
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const text = [];
|
|
1474
|
+
const json = [];
|
|
1475
|
+
for (const member of members) this.#splitText(member, text, json, /* @__PURE__ */ new Set());
|
|
1476
|
+
const schemaOf = (shapes) => this.#input.schemas.convert(shapes.length === 1 ? shapes[0] : {
|
|
1477
|
+
kind: "union",
|
|
1478
|
+
members: shapes
|
|
1479
|
+
}, use, "");
|
|
1480
|
+
const content = {};
|
|
1481
|
+
if (json.length > 0) content["application/json"] = { schema: schemaOf(json) };
|
|
1482
|
+
if (text.length > 0) content["text/plain"] = { schema: schemaOf(text) };
|
|
1483
|
+
return content;
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Sorts the members of a body by the media type the serialiser gives them: strings (string, string
|
|
1487
|
+
* literals, string enums) are text, the rest JSON. A named declaration stays whole when all its
|
|
1488
|
+
* members go to one side.
|
|
1489
|
+
*/
|
|
1490
|
+
#splitText(shape, text, json, seen) {
|
|
1491
|
+
const kind = this.#textKind(shape, new Set(seen));
|
|
1492
|
+
if (kind === "text") text.push(shape);
|
|
1493
|
+
else if (kind === "json") json.push(shape);
|
|
1494
|
+
else if (shape.kind === "union") for (const member of shape.members) this.#splitText(member, text, json, seen);
|
|
1495
|
+
else if (shape.kind === "named") {
|
|
1496
|
+
const named = this.#input.schemas.named(shape.key);
|
|
1497
|
+
if (named === void 0 || seen.has(shape.key)) json.push(shape);
|
|
1498
|
+
else this.#splitText(named.shape, text, json, /* @__PURE__ */ new Set([...seen, shape.key]));
|
|
1499
|
+
} else json.push(shape);
|
|
1500
|
+
}
|
|
1501
|
+
#textKind(shape, seen) {
|
|
1502
|
+
switch (shape.kind) {
|
|
1503
|
+
case "string": return "text";
|
|
1504
|
+
case "literal": return typeof shape.value === "string" ? "text" : "json";
|
|
1505
|
+
case "union": {
|
|
1506
|
+
const kinds = new Set(shape.members.map((member) => this.#textKind(member, seen)));
|
|
1507
|
+
return kinds.size === 1 ? [...kinds][0] : "mixed";
|
|
1508
|
+
}
|
|
1509
|
+
case "intersection": return shape.members.some((member) => this.#textKind(member, seen) === "text") ? "text" : "json";
|
|
1510
|
+
case "named": {
|
|
1511
|
+
const named = this.#input.schemas.named(shape.key);
|
|
1512
|
+
if (named === void 0 || seen.has(shape.key)) return "json";
|
|
1513
|
+
seen.add(shape.key);
|
|
1514
|
+
return this.#textKind(named.shape, seen);
|
|
1515
|
+
}
|
|
1516
|
+
default: return "json";
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/** `@Sse` routes: a 200 event stream; the type of the events' `data` when the return type tells it. */
|
|
1520
|
+
#sse() {
|
|
1521
|
+
const { method } = this.#source;
|
|
1522
|
+
const media = { schema: { type: "string" } };
|
|
1523
|
+
if (method?.returnType !== void 0) {
|
|
1524
|
+
const result = this.#input.schemas.shape({ returnOf: method });
|
|
1525
|
+
const use = this.#use("the events", "output");
|
|
1526
|
+
this.#input.schemas.register(result, use);
|
|
1527
|
+
const data = eventData(result.root);
|
|
1528
|
+
if (data !== void 0) media["x-znight-event-schema"] = this.#input.schemas.convert(data, use, "");
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
status: 200,
|
|
1532
|
+
dynamic: true,
|
|
1533
|
+
empty: void 0,
|
|
1534
|
+
content: { "text/event-stream": media },
|
|
1535
|
+
problem: void 0,
|
|
1536
|
+
hints: [],
|
|
1537
|
+
location: void 0
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
#declaredResponse(decorator, responses, success) {
|
|
1541
|
+
const [statusArg, optionsArg] = decorator.args;
|
|
1542
|
+
const raw = statusArg?.value;
|
|
1543
|
+
const status = typeof raw === "number" ? String(raw) : raw;
|
|
1544
|
+
if (typeof status !== "string" || !isResponseStatus(status)) {
|
|
1545
|
+
invalidValue(this.#ctx, statusArg ?? decorator, `Invalid response status ${describe(raw)}: expected a code (100-599), "2XX"-like or "default"`);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
const options = optionsArg?.value;
|
|
1549
|
+
if (options !== void 0 && !isRecord(options)) {
|
|
1550
|
+
invalidValue(this.#ctx, optionsArg ?? decorator, "@ApiResponse options must be { description?, schema?, contentType?, headers?, example? }");
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
const record = isRecord(options) ? options : {};
|
|
1554
|
+
for (const key of Object.keys(record)) if (![
|
|
1555
|
+
"description",
|
|
1556
|
+
"schema",
|
|
1557
|
+
"contentType",
|
|
1558
|
+
"headers",
|
|
1559
|
+
"example"
|
|
1560
|
+
].includes(key)) invalidValue(this.#ctx, optionsArg ?? decorator, `@ApiResponse: unknown option "${key}"`);
|
|
1561
|
+
const reserved = findReservedKey(record);
|
|
1562
|
+
if (reserved !== void 0) invalidValue(this.#ctx, optionsArg ?? decorator, `"$znight" is a reserved key (${reserved})`);
|
|
1563
|
+
const code = Number(status);
|
|
1564
|
+
if (Number.isInteger(code) && code >= 200 && code < 300 && code !== success.status && code !== success.empty) {
|
|
1565
|
+
if (this.#source.route.status !== void 0 || !success.dynamic) {
|
|
1566
|
+
this.#ctx.report({
|
|
1567
|
+
code: Codes.InconsistentOperation,
|
|
1568
|
+
severity: "error",
|
|
1569
|
+
message: `@ApiResponse(${status}) on ${this.#label}, whose success status is always ${success.status}`,
|
|
1570
|
+
location: decorator.location,
|
|
1571
|
+
hints: [this.#source.route.status === void 0 ? `Return an HttpResponse to choose the status, or document ${success.status}` : `@HttpCode(${this.#source.route.status}) sets the status: document ${this.#source.route.status}`]
|
|
1572
|
+
});
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
let schema;
|
|
1577
|
+
const schemaOption = record["schema"];
|
|
1578
|
+
if (isRef(schemaOption)) schema = this.#input.slots.use(schemaOption, "output");
|
|
1579
|
+
else if (schemaOption !== void 0) invalidValue(this.#ctx, optionsArg ?? decorator, "@ApiResponse schema must be a Standard Schema (an exported schema)");
|
|
1580
|
+
else if (decorator.typeArgs.length > 0) {
|
|
1581
|
+
const result = this.#input.schemas.shape({
|
|
1582
|
+
decorator,
|
|
1583
|
+
typeArgument: 0
|
|
1584
|
+
});
|
|
1585
|
+
schema = this.#input.schemas.fromResult(result, this.#use(`the ${status} response`, "output", decorator.location));
|
|
1586
|
+
}
|
|
1587
|
+
if (schema !== void 0 && (status === "204" || status === "304")) {
|
|
1588
|
+
this.#ctx.report({
|
|
1589
|
+
code: Codes.InconsistentOperation,
|
|
1590
|
+
severity: "error",
|
|
1591
|
+
message: `@ApiResponse(${status}) on ${this.#label} declares a body: ${status} responses have none`,
|
|
1592
|
+
location: decorator.location,
|
|
1593
|
+
hints: ["Remove the body type, or use another status"]
|
|
1594
|
+
});
|
|
1595
|
+
schema = void 0;
|
|
1596
|
+
}
|
|
1597
|
+
const contentTypes = this.#mediaTypes(record["contentType"], optionsArg ?? decorator);
|
|
1598
|
+
const draft = responses.get(status) ?? {
|
|
1599
|
+
description: void 0,
|
|
1600
|
+
headers: {},
|
|
1601
|
+
content: void 0
|
|
1602
|
+
};
|
|
1603
|
+
if (typeof record["description"] === "string") draft.description = record["description"];
|
|
1604
|
+
const headers = record["headers"];
|
|
1605
|
+
if (headers !== void 0) {
|
|
1606
|
+
if (isRecord(headers)) for (const [name, header] of Object.entries(headers)) {
|
|
1607
|
+
if (!HEADER_NAME.test(name)) {
|
|
1608
|
+
invalidValue(this.#ctx, optionsArg ?? decorator, `Invalid header name ${JSON.stringify(name)}`);
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
const json = toJson(header);
|
|
1612
|
+
const object = isRecord(json) ? json : {};
|
|
1613
|
+
draft.headers[name.toLowerCase()] = {
|
|
1614
|
+
...draft.headers[name.toLowerCase()],
|
|
1615
|
+
...object,
|
|
1616
|
+
schema: object["schema"] ?? { type: "string" }
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
else invalidValue(this.#ctx, optionsArg ?? decorator, "@ApiResponse headers must be { [name]: { description?, schema?, ... } }");
|
|
1620
|
+
}
|
|
1621
|
+
const example = record["example"] === void 0 ? void 0 : toJson(record["example"]);
|
|
1622
|
+
if (schema !== void 0 || contentTypes !== void 0) {
|
|
1623
|
+
const previous = draft.content === void 0 ? void 0 : Object.values(draft.content)[0];
|
|
1624
|
+
const types = contentTypes ?? (schema === void 0 ? [] : ["application/json"]);
|
|
1625
|
+
const content = {};
|
|
1626
|
+
for (const type of types) {
|
|
1627
|
+
const media = {};
|
|
1628
|
+
const mediaSchema = schema ?? previous?.["schema"];
|
|
1629
|
+
if (mediaSchema !== void 0) media["schema"] = mediaSchema;
|
|
1630
|
+
if (example !== void 0) media["example"] = example;
|
|
1631
|
+
content[type] = media;
|
|
1632
|
+
}
|
|
1633
|
+
draft.content = content;
|
|
1634
|
+
} else if (example !== void 0 && draft.content !== void 0) for (const media of Object.values(draft.content)) media["example"] = example;
|
|
1635
|
+
responses.set(status, draft);
|
|
1636
|
+
}
|
|
1637
|
+
#security() {
|
|
1638
|
+
const { schemes } = this.#input;
|
|
1639
|
+
for (const decorators of [this.#source.methodDecorators, this.#source.classDecorators]) {
|
|
1640
|
+
const decorator = decorators.find((candidate) => candidate.identity === Ids.ApiSecurity);
|
|
1641
|
+
if (decorator === void 0) continue;
|
|
1642
|
+
const requirements = [];
|
|
1643
|
+
for (const arg of decorator.args) {
|
|
1644
|
+
const requirement = schemes.requirement(arg.ref ?? arg.value, arg.location);
|
|
1645
|
+
if (requirement !== void 0) requirements.push(requirement);
|
|
1646
|
+
}
|
|
1647
|
+
return schemes.objects(requirements);
|
|
1648
|
+
}
|
|
1649
|
+
const decisions = this.#input.document.decisions;
|
|
1650
|
+
if (decisions.length === 0) return void 0;
|
|
1651
|
+
const { route, controller } = this.#source;
|
|
1652
|
+
const routeGroups = new Set(route.meta.map((entry) => entry.group));
|
|
1653
|
+
const inherited = controller.meta.filter((entry) => !routeGroups.has(entry.group));
|
|
1654
|
+
for (const entries of [route.meta, inherited]) for (const decision of decisions) {
|
|
1655
|
+
const entry = entries.find((candidate) => candidate.identity === decision.identity);
|
|
1656
|
+
if (entry === void 0) continue;
|
|
1657
|
+
decision.used = true;
|
|
1658
|
+
return this.#decisionSecurity(decision, entry);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
#decisionSecurity(decision, entry) {
|
|
1662
|
+
const { schemes } = this.#input;
|
|
1663
|
+
if (!decision.scopesFromValue) return schemes.objects(decision.security);
|
|
1664
|
+
const value = entry.value;
|
|
1665
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
1666
|
+
this.#ctx.report({
|
|
1667
|
+
code: Codes.InvalidSecurityRequirement,
|
|
1668
|
+
severity: "error",
|
|
1669
|
+
message: `The decision on ${decision.text} takes its scopes from the decorator value, but @${entry.decorator}(...) on ${this.#label} is not a static string array`,
|
|
1670
|
+
location: entry.location,
|
|
1671
|
+
hints: [`Write the scopes literally: @${entry.decorator}(['admin'])`]
|
|
1672
|
+
});
|
|
1673
|
+
return schemes.objects(decision.security);
|
|
1674
|
+
}
|
|
1675
|
+
const scopes = value;
|
|
1676
|
+
for (const requirement of decision.security) for (const { scheme } of requirement) {
|
|
1677
|
+
if (!scheme.scoped) {
|
|
1678
|
+
this.#ctx.report({
|
|
1679
|
+
code: Codes.InvalidSecurityRequirement,
|
|
1680
|
+
severity: "error",
|
|
1681
|
+
message: `The decision on ${decision.text} gives scopes to "${scheme.name}" (${String(scheme.definition.type)}): scopes are only allowed on oauth2 and openIdConnect schemes`,
|
|
1682
|
+
location: decision.location,
|
|
1683
|
+
hints: ["Remove scopes: 'value', or use an oauth2 scheme"]
|
|
1684
|
+
});
|
|
1685
|
+
return schemes.objects(decision.security);
|
|
1686
|
+
}
|
|
1687
|
+
const unknown = scheme.scopes === void 0 ? [] : scopes.filter((scope) => !scheme.scopes?.has(scope));
|
|
1688
|
+
if (unknown.length > 0) this.#ctx.report({
|
|
1689
|
+
code: Codes.InvalidSecurityRequirement,
|
|
1690
|
+
severity: "error",
|
|
1691
|
+
message: `@${entry.decorator}(${JSON.stringify(scopes)}) on ${this.#label} uses ${unknown.length > 1 ? "scopes" : "a scope"} that "${scheme.name}" does not declare: ${unknown.join(", ")}`,
|
|
1692
|
+
location: entry.location,
|
|
1693
|
+
related: [{
|
|
1694
|
+
message: "the security scheme",
|
|
1695
|
+
location: scheme.location
|
|
1696
|
+
}],
|
|
1697
|
+
hints: [`Declare the scope in the flows of ${scheme.name}, or fix the decorator value`]
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
return schemes.objects(decision.security, scopes);
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
1703
|
+
/** A media type without declared schema: text is a string, bytes have no schema, anything else is any value. */
|
|
1704
|
+
function defaultMedia(type) {
|
|
1705
|
+
if (type.startsWith("text/")) return { schema: { type: "string" } };
|
|
1706
|
+
return type === "application/octet-stream" ? {} : { schema: {} };
|
|
1707
|
+
}
|
|
1708
|
+
function stringOption(options, key) {
|
|
1709
|
+
const value = options?.[key];
|
|
1710
|
+
return typeof value === "string" ? value : void 0;
|
|
1711
|
+
}
|
|
1712
|
+
function placeholder(location, name) {
|
|
1713
|
+
return {
|
|
1714
|
+
$znight: "parameters",
|
|
1715
|
+
in: location,
|
|
1716
|
+
name,
|
|
1717
|
+
io: "input"
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
function isArrayPlan(plan) {
|
|
1721
|
+
if (plan === void 0) return false;
|
|
1722
|
+
if (plan.kind === "optional") return isArrayPlan(plan.item);
|
|
1723
|
+
return plan.kind === "array";
|
|
1724
|
+
}
|
|
1725
|
+
/** The members of a union (the shape itself otherwise). */
|
|
1726
|
+
function unionMembers(shape) {
|
|
1727
|
+
return shape.kind === "union" ? shape.members : [shape];
|
|
1728
|
+
}
|
|
1729
|
+
/** The shapes without repetitions (`Book | HttpResponse<Book>` documents `Book` once). */
|
|
1730
|
+
function distinct(shapes) {
|
|
1731
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1732
|
+
return shapes.filter((shape) => {
|
|
1733
|
+
let key;
|
|
1734
|
+
try {
|
|
1735
|
+
key = JSON.stringify(shape);
|
|
1736
|
+
} catch {
|
|
1737
|
+
return true;
|
|
1738
|
+
}
|
|
1739
|
+
if (seen.has(key)) return false;
|
|
1740
|
+
seen.add(key);
|
|
1741
|
+
return true;
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
/** Statuses whose responses have no body: the serialiser drops it (204, 304, 1xx). */
|
|
1745
|
+
function isBodylessStatus(status) {
|
|
1746
|
+
return status === 204 || status === 304 || status >= 100 && status < 200;
|
|
1747
|
+
}
|
|
1748
|
+
/** The `T` of `SseEvent<T>` found in a stream type (`AsyncIterable<SseEvent<T>> | HttpResponse`). */
|
|
1749
|
+
function eventData(shape) {
|
|
1750
|
+
if (shape.kind === "reference") {
|
|
1751
|
+
if (shape.identity === HttpIds.SseEvent) return shape.typeArgs[0];
|
|
1752
|
+
for (const arg of shape.typeArgs) {
|
|
1753
|
+
const found = eventData(arg);
|
|
1754
|
+
if (found !== void 0) return found;
|
|
1755
|
+
}
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
if (shape.kind === "union") for (const member of shape.members) {
|
|
1759
|
+
const found = eventData(member);
|
|
1760
|
+
if (found !== void 0) return found;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
/** JSON Schema of a coercion plan (§5.4.2). */
|
|
1764
|
+
function planSchema(plan) {
|
|
1765
|
+
switch (plan.kind) {
|
|
1766
|
+
case "string":
|
|
1767
|
+
case "number":
|
|
1768
|
+
case "integer":
|
|
1769
|
+
case "boolean": return { type: plan.kind };
|
|
1770
|
+
case "bigint": return {
|
|
1771
|
+
type: "string",
|
|
1772
|
+
pattern: "^-?[0-9]+$"
|
|
1773
|
+
};
|
|
1774
|
+
case "date": return {
|
|
1775
|
+
type: "string",
|
|
1776
|
+
format: "date-time"
|
|
1777
|
+
};
|
|
1778
|
+
case "enum": {
|
|
1779
|
+
const types = [...new Set(plan.values.map((value) => typeof value))];
|
|
1780
|
+
return types.length === 1 ? {
|
|
1781
|
+
type: types[0],
|
|
1782
|
+
enum: [...plan.values]
|
|
1783
|
+
} : { enum: [...plan.values] };
|
|
1784
|
+
}
|
|
1785
|
+
case "array": return {
|
|
1786
|
+
type: "array",
|
|
1787
|
+
items: planSchema(plan.item)
|
|
1788
|
+
};
|
|
1789
|
+
case "optional": return planSchema(plan.item);
|
|
1790
|
+
case "raw": return {};
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/** `2 MiB`, `512 KiB`, `1000 bytes`. */
|
|
1794
|
+
function formatSize(bytes) {
|
|
1795
|
+
const units = [
|
|
1796
|
+
"GiB",
|
|
1797
|
+
"MiB",
|
|
1798
|
+
"KiB"
|
|
1799
|
+
];
|
|
1800
|
+
const factors = [
|
|
1801
|
+
1024 ** 3,
|
|
1802
|
+
1024 ** 2,
|
|
1803
|
+
1024
|
|
1804
|
+
];
|
|
1805
|
+
for (const [index, unit] of units.entries()) {
|
|
1806
|
+
const factor = factors[index];
|
|
1807
|
+
if (bytes >= factor && bytes % (factor / 4) === 0) return `${bytes / factor} ${unit}`;
|
|
1808
|
+
}
|
|
1809
|
+
return `${bytes} bytes`;
|
|
1810
|
+
}
|
|
1811
|
+
/** Status keys: codes first in numeric order, then ranges, then `default`. */
|
|
1812
|
+
function compareStatuses(a, b) {
|
|
1813
|
+
const rank = (status) => {
|
|
1814
|
+
if (status === "default") return 2;
|
|
1815
|
+
return /X/.test(status) ? 1 : 0;
|
|
1816
|
+
};
|
|
1817
|
+
return rank(a) - rank(b) || compareStrings(a, b);
|
|
1818
|
+
}
|
|
1819
|
+
/** A handler name as written in a class body: `get`, or `'get-item'` for a quoted key. */
|
|
1820
|
+
function handlerKey(name) {
|
|
1821
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : `'${name.replace(/['\\]/g, "\\$&")}'`;
|
|
1822
|
+
}
|
|
1823
|
+
/** The body type named by the hints (`ItemController` → `ItemResponse`): an example, never a literal `Body`. */
|
|
1824
|
+
function exampleBodyType(controller) {
|
|
1825
|
+
const base = controller.replace(/Controller$/, "");
|
|
1826
|
+
return base === "" ? "ResponseBody" : `${base}Response`;
|
|
1827
|
+
}
|
|
1828
|
+
// src/compiler/schemas.ts
|
|
1829
|
+
/** Standard Schemas referenced by the document, by identity. */
|
|
1830
|
+
var Slots = class {
|
|
1831
|
+
#slots = /* @__PURE__ */ new Map();
|
|
1832
|
+
#names = /* @__PURE__ */ new Map();
|
|
1833
|
+
#report;
|
|
1834
|
+
#reserved;
|
|
1835
|
+
#locate;
|
|
1836
|
+
constructor(report, reserved, locate = () => void 0) {
|
|
1837
|
+
this.#report = report;
|
|
1838
|
+
this.#reserved = reserved;
|
|
1839
|
+
this.#locate = locate;
|
|
1840
|
+
}
|
|
1841
|
+
/** The placeholder of `ref` with this io (registers the slot), `undefined` after a name conflict. */
|
|
1842
|
+
use(ref, io) {
|
|
1843
|
+
const identity = ref.identity ?? `${ref.location.file}#${ref.text}`;
|
|
1844
|
+
let slot = this.#slots.get(identity);
|
|
1845
|
+
if (slot === void 0) {
|
|
1846
|
+
const name = componentName(ref.name.slice(ref.name.lastIndexOf(".") + 1));
|
|
1847
|
+
const other = this.#names.get(name);
|
|
1848
|
+
if (other !== void 0 && other.identity !== identity) {
|
|
1849
|
+
this.#report({
|
|
1850
|
+
code: Codes.SchemaNameConflict,
|
|
1851
|
+
severity: "error",
|
|
1852
|
+
message: `Two schemas are named "${name}" in the OpenAPI document: ${ref.text} and ${other.identity}`,
|
|
1853
|
+
location: ref.location,
|
|
1854
|
+
related: [{
|
|
1855
|
+
message: "the other schema",
|
|
1856
|
+
location: other.location
|
|
1857
|
+
}],
|
|
1858
|
+
hints: ["Rename one of the exported schemas: component names come from their export names"]
|
|
1859
|
+
});
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
if (this.#reserved().has(name)) {
|
|
1863
|
+
this.#report({
|
|
1864
|
+
code: Codes.SchemaNameConflict,
|
|
1865
|
+
severity: "error",
|
|
1866
|
+
message: `The schema ${ref.text} is named "${name}", a name reserved for the standard error components`,
|
|
1867
|
+
location: ref.location,
|
|
1868
|
+
hints: ["Rename the schema, or turn the standard errors off: @OpenApiDocument({ errors: false })"]
|
|
1869
|
+
});
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
this.#names.set(name, {
|
|
1873
|
+
identity,
|
|
1874
|
+
location: ref.location
|
|
1875
|
+
});
|
|
1876
|
+
slot = {
|
|
1877
|
+
name,
|
|
1878
|
+
ref,
|
|
1879
|
+
io: /* @__PURE__ */ new Set(),
|
|
1880
|
+
location: this.#locate(ref) ?? ref.location
|
|
1881
|
+
};
|
|
1882
|
+
this.#slots.set(identity, slot);
|
|
1883
|
+
}
|
|
1884
|
+
slot.io.add(io);
|
|
1885
|
+
return {
|
|
1886
|
+
$znight: "schema",
|
|
1887
|
+
name: slot.name,
|
|
1888
|
+
io
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/** Whether a slot uses this component name. */
|
|
1892
|
+
has(name) {
|
|
1893
|
+
return this.#names.get(name);
|
|
1894
|
+
}
|
|
1895
|
+
/** Adds an io to the slot of `identity` (no effect for an unknown slot). */
|
|
1896
|
+
need(identity, io) {
|
|
1897
|
+
this.#slots.get(identity)?.io.add(io);
|
|
1898
|
+
}
|
|
1899
|
+
/** Slots sorted by name. */
|
|
1900
|
+
list() {
|
|
1901
|
+
return [...this.#slots.values()].sort((a, b) => compareStrings(a.name, b.name));
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
/** Converts shapes to JSON Schema and collects the components. */
|
|
1905
|
+
var Schemas = class {
|
|
1906
|
+
#ctx;
|
|
1907
|
+
#options;
|
|
1908
|
+
#slots;
|
|
1909
|
+
#strict;
|
|
1910
|
+
#reserved;
|
|
1911
|
+
/** Component name by named key. */
|
|
1912
|
+
#names = /* @__PURE__ */ new Map();
|
|
1913
|
+
/** The first named key given each component name. */
|
|
1914
|
+
#keys = /* @__PURE__ */ new Map();
|
|
1915
|
+
/** Components by name. */
|
|
1916
|
+
#components = /* @__PURE__ */ new Map();
|
|
1917
|
+
/** Every named shape seen, by key. */
|
|
1918
|
+
#named = /* @__PURE__ */ new Map();
|
|
1919
|
+
/** Issues reported so far, with the severity they were reported at. */
|
|
1920
|
+
#reported = /* @__PURE__ */ new WeakMap();
|
|
1921
|
+
#conflicts = /* @__PURE__ */ new Set();
|
|
1922
|
+
constructor(ctx, options, slots) {
|
|
1923
|
+
this.#ctx = ctx;
|
|
1924
|
+
this.#slots = slots;
|
|
1925
|
+
this.#strict = options.strict;
|
|
1926
|
+
this.#reserved = options.reserved;
|
|
1927
|
+
this.#options = {
|
|
1928
|
+
schemaAliases: SCHEMA_ALIASES,
|
|
1929
|
+
schemaMembers: SCHEMA_MEMBERS,
|
|
1930
|
+
keep: KEEP,
|
|
1931
|
+
jsdoc: options.jsdoc
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
/** The shape of a target with the options of the extension. */
|
|
1935
|
+
shape(target) {
|
|
1936
|
+
return this.#ctx.types.shape(target, this.#options);
|
|
1937
|
+
}
|
|
1938
|
+
/** Components computed so far, sorted by name. */
|
|
1939
|
+
components() {
|
|
1940
|
+
const result = {};
|
|
1941
|
+
for (const name of [...this.#components.keys()].sort(compareStrings)) {
|
|
1942
|
+
const schema = this.#components.get(name)?.schema;
|
|
1943
|
+
if (schema !== void 0) result[name] = schema;
|
|
1944
|
+
}
|
|
1945
|
+
return result;
|
|
1946
|
+
}
|
|
1947
|
+
/** A named shape seen by a result (`undefined` for an unknown key). */
|
|
1948
|
+
named(key) {
|
|
1949
|
+
return this.#named.get(key);
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Registers the named shapes of `result` and reports its issues. Call it before converting shapes
|
|
1953
|
+
* of the result.
|
|
1954
|
+
*/
|
|
1955
|
+
register(result, use) {
|
|
1956
|
+
for (const named of result.named.values()) this.#named.set(named.key, named);
|
|
1957
|
+
for (const named of result.named.values()) this.#component(named);
|
|
1958
|
+
for (const issue of result.issues) this.#issue(issue, use);
|
|
1959
|
+
}
|
|
1960
|
+
/** JSON Schema of the root of `result` (registers it first). */
|
|
1961
|
+
fromResult(result, use) {
|
|
1962
|
+
this.register(result, use);
|
|
1963
|
+
return this.convert(result.root, use, "");
|
|
1964
|
+
}
|
|
1965
|
+
/** JSON Schema of a shape whose result was {@link register}ed. */
|
|
1966
|
+
convert(shape, use, path) {
|
|
1967
|
+
switch (shape.kind) {
|
|
1968
|
+
case "string": return { type: "string" };
|
|
1969
|
+
case "number": return { type: "number" };
|
|
1970
|
+
case "boolean": return { type: "boolean" };
|
|
1971
|
+
case "null": return { type: "null" };
|
|
1972
|
+
case "undefined":
|
|
1973
|
+
case "unknown": return {};
|
|
1974
|
+
case "never": return { not: {} };
|
|
1975
|
+
case "date": return {
|
|
1976
|
+
type: "string",
|
|
1977
|
+
format: "date-time"
|
|
1978
|
+
};
|
|
1979
|
+
case "url": return {
|
|
1980
|
+
type: "string",
|
|
1981
|
+
format: "uri"
|
|
1982
|
+
};
|
|
1983
|
+
case "bigint": return {
|
|
1984
|
+
type: "string",
|
|
1985
|
+
pattern: "^-?[0-9]+$"
|
|
1986
|
+
};
|
|
1987
|
+
case "binary":
|
|
1988
|
+
if (path !== "") this.#warn(`${path} is binary data: inside a JSON body JSON.stringify writes it as an object of numeric keys (${use.operation})`, use, ["Send bytes as the whole body (application/octet-stream), or encode them (base64 string)"]);
|
|
1989
|
+
return {
|
|
1990
|
+
type: "string",
|
|
1991
|
+
contentMediaType: "application/octet-stream"
|
|
1992
|
+
};
|
|
1993
|
+
case "literal": return {
|
|
1994
|
+
type: typeof shape.value,
|
|
1995
|
+
const: shape.value
|
|
1996
|
+
};
|
|
1997
|
+
case "array": return {
|
|
1998
|
+
type: "array",
|
|
1999
|
+
items: this.convert(shape.items, use, `${path}[]`)
|
|
2000
|
+
};
|
|
2001
|
+
case "tuple": return this.#tuple(shape, use, path);
|
|
2002
|
+
case "object": return this.#object(shape.properties, shape.additional, use, path);
|
|
2003
|
+
case "union": return this.#union(shape.members, use, path);
|
|
2004
|
+
case "intersection": return this.#intersection(shape.members, use, path);
|
|
2005
|
+
case "named": {
|
|
2006
|
+
const name = this.#nameOfKey(shape.key);
|
|
2007
|
+
return name === void 0 ? {} : { $ref: schemaRef(name) };
|
|
2008
|
+
}
|
|
2009
|
+
case "schema": return this.#slots.use(shape.ref, shape.io) ?? {};
|
|
2010
|
+
case "reference":
|
|
2011
|
+
this.#warn(`${path === "" ? "The type" : path} (${shape.name}) cannot be documented inside a JSON body (${use.operation})`, use, []);
|
|
2012
|
+
return {};
|
|
2013
|
+
case "opaque": return {};
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* Checks the components against the schema slots once every operation is built, whatever the order
|
|
2018
|
+
* they were met in: a declaration merged with the schema it is inferred from (`const User =
|
|
2019
|
+
* z.object(...)` and `interface User extends z.infer<typeof User> {}`) is documented by the schema
|
|
2020
|
+
* (its output variant); another declaration with the name of a schema is ZNIGHT034.
|
|
2021
|
+
*/
|
|
2022
|
+
settle() {
|
|
2023
|
+
for (const [name, component] of [...this.#components]) {
|
|
2024
|
+
const slot = this.#slots.has(name);
|
|
2025
|
+
if (slot === void 0) continue;
|
|
2026
|
+
if (slot.identity === component.identity) {
|
|
2027
|
+
this.#components.delete(name);
|
|
2028
|
+
this.#slots.need(slot.identity, "output");
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
const named = this.#named.get(component.key);
|
|
2032
|
+
const key = `${name}\u0000${component.key}`;
|
|
2033
|
+
if (this.#conflicts.has(key)) continue;
|
|
2034
|
+
this.#conflicts.add(key);
|
|
2035
|
+
this.#ctx.report({
|
|
2036
|
+
code: Codes.SchemaNameConflict,
|
|
2037
|
+
severity: "error",
|
|
2038
|
+
message: `Two schemas are named "${name}" in the OpenAPI document: the ${named?.declaration ?? "declaration"} ${named?.name ?? component.identity} and the schema ${slot.identity}`,
|
|
2039
|
+
location: component.location,
|
|
2040
|
+
related: [{
|
|
2041
|
+
message: "the schema",
|
|
2042
|
+
location: slot.location
|
|
2043
|
+
}],
|
|
2044
|
+
hints: ["Rename one of them: component names come from declaration and export names"]
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
#component(named) {
|
|
2049
|
+
const name = this.#nameOf(named);
|
|
2050
|
+
const existing = this.#components.get(name);
|
|
2051
|
+
if (existing !== void 0) {
|
|
2052
|
+
if (existing.key !== named.key) this.#conflict(name, named, existing);
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
if (this.#reserved.has(name)) {
|
|
2056
|
+
const key = `${name}\u0000${named.key}`;
|
|
2057
|
+
if (!this.#conflicts.has(key)) {
|
|
2058
|
+
this.#conflicts.add(key);
|
|
2059
|
+
this.#ctx.report({
|
|
2060
|
+
code: Codes.SchemaNameConflict,
|
|
2061
|
+
severity: "error",
|
|
2062
|
+
message: `${named.name} is named "${name}", a name reserved for the standard error components of the OpenAPI document`,
|
|
2063
|
+
location: named.location,
|
|
2064
|
+
hints: ["Rename one of them: component names come from declaration and export names"]
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
const component = {
|
|
2070
|
+
key: named.key,
|
|
2071
|
+
identity: named.identity,
|
|
2072
|
+
location: named.location,
|
|
2073
|
+
schema: void 0
|
|
2074
|
+
};
|
|
2075
|
+
this.#components.set(name, component);
|
|
2076
|
+
const use = {
|
|
2077
|
+
io: "output",
|
|
2078
|
+
operation: named.name,
|
|
2079
|
+
part: named.name,
|
|
2080
|
+
location: named.location
|
|
2081
|
+
};
|
|
2082
|
+
component.schema = withDoc(this.convert(named.shape, use, named.name), named.doc, (issue) => this.#tagIssue(issue));
|
|
2083
|
+
}
|
|
2084
|
+
#conflict(name, named, existing) {
|
|
2085
|
+
const key = [named.key, existing.key].sort(compareStrings).join("\0");
|
|
2086
|
+
if (this.#conflicts.has(key)) return;
|
|
2087
|
+
this.#conflicts.add(key);
|
|
2088
|
+
this.#ctx.report({
|
|
2089
|
+
code: Codes.SchemaNameConflict,
|
|
2090
|
+
severity: "error",
|
|
2091
|
+
message: `Two declarations are named "${name}" in the OpenAPI document: ${named.identity} and ${existing.identity}`,
|
|
2092
|
+
location: named.location,
|
|
2093
|
+
related: [{
|
|
2094
|
+
message: "the other declaration",
|
|
2095
|
+
location: existing.location
|
|
2096
|
+
}],
|
|
2097
|
+
hints: ["Rename one of them: component names come from declaration names"]
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
#nameOfKey(key) {
|
|
2101
|
+
const named = this.#named.get(key);
|
|
2102
|
+
return named === void 0 ? void 0 : this.#nameOf(named);
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* The component name of a declaration: its name, then the names of its type arguments
|
|
2106
|
+
* (`Page_Book`). An instantiation with an anonymous argument (`Page<{ id: string }>`), or whose
|
|
2107
|
+
* name another instantiation of the same declaration already has (`Box<Book[]>`, `Box<BookArray>`),
|
|
2108
|
+
* also gets a short hash of its key (`Page_object_1a2b3c4d`): two instantiations never clash.
|
|
2109
|
+
*/
|
|
2110
|
+
#nameOf(named) {
|
|
2111
|
+
let name = this.#names.get(named.key);
|
|
2112
|
+
if (name === void 0) {
|
|
2113
|
+
if (named.typeArgs.length === 0) name = componentName(named.name);
|
|
2114
|
+
else {
|
|
2115
|
+
const args = named.typeArgs.map((arg) => this.#argName(arg));
|
|
2116
|
+
name = componentName([named.name, ...args.map((arg) => arg.text)].join("_"));
|
|
2117
|
+
const taken = this.#keys.get(name);
|
|
2118
|
+
if (taken !== void 0 && taken !== named.key && this.#named.get(taken)?.identity === named.identity || args.some((arg) => !arg.exact)) name = `${name}_${shortHash(named.key)}`;
|
|
2119
|
+
}
|
|
2120
|
+
this.#names.set(named.key, name);
|
|
2121
|
+
if (!this.#keys.has(name)) this.#keys.set(name, named.key);
|
|
2122
|
+
}
|
|
2123
|
+
return name;
|
|
2124
|
+
}
|
|
2125
|
+
/** The name of a type argument; `exact: false` when it does not tell the argument apart (`object`). */
|
|
2126
|
+
#argName(shape) {
|
|
2127
|
+
switch (shape.kind) {
|
|
2128
|
+
case "named": return {
|
|
2129
|
+
text: this.#nameOfKey(shape.key) ?? "unknown",
|
|
2130
|
+
exact: true
|
|
2131
|
+
};
|
|
2132
|
+
case "literal": return {
|
|
2133
|
+
text: String(shape.value),
|
|
2134
|
+
exact: true
|
|
2135
|
+
};
|
|
2136
|
+
case "array": {
|
|
2137
|
+
const items = this.#argName(shape.items);
|
|
2138
|
+
return {
|
|
2139
|
+
text: `${items.text}Array`,
|
|
2140
|
+
exact: items.exact
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
case "union":
|
|
2144
|
+
case "intersection": {
|
|
2145
|
+
const members = shape.members.map((member) => this.#argName(member));
|
|
2146
|
+
return {
|
|
2147
|
+
text: members.map((member) => member.text).join(shape.kind === "union" ? "_or_" : "_and_"),
|
|
2148
|
+
exact: members.every((member) => member.exact)
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
case "schema": return {
|
|
2152
|
+
text: componentName(shape.ref.name),
|
|
2153
|
+
exact: true
|
|
2154
|
+
};
|
|
2155
|
+
case "reference": return {
|
|
2156
|
+
text: shape.name,
|
|
2157
|
+
exact: shape.typeArgs.length === 0
|
|
2158
|
+
};
|
|
2159
|
+
case "opaque":
|
|
2160
|
+
case "object":
|
|
2161
|
+
case "tuple": return {
|
|
2162
|
+
text: shape.kind,
|
|
2163
|
+
exact: false
|
|
2164
|
+
};
|
|
2165
|
+
default: return {
|
|
2166
|
+
text: shape.kind,
|
|
2167
|
+
exact: true
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
#tuple(shape, use, path) {
|
|
2172
|
+
const schema = {
|
|
2173
|
+
type: "array",
|
|
2174
|
+
prefixItems: shape.items.map((item, index) => this.convert(item, use, `${path}[${index}]`)),
|
|
2175
|
+
minItems: shape.minItems
|
|
2176
|
+
};
|
|
2177
|
+
if (shape.rest === void 0) {
|
|
2178
|
+
schema["maxItems"] = shape.items.length;
|
|
2179
|
+
schema["items"] = false;
|
|
2180
|
+
} else schema["items"] = this.convert(shape.rest, use, `${path}[]`);
|
|
2181
|
+
return schema;
|
|
2182
|
+
}
|
|
2183
|
+
#object(properties, additional, use, path) {
|
|
2184
|
+
const schema = { type: "object" };
|
|
2185
|
+
if (properties.length > 0) {
|
|
2186
|
+
const props = {};
|
|
2187
|
+
for (const property of properties) {
|
|
2188
|
+
const converted = this.convert(property.shape, use, path === "" ? property.name : `${path}.${property.name}`);
|
|
2189
|
+
props[property.name] = withDoc(converted, property.doc, (issue) => this.#tagIssue(issue));
|
|
2190
|
+
}
|
|
2191
|
+
schema["properties"] = props;
|
|
2192
|
+
const required = properties.filter((property) => !property.optional).map((property) => property.name).sort(compareStrings);
|
|
2193
|
+
if (required.length > 0) schema["required"] = required;
|
|
2194
|
+
}
|
|
2195
|
+
if (additional !== void 0) schema["additionalProperties"] = this.convert(additional, use, `${path}[string]`);
|
|
2196
|
+
return schema;
|
|
2197
|
+
}
|
|
2198
|
+
#union(members, use, path) {
|
|
2199
|
+
const defined = members.filter((member) => member.kind !== "undefined");
|
|
2200
|
+
const nullable = defined.some((member) => member.kind === "null");
|
|
2201
|
+
const rest = defined.filter((member) => member.kind !== "null");
|
|
2202
|
+
if (rest.length === 0) return nullable ? { type: "null" } : {};
|
|
2203
|
+
if (rest.every((member) => member.kind === "literal")) {
|
|
2204
|
+
const values = rest.map((member) => member.value);
|
|
2205
|
+
const types = [...new Set(values.map((value) => typeof value))];
|
|
2206
|
+
const schema = {};
|
|
2207
|
+
if (types.length === 1) schema["type"] = nullable ? [types[0], "null"] : types[0];
|
|
2208
|
+
schema["enum"] = nullable ? [...values, null] : values;
|
|
2209
|
+
return schema;
|
|
2210
|
+
}
|
|
2211
|
+
const converted = rest.map((member) => this.convert(member, use, path));
|
|
2212
|
+
if (converted.length === 1) {
|
|
2213
|
+
const single = converted[0];
|
|
2214
|
+
if (!nullable) return single;
|
|
2215
|
+
if (typeof single["type"] === "string" && !Object.keys(single).some((key) => NOT_NULLABLE_KEYS.has(key))) return {
|
|
2216
|
+
...single,
|
|
2217
|
+
type: [single["type"], "null"]
|
|
2218
|
+
};
|
|
2219
|
+
return { anyOf: [single, { type: "null" }] };
|
|
2220
|
+
}
|
|
2221
|
+
const simple = converted;
|
|
2222
|
+
if (simple.every((schema) => Object.keys(schema).length === 1 && typeof schema["type"] === "string")) {
|
|
2223
|
+
const types = simple.map((schema) => schema["type"]);
|
|
2224
|
+
return { type: nullable ? [...types, "null"] : types };
|
|
2225
|
+
}
|
|
2226
|
+
const discriminator = this.#discriminator(rest);
|
|
2227
|
+
const list = converted.map((schema) => schema);
|
|
2228
|
+
if (nullable) list.push({ type: "null" });
|
|
2229
|
+
if (discriminator === void 0) return { anyOf: list };
|
|
2230
|
+
const mapping = {};
|
|
2231
|
+
rest.forEach((member, index) => {
|
|
2232
|
+
const ref = converted[index]["$ref"];
|
|
2233
|
+
if (member.kind === "named" && typeof ref === "string") mapping[String(discriminator.values[index])] = ref;
|
|
2234
|
+
});
|
|
2235
|
+
const schema = { oneOf: list };
|
|
2236
|
+
if (Object.keys(mapping).length === rest.length) schema["discriminator"] = {
|
|
2237
|
+
propertyName: discriminator.property,
|
|
2238
|
+
mapping
|
|
2239
|
+
};
|
|
2240
|
+
return schema;
|
|
2241
|
+
}
|
|
2242
|
+
/** A required literal property with distinct (string) values in every object member. */
|
|
2243
|
+
#discriminator(members) {
|
|
2244
|
+
const objects = members.map((member) => this.#objectOf(member));
|
|
2245
|
+
if (objects.some((object) => object === void 0)) return void 0;
|
|
2246
|
+
const literals = objects.map((object) => {
|
|
2247
|
+
const map = /* @__PURE__ */ new Map();
|
|
2248
|
+
for (const property of object?.properties ?? []) if (!property.optional && property.shape.kind === "literal" && typeof property.shape.value === "string") map.set(property.name, property.shape.value);
|
|
2249
|
+
return map;
|
|
2250
|
+
});
|
|
2251
|
+
const first = literals[0];
|
|
2252
|
+
if (first === void 0) return void 0;
|
|
2253
|
+
for (const property of [...first.keys()].sort(compareStrings)) {
|
|
2254
|
+
if (!literals.every((map) => map.has(property))) continue;
|
|
2255
|
+
const values = literals.map((map) => map.get(property));
|
|
2256
|
+
if (new Set(values).size === values.length) return {
|
|
2257
|
+
property,
|
|
2258
|
+
values
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
/** The object structure of a shape (named declarations resolved). */
|
|
2263
|
+
#objectOf(shape) {
|
|
2264
|
+
if (shape.kind === "object") return shape;
|
|
2265
|
+
if (shape.kind === "named") {
|
|
2266
|
+
const named = this.#named.get(shape.key);
|
|
2267
|
+
return named === void 0 ? void 0 : this.#objectOf(named.shape);
|
|
2268
|
+
}
|
|
2269
|
+
if (shape.kind === "intersection") {
|
|
2270
|
+
const parts = shape.members.map((member) => this.#objectOf(member));
|
|
2271
|
+
if (parts.some((part) => part === void 0)) return void 0;
|
|
2272
|
+
const properties = /* @__PURE__ */ new Map();
|
|
2273
|
+
for (const part of parts) for (const property of part?.properties ?? []) properties.set(property.name, property);
|
|
2274
|
+
return {
|
|
2275
|
+
kind: "object",
|
|
2276
|
+
properties: [...properties.values()]
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
#intersection(members, use, path) {
|
|
2281
|
+
const merged = this.#objectOf({
|
|
2282
|
+
kind: "intersection",
|
|
2283
|
+
members
|
|
2284
|
+
});
|
|
2285
|
+
if (merged !== void 0) {
|
|
2286
|
+
const additional = members.map((member) => this.#objectOf(member)?.additional).find((value) => value !== void 0);
|
|
2287
|
+
return this.#object(merged.properties, additional, use, path);
|
|
2288
|
+
}
|
|
2289
|
+
return { allOf: members.map((member) => this.convert(member, use, path)) };
|
|
2290
|
+
}
|
|
2291
|
+
#issue(issue, use) {
|
|
2292
|
+
const severity = issue.reason === "bigint" && use.io === "output" || this.#strict ? "error" : "warning";
|
|
2293
|
+
const reported = this.#reported.get(issue);
|
|
2294
|
+
if (reported === "error" || reported === severity) return;
|
|
2295
|
+
this.#reported.set(issue, severity);
|
|
2296
|
+
const subject = issue.path === "" ? `The type of ${use.part}` : issue.path;
|
|
2297
|
+
const location = issue.location ?? use.location;
|
|
2298
|
+
const related = issue.location !== void 0 && use.location !== void 0 ? [{
|
|
2299
|
+
message: `${use.part} of ${use.operation}`,
|
|
2300
|
+
location: use.location
|
|
2301
|
+
}] : [];
|
|
2302
|
+
if (issue.reason === "bigint") {
|
|
2303
|
+
const response = use.io === "output";
|
|
2304
|
+
this.#ctx.report({
|
|
2305
|
+
code: Codes.UnrepresentableType,
|
|
2306
|
+
severity,
|
|
2307
|
+
message: response ? `${subject} is a bigint: JSON.stringify throws on bigint, so ${use.operation} would answer 500` : `${subject} is a bigint: JSON has no bigint (${use.operation})`,
|
|
2308
|
+
...location === void 0 ? {} : { location },
|
|
2309
|
+
related,
|
|
2310
|
+
hints: [response ? "Return a number or a string (String(total)), or give the type a toJSON() method" : "Declare a string and convert it (BigInt(value)), or validate the body with a schema"]
|
|
2311
|
+
});
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
this.#ctx.report({
|
|
2315
|
+
code: Codes.UnrepresentableType,
|
|
2316
|
+
severity,
|
|
2317
|
+
message: `${subject} (${issue.text}) ${REASONS[issue.reason] ?? "has no JSON Schema"}: it is documented as any value (${use.operation})`,
|
|
2318
|
+
...location === void 0 ? {} : { location },
|
|
2319
|
+
related,
|
|
2320
|
+
hints: HINTS[issue.reason] ?? []
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
#warn(message, use, hints) {
|
|
2324
|
+
this.#ctx.report({
|
|
2325
|
+
code: Codes.UnrepresentableType,
|
|
2326
|
+
severity: this.#strict ? "error" : "warning",
|
|
2327
|
+
message,
|
|
2328
|
+
...use.location === void 0 ? {} : { location: use.location },
|
|
2329
|
+
hints
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
#tagIssue(issue) {
|
|
2333
|
+
this.#ctx.report({
|
|
2334
|
+
code: Codes.UnrepresentableType,
|
|
2335
|
+
severity: this.#strict ? "error" : "warning",
|
|
2336
|
+
message: issue.message,
|
|
2337
|
+
location: issue.location,
|
|
2338
|
+
hints: ["JSDoc tag values are JSON (`@minimum 1`, `@example {\"id\": 1}`) or text"]
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2342
|
+
/** 8 hexadecimal characters of the SHA-256 of `text` (deterministic name suffixes). */
|
|
2343
|
+
function shortHash(text) {
|
|
2344
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
2345
|
+
}
|
|
2346
|
+
/** Keywords whose meaning changes when `null` joins the type list (`enum`, `const`...): `anyOf` instead. */
|
|
2347
|
+
const NOT_NULLABLE_KEYS = /* @__PURE__ */ new Set([
|
|
2348
|
+
"$ref",
|
|
2349
|
+
"enum",
|
|
2350
|
+
"const",
|
|
2351
|
+
"anyOf",
|
|
2352
|
+
"oneOf",
|
|
2353
|
+
"allOf",
|
|
2354
|
+
"not"
|
|
2355
|
+
]);
|
|
2356
|
+
const REASONS = {
|
|
2357
|
+
function: "is a function, which JSON.stringify skips",
|
|
2358
|
+
symbol: "is a symbol, which JSON.stringify skips",
|
|
2359
|
+
"type-parameter": "is an unbound type parameter",
|
|
2360
|
+
"non-json": "is not serialised as declared by JSON.stringify",
|
|
2361
|
+
mapped: "is a mapped type the compiler cannot expand",
|
|
2362
|
+
conditional: "is a conditional type the compiler cannot expand",
|
|
2363
|
+
typeof: "is a typeof type that is not a schema",
|
|
2364
|
+
"indexed-access": "is an indexed access type the compiler cannot resolve",
|
|
2365
|
+
"type-operator": "uses a type operator the compiler cannot expand",
|
|
2366
|
+
this: "is a this type",
|
|
2367
|
+
unresolved: "cannot be resolved",
|
|
2368
|
+
"missing-annotation": "has no type annotation",
|
|
2369
|
+
"to-json": "has a toJSON() method without return type annotation",
|
|
2370
|
+
recursive: "is a recursion the compiler cannot follow",
|
|
2371
|
+
unsupported: "has no JSON Schema"
|
|
2372
|
+
};
|
|
2373
|
+
const HINTS = {
|
|
2374
|
+
"non-json": ["Map, Set, RegExp and Error become {} in JSON: use an array or a record, or add a toJSON() method"],
|
|
2375
|
+
"to-json": ["Annotate the return type of toJSON(): toJSON(): BookJson"],
|
|
2376
|
+
"missing-annotation": ["Annotate the type: the compiler reads annotations only"],
|
|
2377
|
+
conditional: ["Declare the structure with an interface, or describe the body with @ApiResponse({ schema })"],
|
|
2378
|
+
mapped: ["Declare the structure with an interface, or describe the body with @ApiResponse({ schema })"],
|
|
2379
|
+
"type-parameter": ["Give the type arguments explicitly"],
|
|
2380
|
+
function: ["Remove the member from the transported type"]
|
|
2381
|
+
};
|
|
2382
|
+
const NUMERIC_TAGS = /* @__PURE__ */ new Set([
|
|
2383
|
+
"minimum",
|
|
2384
|
+
"maximum",
|
|
2385
|
+
"exclusiveMinimum",
|
|
2386
|
+
"exclusiveMaximum",
|
|
2387
|
+
"multipleOf"
|
|
2388
|
+
]);
|
|
2389
|
+
const COUNT_TAGS = /* @__PURE__ */ new Set([
|
|
2390
|
+
"minLength",
|
|
2391
|
+
"maxLength",
|
|
2392
|
+
"minItems",
|
|
2393
|
+
"maxItems"
|
|
2394
|
+
]);
|
|
2395
|
+
/** Adds the JSDoc text and tags of `doc` to a schema (§6.2 "JSDoc tags on types and properties"). */
|
|
2396
|
+
function withDoc(schema, doc, report) {
|
|
2397
|
+
if (doc === void 0) return schema;
|
|
2398
|
+
const result = { ...schema };
|
|
2399
|
+
if (doc.text !== "") result["description"] = doc.text;
|
|
2400
|
+
for (const tag of doc.tags) {
|
|
2401
|
+
const text = tag.text.trim();
|
|
2402
|
+
const invalid = (expected) => report({
|
|
2403
|
+
message: `Invalid @${tag.name} value ${JSON.stringify(text)}: expected ${expected}`,
|
|
2404
|
+
location: tag.location
|
|
2405
|
+
});
|
|
2406
|
+
if (tag.name === "deprecated") result["deprecated"] = true;
|
|
2407
|
+
else if (tag.name === "example") result["examples"] = [...Array.isArray(result["examples"]) ? result["examples"] : [], parseTagValue(stripFence(text))];
|
|
2408
|
+
else if (tag.name === "default") result["default"] = parseTagValue(text);
|
|
2409
|
+
else if (tag.name === "format") {
|
|
2410
|
+
if (text === "" || /\s/.test(text)) invalid("a format name (date, email, int32...)");
|
|
2411
|
+
else {
|
|
2412
|
+
result["format"] = text;
|
|
2413
|
+
if ((text === "int32" || text === "int64") && result["type"] === "number") result["type"] = "integer";
|
|
2414
|
+
}
|
|
2415
|
+
} else if (NUMERIC_TAGS.has(tag.name)) {
|
|
2416
|
+
const value = parseTagValue(text);
|
|
2417
|
+
if (typeof value === "number" && Number.isFinite(value)) result[tag.name] = value;
|
|
2418
|
+
else invalid("a number");
|
|
2419
|
+
} else if (COUNT_TAGS.has(tag.name)) {
|
|
2420
|
+
const value = parseTagValue(text);
|
|
2421
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) result[tag.name] = value;
|
|
2422
|
+
else invalid("a non-negative integer");
|
|
2423
|
+
} else if (tag.name === "pattern") try {
|
|
2424
|
+
new RegExp(text, "u");
|
|
2425
|
+
result["pattern"] = text;
|
|
2426
|
+
} catch {
|
|
2427
|
+
invalid("a regular expression");
|
|
2428
|
+
}
|
|
2429
|
+
else if (tag.name === "uniqueItems") {
|
|
2430
|
+
const value = text === "" ? true : parseTagValue(text);
|
|
2431
|
+
if (typeof value === "boolean") result["uniqueItems"] = value;
|
|
2432
|
+
else invalid("true or false");
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
return result;
|
|
2436
|
+
}
|
|
2437
|
+
/** A tag value: JSON when it parses, the text otherwise. */
|
|
2438
|
+
function parseTagValue(text) {
|
|
2439
|
+
try {
|
|
2440
|
+
return JSON.parse(text);
|
|
2441
|
+
} catch {
|
|
2442
|
+
return text;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
function stripFence(text) {
|
|
2446
|
+
return /^```[\w-]*\n([\s\S]*?)\n```$/.exec(text)?.[1]?.trim() ?? text;
|
|
2447
|
+
}
|
|
2448
|
+
// src/compiler/validate.ts
|
|
2449
|
+
/**
|
|
2450
|
+
* Models by compile. The key is `graph.extensions`: the validate and generate contexts of one
|
|
2451
|
+
* compile get different graph objects (diagnostics, pruning) sharing this array.
|
|
2452
|
+
*/
|
|
2453
|
+
const models = /* @__PURE__ */ new WeakMap();
|
|
2454
|
+
/** `validate` of the extension. */
|
|
2455
|
+
function validateOpenApi(ctx) {
|
|
2456
|
+
models.set(ctx.graph.extensions, buildModel(ctx));
|
|
2457
|
+
}
|
|
2458
|
+
/** The model built by `validate` for the compile of `graph`. */
|
|
2459
|
+
function modelOf(graph) {
|
|
2460
|
+
return models.get(graph.extensions);
|
|
2461
|
+
}
|
|
2462
|
+
function buildModel(ctx) {
|
|
2463
|
+
const schemes = new SchemeRegistry(ctx);
|
|
2464
|
+
const document = readDocument(ctx, schemes);
|
|
2465
|
+
const reserved = new Set(document.errors === false ? [] : [ERROR_COMPONENTS.body, ERROR_COMPONENTS.validation]);
|
|
2466
|
+
const slots = new Slots((diagnostic) => ctx.report(diagnostic), () => reserved, (ref) => declarationOf(ctx, ref.identity));
|
|
2467
|
+
const schemas = new Schemas(ctx, {
|
|
2468
|
+
jsdoc: document.jsdoc,
|
|
2469
|
+
strict: document.strict,
|
|
2470
|
+
reserved
|
|
2471
|
+
}, slots);
|
|
2472
|
+
const errorsSlot = document.errors === false || document.errors.schema === void 0 ? void 0 : slots.use(document.errors.schema, "output");
|
|
2473
|
+
const { operations, tagDescriptions } = buildOperations({
|
|
2474
|
+
ctx,
|
|
2475
|
+
document,
|
|
2476
|
+
schemes,
|
|
2477
|
+
schemas,
|
|
2478
|
+
slots
|
|
2479
|
+
});
|
|
2480
|
+
schemas.settle();
|
|
2481
|
+
const rootSecurity = document.security === void 0 ? void 0 : schemes.objects(document.security);
|
|
2482
|
+
const securitySchemes = {};
|
|
2483
|
+
for (const name of [...schemes.used.keys()].sort(compareStrings)) securitySchemes[name] = schemes.used.get(name)?.definition;
|
|
2484
|
+
return {
|
|
2485
|
+
document: documentTemplate(document, rootSecurity),
|
|
2486
|
+
operations,
|
|
2487
|
+
components: schemas.components(),
|
|
2488
|
+
securitySchemes,
|
|
2489
|
+
slots: slots.list(),
|
|
2490
|
+
errors: errorsOf(document.errors, errorsSlot?.name),
|
|
2491
|
+
tagDescriptions,
|
|
2492
|
+
root: ctx.options.root
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
function errorsOf(errors, schema) {
|
|
2496
|
+
if (errors === false) return void 0;
|
|
2497
|
+
return schema === void 0 ? { contentType: errors.contentType } : {
|
|
2498
|
+
contentType: errors.contentType,
|
|
2499
|
+
schema
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
/** Location of the declaration of an identity (`export const CreateBook = ...`). */
|
|
2503
|
+
function declarationOf(ctx, identity) {
|
|
2504
|
+
if (identity === void 0) return void 0;
|
|
2505
|
+
const symbol = ctx.project.lookupIdentity(identity);
|
|
2506
|
+
if (symbol.kind !== "declaration") return void 0;
|
|
2507
|
+
const declaration = symbol.declarations[0];
|
|
2508
|
+
if (declaration === void 0) return void 0;
|
|
2509
|
+
return ctx.project.getFile(declaration.file)?.location(declaration.span);
|
|
2510
|
+
}
|
|
2511
|
+
/** `{ file, line, column }` of a location, the file relative to `root`. */
|
|
2512
|
+
function sourceOf(location, root) {
|
|
2513
|
+
return {
|
|
2514
|
+
file: location.file.startsWith(`${root}/`) ? location.file.slice(root.length + 1) : location.file,
|
|
2515
|
+
line: location.line,
|
|
2516
|
+
column: location.column
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
// src/compiler/generate.ts
|
|
2520
|
+
/** `generate` of the extension. */
|
|
2521
|
+
function generateOpenApi(ctx) {
|
|
2522
|
+
const model = modelOf(ctx.graph) ?? emptyModel(ctx.options.root);
|
|
2523
|
+
const operations = model.operations.map((operation) => ({
|
|
2524
|
+
controller: operation.controller,
|
|
2525
|
+
handler: operation.handler,
|
|
2526
|
+
route: operation.route,
|
|
2527
|
+
method: operation.method,
|
|
2528
|
+
path: operation.path,
|
|
2529
|
+
operation: operation.operation
|
|
2530
|
+
}));
|
|
2531
|
+
const slots = model.slots.map((slot) => ({
|
|
2532
|
+
name: slot.name,
|
|
2533
|
+
io: [...slot.io].sort()
|
|
2534
|
+
}));
|
|
2535
|
+
const fingerprint = fingerprintOf({
|
|
2536
|
+
version: 1,
|
|
2537
|
+
document: model.document,
|
|
2538
|
+
operations,
|
|
2539
|
+
components: model.components,
|
|
2540
|
+
securitySchemes: model.securitySchemes,
|
|
2541
|
+
schemas: slots,
|
|
2542
|
+
errors: model.errors,
|
|
2543
|
+
tagDescriptions: model.tagDescriptions
|
|
2544
|
+
});
|
|
2545
|
+
const lit = (value) => ctx.lit(value);
|
|
2546
|
+
const entries = [
|
|
2547
|
+
["version", lit(1)],
|
|
2548
|
+
["fingerprint", lit(fingerprint)],
|
|
2549
|
+
["document", lit(model.document)],
|
|
2550
|
+
["operations", lit(operations)],
|
|
2551
|
+
["components", lit(model.components)],
|
|
2552
|
+
["securitySchemes", lit(model.securitySchemes)],
|
|
2553
|
+
["schemas", ctx.arr(model.slots.map((slot) => ctx.obj([
|
|
2554
|
+
["name", slot.name],
|
|
2555
|
+
["schema", ctx.importValue(slot.ref)],
|
|
2556
|
+
["io", lit([...slot.io].sort())],
|
|
2557
|
+
["source", lit(sourceOf(slot.location, model.root))]
|
|
2558
|
+
])))]
|
|
2559
|
+
];
|
|
2560
|
+
if (model.errors !== void 0) entries.push(["errors", lit(model.errors)]);
|
|
2561
|
+
if (Object.keys(model.tagDescriptions).length > 0) entries.push(["tagDescriptions", lit(model.tagDescriptions)]);
|
|
2562
|
+
return ctx.obj(entries);
|
|
2563
|
+
}
|
|
2564
|
+
/** SHA-256 (base64url) of the canonical JSON of the section (schema values replaced by their names). */
|
|
2565
|
+
function fingerprintOf(section) {
|
|
2566
|
+
return createHash("sha256").update(canonicalJson(section)).digest("base64url");
|
|
2567
|
+
}
|
|
2568
|
+
function emptyModel(root) {
|
|
2569
|
+
return {
|
|
2570
|
+
document: {
|
|
2571
|
+
openapi: OPENAPI_VERSION,
|
|
2572
|
+
info: {
|
|
2573
|
+
title: "API",
|
|
2574
|
+
version: "0.0.0"
|
|
2575
|
+
}
|
|
2576
|
+
},
|
|
2577
|
+
operations: [],
|
|
2578
|
+
components: {},
|
|
2579
|
+
securitySchemes: {},
|
|
2580
|
+
slots: [],
|
|
2581
|
+
errors: { contentType: "application/json" },
|
|
2582
|
+
tagDescriptions: {},
|
|
2583
|
+
root
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
// src/compiler/index.ts
|
|
2587
|
+
/** Decorators owned by the extension, by canonical identity (static / value arguments, §4.6). */
|
|
2588
|
+
const decorators = Object.freeze({
|
|
2589
|
+
[Ids.OpenApiDocument]: {
|
|
2590
|
+
targets: ["class"],
|
|
2591
|
+
params: ["value"]
|
|
2592
|
+
},
|
|
2593
|
+
[Ids.ApiOperation]: {
|
|
2594
|
+
targets: ["method"],
|
|
2595
|
+
params: ["static"]
|
|
2596
|
+
},
|
|
2597
|
+
[Ids.ApiTags]: {
|
|
2598
|
+
targets: ["class", "method"],
|
|
2599
|
+
params: [],
|
|
2600
|
+
rest: "static"
|
|
2601
|
+
},
|
|
2602
|
+
[Ids.ApiResponse]: {
|
|
2603
|
+
targets: ["method"],
|
|
2604
|
+
params: ["static", "value"]
|
|
2605
|
+
},
|
|
2606
|
+
[Ids.ApiBody]: {
|
|
2607
|
+
targets: ["method"],
|
|
2608
|
+
params: ["value"]
|
|
2609
|
+
},
|
|
2610
|
+
[Ids.ApiQuery]: {
|
|
2611
|
+
targets: ["method"],
|
|
2612
|
+
params: ["static", "static"]
|
|
2613
|
+
},
|
|
2614
|
+
[Ids.ApiHeader]: {
|
|
2615
|
+
targets: ["class", "method"],
|
|
2616
|
+
params: ["static", "static"]
|
|
2617
|
+
},
|
|
2618
|
+
[Ids.ApiSecurity]: {
|
|
2619
|
+
targets: ["class", "method"],
|
|
2620
|
+
params: [],
|
|
2621
|
+
rest: "value"
|
|
2622
|
+
},
|
|
2623
|
+
[Ids.ApiExclude]: {
|
|
2624
|
+
targets: ["class", "method"],
|
|
2625
|
+
params: []
|
|
2626
|
+
}
|
|
2627
|
+
});
|
|
2628
|
+
/** The `@znight/openapi` compiler extension. */
|
|
2629
|
+
const openapi = Object.freeze({
|
|
2630
|
+
apiVersion: 1,
|
|
2631
|
+
name: EXTENSION_NAME,
|
|
2632
|
+
decorators,
|
|
2633
|
+
analyzeClass: analyzeOpenApiClass,
|
|
2634
|
+
validate: validateOpenApi,
|
|
2635
|
+
generate: generateOpenApi
|
|
2636
|
+
});
|
|
2637
|
+
export { openapi as default };
|
|
2638
|
+
|
|
2639
|
+
//# sourceMappingURL=compiler.mjs.map
|