@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,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 };