@nestia/sdk 2.6.2 → 2.6.3
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/lib/analyses/ControllerAnalyzer.js +3 -3
- package/lib/analyses/ControllerAnalyzer.js.map +1 -1
- package/lib/analyses/ImportAnalyzer.d.ts +1 -2
- package/lib/analyses/ImportAnalyzer.js +2 -2
- package/lib/analyses/ImportAnalyzer.js.map +1 -1
- package/lib/analyses/ReflectAnalyzer.js +2 -2
- package/lib/analyses/ReflectAnalyzer.js.map +1 -1
- package/lib/generates/SwaggerGenerator.js +4 -4
- package/lib/generates/SwaggerGenerator.js.map +1 -1
- package/lib/generates/internal/ImportDictionary.js +6 -8
- package/lib/generates/internal/ImportDictionary.js.map +1 -1
- package/lib/structures/TypeEntry.js +2 -2
- package/lib/structures/TypeEntry.js.map +1 -1
- package/package.json +4 -4
- package/src/INestiaConfig.ts +248 -248
- package/src/NestiaSdkApplication.ts +255 -255
- package/src/analyses/ControllerAnalyzer.ts +402 -402
- package/src/analyses/ExceptionAnalyzer.ts +148 -148
- package/src/analyses/ImportAnalyzer.ts +1 -2
- package/src/analyses/ReflectAnalyzer.ts +463 -463
- package/src/analyses/SecurityAnalyzer.ts +24 -24
- package/src/generates/CloneGenerator.ts +62 -62
- package/src/generates/E2eGenerator.ts +66 -66
- package/src/generates/SdkGenerator.ts +84 -84
- package/src/generates/SwaggerGenerator.ts +446 -446
- package/src/generates/internal/E2eFileProgrammer.ts +182 -182
- package/src/generates/internal/FilePrinter.ts +53 -53
- package/src/generates/internal/ImportDictionary.ts +147 -149
- package/src/generates/internal/SdkAliasCollection.ts +152 -152
- package/src/generates/internal/SdkCloneProgrammer.ts +155 -155
- package/src/generates/internal/SdkFileProgrammer.ts +115 -115
- package/src/generates/internal/SdkFunctionProgrammer.ts +298 -298
- package/src/generates/internal/SdkImportWizard.ts +55 -55
- package/src/generates/internal/SdkNamespaceProgrammer.ts +510 -510
- package/src/generates/internal/SdkRouteProgrammer.ts +83 -83
- package/src/generates/internal/SdkSimulationProgrammer.ts +365 -365
- package/src/generates/internal/SdkTypeProgrammer.ts +385 -385
- package/src/generates/internal/SwaggerSchemaGenerator.ts +438 -438
- package/src/structures/IController.ts +94 -94
- package/src/structures/IRoute.ts +53 -53
- package/src/structures/ISwagger.ts +91 -91
- package/src/structures/ISwaggerRoute.ts +54 -54
- package/src/structures/ISwaggerSecurityScheme.ts +65 -65
- package/src/structures/ParamCategory.ts +1 -1
- package/src/structures/TypeEntry.ts +1 -1
- package/src/utils/StringUtil.ts +6 -6
|
@@ -1,446 +1,446 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { Singleton } from "tstl
|
|
4
|
-
import ts from "typescript";
|
|
5
|
-
import typia, { IJsonApplication, IJsonComponents } from "typia";
|
|
6
|
-
import { MetadataCollection } from "typia/lib/factories/MetadataCollection";
|
|
7
|
-
import { JsonApplicationProgrammer } from "typia/lib/programmers/json/JsonApplicationProgrammer";
|
|
8
|
-
|
|
9
|
-
import { INestiaConfig } from "../INestiaConfig";
|
|
10
|
-
import { IRoute } from "../structures/IRoute";
|
|
11
|
-
import { ISwagger } from "../structures/ISwagger";
|
|
12
|
-
import { ISwaggerError } from "../structures/ISwaggerError";
|
|
13
|
-
import { ISwaggerInfo } from "../structures/ISwaggerInfo";
|
|
14
|
-
import { ISwaggerLazyProperty } from "../structures/ISwaggerLazyProperty";
|
|
15
|
-
import { ISwaggerLazySchema } from "../structures/ISwaggerLazySchema";
|
|
16
|
-
import { ISwaggerRoute } from "../structures/ISwaggerRoute";
|
|
17
|
-
import { ISwaggerSecurityScheme } from "../structures/ISwaggerSecurityScheme";
|
|
18
|
-
import { FileRetriever } from "../utils/FileRetriever";
|
|
19
|
-
import { MapUtil } from "../utils/MapUtil";
|
|
20
|
-
import { SwaggerSchemaGenerator } from "./internal/SwaggerSchemaGenerator";
|
|
21
|
-
|
|
22
|
-
export namespace SwaggerGenerator {
|
|
23
|
-
export interface IProps {
|
|
24
|
-
config: INestiaConfig.ISwaggerConfig;
|
|
25
|
-
checker: ts.TypeChecker;
|
|
26
|
-
collection: MetadataCollection;
|
|
27
|
-
lazySchemas: Array<ISwaggerLazySchema>;
|
|
28
|
-
lazyProperties: Array<ISwaggerLazyProperty>;
|
|
29
|
-
errors: ISwaggerError[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const generate =
|
|
33
|
-
(checker: ts.TypeChecker) =>
|
|
34
|
-
(config: INestiaConfig.ISwaggerConfig) =>
|
|
35
|
-
async (routeList: IRoute[]): Promise<void> => {
|
|
36
|
-
console.log("Generating Swagger Documents");
|
|
37
|
-
|
|
38
|
-
// VALIDATE SECURITY
|
|
39
|
-
validate_security(config)(routeList);
|
|
40
|
-
|
|
41
|
-
// PREPARE ASSETS
|
|
42
|
-
const parsed: path.ParsedPath = path.parse(config.output);
|
|
43
|
-
const directory: string = path.dirname(parsed.dir);
|
|
44
|
-
if (fs.existsSync(directory) === false)
|
|
45
|
-
try {
|
|
46
|
-
await fs.promises.mkdir(directory);
|
|
47
|
-
} catch {}
|
|
48
|
-
if (fs.existsSync(directory) === false)
|
|
49
|
-
throw new Error(
|
|
50
|
-
`Error on NestiaApplication.swagger(): failed to create output directory: ${directory}`,
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const location: string = !!parsed.ext
|
|
54
|
-
? path.resolve(config.output)
|
|
55
|
-
: path.join(path.resolve(config.output), "swagger.json");
|
|
56
|
-
|
|
57
|
-
const collection: MetadataCollection = new MetadataCollection({
|
|
58
|
-
replace: MetadataCollection.replace,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// CONSTRUCT SWAGGER DOCUMENTS
|
|
62
|
-
const errors: ISwaggerError[] = [];
|
|
63
|
-
const lazySchemas: Array<ISwaggerLazySchema> = [];
|
|
64
|
-
const lazyProperties: Array<ISwaggerLazyProperty> = [];
|
|
65
|
-
const swagger: ISwagger = await initialize(config);
|
|
66
|
-
const pathDict: Map<string, Record<string, ISwaggerRoute>> = new Map();
|
|
67
|
-
|
|
68
|
-
for (const route of routeList) {
|
|
69
|
-
if (route.jsDocTags.find((tag) => tag.name === "internal")) continue;
|
|
70
|
-
|
|
71
|
-
const path: Record<string, ISwaggerRoute> = MapUtil.take(
|
|
72
|
-
pathDict,
|
|
73
|
-
get_path(route.path, route.parameters),
|
|
74
|
-
() => ({}),
|
|
75
|
-
);
|
|
76
|
-
path[route.method.toLowerCase()] = generate_route({
|
|
77
|
-
config,
|
|
78
|
-
checker,
|
|
79
|
-
collection,
|
|
80
|
-
lazySchemas,
|
|
81
|
-
lazyProperties,
|
|
82
|
-
errors,
|
|
83
|
-
})(route);
|
|
84
|
-
}
|
|
85
|
-
swagger.paths = {};
|
|
86
|
-
for (const [path, routes] of pathDict) swagger.paths[path] = routes;
|
|
87
|
-
|
|
88
|
-
// FILL JSON-SCHEMAS
|
|
89
|
-
const application: IJsonApplication = JsonApplicationProgrammer.write({
|
|
90
|
-
purpose: "swagger",
|
|
91
|
-
})(lazySchemas.map(({ metadata }) => metadata));
|
|
92
|
-
swagger.components = {
|
|
93
|
-
...(swagger.components ?? {}),
|
|
94
|
-
...(application.components ?? {}),
|
|
95
|
-
};
|
|
96
|
-
lazySchemas.forEach(({ schema }, index) => {
|
|
97
|
-
Object.assign(schema, application.schemas[index]!);
|
|
98
|
-
});
|
|
99
|
-
for (const p of lazyProperties)
|
|
100
|
-
Object.assign(
|
|
101
|
-
p.schema,
|
|
102
|
-
(
|
|
103
|
-
application.components.schemas?.[
|
|
104
|
-
p.object
|
|
105
|
-
] as IJsonComponents.IObject
|
|
106
|
-
)?.properties[p.property],
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
// CONFIGURE SECURITY
|
|
110
|
-
if (config.security) fill_security(config.security, swagger);
|
|
111
|
-
|
|
112
|
-
// REPORT ERRORS
|
|
113
|
-
if (errors.length) {
|
|
114
|
-
for (const e of errors)
|
|
115
|
-
console.error(
|
|
116
|
-
`${path.relative(e.route.location, process.cwd())}:${
|
|
117
|
-
e.route.target.class.name
|
|
118
|
-
}.${e.route.target.function.name}:${
|
|
119
|
-
e.from
|
|
120
|
-
} - error TS(@nestia/sdk): invalid type detected.\n\n` +
|
|
121
|
-
e.messages.map((m) => ` - ${m}`).join("\n"),
|
|
122
|
-
"\n\n",
|
|
123
|
-
);
|
|
124
|
-
throw new TypeError("Invalid type detected");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// SWAGGER CUSTOMIZER
|
|
128
|
-
const customizer = {
|
|
129
|
-
at: new Singleton(() => {
|
|
130
|
-
const functor: Map<Function, Endpoint> = new Map();
|
|
131
|
-
for (const route of routeList) {
|
|
132
|
-
const method = route.method.toLowerCase();
|
|
133
|
-
const path = get_path(route.path, route.parameters);
|
|
134
|
-
functor.set(route.target.function, {
|
|
135
|
-
method,
|
|
136
|
-
path,
|
|
137
|
-
route: swagger.paths[path][method],
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
return functor;
|
|
141
|
-
}),
|
|
142
|
-
get: new Singleton(() => (key: Accessor): ISwaggerRoute | undefined => {
|
|
143
|
-
const method: string = key.method.toLowerCase();
|
|
144
|
-
const path: string =
|
|
145
|
-
"/" +
|
|
146
|
-
key.path
|
|
147
|
-
.split("/")
|
|
148
|
-
.filter((str) => !!str.length)
|
|
149
|
-
.map((str) =>
|
|
150
|
-
str.startsWith(":") ? `{${str.substring(1)}}` : str,
|
|
151
|
-
)
|
|
152
|
-
.join("/");
|
|
153
|
-
return swagger.paths[path]?.[method];
|
|
154
|
-
}),
|
|
155
|
-
};
|
|
156
|
-
for (const route of routeList) {
|
|
157
|
-
if (
|
|
158
|
-
false ===
|
|
159
|
-
Reflect.hasMetadata(
|
|
160
|
-
"nestia/SwaggerCustomizer",
|
|
161
|
-
route.controller.prototype,
|
|
162
|
-
route.target.function.name,
|
|
163
|
-
)
|
|
164
|
-
)
|
|
165
|
-
continue;
|
|
166
|
-
|
|
167
|
-
const path: string = get_path(route.path, route.parameters);
|
|
168
|
-
const method: string = route.method.toLowerCase();
|
|
169
|
-
const target: ISwaggerRoute = swagger.paths[path][method];
|
|
170
|
-
const closure: Function | Function[] = Reflect.getMetadata(
|
|
171
|
-
"nestia/SwaggerCustomizer",
|
|
172
|
-
route.controller.prototype,
|
|
173
|
-
route.target.function.name,
|
|
174
|
-
);
|
|
175
|
-
const array: Function[] = Array.isArray(closure) ? closure : [closure];
|
|
176
|
-
for (const fn of array)
|
|
177
|
-
fn({
|
|
178
|
-
route: target,
|
|
179
|
-
method,
|
|
180
|
-
path,
|
|
181
|
-
swagger,
|
|
182
|
-
at: (func: Function) => customizer.at.get().get(func),
|
|
183
|
-
get: (accessor: Accessor) => customizer.get.get()(accessor),
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// DO GENERATE
|
|
188
|
-
await fs.promises.writeFile(
|
|
189
|
-
location,
|
|
190
|
-
!config.beautify
|
|
191
|
-
? JSON.stringify(swagger)
|
|
192
|
-
: JSON.stringify(
|
|
193
|
-
swagger,
|
|
194
|
-
null,
|
|
195
|
-
typeof config.beautify === "number" ? config.beautify : 2,
|
|
196
|
-
),
|
|
197
|
-
"utf8",
|
|
198
|
-
);
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const validate_security =
|
|
202
|
-
(config: INestiaConfig.ISwaggerConfig) =>
|
|
203
|
-
(routeList: IRoute[]): void | never => {
|
|
204
|
-
const securityMap: Map<
|
|
205
|
-
string,
|
|
206
|
-
{ scheme: ISwaggerSecurityScheme; scopes: Set<string> }
|
|
207
|
-
> = new Map();
|
|
208
|
-
for (const [key, value] of Object.entries(config.security ?? {}))
|
|
209
|
-
securityMap.set(key, {
|
|
210
|
-
scheme: emend_security(value),
|
|
211
|
-
scopes:
|
|
212
|
-
value.type === "oauth2"
|
|
213
|
-
? new Set([
|
|
214
|
-
...Object.keys(value.flows.authorizationCode?.scopes ?? {}),
|
|
215
|
-
...Object.keys(value.flows.implicit?.scopes ?? {}),
|
|
216
|
-
...Object.keys(value.flows.password?.scopes ?? {}),
|
|
217
|
-
...Object.keys(value.flows.clientCredentials?.scopes ?? {}),
|
|
218
|
-
])
|
|
219
|
-
: new Set(),
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const validate =
|
|
223
|
-
(reporter: (str: string) => void) =>
|
|
224
|
-
(key: string, scopes: string[]) => {
|
|
225
|
-
const security = securityMap.get(key);
|
|
226
|
-
if (security === undefined)
|
|
227
|
-
return reporter(`target security scheme "${key}" does not exists.`);
|
|
228
|
-
else if (scopes.length === 0) return;
|
|
229
|
-
else if (security.scheme.type !== "oauth2")
|
|
230
|
-
return reporter(
|
|
231
|
-
`target security scheme "${key}" is not "oauth2" type, but you've configured the scopes.`,
|
|
232
|
-
);
|
|
233
|
-
for (const s of scopes)
|
|
234
|
-
if (security.scopes.has(s) === false)
|
|
235
|
-
reporter(
|
|
236
|
-
`target security scheme "${key}" does not have a specific scope "${s}".`,
|
|
237
|
-
);
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
const violations: string[] = [];
|
|
241
|
-
for (const route of routeList)
|
|
242
|
-
for (const record of route.security)
|
|
243
|
-
for (const [key, scopes] of Object.entries(record))
|
|
244
|
-
validate((str) =>
|
|
245
|
-
violations.push(
|
|
246
|
-
` - ${str} (${route.target.class.name}.${route.target.function.name}() at "${route.location}")`,
|
|
247
|
-
),
|
|
248
|
-
)(key, scopes);
|
|
249
|
-
|
|
250
|
-
if (violations.length)
|
|
251
|
-
throw new Error(
|
|
252
|
-
`Error on NestiaApplication.swagger(): invalid security specification. Check your "nestia.config.ts" file's "swagger.security" property, or each controller methods.\n` +
|
|
253
|
-
`\n` +
|
|
254
|
-
`List of violations:\n` +
|
|
255
|
-
violations.join("\n"),
|
|
256
|
-
);
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
/* ---------------------------------------------------------
|
|
260
|
-
INITIALIZERS
|
|
261
|
-
--------------------------------------------------------- */
|
|
262
|
-
const initialize = async (
|
|
263
|
-
config: INestiaConfig.ISwaggerConfig,
|
|
264
|
-
): Promise<ISwagger> => {
|
|
265
|
-
const pack = new Singleton(
|
|
266
|
-
async (): Promise<Partial<ISwaggerInfo> | null> => {
|
|
267
|
-
const location: string | null = await FileRetriever.file(
|
|
268
|
-
"package.json",
|
|
269
|
-
)(process.cwd());
|
|
270
|
-
if (location === null) return null;
|
|
271
|
-
|
|
272
|
-
try {
|
|
273
|
-
const content: string = await fs.promises.readFile(location, "utf8");
|
|
274
|
-
const data = typia.json.assertParse<{
|
|
275
|
-
name?: string;
|
|
276
|
-
version?: string;
|
|
277
|
-
description?: string;
|
|
278
|
-
license?:
|
|
279
|
-
| string
|
|
280
|
-
| {
|
|
281
|
-
type: string;
|
|
282
|
-
/**
|
|
283
|
-
* @format uri
|
|
284
|
-
*/
|
|
285
|
-
url: string;
|
|
286
|
-
};
|
|
287
|
-
}>(content);
|
|
288
|
-
return {
|
|
289
|
-
title: data.name,
|
|
290
|
-
version: data.version,
|
|
291
|
-
description: data.description,
|
|
292
|
-
license: data.license
|
|
293
|
-
? typeof data.license === "string"
|
|
294
|
-
? { name: data.license }
|
|
295
|
-
: typeof data.license === "object"
|
|
296
|
-
? {
|
|
297
|
-
name: data.license.type,
|
|
298
|
-
url: data.license.url,
|
|
299
|
-
}
|
|
300
|
-
: undefined
|
|
301
|
-
: undefined,
|
|
302
|
-
};
|
|
303
|
-
} catch {
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
},
|
|
307
|
-
);
|
|
308
|
-
|
|
309
|
-
return {
|
|
310
|
-
openapi: "3.0.1",
|
|
311
|
-
servers: config.servers ?? [
|
|
312
|
-
{
|
|
313
|
-
url: "https://github.com/samchon/nestia",
|
|
314
|
-
description: "insert your server url",
|
|
315
|
-
},
|
|
316
|
-
],
|
|
317
|
-
info: {
|
|
318
|
-
...(config.info ?? {}),
|
|
319
|
-
version: config.info?.version ?? (await pack.get())?.version ?? "0.1.0",
|
|
320
|
-
title:
|
|
321
|
-
config.info?.title ??
|
|
322
|
-
(await pack.get())?.title ??
|
|
323
|
-
"Swagger Documents",
|
|
324
|
-
description:
|
|
325
|
-
config.info?.description ??
|
|
326
|
-
(await pack.get())?.description ??
|
|
327
|
-
"Generated by nestia - https://github.com/samchon/nestia",
|
|
328
|
-
license: config.info?.license ?? (await pack.get())?.license,
|
|
329
|
-
},
|
|
330
|
-
paths: {},
|
|
331
|
-
components: {},
|
|
332
|
-
};
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
function get_path(path: string, parameters: IRoute.IParameter[]): string {
|
|
336
|
-
const filtered: IRoute.IParameter[] = parameters.filter(
|
|
337
|
-
(param) => param.category === "param" && !!param.field,
|
|
338
|
-
);
|
|
339
|
-
for (const param of filtered)
|
|
340
|
-
path = path.replace(`:${param.field}`, `{${param.field}}`);
|
|
341
|
-
return path;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const generate_route =
|
|
345
|
-
(props: IProps) =>
|
|
346
|
-
(route: IRoute): ISwaggerRoute => {
|
|
347
|
-
const body = route.parameters.find((param) => param.category === "body");
|
|
348
|
-
const getJsDocTexts = (name: string) =>
|
|
349
|
-
route.jsDocTags
|
|
350
|
-
.filter(
|
|
351
|
-
(tag) =>
|
|
352
|
-
tag.name === name &&
|
|
353
|
-
tag.text &&
|
|
354
|
-
tag.text.find(
|
|
355
|
-
(elem) => elem.kind === "text" && elem.text.length,
|
|
356
|
-
) !== undefined,
|
|
357
|
-
)
|
|
358
|
-
.map((tag) => tag.text!.find((elem) => elem.kind === "text")!.text);
|
|
359
|
-
|
|
360
|
-
const description: string | undefined = route.description?.length
|
|
361
|
-
? route.description
|
|
362
|
-
: undefined;
|
|
363
|
-
const summary: string | undefined = (() => {
|
|
364
|
-
if (description === undefined) return undefined;
|
|
365
|
-
|
|
366
|
-
const [explicit] = getJsDocTexts("summary");
|
|
367
|
-
if (explicit?.length) return explicit;
|
|
368
|
-
|
|
369
|
-
const index: number = description.indexOf("\n");
|
|
370
|
-
const top: string = (
|
|
371
|
-
index === -1 ? description : description.substring(0, index)
|
|
372
|
-
).trim();
|
|
373
|
-
return top.endsWith(".") ? top.substring(0, top.length - 1) : undefined;
|
|
374
|
-
})();
|
|
375
|
-
const deprecated = route.jsDocTags.find(
|
|
376
|
-
(tag) => tag.name === "deprecated",
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
return {
|
|
380
|
-
deprecated: deprecated ? true : undefined,
|
|
381
|
-
tags: [...route.swaggerTags, ...new Set([...getJsDocTexts("tag")])],
|
|
382
|
-
operationId:
|
|
383
|
-
route.operationId ??
|
|
384
|
-
props.config.operationId?.({
|
|
385
|
-
class: route.target.class.name,
|
|
386
|
-
function: route.target.function.name,
|
|
387
|
-
method: route.method as "GET",
|
|
388
|
-
path: route.path,
|
|
389
|
-
}),
|
|
390
|
-
parameters: route.parameters
|
|
391
|
-
.filter((param) => param.category !== "body")
|
|
392
|
-
.map((param) => SwaggerSchemaGenerator.parameter(props)(route)(param))
|
|
393
|
-
.flat(),
|
|
394
|
-
requestBody: body
|
|
395
|
-
? SwaggerSchemaGenerator.body(props)(route)(body)
|
|
396
|
-
: undefined,
|
|
397
|
-
responses: SwaggerSchemaGenerator.response(props)(route),
|
|
398
|
-
summary,
|
|
399
|
-
description,
|
|
400
|
-
security: route.security.length ? route.security : undefined,
|
|
401
|
-
...(props.config.additional === true
|
|
402
|
-
? {
|
|
403
|
-
"x-nestia-namespace": [
|
|
404
|
-
...route.path
|
|
405
|
-
.split("/")
|
|
406
|
-
.filter((str) => str.length && str[0] !== ":"),
|
|
407
|
-
route.name,
|
|
408
|
-
].join("."),
|
|
409
|
-
"x-nestia-jsDocTags": route.jsDocTags,
|
|
410
|
-
"x-nestia-method": route.method,
|
|
411
|
-
}
|
|
412
|
-
: {}),
|
|
413
|
-
};
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
function fill_security(
|
|
417
|
-
security: Required<INestiaConfig.ISwaggerConfig>["security"],
|
|
418
|
-
swagger: ISwagger,
|
|
419
|
-
): void {
|
|
420
|
-
swagger.components.securitySchemes = {};
|
|
421
|
-
for (const [key, value] of Object.entries(security))
|
|
422
|
-
swagger.components.securitySchemes[key] = emend_security(value);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function emend_security(
|
|
426
|
-
input: ISwaggerSecurityScheme,
|
|
427
|
-
): ISwaggerSecurityScheme {
|
|
428
|
-
if (input.type === "apiKey")
|
|
429
|
-
return {
|
|
430
|
-
...input,
|
|
431
|
-
in: input.in ?? "header",
|
|
432
|
-
name: input.name ?? "Authorization",
|
|
433
|
-
};
|
|
434
|
-
return input;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
interface Accessor {
|
|
439
|
-
method: string;
|
|
440
|
-
path: string;
|
|
441
|
-
}
|
|
442
|
-
interface Endpoint {
|
|
443
|
-
method: string;
|
|
444
|
-
path: string;
|
|
445
|
-
route: ISwaggerRoute;
|
|
446
|
-
}
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Singleton } from "tstl";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import typia, { IJsonApplication, IJsonComponents } from "typia";
|
|
6
|
+
import { MetadataCollection } from "typia/lib/factories/MetadataCollection";
|
|
7
|
+
import { JsonApplicationProgrammer } from "typia/lib/programmers/json/JsonApplicationProgrammer";
|
|
8
|
+
|
|
9
|
+
import { INestiaConfig } from "../INestiaConfig";
|
|
10
|
+
import { IRoute } from "../structures/IRoute";
|
|
11
|
+
import { ISwagger } from "../structures/ISwagger";
|
|
12
|
+
import { ISwaggerError } from "../structures/ISwaggerError";
|
|
13
|
+
import { ISwaggerInfo } from "../structures/ISwaggerInfo";
|
|
14
|
+
import { ISwaggerLazyProperty } from "../structures/ISwaggerLazyProperty";
|
|
15
|
+
import { ISwaggerLazySchema } from "../structures/ISwaggerLazySchema";
|
|
16
|
+
import { ISwaggerRoute } from "../structures/ISwaggerRoute";
|
|
17
|
+
import { ISwaggerSecurityScheme } from "../structures/ISwaggerSecurityScheme";
|
|
18
|
+
import { FileRetriever } from "../utils/FileRetriever";
|
|
19
|
+
import { MapUtil } from "../utils/MapUtil";
|
|
20
|
+
import { SwaggerSchemaGenerator } from "./internal/SwaggerSchemaGenerator";
|
|
21
|
+
|
|
22
|
+
export namespace SwaggerGenerator {
|
|
23
|
+
export interface IProps {
|
|
24
|
+
config: INestiaConfig.ISwaggerConfig;
|
|
25
|
+
checker: ts.TypeChecker;
|
|
26
|
+
collection: MetadataCollection;
|
|
27
|
+
lazySchemas: Array<ISwaggerLazySchema>;
|
|
28
|
+
lazyProperties: Array<ISwaggerLazyProperty>;
|
|
29
|
+
errors: ISwaggerError[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const generate =
|
|
33
|
+
(checker: ts.TypeChecker) =>
|
|
34
|
+
(config: INestiaConfig.ISwaggerConfig) =>
|
|
35
|
+
async (routeList: IRoute[]): Promise<void> => {
|
|
36
|
+
console.log("Generating Swagger Documents");
|
|
37
|
+
|
|
38
|
+
// VALIDATE SECURITY
|
|
39
|
+
validate_security(config)(routeList);
|
|
40
|
+
|
|
41
|
+
// PREPARE ASSETS
|
|
42
|
+
const parsed: path.ParsedPath = path.parse(config.output);
|
|
43
|
+
const directory: string = path.dirname(parsed.dir);
|
|
44
|
+
if (fs.existsSync(directory) === false)
|
|
45
|
+
try {
|
|
46
|
+
await fs.promises.mkdir(directory);
|
|
47
|
+
} catch {}
|
|
48
|
+
if (fs.existsSync(directory) === false)
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Error on NestiaApplication.swagger(): failed to create output directory: ${directory}`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const location: string = !!parsed.ext
|
|
54
|
+
? path.resolve(config.output)
|
|
55
|
+
: path.join(path.resolve(config.output), "swagger.json");
|
|
56
|
+
|
|
57
|
+
const collection: MetadataCollection = new MetadataCollection({
|
|
58
|
+
replace: MetadataCollection.replace,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// CONSTRUCT SWAGGER DOCUMENTS
|
|
62
|
+
const errors: ISwaggerError[] = [];
|
|
63
|
+
const lazySchemas: Array<ISwaggerLazySchema> = [];
|
|
64
|
+
const lazyProperties: Array<ISwaggerLazyProperty> = [];
|
|
65
|
+
const swagger: ISwagger = await initialize(config);
|
|
66
|
+
const pathDict: Map<string, Record<string, ISwaggerRoute>> = new Map();
|
|
67
|
+
|
|
68
|
+
for (const route of routeList) {
|
|
69
|
+
if (route.jsDocTags.find((tag) => tag.name === "internal")) continue;
|
|
70
|
+
|
|
71
|
+
const path: Record<string, ISwaggerRoute> = MapUtil.take(
|
|
72
|
+
pathDict,
|
|
73
|
+
get_path(route.path, route.parameters),
|
|
74
|
+
() => ({}),
|
|
75
|
+
);
|
|
76
|
+
path[route.method.toLowerCase()] = generate_route({
|
|
77
|
+
config,
|
|
78
|
+
checker,
|
|
79
|
+
collection,
|
|
80
|
+
lazySchemas,
|
|
81
|
+
lazyProperties,
|
|
82
|
+
errors,
|
|
83
|
+
})(route);
|
|
84
|
+
}
|
|
85
|
+
swagger.paths = {};
|
|
86
|
+
for (const [path, routes] of pathDict) swagger.paths[path] = routes;
|
|
87
|
+
|
|
88
|
+
// FILL JSON-SCHEMAS
|
|
89
|
+
const application: IJsonApplication = JsonApplicationProgrammer.write({
|
|
90
|
+
purpose: "swagger",
|
|
91
|
+
})(lazySchemas.map(({ metadata }) => metadata));
|
|
92
|
+
swagger.components = {
|
|
93
|
+
...(swagger.components ?? {}),
|
|
94
|
+
...(application.components ?? {}),
|
|
95
|
+
};
|
|
96
|
+
lazySchemas.forEach(({ schema }, index) => {
|
|
97
|
+
Object.assign(schema, application.schemas[index]!);
|
|
98
|
+
});
|
|
99
|
+
for (const p of lazyProperties)
|
|
100
|
+
Object.assign(
|
|
101
|
+
p.schema,
|
|
102
|
+
(
|
|
103
|
+
application.components.schemas?.[
|
|
104
|
+
p.object
|
|
105
|
+
] as IJsonComponents.IObject
|
|
106
|
+
)?.properties[p.property],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// CONFIGURE SECURITY
|
|
110
|
+
if (config.security) fill_security(config.security, swagger);
|
|
111
|
+
|
|
112
|
+
// REPORT ERRORS
|
|
113
|
+
if (errors.length) {
|
|
114
|
+
for (const e of errors)
|
|
115
|
+
console.error(
|
|
116
|
+
`${path.relative(e.route.location, process.cwd())}:${
|
|
117
|
+
e.route.target.class.name
|
|
118
|
+
}.${e.route.target.function.name}:${
|
|
119
|
+
e.from
|
|
120
|
+
} - error TS(@nestia/sdk): invalid type detected.\n\n` +
|
|
121
|
+
e.messages.map((m) => ` - ${m}`).join("\n"),
|
|
122
|
+
"\n\n",
|
|
123
|
+
);
|
|
124
|
+
throw new TypeError("Invalid type detected");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// SWAGGER CUSTOMIZER
|
|
128
|
+
const customizer = {
|
|
129
|
+
at: new Singleton(() => {
|
|
130
|
+
const functor: Map<Function, Endpoint> = new Map();
|
|
131
|
+
for (const route of routeList) {
|
|
132
|
+
const method = route.method.toLowerCase();
|
|
133
|
+
const path = get_path(route.path, route.parameters);
|
|
134
|
+
functor.set(route.target.function, {
|
|
135
|
+
method,
|
|
136
|
+
path,
|
|
137
|
+
route: swagger.paths[path][method],
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return functor;
|
|
141
|
+
}),
|
|
142
|
+
get: new Singleton(() => (key: Accessor): ISwaggerRoute | undefined => {
|
|
143
|
+
const method: string = key.method.toLowerCase();
|
|
144
|
+
const path: string =
|
|
145
|
+
"/" +
|
|
146
|
+
key.path
|
|
147
|
+
.split("/")
|
|
148
|
+
.filter((str) => !!str.length)
|
|
149
|
+
.map((str) =>
|
|
150
|
+
str.startsWith(":") ? `{${str.substring(1)}}` : str,
|
|
151
|
+
)
|
|
152
|
+
.join("/");
|
|
153
|
+
return swagger.paths[path]?.[method];
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
for (const route of routeList) {
|
|
157
|
+
if (
|
|
158
|
+
false ===
|
|
159
|
+
Reflect.hasMetadata(
|
|
160
|
+
"nestia/SwaggerCustomizer",
|
|
161
|
+
route.controller.prototype,
|
|
162
|
+
route.target.function.name,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
continue;
|
|
166
|
+
|
|
167
|
+
const path: string = get_path(route.path, route.parameters);
|
|
168
|
+
const method: string = route.method.toLowerCase();
|
|
169
|
+
const target: ISwaggerRoute = swagger.paths[path][method];
|
|
170
|
+
const closure: Function | Function[] = Reflect.getMetadata(
|
|
171
|
+
"nestia/SwaggerCustomizer",
|
|
172
|
+
route.controller.prototype,
|
|
173
|
+
route.target.function.name,
|
|
174
|
+
);
|
|
175
|
+
const array: Function[] = Array.isArray(closure) ? closure : [closure];
|
|
176
|
+
for (const fn of array)
|
|
177
|
+
fn({
|
|
178
|
+
route: target,
|
|
179
|
+
method,
|
|
180
|
+
path,
|
|
181
|
+
swagger,
|
|
182
|
+
at: (func: Function) => customizer.at.get().get(func),
|
|
183
|
+
get: (accessor: Accessor) => customizer.get.get()(accessor),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// DO GENERATE
|
|
188
|
+
await fs.promises.writeFile(
|
|
189
|
+
location,
|
|
190
|
+
!config.beautify
|
|
191
|
+
? JSON.stringify(swagger)
|
|
192
|
+
: JSON.stringify(
|
|
193
|
+
swagger,
|
|
194
|
+
null,
|
|
195
|
+
typeof config.beautify === "number" ? config.beautify : 2,
|
|
196
|
+
),
|
|
197
|
+
"utf8",
|
|
198
|
+
);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const validate_security =
|
|
202
|
+
(config: INestiaConfig.ISwaggerConfig) =>
|
|
203
|
+
(routeList: IRoute[]): void | never => {
|
|
204
|
+
const securityMap: Map<
|
|
205
|
+
string,
|
|
206
|
+
{ scheme: ISwaggerSecurityScheme; scopes: Set<string> }
|
|
207
|
+
> = new Map();
|
|
208
|
+
for (const [key, value] of Object.entries(config.security ?? {}))
|
|
209
|
+
securityMap.set(key, {
|
|
210
|
+
scheme: emend_security(value),
|
|
211
|
+
scopes:
|
|
212
|
+
value.type === "oauth2"
|
|
213
|
+
? new Set([
|
|
214
|
+
...Object.keys(value.flows.authorizationCode?.scopes ?? {}),
|
|
215
|
+
...Object.keys(value.flows.implicit?.scopes ?? {}),
|
|
216
|
+
...Object.keys(value.flows.password?.scopes ?? {}),
|
|
217
|
+
...Object.keys(value.flows.clientCredentials?.scopes ?? {}),
|
|
218
|
+
])
|
|
219
|
+
: new Set(),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const validate =
|
|
223
|
+
(reporter: (str: string) => void) =>
|
|
224
|
+
(key: string, scopes: string[]) => {
|
|
225
|
+
const security = securityMap.get(key);
|
|
226
|
+
if (security === undefined)
|
|
227
|
+
return reporter(`target security scheme "${key}" does not exists.`);
|
|
228
|
+
else if (scopes.length === 0) return;
|
|
229
|
+
else if (security.scheme.type !== "oauth2")
|
|
230
|
+
return reporter(
|
|
231
|
+
`target security scheme "${key}" is not "oauth2" type, but you've configured the scopes.`,
|
|
232
|
+
);
|
|
233
|
+
for (const s of scopes)
|
|
234
|
+
if (security.scopes.has(s) === false)
|
|
235
|
+
reporter(
|
|
236
|
+
`target security scheme "${key}" does not have a specific scope "${s}".`,
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const violations: string[] = [];
|
|
241
|
+
for (const route of routeList)
|
|
242
|
+
for (const record of route.security)
|
|
243
|
+
for (const [key, scopes] of Object.entries(record))
|
|
244
|
+
validate((str) =>
|
|
245
|
+
violations.push(
|
|
246
|
+
` - ${str} (${route.target.class.name}.${route.target.function.name}() at "${route.location}")`,
|
|
247
|
+
),
|
|
248
|
+
)(key, scopes);
|
|
249
|
+
|
|
250
|
+
if (violations.length)
|
|
251
|
+
throw new Error(
|
|
252
|
+
`Error on NestiaApplication.swagger(): invalid security specification. Check your "nestia.config.ts" file's "swagger.security" property, or each controller methods.\n` +
|
|
253
|
+
`\n` +
|
|
254
|
+
`List of violations:\n` +
|
|
255
|
+
violations.join("\n"),
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/* ---------------------------------------------------------
|
|
260
|
+
INITIALIZERS
|
|
261
|
+
--------------------------------------------------------- */
|
|
262
|
+
const initialize = async (
|
|
263
|
+
config: INestiaConfig.ISwaggerConfig,
|
|
264
|
+
): Promise<ISwagger> => {
|
|
265
|
+
const pack = new Singleton(
|
|
266
|
+
async (): Promise<Partial<ISwaggerInfo> | null> => {
|
|
267
|
+
const location: string | null = await FileRetriever.file(
|
|
268
|
+
"package.json",
|
|
269
|
+
)(process.cwd());
|
|
270
|
+
if (location === null) return null;
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const content: string = await fs.promises.readFile(location, "utf8");
|
|
274
|
+
const data = typia.json.assertParse<{
|
|
275
|
+
name?: string;
|
|
276
|
+
version?: string;
|
|
277
|
+
description?: string;
|
|
278
|
+
license?:
|
|
279
|
+
| string
|
|
280
|
+
| {
|
|
281
|
+
type: string;
|
|
282
|
+
/**
|
|
283
|
+
* @format uri
|
|
284
|
+
*/
|
|
285
|
+
url: string;
|
|
286
|
+
};
|
|
287
|
+
}>(content);
|
|
288
|
+
return {
|
|
289
|
+
title: data.name,
|
|
290
|
+
version: data.version,
|
|
291
|
+
description: data.description,
|
|
292
|
+
license: data.license
|
|
293
|
+
? typeof data.license === "string"
|
|
294
|
+
? { name: data.license }
|
|
295
|
+
: typeof data.license === "object"
|
|
296
|
+
? {
|
|
297
|
+
name: data.license.type,
|
|
298
|
+
url: data.license.url,
|
|
299
|
+
}
|
|
300
|
+
: undefined
|
|
301
|
+
: undefined,
|
|
302
|
+
};
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
openapi: "3.0.1",
|
|
311
|
+
servers: config.servers ?? [
|
|
312
|
+
{
|
|
313
|
+
url: "https://github.com/samchon/nestia",
|
|
314
|
+
description: "insert your server url",
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
info: {
|
|
318
|
+
...(config.info ?? {}),
|
|
319
|
+
version: config.info?.version ?? (await pack.get())?.version ?? "0.1.0",
|
|
320
|
+
title:
|
|
321
|
+
config.info?.title ??
|
|
322
|
+
(await pack.get())?.title ??
|
|
323
|
+
"Swagger Documents",
|
|
324
|
+
description:
|
|
325
|
+
config.info?.description ??
|
|
326
|
+
(await pack.get())?.description ??
|
|
327
|
+
"Generated by nestia - https://github.com/samchon/nestia",
|
|
328
|
+
license: config.info?.license ?? (await pack.get())?.license,
|
|
329
|
+
},
|
|
330
|
+
paths: {},
|
|
331
|
+
components: {},
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
function get_path(path: string, parameters: IRoute.IParameter[]): string {
|
|
336
|
+
const filtered: IRoute.IParameter[] = parameters.filter(
|
|
337
|
+
(param) => param.category === "param" && !!param.field,
|
|
338
|
+
);
|
|
339
|
+
for (const param of filtered)
|
|
340
|
+
path = path.replace(`:${param.field}`, `{${param.field}}`);
|
|
341
|
+
return path;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const generate_route =
|
|
345
|
+
(props: IProps) =>
|
|
346
|
+
(route: IRoute): ISwaggerRoute => {
|
|
347
|
+
const body = route.parameters.find((param) => param.category === "body");
|
|
348
|
+
const getJsDocTexts = (name: string) =>
|
|
349
|
+
route.jsDocTags
|
|
350
|
+
.filter(
|
|
351
|
+
(tag) =>
|
|
352
|
+
tag.name === name &&
|
|
353
|
+
tag.text &&
|
|
354
|
+
tag.text.find(
|
|
355
|
+
(elem) => elem.kind === "text" && elem.text.length,
|
|
356
|
+
) !== undefined,
|
|
357
|
+
)
|
|
358
|
+
.map((tag) => tag.text!.find((elem) => elem.kind === "text")!.text);
|
|
359
|
+
|
|
360
|
+
const description: string | undefined = route.description?.length
|
|
361
|
+
? route.description
|
|
362
|
+
: undefined;
|
|
363
|
+
const summary: string | undefined = (() => {
|
|
364
|
+
if (description === undefined) return undefined;
|
|
365
|
+
|
|
366
|
+
const [explicit] = getJsDocTexts("summary");
|
|
367
|
+
if (explicit?.length) return explicit;
|
|
368
|
+
|
|
369
|
+
const index: number = description.indexOf("\n");
|
|
370
|
+
const top: string = (
|
|
371
|
+
index === -1 ? description : description.substring(0, index)
|
|
372
|
+
).trim();
|
|
373
|
+
return top.endsWith(".") ? top.substring(0, top.length - 1) : undefined;
|
|
374
|
+
})();
|
|
375
|
+
const deprecated = route.jsDocTags.find(
|
|
376
|
+
(tag) => tag.name === "deprecated",
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
deprecated: deprecated ? true : undefined,
|
|
381
|
+
tags: [...route.swaggerTags, ...new Set([...getJsDocTexts("tag")])],
|
|
382
|
+
operationId:
|
|
383
|
+
route.operationId ??
|
|
384
|
+
props.config.operationId?.({
|
|
385
|
+
class: route.target.class.name,
|
|
386
|
+
function: route.target.function.name,
|
|
387
|
+
method: route.method as "GET",
|
|
388
|
+
path: route.path,
|
|
389
|
+
}),
|
|
390
|
+
parameters: route.parameters
|
|
391
|
+
.filter((param) => param.category !== "body")
|
|
392
|
+
.map((param) => SwaggerSchemaGenerator.parameter(props)(route)(param))
|
|
393
|
+
.flat(),
|
|
394
|
+
requestBody: body
|
|
395
|
+
? SwaggerSchemaGenerator.body(props)(route)(body)
|
|
396
|
+
: undefined,
|
|
397
|
+
responses: SwaggerSchemaGenerator.response(props)(route),
|
|
398
|
+
summary,
|
|
399
|
+
description,
|
|
400
|
+
security: route.security.length ? route.security : undefined,
|
|
401
|
+
...(props.config.additional === true
|
|
402
|
+
? {
|
|
403
|
+
"x-nestia-namespace": [
|
|
404
|
+
...route.path
|
|
405
|
+
.split("/")
|
|
406
|
+
.filter((str) => str.length && str[0] !== ":"),
|
|
407
|
+
route.name,
|
|
408
|
+
].join("."),
|
|
409
|
+
"x-nestia-jsDocTags": route.jsDocTags,
|
|
410
|
+
"x-nestia-method": route.method,
|
|
411
|
+
}
|
|
412
|
+
: {}),
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
function fill_security(
|
|
417
|
+
security: Required<INestiaConfig.ISwaggerConfig>["security"],
|
|
418
|
+
swagger: ISwagger,
|
|
419
|
+
): void {
|
|
420
|
+
swagger.components.securitySchemes = {};
|
|
421
|
+
for (const [key, value] of Object.entries(security))
|
|
422
|
+
swagger.components.securitySchemes[key] = emend_security(value);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function emend_security(
|
|
426
|
+
input: ISwaggerSecurityScheme,
|
|
427
|
+
): ISwaggerSecurityScheme {
|
|
428
|
+
if (input.type === "apiKey")
|
|
429
|
+
return {
|
|
430
|
+
...input,
|
|
431
|
+
in: input.in ?? "header",
|
|
432
|
+
name: input.name ?? "Authorization",
|
|
433
|
+
};
|
|
434
|
+
return input;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface Accessor {
|
|
439
|
+
method: string;
|
|
440
|
+
path: string;
|
|
441
|
+
}
|
|
442
|
+
interface Endpoint {
|
|
443
|
+
method: string;
|
|
444
|
+
path: string;
|
|
445
|
+
route: ISwaggerRoute;
|
|
446
|
+
}
|