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