@twin.org/ts-to-openapi 0.0.1-next.2
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 +201 -0
- package/README.md +21 -0
- package/bin/index.js +8 -0
- package/dist/cjs/index.cjs +1124 -0
- package/dist/esm/index.mjs +1118 -0
- package/dist/locales/en.json +274 -0
- package/dist/types/cli.d.ts +19 -0
- package/dist/types/commands/httpStatusCodeMap.d.ts +20 -0
- package/dist/types/commands/tsToOpenApi.d.ts +21 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/models/IInputPath.d.ts +68 -0
- package/dist/types/models/IInputResult.d.ts +15 -0
- package/dist/types/models/IOpenApi.d.ts +54 -0
- package/dist/types/models/IOpenApiExample.d.ts +13 -0
- package/dist/types/models/IOpenApiHeader.d.ts +19 -0
- package/dist/types/models/IOpenApiPathMethod.d.ts +64 -0
- package/dist/types/models/IOpenApiResponse.d.ts +32 -0
- package/dist/types/models/IOpenApiSecurityScheme.d.ts +25 -0
- package/dist/types/models/IPackageJson.d.ts +15 -0
- package/dist/types/models/ITsToOpenApiConfig.d.ts +61 -0
- package/dist/types/models/ITsToOpenApiConfigEntryPoint.d.ts +17 -0
- package/docs/changelog.md +5 -0
- package/docs/examples.md +890 -0
- package/docs/reference/classes/CLI.md +67 -0
- package/docs/reference/functions/actionCommandTsToOpenApi.md +23 -0
- package/docs/reference/functions/buildCommandTsToOpenApi.md +15 -0
- package/docs/reference/functions/tsToOpenApi.md +23 -0
- package/docs/reference/index.md +16 -0
- package/docs/reference/interfaces/ITsToOpenApiConfig.md +79 -0
- package/docs/reference/interfaces/ITsToOpenApiConfigEntryPoint.md +27 -0
- package/locales/en.json +45 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { CLIDisplay, CLIUtils, CLIBase } from '@twin.org/cli-core';
|
|
4
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { I18n, GeneralError, Is, StringHelper, ObjectHelper } from '@twin.org/core';
|
|
6
|
+
import { HttpStatusCode, MimeTypes } from '@twin.org/web';
|
|
7
|
+
import { createGenerator } from 'ts-json-schema-generator';
|
|
8
|
+
|
|
9
|
+
const HTTP_STATUS_CODE_MAP = {
|
|
10
|
+
ok: {
|
|
11
|
+
code: HttpStatusCode.ok,
|
|
12
|
+
responseType: "IOkResponse"
|
|
13
|
+
},
|
|
14
|
+
created: {
|
|
15
|
+
code: HttpStatusCode.created,
|
|
16
|
+
responseType: "ICreatedResponse"
|
|
17
|
+
},
|
|
18
|
+
accepted: {
|
|
19
|
+
code: HttpStatusCode.accepted,
|
|
20
|
+
responseType: "IAcceptedResponse"
|
|
21
|
+
},
|
|
22
|
+
noContent: {
|
|
23
|
+
code: HttpStatusCode.noContent,
|
|
24
|
+
responseType: "INoContentResponse"
|
|
25
|
+
},
|
|
26
|
+
badRequest: {
|
|
27
|
+
code: HttpStatusCode.badRequest,
|
|
28
|
+
responseType: "IBadRequestResponse",
|
|
29
|
+
example: {
|
|
30
|
+
name: "GeneralError",
|
|
31
|
+
message: "component.error",
|
|
32
|
+
properties: {
|
|
33
|
+
foo: "bar"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
unauthorized: {
|
|
38
|
+
code: HttpStatusCode.unauthorized,
|
|
39
|
+
responseType: "IUnauthorizedResponse",
|
|
40
|
+
example: {
|
|
41
|
+
name: "UnauthorizedError",
|
|
42
|
+
message: "component.error"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
forbidden: {
|
|
46
|
+
code: HttpStatusCode.forbidden,
|
|
47
|
+
responseType: "IForbiddenResponse",
|
|
48
|
+
example: {
|
|
49
|
+
name: "NotImplementedError",
|
|
50
|
+
message: "component.error",
|
|
51
|
+
properties: {
|
|
52
|
+
method: "aMethod"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
notFound: {
|
|
57
|
+
code: HttpStatusCode.notFound,
|
|
58
|
+
responseType: "INotFoundResponse",
|
|
59
|
+
example: {
|
|
60
|
+
name: "NotFoundError",
|
|
61
|
+
message: "component.error",
|
|
62
|
+
properties: {
|
|
63
|
+
notFoundId: "1"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
conflict: {
|
|
68
|
+
code: HttpStatusCode.conflict,
|
|
69
|
+
responseType: "IConflictResponse",
|
|
70
|
+
example: {
|
|
71
|
+
name: "ConflictError",
|
|
72
|
+
message: "component.error",
|
|
73
|
+
properties: {
|
|
74
|
+
conflicts: ["1"]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
internalServerError: {
|
|
79
|
+
code: HttpStatusCode.internalServerError,
|
|
80
|
+
responseType: "IInternalServerErrorResponse",
|
|
81
|
+
example: {
|
|
82
|
+
name: "InternalServerError",
|
|
83
|
+
message: "component.error"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
unprocessableEntity: {
|
|
87
|
+
code: HttpStatusCode.unprocessableEntity,
|
|
88
|
+
responseType: "IUnprocessableEntityResponse",
|
|
89
|
+
example: {
|
|
90
|
+
name: "UnprocessableError",
|
|
91
|
+
message: "component.error"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Get the HTTP status code from the error code type.
|
|
97
|
+
* @param errorCodeType The error code type.
|
|
98
|
+
* @returns The HTTP status code.
|
|
99
|
+
*/
|
|
100
|
+
function getHttpStatusCodeFromType(errorCodeType) {
|
|
101
|
+
for (const httpStatusCodeType of Object.values(HTTP_STATUS_CODE_MAP)) {
|
|
102
|
+
if (httpStatusCodeType.responseType === errorCodeType) {
|
|
103
|
+
return httpStatusCodeType.code;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return HttpStatusCode.ok;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Get the HTTP example from the error code type.
|
|
110
|
+
* @param errorCodeType The error code type.
|
|
111
|
+
* @returns The example.
|
|
112
|
+
*/
|
|
113
|
+
function getHttpExampleFromType(errorCodeType) {
|
|
114
|
+
for (const httpStatusCodeType of Object.values(HTTP_STATUS_CODE_MAP)) {
|
|
115
|
+
if (httpStatusCodeType.responseType === errorCodeType) {
|
|
116
|
+
return httpStatusCodeType.example;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Copyright 2024 IOTA Stiftung.
|
|
122
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
123
|
+
/**
|
|
124
|
+
* Build the root command to be consumed by the CLI.
|
|
125
|
+
* @param program The command to build on.
|
|
126
|
+
*/
|
|
127
|
+
function buildCommandTsToOpenApi(program) {
|
|
128
|
+
program
|
|
129
|
+
.argument(I18n.formatMessage("commands.ts-to-openapi.options.config.param"), I18n.formatMessage("commands.ts-to-openapi.options.config.description"))
|
|
130
|
+
.argument(I18n.formatMessage("commands.ts-to-openapi.options.output-file.param"), I18n.formatMessage("commands.ts-to-openapi.options.output-file.description"))
|
|
131
|
+
.action(async (config, outputFile, opts) => {
|
|
132
|
+
await actionCommandTsToOpenApi(config, outputFile);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Action the root command.
|
|
137
|
+
* @param configFile The optional configuration file.
|
|
138
|
+
* @param outputFile The output file for the generation OpenApi spec.
|
|
139
|
+
* @param opts The options for the command.
|
|
140
|
+
*/
|
|
141
|
+
async function actionCommandTsToOpenApi(configFile, outputFile, opts) {
|
|
142
|
+
let outputWorkingDir;
|
|
143
|
+
try {
|
|
144
|
+
let config;
|
|
145
|
+
const fullConfigFile = path.resolve(configFile);
|
|
146
|
+
const fullOutputFile = path.resolve(outputFile);
|
|
147
|
+
outputWorkingDir = path.join(path.dirname(fullOutputFile), "working");
|
|
148
|
+
CLIDisplay.value(I18n.formatMessage("commands.ts-to-openapi.labels.configJson"), fullConfigFile);
|
|
149
|
+
CLIDisplay.value(I18n.formatMessage("commands.ts-to-openapi.labels.outputFile"), fullOutputFile);
|
|
150
|
+
CLIDisplay.value(I18n.formatMessage("commands.ts-to-openapi.labels.outputWorkingDir"), outputWorkingDir);
|
|
151
|
+
CLIDisplay.break();
|
|
152
|
+
try {
|
|
153
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.loadingConfigJson"));
|
|
154
|
+
CLIDisplay.break();
|
|
155
|
+
config = await CLIUtils.readJsonFile(fullConfigFile);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw new GeneralError("commands", "commands.ts-to-openapi.configFailed", undefined, err);
|
|
159
|
+
}
|
|
160
|
+
if (Is.empty(config)) {
|
|
161
|
+
throw new GeneralError("commands", "commands.ts-to-openapi.configFailed");
|
|
162
|
+
}
|
|
163
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.creatingWorkingDir"));
|
|
164
|
+
await mkdir(outputWorkingDir, { recursive: true });
|
|
165
|
+
CLIDisplay.break();
|
|
166
|
+
await tsToOpenApi(config ?? {}, fullOutputFile, outputWorkingDir);
|
|
167
|
+
CLIDisplay.break();
|
|
168
|
+
CLIDisplay.done();
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
try {
|
|
172
|
+
if (outputWorkingDir) {
|
|
173
|
+
await rm(outputWorkingDir, { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Convert the TypeScript definitions to OpenAPI spec.
|
|
181
|
+
* @param config The configuration for the app.
|
|
182
|
+
* @param outputFile The location of the file to output the OpenAPI spec.
|
|
183
|
+
* @param workingDirectory The folder the app was run from.
|
|
184
|
+
*/
|
|
185
|
+
async function tsToOpenApi(config, outputFile, workingDirectory) {
|
|
186
|
+
await writeFile(path.join(workingDirectory, "package.json"), JSON.stringify({
|
|
187
|
+
version: "1.0.0",
|
|
188
|
+
name: "ts-to-openapi-working",
|
|
189
|
+
dependencies: {}
|
|
190
|
+
}, undefined, "\t"));
|
|
191
|
+
await writeFile(path.join(workingDirectory, "tsconfig.json"), JSON.stringify({
|
|
192
|
+
compilerOptions: {}
|
|
193
|
+
}, undefined, "\t"));
|
|
194
|
+
const openApi = {
|
|
195
|
+
openapi: "3.1.0",
|
|
196
|
+
info: {
|
|
197
|
+
title: config.title,
|
|
198
|
+
description: config.description,
|
|
199
|
+
version: config.version,
|
|
200
|
+
license: {
|
|
201
|
+
name: config.licenseName,
|
|
202
|
+
url: config.licenseUrl
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
servers: Is.arrayValue(config.servers) ? config.servers.map(s => ({ url: s })) : undefined,
|
|
206
|
+
tags: [],
|
|
207
|
+
paths: {}
|
|
208
|
+
};
|
|
209
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.creatingSecuritySchemas"));
|
|
210
|
+
CLIDisplay.break();
|
|
211
|
+
const authSecurity = [];
|
|
212
|
+
const securitySchemes = {};
|
|
213
|
+
buildSecurity(config, securitySchemes, authSecurity);
|
|
214
|
+
const types = Object.values(HTTP_STATUS_CODE_MAP).map(h => h.responseType);
|
|
215
|
+
const inputResults = [];
|
|
216
|
+
const typeRoots = [];
|
|
217
|
+
const restRoutesAndTags = await loadPackages(config, workingDirectory, typeRoots);
|
|
218
|
+
for (const restRouteAndTag of restRoutesAndTags) {
|
|
219
|
+
const paths = await processPackageRestDetails(restRouteAndTag.restRoutes);
|
|
220
|
+
inputResults.push({
|
|
221
|
+
paths,
|
|
222
|
+
tags: restRouteAndTag.tags
|
|
223
|
+
});
|
|
224
|
+
for (const inputPath of paths) {
|
|
225
|
+
if (inputPath.requestType && !types.includes(inputPath.requestType)) {
|
|
226
|
+
types.push(inputPath.requestType);
|
|
227
|
+
}
|
|
228
|
+
if (inputPath.responseType) {
|
|
229
|
+
const rt = inputPath.responseType.map(i => i.type);
|
|
230
|
+
for (const r of rt) {
|
|
231
|
+
if (r && inputPath.responseType && !types.includes(r)) {
|
|
232
|
+
types.push(r);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.generatingSchemas"));
|
|
239
|
+
const schemas = await generateSchemas(typeRoots, types, workingDirectory);
|
|
240
|
+
const usedCommonResponseTypes = [];
|
|
241
|
+
for (let i = 0; i < inputResults.length; i++) {
|
|
242
|
+
const result = inputResults[i];
|
|
243
|
+
for (const tag of result.tags) {
|
|
244
|
+
const exists = openApi.tags?.find(t => t.name === tag.name);
|
|
245
|
+
if (!exists) {
|
|
246
|
+
openApi.tags?.push(tag);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const inputPath of result.paths) {
|
|
250
|
+
const responses = [];
|
|
251
|
+
const responseTypes = inputPath.responseType;
|
|
252
|
+
const pathSpecificAuthSecurity = [];
|
|
253
|
+
if (authSecurity.length > 0 && !inputPath.skipAuth) {
|
|
254
|
+
pathSpecificAuthSecurity.push(...authSecurity);
|
|
255
|
+
}
|
|
256
|
+
if (pathSpecificAuthSecurity.length > 0) {
|
|
257
|
+
responseTypes.push({
|
|
258
|
+
statusCode: HttpStatusCode.unauthorized,
|
|
259
|
+
type: "IUnauthorizedResponse"
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
for (const responseType of responseTypes) {
|
|
263
|
+
if (schemas[responseType.type]) {
|
|
264
|
+
let headers;
|
|
265
|
+
let examples;
|
|
266
|
+
if (Is.arrayValue(responseType.examples)) {
|
|
267
|
+
for (const example of responseType.examples) {
|
|
268
|
+
if (Is.object(example.response)) {
|
|
269
|
+
if (Is.objectValue(example.response.headers)) {
|
|
270
|
+
headers ??= {};
|
|
271
|
+
const headersSchema = schemas[responseType.type].properties
|
|
272
|
+
?.headers;
|
|
273
|
+
for (const header in example.response.headers) {
|
|
274
|
+
const headerValue = example.response.headers[header];
|
|
275
|
+
const propertySchema = headersSchema.properties?.[header];
|
|
276
|
+
const schemaType = Is.object(propertySchema)
|
|
277
|
+
? propertySchema?.type
|
|
278
|
+
: undefined;
|
|
279
|
+
headers[header] = {
|
|
280
|
+
schema: {
|
|
281
|
+
type: Is.string(schemaType) ? schemaType : "string"
|
|
282
|
+
},
|
|
283
|
+
description: `e.g. ${Is.array(headerValue) ? headerValue.join(",") : headerValue}`
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!Is.undefined(example.response.body)) {
|
|
288
|
+
examples ??= {};
|
|
289
|
+
examples[example.id] = {
|
|
290
|
+
summary: example.description,
|
|
291
|
+
value: example.response.body
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const statusExample = getHttpExampleFromType(responseType.type);
|
|
299
|
+
if (statusExample) {
|
|
300
|
+
examples = {};
|
|
301
|
+
examples.exampleResponse = {
|
|
302
|
+
value: statusExample
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
let mimeType;
|
|
307
|
+
let schemaType;
|
|
308
|
+
let schemaFormat;
|
|
309
|
+
let schemaRef;
|
|
310
|
+
let description = schemas[responseType.type]?.description;
|
|
311
|
+
if (Is.stringValue(responseType.mimeType)) {
|
|
312
|
+
mimeType = responseType.mimeType;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
const hasBody = Is.notEmpty(schemas[responseType.type]?.properties?.body);
|
|
316
|
+
if (hasBody) {
|
|
317
|
+
mimeType = MimeTypes.Json;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
mimeType = MimeTypes.PlainText;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Perform some special handling for binary octet-streams to produce a nicer spec output
|
|
324
|
+
if (responseType.type === "Uint8Array") {
|
|
325
|
+
schemaType = "string";
|
|
326
|
+
schemaFormat = "binary";
|
|
327
|
+
schemaRef = undefined;
|
|
328
|
+
description = "Binary data";
|
|
329
|
+
if (Is.objectValue(examples)) {
|
|
330
|
+
const exampleKeys = Object.keys(examples);
|
|
331
|
+
const firstExample = examples[exampleKeys[0]];
|
|
332
|
+
description = firstExample.summary;
|
|
333
|
+
firstExample.summary = "Binary Data";
|
|
334
|
+
for (const exampleKey in examples) {
|
|
335
|
+
examples[exampleKey].value = "";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
schemaRef = `#/definitions/${responseType.type}`;
|
|
341
|
+
}
|
|
342
|
+
responses.push({
|
|
343
|
+
code: responseType.statusCode,
|
|
344
|
+
description,
|
|
345
|
+
content: responseType.type === "ICreatedResponse" ||
|
|
346
|
+
responseType.type === "INoContentResponse"
|
|
347
|
+
? undefined
|
|
348
|
+
: {
|
|
349
|
+
[mimeType]: {
|
|
350
|
+
schema: {
|
|
351
|
+
$ref: schemaRef,
|
|
352
|
+
type: schemaType,
|
|
353
|
+
format: schemaFormat
|
|
354
|
+
},
|
|
355
|
+
examples
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
headers
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (!usedCommonResponseTypes.includes(responseType.type)) {
|
|
362
|
+
usedCommonResponseTypes.push(responseType.type);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (inputPath.responseCodes.length > 0) {
|
|
366
|
+
for (const responseCode of inputPath.responseCodes) {
|
|
367
|
+
const responseCodeDetails = HTTP_STATUS_CODE_MAP[responseCode];
|
|
368
|
+
// Only include the response code if it hasn't already been
|
|
369
|
+
// included with a specific response
|
|
370
|
+
if (!responseTypes.some(r => r.statusCode === responseCodeDetails.code)) {
|
|
371
|
+
let examples;
|
|
372
|
+
if (responseCodeDetails.example) {
|
|
373
|
+
examples = {
|
|
374
|
+
exampleResponse: {
|
|
375
|
+
value: responseCodeDetails.example
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (schemas[responseCodeDetails.responseType]) {
|
|
380
|
+
responses.push({
|
|
381
|
+
code: responseCodeDetails.code,
|
|
382
|
+
description: schemas[responseCodeDetails.responseType].description,
|
|
383
|
+
content: {
|
|
384
|
+
[MimeTypes.Json]: {
|
|
385
|
+
schema: {
|
|
386
|
+
$ref: `#/definitions/${responseCodeDetails.responseType}`
|
|
387
|
+
},
|
|
388
|
+
examples
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (!usedCommonResponseTypes.includes(responseCodeDetails.responseType)) {
|
|
394
|
+
usedCommonResponseTypes.push(responseCodeDetails.responseType);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const pathQueryHeaderParams = inputPath.pathParameters.map(p => ({
|
|
400
|
+
name: p,
|
|
401
|
+
description: "",
|
|
402
|
+
required: true,
|
|
403
|
+
schema: {
|
|
404
|
+
type: "string"
|
|
405
|
+
},
|
|
406
|
+
in: "path",
|
|
407
|
+
style: "simple"
|
|
408
|
+
}));
|
|
409
|
+
const requestExample = inputPath.requestExamples?.[0]
|
|
410
|
+
?.request;
|
|
411
|
+
if (Is.object(requestExample?.pathParams)) {
|
|
412
|
+
for (const pathOrQueryParam of pathQueryHeaderParams) {
|
|
413
|
+
if (requestExample.pathParams[pathOrQueryParam.name]) {
|
|
414
|
+
pathOrQueryParam.example = requestExample.pathParams[pathOrQueryParam.name];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
let requestObject = inputPath.requestType
|
|
419
|
+
? schemas[inputPath.requestType]
|
|
420
|
+
: undefined;
|
|
421
|
+
if (requestObject?.properties) {
|
|
422
|
+
// If there are any properties other than body, query, pathParams and headers
|
|
423
|
+
// we should throw an error as we don't know what to do with them
|
|
424
|
+
const otherKeys = Object.keys(requestObject.properties).filter(k => !["body", "query", "pathParams", "headers"].includes(k));
|
|
425
|
+
if (otherKeys.length > 0) {
|
|
426
|
+
throw new GeneralError("commands", "commands.ts-to-openapi.unsupportedProperties", {
|
|
427
|
+
keys: otherKeys.join(", ")
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// If there is a path params object convert these to params
|
|
431
|
+
if (Is.object(requestObject.properties.pathParams)) {
|
|
432
|
+
for (const pathParam of pathQueryHeaderParams) {
|
|
433
|
+
const prop = requestObject.properties.pathParams.properties?.[pathParam.name];
|
|
434
|
+
if (Is.object(prop)) {
|
|
435
|
+
pathParam.description = prop.description ?? pathParam.description;
|
|
436
|
+
pathParam.schema = {
|
|
437
|
+
type: prop.type,
|
|
438
|
+
enum: prop.enum,
|
|
439
|
+
$ref: prop.$ref
|
|
440
|
+
};
|
|
441
|
+
pathParam.required = true;
|
|
442
|
+
delete requestObject.properties.pathParams.properties?.[pathParam.name];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
delete requestObject.properties.pathParams;
|
|
446
|
+
}
|
|
447
|
+
// If there is a query object convert these to params as well
|
|
448
|
+
if (Is.object(requestObject.properties.query)) {
|
|
449
|
+
for (const prop in requestObject.properties.query.properties) {
|
|
450
|
+
const queryProp = requestObject.properties.query.properties[prop];
|
|
451
|
+
if (Is.object(queryProp)) {
|
|
452
|
+
let example;
|
|
453
|
+
if (Is.object(requestExample.query) && requestExample.query[prop]) {
|
|
454
|
+
example = requestExample.query[prop];
|
|
455
|
+
}
|
|
456
|
+
pathQueryHeaderParams.push({
|
|
457
|
+
name: prop,
|
|
458
|
+
description: queryProp.description,
|
|
459
|
+
required: Boolean(requestObject.required?.includes(prop)),
|
|
460
|
+
schema: {
|
|
461
|
+
type: queryProp.type,
|
|
462
|
+
enum: queryProp.enum,
|
|
463
|
+
$ref: queryProp.$ref
|
|
464
|
+
},
|
|
465
|
+
in: "query",
|
|
466
|
+
example
|
|
467
|
+
});
|
|
468
|
+
delete requestObject.properties.query.properties[prop];
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
delete requestObject.properties.query;
|
|
472
|
+
}
|
|
473
|
+
// If there are headers in the object convert these to spec params
|
|
474
|
+
if (Is.object(requestObject.properties.headers)) {
|
|
475
|
+
const headerProperties = requestObject.properties.headers.properties;
|
|
476
|
+
for (const prop in headerProperties) {
|
|
477
|
+
const headerSchema = headerProperties[prop];
|
|
478
|
+
if (Is.object(headerSchema)) {
|
|
479
|
+
let example;
|
|
480
|
+
if (Is.object(requestExample.headers) && requestExample.headers[prop]) {
|
|
481
|
+
example = requestExample.headers[prop];
|
|
482
|
+
}
|
|
483
|
+
pathQueryHeaderParams.push({
|
|
484
|
+
name: prop,
|
|
485
|
+
description: headerSchema.description,
|
|
486
|
+
required: true,
|
|
487
|
+
schema: {
|
|
488
|
+
type: "string"
|
|
489
|
+
},
|
|
490
|
+
in: "header",
|
|
491
|
+
style: "simple",
|
|
492
|
+
example
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
delete requestObject.properties.headers;
|
|
497
|
+
}
|
|
498
|
+
// If we have used all the properties from the object in the
|
|
499
|
+
// path we should remove it.
|
|
500
|
+
if (Object.keys(requestObject.properties).length === 0 && inputPath.requestType) {
|
|
501
|
+
delete schemas[inputPath.requestType];
|
|
502
|
+
requestObject = undefined;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (config.restRoutes) {
|
|
506
|
+
let fullPath = StringHelper.trimTrailingSlashes(inputPath.path);
|
|
507
|
+
if (fullPath.length === 0) {
|
|
508
|
+
fullPath = "/";
|
|
509
|
+
}
|
|
510
|
+
openApi.paths[fullPath] ??= {};
|
|
511
|
+
const method = inputPath.method.toLowerCase();
|
|
512
|
+
openApi.paths[fullPath][method] = {
|
|
513
|
+
operationId: inputPath.operationId,
|
|
514
|
+
summary: inputPath.summary,
|
|
515
|
+
tags: [inputPath.tag],
|
|
516
|
+
parameters: pathQueryHeaderParams.length > 0
|
|
517
|
+
? pathQueryHeaderParams.map(p => ({
|
|
518
|
+
name: p.name,
|
|
519
|
+
description: p.description,
|
|
520
|
+
in: p.in,
|
|
521
|
+
required: p.required,
|
|
522
|
+
schema: p.schema,
|
|
523
|
+
style: p.style,
|
|
524
|
+
example: p.example
|
|
525
|
+
}))
|
|
526
|
+
: undefined
|
|
527
|
+
};
|
|
528
|
+
if (pathSpecificAuthSecurity.length > 0) {
|
|
529
|
+
openApi.paths[fullPath][method].security = pathSpecificAuthSecurity;
|
|
530
|
+
}
|
|
531
|
+
if (requestObject && inputPath.requestType) {
|
|
532
|
+
let examples;
|
|
533
|
+
if (Is.arrayValue(inputPath.requestExamples)) {
|
|
534
|
+
for (const example of inputPath.requestExamples) {
|
|
535
|
+
if (Is.object(example.request) &&
|
|
536
|
+
!Is.undefined(example.request.body)) {
|
|
537
|
+
examples ??= {};
|
|
538
|
+
examples[example.id] = {
|
|
539
|
+
summary: example.description,
|
|
540
|
+
value: example.request.body
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
let requestMimeType;
|
|
546
|
+
if (Is.stringValue(inputPath.requestMimeType)) {
|
|
547
|
+
requestMimeType = inputPath.requestMimeType;
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
const hasBody = Is.notEmpty(schemas[inputPath.requestType]?.properties?.body);
|
|
551
|
+
if (hasBody) {
|
|
552
|
+
requestMimeType = MimeTypes.Json;
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
requestMimeType = MimeTypes.PlainText;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
openApi.paths[fullPath][method].requestBody = {
|
|
559
|
+
description: requestObject.description,
|
|
560
|
+
required: true,
|
|
561
|
+
content: {
|
|
562
|
+
[requestMimeType]: {
|
|
563
|
+
schema: {
|
|
564
|
+
$ref: `#/definitions/${inputPath.requestType}`
|
|
565
|
+
},
|
|
566
|
+
examples
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
if (responses.length > 0) {
|
|
572
|
+
const openApiResponses = {};
|
|
573
|
+
for (const response of responses) {
|
|
574
|
+
const code = response.code;
|
|
575
|
+
if (code) {
|
|
576
|
+
delete response.code;
|
|
577
|
+
openApiResponses[code] ??= {};
|
|
578
|
+
openApiResponses[code] = ObjectHelper.merge(openApiResponses[code], response);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
openApi.paths[fullPath][method].responses = openApiResponses;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await finaliseOutput(usedCommonResponseTypes, schemas, openApi, securitySchemes, config.externalReferences, outputFile);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Finalise the schemas and output the spec.
|
|
590
|
+
* @param usedCommonResponseTypes The common response types used.
|
|
591
|
+
* @param schemas The schemas.
|
|
592
|
+
* @param openApi The OpenAPI spec.
|
|
593
|
+
* @param securitySchemes The security schemes.
|
|
594
|
+
* @param externalReferences The external references.
|
|
595
|
+
* @param outputFile The output file.
|
|
596
|
+
*/
|
|
597
|
+
async function finaliseOutput(usedCommonResponseTypes, schemas, openApi, securitySchemes, externalReferences, outputFile) {
|
|
598
|
+
CLIDisplay.break();
|
|
599
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.finalisingSchemas"));
|
|
600
|
+
// Remove the response codes that we haven't used
|
|
601
|
+
for (const httpStatusCode in HTTP_STATUS_CODE_MAP) {
|
|
602
|
+
if (!usedCommonResponseTypes.includes(HTTP_STATUS_CODE_MAP[httpStatusCode].responseType)) {
|
|
603
|
+
delete schemas[HTTP_STATUS_CODE_MAP[httpStatusCode].responseType];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const substituteSchemas = [];
|
|
607
|
+
// Remove the I, < and > from names
|
|
608
|
+
const finalSchemas = {};
|
|
609
|
+
for (const schema in schemas) {
|
|
610
|
+
const props = schemas[schema].properties;
|
|
611
|
+
let skipSchema = false;
|
|
612
|
+
if (Is.object(props)) {
|
|
613
|
+
tidySchemaProperties(props);
|
|
614
|
+
// Any request/response objects should be added to the final schemas
|
|
615
|
+
// but only the body property, if there is no body then we don't
|
|
616
|
+
// need to add it to the schemas
|
|
617
|
+
if (schema.endsWith("Response") || schema.endsWith("Request")) {
|
|
618
|
+
if (Is.object(props.body)) {
|
|
619
|
+
schemas[schema] = props.body;
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
skipSchema = true;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// If the schema is external then remove it from the final schemas
|
|
627
|
+
if (Is.object(externalReferences)) {
|
|
628
|
+
for (const external in externalReferences) {
|
|
629
|
+
if (new RegExp(`^${external}$`).test(schema)) {
|
|
630
|
+
skipSchema = true;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (!skipSchema) {
|
|
636
|
+
// If the final schema has no properties and is just a ref to another object type
|
|
637
|
+
// then replace the references with that of the referenced type
|
|
638
|
+
const ref = schemas[schema].$ref;
|
|
639
|
+
if (!Is.arrayValue(schemas[schema].properties) && Is.stringValue(ref)) {
|
|
640
|
+
substituteSchemas.push({ from: schema, to: ref });
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
let finalName = schema;
|
|
644
|
+
// If the type has an interface name e.g. ISomething then strip the I
|
|
645
|
+
if (/I[A-Z]/.test(finalName)) {
|
|
646
|
+
finalName = finalName.slice(1);
|
|
647
|
+
}
|
|
648
|
+
finalName = finalName.replace("<", "_").replace(">", "_");
|
|
649
|
+
if (finalName.endsWith("[]")) {
|
|
650
|
+
finalName = `ListOf${finalName.slice(0, -2)}`;
|
|
651
|
+
}
|
|
652
|
+
finalSchemas[finalName] = schemas[schema];
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (finalSchemas.HttpStatusCode) {
|
|
657
|
+
delete finalSchemas.HttpStatusCode;
|
|
658
|
+
}
|
|
659
|
+
const schemaKeys = Object.keys(finalSchemas);
|
|
660
|
+
schemaKeys.sort();
|
|
661
|
+
const sortedSchemas = {};
|
|
662
|
+
for (const key of schemaKeys) {
|
|
663
|
+
sortedSchemas[key] = finalSchemas[key];
|
|
664
|
+
}
|
|
665
|
+
openApi.components = {
|
|
666
|
+
schemas: sortedSchemas,
|
|
667
|
+
securitySchemes
|
|
668
|
+
};
|
|
669
|
+
let json = JSON.stringify(openApi, undefined, " ");
|
|
670
|
+
// Remove the reference only schemas, repeating until no more substitutions
|
|
671
|
+
let performedSubstitution;
|
|
672
|
+
do {
|
|
673
|
+
performedSubstitution = false;
|
|
674
|
+
for (const substituteSchema of substituteSchemas) {
|
|
675
|
+
const schemaParts = substituteSchema.to.split("/");
|
|
676
|
+
const find = new RegExp(`#/definitions/${substituteSchema.from}`, "g");
|
|
677
|
+
if (find.test(json)) {
|
|
678
|
+
json = json.replace(find, `#/definitions/${schemaParts[schemaParts.length - 1]}`);
|
|
679
|
+
performedSubstitution = true;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
} while (performedSubstitution);
|
|
683
|
+
// Update the location of the components
|
|
684
|
+
json = json.replace(/#\/definitions\//g, "#/components/schemas/");
|
|
685
|
+
// Remove the I from the type names as long as they are interfaces
|
|
686
|
+
json = json.replace(/#\/components\/schemas\/I([A-Z].*)/g, "#/components/schemas/$1");
|
|
687
|
+
// Remove the array [] from the type names
|
|
688
|
+
// eslint-disable-next-line unicorn/better-regex
|
|
689
|
+
json = json.replace(/#\/components\/schemas\/(.*)\[\]/g, "#/components/schemas/ListOf$1");
|
|
690
|
+
// Remove the partial markers
|
|
691
|
+
json = json.replace(/Partial%3CI(.*?)%3E/g, "$1");
|
|
692
|
+
// Cleanup the generic markers
|
|
693
|
+
json = json.replace(/%3Cunknown%3E/g, "");
|
|
694
|
+
// Remove external references
|
|
695
|
+
if (Is.objectValue(externalReferences)) {
|
|
696
|
+
for (const external in externalReferences) {
|
|
697
|
+
json = json.replace(new RegExp(`#/components/schemas/${StringHelper.stripPrefix(external)}`, "g"), externalReferences[external]);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.writingOutputFile"), outputFile);
|
|
701
|
+
try {
|
|
702
|
+
await mkdir(path.dirname(outputFile), { recursive: true });
|
|
703
|
+
}
|
|
704
|
+
catch { }
|
|
705
|
+
await writeFile(outputFile, json);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Build the security schemas from the config.
|
|
709
|
+
* @param config The configuration.
|
|
710
|
+
* @param securitySchemes The security schemes.
|
|
711
|
+
* @param authSecurity The auth security.
|
|
712
|
+
*/
|
|
713
|
+
function buildSecurity(config, securitySchemes, authSecurity) {
|
|
714
|
+
if (Is.arrayValue(config.authMethods)) {
|
|
715
|
+
for (const authMethod of config.authMethods) {
|
|
716
|
+
const security = {};
|
|
717
|
+
if (authMethod === "basic") {
|
|
718
|
+
securitySchemes.basicAuthScheme = {
|
|
719
|
+
type: "http",
|
|
720
|
+
scheme: "basic"
|
|
721
|
+
};
|
|
722
|
+
security.basicAuthScheme = [];
|
|
723
|
+
}
|
|
724
|
+
else if (authMethod === "jwtBearer") {
|
|
725
|
+
securitySchemes.jwtBearerAuthScheme = {
|
|
726
|
+
type: "http",
|
|
727
|
+
scheme: "bearer",
|
|
728
|
+
bearerFormat: "JWT"
|
|
729
|
+
};
|
|
730
|
+
security.jwtBearerAuthScheme = [];
|
|
731
|
+
}
|
|
732
|
+
else if (authMethod === "jwtCookie") {
|
|
733
|
+
securitySchemes.jwtCookieAuthScheme = {
|
|
734
|
+
type: "apiKey",
|
|
735
|
+
in: "cookie",
|
|
736
|
+
name: "auth_token"
|
|
737
|
+
};
|
|
738
|
+
security.jwtCookieAuthScheme = [];
|
|
739
|
+
}
|
|
740
|
+
authSecurity.push(security);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Process the REST details for a package.
|
|
746
|
+
* @param baseDir The base directory other locations are relative to.
|
|
747
|
+
* @param prefix The prefix.
|
|
748
|
+
* @param restDetails The package details.
|
|
749
|
+
* @returns The paths and schemas for the input.
|
|
750
|
+
* @internal
|
|
751
|
+
*/
|
|
752
|
+
async function processPackageRestDetails(restRoutes) {
|
|
753
|
+
const paths = [];
|
|
754
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.processingRoutes"));
|
|
755
|
+
for (const route of restRoutes) {
|
|
756
|
+
CLIDisplay.value(I18n.formatMessage("commands.ts-to-openapi.labels.route"), `${route.operationId} ${route.method} ${route.path}`, 1);
|
|
757
|
+
const pathParameters = [];
|
|
758
|
+
const pathPaths = route.path.split("/");
|
|
759
|
+
const finalPathParts = [];
|
|
760
|
+
for (const part of pathPaths) {
|
|
761
|
+
if (part.startsWith(":")) {
|
|
762
|
+
finalPathParts.push(`{${part.slice(1)}}`);
|
|
763
|
+
pathParameters.push(part.slice(1));
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
finalPathParts.push(part);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const responseType = [];
|
|
770
|
+
// If there is no response type automatically add a success
|
|
771
|
+
if (Is.empty(route.responseType)) {
|
|
772
|
+
// But only if we haven't got a response already for different content type
|
|
773
|
+
if (responseType.length === 0) {
|
|
774
|
+
responseType.push({
|
|
775
|
+
type: "IOkResponse",
|
|
776
|
+
statusCode: HttpStatusCode.ok
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else if (Is.array(route.responseType)) {
|
|
781
|
+
// Find the response codes for the response types
|
|
782
|
+
for (const rt of route.responseType) {
|
|
783
|
+
const responseCode = getHttpStatusCodeFromType(rt.type);
|
|
784
|
+
responseType.push({
|
|
785
|
+
...rt,
|
|
786
|
+
mimeType: rt.mimeType,
|
|
787
|
+
statusCode: responseCode,
|
|
788
|
+
examples: rt.examples
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const inputPath = {
|
|
793
|
+
path: finalPathParts.join("/"),
|
|
794
|
+
method: route.method,
|
|
795
|
+
pathParameters,
|
|
796
|
+
operationId: route.operationId,
|
|
797
|
+
tag: route.tag,
|
|
798
|
+
summary: route.summary,
|
|
799
|
+
requestType: route.requestType?.type,
|
|
800
|
+
requestMimeType: route.requestType?.mimeType,
|
|
801
|
+
requestExamples: route.requestType?.examples,
|
|
802
|
+
responseType,
|
|
803
|
+
responseCodes: ["badRequest", "internalServerError"],
|
|
804
|
+
skipAuth: route.skipAuth ?? false
|
|
805
|
+
};
|
|
806
|
+
const handlerSource = route.handler.toString();
|
|
807
|
+
let match;
|
|
808
|
+
const re = /httpstatuscode\.([_a-z]*)/gi;
|
|
809
|
+
while ((match = re.exec(handlerSource)) !== null) {
|
|
810
|
+
inputPath.responseCodes.push(match[1]);
|
|
811
|
+
}
|
|
812
|
+
paths.push(inputPath);
|
|
813
|
+
}
|
|
814
|
+
CLIDisplay.break();
|
|
815
|
+
return paths;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Generate schemas for the models.
|
|
819
|
+
* @param modelDirWildcards The filenames for all the models.
|
|
820
|
+
* @param types The types of the schema objects.
|
|
821
|
+
* @param outputWorkingDir The working directory.
|
|
822
|
+
* @returns Nothing.
|
|
823
|
+
* @internal
|
|
824
|
+
*/
|
|
825
|
+
async function generateSchemas(modelDirWildcards, types, outputWorkingDir) {
|
|
826
|
+
const allSchemas = {};
|
|
827
|
+
const arraySingularTypes = [];
|
|
828
|
+
for (const type of types) {
|
|
829
|
+
if (type.endsWith("[]")) {
|
|
830
|
+
const singularType = type.slice(0, -2);
|
|
831
|
+
arraySingularTypes.push(singularType);
|
|
832
|
+
if (!types.includes(singularType)) {
|
|
833
|
+
types.push(singularType);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
for (const files of modelDirWildcards) {
|
|
838
|
+
CLIDisplay.value(I18n.formatMessage("commands.ts-to-openapi.progress.models"), files.replace(/\\/g, "/"), 1);
|
|
839
|
+
const generator = createGenerator({
|
|
840
|
+
path: files.replace(/\\/g, "/"),
|
|
841
|
+
type: "*",
|
|
842
|
+
tsconfig: path.join(outputWorkingDir, "tsconfig.json"),
|
|
843
|
+
skipTypeCheck: true,
|
|
844
|
+
expose: "all"
|
|
845
|
+
});
|
|
846
|
+
const schema = generator.createSchema("*");
|
|
847
|
+
if (schema.definitions) {
|
|
848
|
+
for (const def in schema.definitions) {
|
|
849
|
+
// Remove the partial markers
|
|
850
|
+
let defSub = def.replace(/^Partial<(.*?)>/g, "$1");
|
|
851
|
+
// Cleanup the generic markers
|
|
852
|
+
defSub = defSub.replace(/</g, "%3C").replace(/>/g, "%3E");
|
|
853
|
+
allSchemas[defSub] = schema.definitions[def];
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
const referencedSchemas = {};
|
|
858
|
+
extractTypes(allSchemas, types, referencedSchemas);
|
|
859
|
+
for (const arraySingularType of arraySingularTypes) {
|
|
860
|
+
referencedSchemas[`${arraySingularType}[]`] = {
|
|
861
|
+
type: "array",
|
|
862
|
+
items: {
|
|
863
|
+
$ref: `#/components/schemas/${arraySingularType}`
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
return referencedSchemas;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Extract the required types from all the known schemas.
|
|
871
|
+
* @param allSchemas All the known schemas.
|
|
872
|
+
* @param requiredTypes The required types.
|
|
873
|
+
* @param referencedSchemas The references schemas.
|
|
874
|
+
* @internal
|
|
875
|
+
*/
|
|
876
|
+
function extractTypes(allSchemas, requiredTypes, referencedSchemas) {
|
|
877
|
+
for (const type of requiredTypes) {
|
|
878
|
+
if (allSchemas[type] && !referencedSchemas[type]) {
|
|
879
|
+
referencedSchemas[type] = allSchemas[type];
|
|
880
|
+
extractTypesFromSchema(allSchemas, allSchemas[type], referencedSchemas);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Extract type from properties definition.
|
|
886
|
+
* @param allTypes All the known types.
|
|
887
|
+
* @param schema The schema to extract from.
|
|
888
|
+
* @param output The output types.
|
|
889
|
+
* @internal
|
|
890
|
+
*/
|
|
891
|
+
function extractTypesFromSchema(allTypes, schema, output) {
|
|
892
|
+
const additionalTypes = [];
|
|
893
|
+
if (Is.stringValue(schema.$ref)) {
|
|
894
|
+
additionalTypes.push(schema.$ref.replace("#/definitions/", "").replace(/^Partial%3C(.*?)%3E/g, "$1"));
|
|
895
|
+
}
|
|
896
|
+
else if (Is.object(schema.items)) {
|
|
897
|
+
if (Is.arrayValue(schema.items)) {
|
|
898
|
+
for (const itemSchema of schema.items) {
|
|
899
|
+
extractTypesFromSchema(allTypes, itemSchema, output);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
extractTypesFromSchema(allTypes, schema.items, output);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
else if (Is.object(schema.properties) || Is.object(schema.additionalProperties)) {
|
|
907
|
+
if (Is.object(schema.properties)) {
|
|
908
|
+
for (const prop in schema.properties) {
|
|
909
|
+
const p = schema.properties[prop];
|
|
910
|
+
if (Is.object(p)) {
|
|
911
|
+
extractTypesFromSchema(allTypes, p, output);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (Is.object(schema.additionalProperties)) {
|
|
916
|
+
extractTypesFromSchema(allTypes, schema.additionalProperties, output);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
else if (Is.arrayValue(schema.anyOf)) {
|
|
920
|
+
for (const prop of schema.anyOf) {
|
|
921
|
+
if (Is.object(prop)) {
|
|
922
|
+
extractTypesFromSchema(allTypes, prop, output);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
else if (Is.arrayValue(schema.oneOf)) {
|
|
927
|
+
for (const prop of schema.oneOf) {
|
|
928
|
+
if (Is.object(prop)) {
|
|
929
|
+
extractTypesFromSchema(allTypes, prop, output);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (additionalTypes.length > 0) {
|
|
934
|
+
extractTypes(allTypes, additionalTypes, output);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Tidy up the schemas for use in OpenAPI context.
|
|
939
|
+
* @param props The properties to tidy up.
|
|
940
|
+
* @internal
|
|
941
|
+
*/
|
|
942
|
+
function tidySchemaProperties(props) {
|
|
943
|
+
for (const prop in props) {
|
|
944
|
+
const p = props[prop];
|
|
945
|
+
if (Is.object(p)) {
|
|
946
|
+
// For OpenAPI we don't include a description for
|
|
947
|
+
// items that have refs
|
|
948
|
+
if (p.$ref) {
|
|
949
|
+
delete p.description;
|
|
950
|
+
}
|
|
951
|
+
if (p.properties) {
|
|
952
|
+
tidySchemaProperties(p.properties);
|
|
953
|
+
}
|
|
954
|
+
if (p.items &&
|
|
955
|
+
Is.object(p.items) &&
|
|
956
|
+
Is.object(p.items.properties)) {
|
|
957
|
+
tidySchemaProperties(p.items.properties);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Load the packages from config and get the routes and tags from them.
|
|
964
|
+
* @param tsToOpenApiConfig The app config.
|
|
965
|
+
* @param outputWorkingDir The working directory.
|
|
966
|
+
* @param typeRoots The model roots.
|
|
967
|
+
* @returns The routes and tags for each package.
|
|
968
|
+
* @internal
|
|
969
|
+
*/
|
|
970
|
+
async function loadPackages(tsToOpenApiConfig, outputWorkingDir, typeRoots) {
|
|
971
|
+
const restRoutes = [];
|
|
972
|
+
let localNpmRoot = await CLIUtils.findNpmRoot(process.cwd());
|
|
973
|
+
localNpmRoot = localNpmRoot.replace(/[/\\]node_modules/, "");
|
|
974
|
+
const packages = [];
|
|
975
|
+
const localPackages = [];
|
|
976
|
+
for (const configRestRoutes of tsToOpenApiConfig.restRoutes) {
|
|
977
|
+
if (Is.stringValue(configRestRoutes.package)) {
|
|
978
|
+
const existsLocally = await CLIUtils.dirExists(path.join(localNpmRoot, "node_modules", configRestRoutes.package));
|
|
979
|
+
if (existsLocally) {
|
|
980
|
+
if (!localPackages.includes(configRestRoutes.package)) {
|
|
981
|
+
localPackages.push(configRestRoutes.package);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
const version = configRestRoutes.version ?? "latest";
|
|
986
|
+
const newPackage = `${configRestRoutes.package}@${version}`;
|
|
987
|
+
if (!packages.includes(newPackage)) {
|
|
988
|
+
packages.push(`${configRestRoutes.package}@${version}`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
if (packages.length > 0) {
|
|
994
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.installingNpmPackages"), packages.join(" "));
|
|
995
|
+
await CLIUtils.runShellCmd("npm", ["install", ...packages], outputWorkingDir);
|
|
996
|
+
CLIDisplay.break();
|
|
997
|
+
}
|
|
998
|
+
for (const configRestRoutes of tsToOpenApiConfig.restRoutes) {
|
|
999
|
+
const typeFolders = ["models", "errors"];
|
|
1000
|
+
const packageName = configRestRoutes.package;
|
|
1001
|
+
const packageRoot = configRestRoutes.packageRoot;
|
|
1002
|
+
if (!Is.stringValue(packageName) && !Is.stringValue(packageRoot)) {
|
|
1003
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
1004
|
+
throw new Error("Package name or root must be specified");
|
|
1005
|
+
}
|
|
1006
|
+
let rootFolder;
|
|
1007
|
+
let npmResolveFolder;
|
|
1008
|
+
if (Is.stringValue(packageName)) {
|
|
1009
|
+
if (localPackages.includes(packageName)) {
|
|
1010
|
+
npmResolveFolder = localNpmRoot;
|
|
1011
|
+
rootFolder = path.join(localNpmRoot, "node_modules", packageName);
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
npmResolveFolder = outputWorkingDir;
|
|
1015
|
+
rootFolder = path.join(outputWorkingDir, "node_modules", packageName);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
rootFolder = path.resolve(packageRoot ?? "");
|
|
1020
|
+
npmResolveFolder = rootFolder;
|
|
1021
|
+
}
|
|
1022
|
+
const pkgJson = (await CLIUtils.readJsonFile(path.join(rootFolder, "package.json"))) ?? { name: "" };
|
|
1023
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.processingPackage"), pkgJson.name);
|
|
1024
|
+
for (const typeFolder of typeFolders) {
|
|
1025
|
+
const typesDir = path.join(rootFolder, "dist", "types", typeFolder);
|
|
1026
|
+
if (await CLIUtils.dirExists(typesDir)) {
|
|
1027
|
+
const newRoot = path.join(typesDir, "**/*.ts");
|
|
1028
|
+
if (!typeRoots.includes(newRoot)) {
|
|
1029
|
+
typeRoots.push(newRoot);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (pkgJson.dependencies) {
|
|
1034
|
+
const nodeModulesFolder = await CLIUtils.findNpmRoot(npmResolveFolder);
|
|
1035
|
+
for (const dep in pkgJson.dependencies) {
|
|
1036
|
+
if (dep.startsWith("@twin.org")) {
|
|
1037
|
+
for (const typeFolder of typeFolders) {
|
|
1038
|
+
const typesDirDep = path.join(nodeModulesFolder, dep, "dist", "types", typeFolder);
|
|
1039
|
+
if (await CLIUtils.dirExists(typesDirDep)) {
|
|
1040
|
+
const newRoot = path.join(typesDirDep, "**/*.ts");
|
|
1041
|
+
if (!typeRoots.includes(newRoot)) {
|
|
1042
|
+
typeRoots.push(newRoot);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
CLIDisplay.task(I18n.formatMessage("commands.ts-to-openapi.progress.importingModule"), pkgJson.name);
|
|
1050
|
+
const pkg = await import(`file://${path.join(rootFolder, "dist/esm/index.mjs")}`);
|
|
1051
|
+
if (!Is.array(pkg.restEntryPoints)) {
|
|
1052
|
+
throw new GeneralError("commands", "commands.ts-to-openapi.missingRestRoutesEntryPoints", {
|
|
1053
|
+
method: "restEntryPoints",
|
|
1054
|
+
package: pkgJson.name
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
const packageEntryPoints = pkg.restEntryPoints;
|
|
1058
|
+
const entryPoints = configRestRoutes.entryPoints ?? packageEntryPoints.map(e => ({ name: e.name }));
|
|
1059
|
+
for (const entryPoint of entryPoints) {
|
|
1060
|
+
const packageEntryPoint = packageEntryPoints.find(e => e.name === entryPoint.name);
|
|
1061
|
+
if (!Is.object(packageEntryPoint)) {
|
|
1062
|
+
throw new GeneralError("commands", "commands.ts-to-openapi.missingRestRoutesEntryPoint", {
|
|
1063
|
+
entryPoint: entryPoint.name,
|
|
1064
|
+
package: pkgJson.name
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
let baseRouteName = StringHelper.trimTrailingSlashes(entryPoint.baseRoutePath ?? packageEntryPoint.defaultBaseRoute ?? "");
|
|
1068
|
+
if (baseRouteName.length > 0) {
|
|
1069
|
+
baseRouteName = `/${StringHelper.trimLeadingSlashes(baseRouteName)}`;
|
|
1070
|
+
}
|
|
1071
|
+
let routes = packageEntryPoint.generateRoutes(baseRouteName, "dummy-service");
|
|
1072
|
+
routes = routes.filter(r => !(r.excludeFromSpec ?? false));
|
|
1073
|
+
if (Is.stringValue(entryPoint.operationIdDistinguisher)) {
|
|
1074
|
+
for (const route of routes) {
|
|
1075
|
+
route.operationId = `${route.operationId}${entryPoint.operationIdDistinguisher}`;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
restRoutes.push({
|
|
1079
|
+
restRoutes: routes,
|
|
1080
|
+
tags: packageEntryPoint.tags
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
CLIDisplay.break();
|
|
1084
|
+
}
|
|
1085
|
+
return restRoutes;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Copyright 2024 IOTA Stiftung.
|
|
1089
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
1090
|
+
/**
|
|
1091
|
+
* The main entry point for the CLI.
|
|
1092
|
+
*/
|
|
1093
|
+
class CLI extends CLIBase {
|
|
1094
|
+
/**
|
|
1095
|
+
* Run the app.
|
|
1096
|
+
* @param argv The process arguments.
|
|
1097
|
+
* @param localesDirectory The directory for the locales, default to relative to the script.
|
|
1098
|
+
* @returns The exit code.
|
|
1099
|
+
*/
|
|
1100
|
+
async run(argv, localesDirectory) {
|
|
1101
|
+
return this.execute({
|
|
1102
|
+
title: "TWIN TypeScript To OpenAPI",
|
|
1103
|
+
appName: "ts-to-openapi",
|
|
1104
|
+
version: "0.0.1-next.2",
|
|
1105
|
+
icon: "⚙️ ",
|
|
1106
|
+
supportsEnvFiles: false
|
|
1107
|
+
}, localesDirectory ?? path.join(path.dirname(fileURLToPath(import.meta.url)), "../locales"), argv);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Configure any options or actions at the root program level.
|
|
1111
|
+
* @param program The root program command.
|
|
1112
|
+
*/
|
|
1113
|
+
configureRoot(program) {
|
|
1114
|
+
buildCommandTsToOpenApi(program);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export { CLI, actionCommandTsToOpenApi, buildCommandTsToOpenApi, tsToOpenApi };
|