@nestia/migrate 4.5.2 → 4.6.1-dev.20250117

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 (50) hide show
  1. package/README.md +87 -87
  2. package/lib/bundles/NEST_TEMPLATE.js +66 -66
  3. package/lib/bundles/NEST_TEMPLATE.js.map +1 -1
  4. package/lib/bundles/SDK_TEMPLATE.js +30 -30
  5. package/lib/bundles/SDK_TEMPLATE.js.map +1 -1
  6. package/lib/index.mjs +92 -92
  7. package/lib/index.mjs.map +1 -1
  8. package/lib/utils/openapi-down-convert/converter.js +2 -2
  9. package/package.json +9 -9
  10. package/src/MigrateApplication.ts +107 -107
  11. package/src/analyzers/MigrateApplicationAnalyzer.ts +18 -18
  12. package/src/analyzers/MigrateControllerAnalyzer.ts +51 -51
  13. package/src/archivers/MigrateFileArchiver.ts +38 -38
  14. package/src/bundles/NEST_TEMPLATE.ts +66 -66
  15. package/src/bundles/SDK_TEMPLATE.ts +30 -30
  16. package/src/executable/bundle.js +125 -125
  17. package/src/executable/migrate.ts +7 -7
  18. package/src/factories/TypeLiteralFactory.ts +57 -57
  19. package/src/index.ts +4 -4
  20. package/src/internal/MigrateCommander.ts +86 -86
  21. package/src/internal/MigrateInquirer.ts +89 -89
  22. package/src/module.ts +8 -8
  23. package/src/programmers/MigrateApiFileProgrammer.ts +49 -49
  24. package/src/programmers/MigrateApiFunctionProgrammer.ts +210 -210
  25. package/src/programmers/MigrateApiNamespaceProgrammer.ts +417 -417
  26. package/src/programmers/MigrateApiProgrammer.ts +103 -103
  27. package/src/programmers/MigrateApiSimulatationProgrammer.ts +324 -324
  28. package/src/programmers/MigrateApiStartProgrammer.ts +194 -194
  29. package/src/programmers/MigrateDtoProgrammer.ts +87 -87
  30. package/src/programmers/MigrateE2eFileProgrammer.ts +117 -117
  31. package/src/programmers/MigrateE2eProgrammer.ts +34 -34
  32. package/src/programmers/MigrateImportProgrammer.ts +118 -118
  33. package/src/programmers/MigrateNestControllerProgrammer.ts +50 -50
  34. package/src/programmers/MigrateNestMethodProgrammer.ts +393 -393
  35. package/src/programmers/MigrateNestModuleProgrammer.ts +65 -65
  36. package/src/programmers/MigrateNestProgrammer.ts +81 -81
  37. package/src/programmers/MigrateSchemaProgrammer.ts +373 -373
  38. package/src/structures/IHttpMigrateController.ts +8 -8
  39. package/src/structures/IHttpMigrateDto.ts +8 -8
  40. package/src/structures/IHttpMigrateFile.ts +5 -5
  41. package/src/structures/IHttpMigrateProgram.ts +27 -27
  42. package/src/structures/IHttpMigrateRoute.ts +1 -1
  43. package/src/structures/IHttpMigrateSchema.ts +4 -4
  44. package/src/utils/FilePrinter.ts +36 -36
  45. package/src/utils/MapUtil.ts +13 -13
  46. package/src/utils/OpenApiTypeChecker.ts +73 -73
  47. package/src/utils/SetupWizard.ts +12 -12
  48. package/src/utils/StringUtil.ts +113 -113
  49. package/src/utils/openapi-down-convert/RefVisitor.ts +139 -139
  50. package/src/utils/openapi-down-convert/converter.ts +527 -527
@@ -1,527 +1,527 @@
1
- /** OpenAPI Down Converted - convert an OAS document from OAS 3.1 to OAS 3.0 */
2
- import {
3
- JsonNode,
4
- SchemaObject,
5
- SchemaVisitor,
6
- visitRefObjects,
7
- visitSchemaObjects,
8
- walkObject,
9
- } from "./RefVisitor";
10
-
11
- /** Lightweight OAS document top-level fields */
12
- interface OpenAPI3 {
13
- openapi: string;
14
- info: object;
15
- paths: object;
16
- components: object;
17
- tags: object;
18
- }
19
-
20
- /** Options for the converter instantiation */
21
- export interface ConverterOptions {
22
- /** if `true`, log conversion transformations to stderr */
23
- verbose?: boolean;
24
- /** if `true`, remove `id` values in schema examples, to bypass
25
- * [Spectral issue 2081](https://github.com/stoplightio/spectral/issues/2081)
26
- */
27
- deleteExampleWithId?: boolean;
28
- /** If `true`, replace a `$ref` object that has siblings into an `allOf` */
29
- allOfTransform?: boolean;
30
-
31
- /**
32
- * The authorizationUrl for openIdConnect -> oauth2 transformation
33
- */
34
- authorizationUrl?: string;
35
- /** The tokenUrl for openIdConnect -> oauth2 transformation */
36
- tokenUrl?: string;
37
- /** Name of YAML/JSON file with scope descriptions.
38
- * This is a simple map in the format
39
- * `{ scope1: "description of scope1", ... }`
40
- */
41
- scopeDescriptionFile?: string;
42
- /** Earlier versions of the tool converted $comment to x-comment
43
- * in JSON Schemas. The tool now deletes $comment values by default.
44
- * Use this option to preserve the conversion and not delete
45
- * comments.
46
- */
47
- convertSchemaComments?: boolean;
48
- }
49
-
50
- export class Converter {
51
- private openapi30: OpenAPI3;
52
- private verbose = false;
53
- private deleteExampleWithId = false;
54
- private allOfTransform = false;
55
- private authorizationUrl: string;
56
- /** The tokenUrl for openIdConnect -> oauth2 transformation */
57
- private tokenUrl: string;
58
- private scopeDescriptions = undefined;
59
- private convertSchemaComments = false;
60
- private returnCode = 0;
61
-
62
- /**
63
- * Construct a new Converter
64
- * @throws Error if the scopeDescriptionFile (if specified) cannot be read or parsed as YAML/JSON
65
- */
66
- constructor(openapiDocument: object, options?: ConverterOptions) {
67
- this.openapi30 = Converter.deepClone(openapiDocument) as OpenAPI3;
68
- this.verbose = Boolean(options?.verbose);
69
- this.deleteExampleWithId = Boolean(options?.deleteExampleWithId);
70
- this.allOfTransform = Boolean(options?.allOfTransform);
71
- this.authorizationUrl =
72
- options?.authorizationUrl || "https://www.example.com/oauth2/authorize";
73
- this.tokenUrl = options?.tokenUrl || "https://www.example.com/oauth2/token";
74
- this.loadScopeDescriptions(options?.scopeDescriptionFile);
75
- this.convertSchemaComments = !!options?.convertSchemaComments;
76
- }
77
-
78
- /** Load the scopes.yaml file and save in this.scopeDescriptions
79
- * @throws Error if the file cannot be read or parsed as YAML/JSON
80
- */
81
- private loadScopeDescriptions(scopeDescriptionFile?: string) {
82
- if (!scopeDescriptionFile) {
83
- return;
84
- }
85
- }
86
-
87
- /**
88
- * Log a message to console.warn stream if verbose is true
89
- * @param message parameters for console.warn
90
- */
91
- private log(...message: any[]) {
92
- if (this.verbose) {
93
- this.warn(...message);
94
- }
95
- }
96
-
97
- /**
98
- * Log a message to console.warn stream. Prefix the message string with `Warning: `
99
- * if it does not already have that text.
100
- * @param message parameters for console.warn
101
- */
102
- private warn(...message: any[]) {
103
- if (!message[0].startsWith("Warning")) {
104
- message[0] = `Warning: ${message[0]}`;
105
- }
106
- console.warn(...message);
107
- }
108
-
109
- /**
110
- * Log an error message to `console.error` stream. Prefix the message string with `Error: `
111
- * if it does not already start with `'Error'`. Increments the `returnCode`, causing
112
- * the CLI to throw an Error when done.
113
- * @param message parameters for `console.error`
114
- */
115
- private error(...message: any[]) {
116
- if (!message[0].startsWith("Error")) {
117
- message[0] = `Error: ${message[0]}`;
118
- }
119
- this.returnCode++;
120
- console.error(...message);
121
- }
122
-
123
- /**
124
- * Convert the OpenAPI document to 3.0
125
- * @returns the converted document. The input is not modified.
126
- */
127
- public convert(): object {
128
- this.log("Converting from OpenAPI 3.1 to 3.0");
129
- this.openapi30.openapi = "3.0.3";
130
- this.removeLicenseIdentifier();
131
- this.convertSchemaRef();
132
- this.simplifyNonSchemaRef();
133
- if (this.scopeDescriptions) {
134
- this.convertSecuritySchemes();
135
- }
136
- this.convertJsonSchemaExamples();
137
- this.convertJsonSchemaContentEncoding();
138
- this.convertJsonSchemaContentMediaType();
139
- this.convertConstToEnum();
140
- this.convertNullableTypeArray();
141
- this.removeWebhooksObject();
142
- this.removeUnsupportedSchemaKeywords();
143
- if (this.convertSchemaComments) {
144
- this.renameSchema$comment();
145
- } else {
146
- this.deleteSchema$comment();
147
- }
148
- if (this.returnCode > 0) {
149
- throw new Error("Cannot down convert this OpenAPI definition.");
150
- }
151
- return this.openapi30;
152
- }
153
-
154
- /**
155
- * OpenAPI 3.1 uses JSON Schema 2020-12 which allows schema `examples`;
156
- * OpenAPI 3.0 uses JSON Scheme Draft 7 which only allows `example`.
157
- * Replace all `examples` with `example`, using `examples[0]`
158
- */
159
- convertJsonSchemaExamples() {
160
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
161
- for (const key in schema) {
162
- const subSchema = schema[key];
163
- if (subSchema !== null && typeof subSchema === "object") {
164
- if (key === "examples") {
165
- const examples = schema["examples"];
166
- if (Array.isArray(examples) && examples.length > 0) {
167
- delete schema["examples"];
168
- const first = examples[0];
169
- if (
170
- this.deleteExampleWithId &&
171
- first != null &&
172
- typeof first === "object" &&
173
- first.hasOwnProperty("id")
174
- ) {
175
- this.log(
176
- `Deleted schema example with \`id\` property:\n${this.json(examples)}`,
177
- );
178
- } else {
179
- schema["example"] = first;
180
- this.log(
181
- `Replaces examples with examples[0]. Old examples:\n${this.json(examples)}`,
182
- );
183
- }
184
- // TODO: Add an else here to check example for `id` and delete the example if this.deleteExampleWithId
185
- // We've put most of those in `examples` so this is probably not needed, but it would be more robust.
186
- }
187
- } else {
188
- schema[key] = walkObject(subSchema, schemaVisitor);
189
- }
190
- }
191
- }
192
- return schema;
193
- };
194
- visitSchemaObjects(this.openapi30, schemaVisitor);
195
- }
196
-
197
- private walkNestedSchemaObjects(schema: any, schemaVisitor: any) {
198
- for (const key in schema) {
199
- const subSchema = schema[key];
200
- if (subSchema !== null && typeof subSchema === "object") {
201
- schema[key] = walkObject(subSchema, schemaVisitor);
202
- }
203
- }
204
- return schema;
205
- }
206
-
207
- /**
208
- * OpenAPI 3.1 uses JSON Schema 2020-12 which allows `const`
209
- * OpenAPI 3.0 uses JSON Scheme Draft 7 which only allows `enum`.
210
- * Replace all `const: value` with `enum: [ value ]`
211
- */
212
- convertConstToEnum() {
213
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
214
- if (schema["const"]) {
215
- const constant = schema["const"];
216
- delete schema["const"];
217
- schema["enum"] = [constant];
218
- this.log(`Converted const: ${constant} to enum`);
219
- }
220
- return this.walkNestedSchemaObjects(schema, schemaVisitor);
221
- };
222
- visitSchemaObjects(this.openapi30, schemaVisitor);
223
- }
224
-
225
- /**
226
- * Convert 2-element type arrays containing 'null' to
227
- * string type and `nullable: true`
228
- */
229
- convertNullableTypeArray() {
230
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
231
- if (schema.hasOwnProperty("type")) {
232
- const schemaType = schema["type"];
233
- if (
234
- Array.isArray(schemaType) &&
235
- schemaType.length === 2 &&
236
- schemaType.includes("null")
237
- ) {
238
- const nonNull = schemaType.filter((_) => _ !== "null")[0];
239
- schema["type"] = nonNull;
240
- schema["nullable"] = true;
241
- this.log(`Converted schema type array to nullable`);
242
- }
243
- }
244
- return this.walkNestedSchemaObjects(schema, schemaVisitor);
245
- };
246
- visitSchemaObjects(this.openapi30, schemaVisitor);
247
- }
248
-
249
- removeWebhooksObject() {
250
- if (Object.hasOwnProperty.call(this.openapi30, "webhooks")) {
251
- this.log(`Deleted webhooks object`);
252
- delete (this.openapi30 as any)["webhooks"];
253
- }
254
- }
255
- removeUnsupportedSchemaKeywords() {
256
- const keywordsToRemove = [
257
- "$id",
258
- "$schema",
259
- "unevaluatedProperties",
260
- "contentMediaType",
261
- "patternProperties",
262
- ];
263
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
264
- keywordsToRemove.forEach((key) => {
265
- if (schema.hasOwnProperty(key)) {
266
- delete schema[key];
267
- this.log(`Removed unsupported schema keyword ${key}`);
268
- }
269
- });
270
- return this.walkNestedSchemaObjects(schema, schemaVisitor);
271
- };
272
- visitSchemaObjects(this.openapi30, schemaVisitor);
273
- }
274
-
275
- renameSchema$comment() {
276
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
277
- if (schema.hasOwnProperty("$comment")) {
278
- schema["x-comment"] = schema["$comment"];
279
- delete schema["$comment"];
280
- this.log(`schema $comment renamed to x-comment`);
281
- }
282
- return this.walkNestedSchemaObjects(schema, schemaVisitor);
283
- };
284
- visitSchemaObjects(this.openapi30, schemaVisitor);
285
- }
286
-
287
- private deleteSchema$comment() {
288
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
289
- if (schema.hasOwnProperty("$comment")) {
290
- const comment = schema["$comment"];
291
- delete schema["$comment"];
292
- this.log(`schema $comment deleted: ${comment}`);
293
- }
294
- return this.walkNestedSchemaObjects(schema, schemaVisitor);
295
- };
296
- visitSchemaObjects(this.openapi30, schemaVisitor);
297
- }
298
-
299
- /**
300
- * Convert
301
- * ```
302
- * contentMediaType: 'application/octet-stream'
303
- * ```
304
- * to
305
- * ```
306
- * format: binary
307
- * ```
308
- * in `type: string` schemas.
309
- * Warn if schema has a `format` already and it is not `binary`.
310
- */
311
- convertJsonSchemaContentMediaType() {
312
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
313
- if (
314
- schema.hasOwnProperty("type") &&
315
- schema["type"] === "string" &&
316
- schema.hasOwnProperty("contentMediaType") &&
317
- schema["contentMediaType"] === "application/octet-stream"
318
- ) {
319
- if (schema.hasOwnProperty("format")) {
320
- if (schema["format"] === "binary") {
321
- this.log(
322
- `Deleted schema contentMediaType: application/octet-stream (leaving format: binary)`,
323
- );
324
- delete schema["contentMediaType"];
325
- } else {
326
- this.error(
327
- `Unable to down-convert schema with contentMediaType: application/octet-stream to format: binary because the schema already has a format (${schema["format"]})`,
328
- );
329
- }
330
- } else {
331
- delete schema["contentMediaType"];
332
- schema["format"] = "binary";
333
- this.log(
334
- `Converted schema contentMediaType: application/octet-stream to format: binary`,
335
- );
336
- }
337
- }
338
- return this.walkNestedSchemaObjects(schema, schemaVisitor);
339
- };
340
- visitSchemaObjects(this.openapi30, schemaVisitor);
341
- }
342
-
343
- /**
344
- * Convert
345
- * ```
346
- * contentEncoding: base64
347
- * ```
348
- * to
349
- * ```
350
- * format: byte
351
- * ```
352
- * in `type: string` schemas. It is an error if the schema has a `format` already
353
- * and it is not `byte`.
354
- */
355
- convertJsonSchemaContentEncoding() {
356
- const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
357
- if (
358
- schema.hasOwnProperty("type") &&
359
- schema["type"] === "string" &&
360
- schema.hasOwnProperty("contentEncoding")
361
- ) {
362
- if (schema["contentEncoding"] === "base64") {
363
- if (schema.hasOwnProperty("format")) {
364
- if (schema["format"] === "byte") {
365
- this.log(
366
- `Deleted schema contentEncoding: base64 (leaving format: byte)`,
367
- );
368
- delete schema["contentEncoding"];
369
- } else {
370
- this.error(
371
- `Unable to down-convert schema contentEncoding: base64 to format: byte because the schema already has a format (${schema["format"]})`,
372
- );
373
- }
374
- } else {
375
- delete schema["contentEncoding"];
376
- schema["format"] = "byte";
377
- this.log(
378
- `Converted schema: 'contentEncoding: base64' to 'format: byte'`,
379
- );
380
- }
381
- } else {
382
- this.error(
383
- `Unable to down-convert contentEncoding: ${schema["contentEncoding"]}`,
384
- );
385
- }
386
- }
387
- return this.walkNestedSchemaObjects(schema, schemaVisitor);
388
- };
389
- visitSchemaObjects(this.openapi30, schemaVisitor);
390
- }
391
-
392
- private json(x: any) {
393
- return JSON.stringify(x, null, 2);
394
- }
395
-
396
- /**
397
- * OpenAPI 3.1 defines a new `openIdConnect` security scheme.
398
- * Down-convert the scheme to `oauth2` / authorization code flow.
399
- * Collect all the scopes used in any security requirements within
400
- * operations and add them to the scheme. Also define the
401
- * URLs to the `authorizationUrl` and `tokenUrl` of `oauth2`.
402
- */
403
- convertSecuritySchemes() {
404
- const oauth2Scopes = (schemeName: string): object => {
405
- const scopes = {} as any;
406
- const paths: any = this.openapi30?.paths ?? {};
407
- for (const path in paths) {
408
- for (const op in paths[path]) {
409
- if (op === "parameters") {
410
- continue;
411
- }
412
- const operation = paths[path][op];
413
- const sec = operation?.security as object[];
414
- sec.forEach((s: any) => {
415
- const requirement = s?.[schemeName] as string[];
416
- if (requirement) {
417
- requirement.forEach((scope) => {
418
- scopes[scope] =
419
- this.scopeDescriptions?.[scope] ??
420
- `TODO: describe the '${scope}' scope`;
421
- });
422
- }
423
- });
424
- }
425
- }
426
- return scopes;
427
- };
428
- const schemes =
429
- (this.openapi30?.components as any)?.["securitySchemes"] ?? {};
430
- for (const schemeName in schemes) {
431
- const scheme = schemes[schemeName];
432
- const type = scheme.type;
433
- if (type === "openIdConnect") {
434
- this.log(
435
- `Converting openIdConnect security scheme to oauth2/authorizationCode`,
436
- );
437
- scheme.type = "oauth2";
438
- const openIdConnectUrl = scheme.openIdConnectUrl;
439
- scheme.description = `OAuth2 Authorization Code Flow. The client may
440
- GET the OpenID Connect configuration JSON from \`${openIdConnectUrl}\`
441
- to get the correct \`authorizationUrl\` and \`tokenUrl\`.`;
442
- delete scheme.openIdConnectUrl;
443
- const scopes = oauth2Scopes(schemeName);
444
- scheme.flows = {
445
- authorizationCode: {
446
- // TODO: add options for these URLs
447
- authorizationUrl: this.authorizationUrl,
448
- tokenUrl: this.tokenUrl,
449
- scopes: scopes,
450
- },
451
- };
452
- }
453
- }
454
- }
455
-
456
- /**
457
- * Find remaining OpenAPI 3.0 [Reference Objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#referenceObject)
458
- * and down convert them to [JSON Reference](https://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03) objects
459
- * with _only_ a `$ref` property.
460
- */
461
- simplifyNonSchemaRef() {
462
- visitRefObjects(this.openapi30, (node: any): JsonNode => {
463
- if (Object.keys(node).length === 1) {
464
- return node;
465
- } else {
466
- this.log(
467
- `Down convert reference object to JSON Reference:\n${JSON.stringify(node, null, 3)}`,
468
- );
469
- Object.keys(node)
470
- .filter((key) => key !== "$ref")
471
- .forEach((key) => delete node[key]);
472
- return node;
473
- }
474
- });
475
- }
476
-
477
- removeLicenseIdentifier() {
478
- if ((this.openapi30 as any)?.["info"]?.["license"]?.["identifier"]) {
479
- this.log(
480
- `Removed info.license.identifier: ${(this.openapi30 as any)["info"]["license"]["identifier"]}`,
481
- );
482
- delete (this.openapi30 as any)["info"]["license"]["identifier"];
483
- }
484
- }
485
-
486
- // This transformation ends up breaking openapi-generator
487
- // SDK gen (typescript-axios, typescript-angular)
488
- // so it is disabled unless the `allOfTransform` option is `true`.
489
-
490
- convertSchemaRef() {
491
- /**
492
- * In a JSON Schema, replace `{ blah blah, $ref: "uri"}`
493
- * with `{ blah blah, allOf: [ $ref: "uri" ]}`
494
- * @param object an object that may contain JSON schemas (directly
495
- * or in sub-objects)
496
- */
497
- const simplifyRefObjectsInSchemas = (
498
- object: SchemaObject,
499
- ): SchemaObject => {
500
- return visitRefObjects(object, (node: any): JsonNode => {
501
- if (Object.keys(node).length === 1) {
502
- return node;
503
- } else {
504
- this.log(
505
- `Converting JSON Schema $ref ${this.json(node)} to allOf: [ $ref ]`,
506
- );
507
- node["allOf"] = [{ $ref: node.$ref }];
508
- delete node.$ref;
509
- return node;
510
- }
511
- });
512
- };
513
-
514
- if (this.allOfTransform) {
515
- visitSchemaObjects(
516
- this.openapi30,
517
- (schema: SchemaObject): SchemaObject => {
518
- return simplifyRefObjectsInSchemas(schema);
519
- },
520
- );
521
- }
522
- }
523
-
524
- public static deepClone(obj: object): object {
525
- return JSON.parse(JSON.stringify(obj)); // kinda simple way to clone, but it works...
526
- }
527
- }
1
+ /** OpenAPI Down Converted - convert an OAS document from OAS 3.1 to OAS 3.0 */
2
+ import {
3
+ JsonNode,
4
+ SchemaObject,
5
+ SchemaVisitor,
6
+ visitRefObjects,
7
+ visitSchemaObjects,
8
+ walkObject,
9
+ } from "./RefVisitor";
10
+
11
+ /** Lightweight OAS document top-level fields */
12
+ interface OpenAPI3 {
13
+ openapi: string;
14
+ info: object;
15
+ paths: object;
16
+ components: object;
17
+ tags: object;
18
+ }
19
+
20
+ /** Options for the converter instantiation */
21
+ export interface ConverterOptions {
22
+ /** if `true`, log conversion transformations to stderr */
23
+ verbose?: boolean;
24
+ /** if `true`, remove `id` values in schema examples, to bypass
25
+ * [Spectral issue 2081](https://github.com/stoplightio/spectral/issues/2081)
26
+ */
27
+ deleteExampleWithId?: boolean;
28
+ /** If `true`, replace a `$ref` object that has siblings into an `allOf` */
29
+ allOfTransform?: boolean;
30
+
31
+ /**
32
+ * The authorizationUrl for openIdConnect -> oauth2 transformation
33
+ */
34
+ authorizationUrl?: string;
35
+ /** The tokenUrl for openIdConnect -> oauth2 transformation */
36
+ tokenUrl?: string;
37
+ /** Name of YAML/JSON file with scope descriptions.
38
+ * This is a simple map in the format
39
+ * `{ scope1: "description of scope1", ... }`
40
+ */
41
+ scopeDescriptionFile?: string;
42
+ /** Earlier versions of the tool converted $comment to x-comment
43
+ * in JSON Schemas. The tool now deletes $comment values by default.
44
+ * Use this option to preserve the conversion and not delete
45
+ * comments.
46
+ */
47
+ convertSchemaComments?: boolean;
48
+ }
49
+
50
+ export class Converter {
51
+ private openapi30: OpenAPI3;
52
+ private verbose = false;
53
+ private deleteExampleWithId = false;
54
+ private allOfTransform = false;
55
+ private authorizationUrl: string;
56
+ /** The tokenUrl for openIdConnect -> oauth2 transformation */
57
+ private tokenUrl: string;
58
+ private scopeDescriptions = undefined;
59
+ private convertSchemaComments = false;
60
+ private returnCode = 0;
61
+
62
+ /**
63
+ * Construct a new Converter
64
+ * @throws Error if the scopeDescriptionFile (if specified) cannot be read or parsed as YAML/JSON
65
+ */
66
+ constructor(openapiDocument: object, options?: ConverterOptions) {
67
+ this.openapi30 = Converter.deepClone(openapiDocument) as OpenAPI3;
68
+ this.verbose = Boolean(options?.verbose);
69
+ this.deleteExampleWithId = Boolean(options?.deleteExampleWithId);
70
+ this.allOfTransform = Boolean(options?.allOfTransform);
71
+ this.authorizationUrl =
72
+ options?.authorizationUrl || "https://www.example.com/oauth2/authorize";
73
+ this.tokenUrl = options?.tokenUrl || "https://www.example.com/oauth2/token";
74
+ this.loadScopeDescriptions(options?.scopeDescriptionFile);
75
+ this.convertSchemaComments = !!options?.convertSchemaComments;
76
+ }
77
+
78
+ /** Load the scopes.yaml file and save in this.scopeDescriptions
79
+ * @throws Error if the file cannot be read or parsed as YAML/JSON
80
+ */
81
+ private loadScopeDescriptions(scopeDescriptionFile?: string) {
82
+ if (!scopeDescriptionFile) {
83
+ return;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Log a message to console.warn stream if verbose is true
89
+ * @param message parameters for console.warn
90
+ */
91
+ private log(...message: any[]) {
92
+ if (this.verbose) {
93
+ this.warn(...message);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Log a message to console.warn stream. Prefix the message string with `Warning: `
99
+ * if it does not already have that text.
100
+ * @param message parameters for console.warn
101
+ */
102
+ private warn(...message: any[]) {
103
+ if (!message[0].startsWith("Warning")) {
104
+ message[0] = `Warning: ${message[0]}`;
105
+ }
106
+ console.warn(...message);
107
+ }
108
+
109
+ /**
110
+ * Log an error message to `console.error` stream. Prefix the message string with `Error: `
111
+ * if it does not already start with `'Error'`. Increments the `returnCode`, causing
112
+ * the CLI to throw an Error when done.
113
+ * @param message parameters for `console.error`
114
+ */
115
+ private error(...message: any[]) {
116
+ if (!message[0].startsWith("Error")) {
117
+ message[0] = `Error: ${message[0]}`;
118
+ }
119
+ this.returnCode++;
120
+ console.error(...message);
121
+ }
122
+
123
+ /**
124
+ * Convert the OpenAPI document to 3.0
125
+ * @returns the converted document. The input is not modified.
126
+ */
127
+ public convert(): object {
128
+ this.log("Converting from OpenAPI 3.1 to 3.0");
129
+ this.openapi30.openapi = "3.0.3";
130
+ this.removeLicenseIdentifier();
131
+ this.convertSchemaRef();
132
+ this.simplifyNonSchemaRef();
133
+ if (this.scopeDescriptions) {
134
+ this.convertSecuritySchemes();
135
+ }
136
+ this.convertJsonSchemaExamples();
137
+ this.convertJsonSchemaContentEncoding();
138
+ this.convertJsonSchemaContentMediaType();
139
+ this.convertConstToEnum();
140
+ this.convertNullableTypeArray();
141
+ this.removeWebhooksObject();
142
+ this.removeUnsupportedSchemaKeywords();
143
+ if (this.convertSchemaComments) {
144
+ this.renameSchema$comment();
145
+ } else {
146
+ this.deleteSchema$comment();
147
+ }
148
+ if (this.returnCode > 0) {
149
+ throw new Error("Cannot down convert this OpenAPI definition.");
150
+ }
151
+ return this.openapi30;
152
+ }
153
+
154
+ /**
155
+ * OpenAPI 3.1 uses JSON Schema 2020-12 which allows schema `examples`;
156
+ * OpenAPI 3.0 uses JSON Scheme Draft 7 which only allows `example`.
157
+ * Replace all `examples` with `example`, using `examples[0]`
158
+ */
159
+ convertJsonSchemaExamples() {
160
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
161
+ for (const key in schema) {
162
+ const subSchema = schema[key];
163
+ if (subSchema !== null && typeof subSchema === "object") {
164
+ if (key === "examples") {
165
+ const examples = schema["examples"];
166
+ if (Array.isArray(examples) && examples.length > 0) {
167
+ delete schema["examples"];
168
+ const first = examples[0];
169
+ if (
170
+ this.deleteExampleWithId &&
171
+ first != null &&
172
+ typeof first === "object" &&
173
+ first.hasOwnProperty("id")
174
+ ) {
175
+ this.log(
176
+ `Deleted schema example with \`id\` property:\n${this.json(examples)}`,
177
+ );
178
+ } else {
179
+ schema["example"] = first;
180
+ this.log(
181
+ `Replaces examples with examples[0]. Old examples:\n${this.json(examples)}`,
182
+ );
183
+ }
184
+ // TODO: Add an else here to check example for `id` and delete the example if this.deleteExampleWithId
185
+ // We've put most of those in `examples` so this is probably not needed, but it would be more robust.
186
+ }
187
+ } else {
188
+ schema[key] = walkObject(subSchema, schemaVisitor);
189
+ }
190
+ }
191
+ }
192
+ return schema;
193
+ };
194
+ visitSchemaObjects(this.openapi30, schemaVisitor);
195
+ }
196
+
197
+ private walkNestedSchemaObjects(schema: any, schemaVisitor: any) {
198
+ for (const key in schema) {
199
+ const subSchema = schema[key];
200
+ if (subSchema !== null && typeof subSchema === "object") {
201
+ schema[key] = walkObject(subSchema, schemaVisitor);
202
+ }
203
+ }
204
+ return schema;
205
+ }
206
+
207
+ /**
208
+ * OpenAPI 3.1 uses JSON Schema 2020-12 which allows `const`
209
+ * OpenAPI 3.0 uses JSON Scheme Draft 7 which only allows `enum`.
210
+ * Replace all `const: value` with `enum: [ value ]`
211
+ */
212
+ convertConstToEnum() {
213
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
214
+ if (schema["const"]) {
215
+ const constant = schema["const"];
216
+ delete schema["const"];
217
+ schema["enum"] = [constant];
218
+ this.log(`Converted const: ${constant} to enum`);
219
+ }
220
+ return this.walkNestedSchemaObjects(schema, schemaVisitor);
221
+ };
222
+ visitSchemaObjects(this.openapi30, schemaVisitor);
223
+ }
224
+
225
+ /**
226
+ * Convert 2-element type arrays containing 'null' to
227
+ * string type and `nullable: true`
228
+ */
229
+ convertNullableTypeArray() {
230
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
231
+ if (schema.hasOwnProperty("type")) {
232
+ const schemaType = schema["type"];
233
+ if (
234
+ Array.isArray(schemaType) &&
235
+ schemaType.length === 2 &&
236
+ schemaType.includes("null")
237
+ ) {
238
+ const nonNull = schemaType.filter((_) => _ !== "null")[0];
239
+ schema["type"] = nonNull;
240
+ schema["nullable"] = true;
241
+ this.log(`Converted schema type array to nullable`);
242
+ }
243
+ }
244
+ return this.walkNestedSchemaObjects(schema, schemaVisitor);
245
+ };
246
+ visitSchemaObjects(this.openapi30, schemaVisitor);
247
+ }
248
+
249
+ removeWebhooksObject() {
250
+ if (Object.hasOwnProperty.call(this.openapi30, "webhooks")) {
251
+ this.log(`Deleted webhooks object`);
252
+ delete (this.openapi30 as any)["webhooks"];
253
+ }
254
+ }
255
+ removeUnsupportedSchemaKeywords() {
256
+ const keywordsToRemove = [
257
+ "$id",
258
+ "$schema",
259
+ "unevaluatedProperties",
260
+ "contentMediaType",
261
+ "patternProperties",
262
+ ];
263
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
264
+ keywordsToRemove.forEach((key) => {
265
+ if (schema.hasOwnProperty(key)) {
266
+ delete schema[key];
267
+ this.log(`Removed unsupported schema keyword ${key}`);
268
+ }
269
+ });
270
+ return this.walkNestedSchemaObjects(schema, schemaVisitor);
271
+ };
272
+ visitSchemaObjects(this.openapi30, schemaVisitor);
273
+ }
274
+
275
+ renameSchema$comment() {
276
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
277
+ if (schema.hasOwnProperty("$comment")) {
278
+ schema["x-comment"] = schema["$comment"];
279
+ delete schema["$comment"];
280
+ this.log(`schema $comment renamed to x-comment`);
281
+ }
282
+ return this.walkNestedSchemaObjects(schema, schemaVisitor);
283
+ };
284
+ visitSchemaObjects(this.openapi30, schemaVisitor);
285
+ }
286
+
287
+ private deleteSchema$comment() {
288
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
289
+ if (schema.hasOwnProperty("$comment")) {
290
+ const comment = schema["$comment"];
291
+ delete schema["$comment"];
292
+ this.log(`schema $comment deleted: ${comment}`);
293
+ }
294
+ return this.walkNestedSchemaObjects(schema, schemaVisitor);
295
+ };
296
+ visitSchemaObjects(this.openapi30, schemaVisitor);
297
+ }
298
+
299
+ /**
300
+ * Convert
301
+ * ```
302
+ * contentMediaType: 'application/octet-stream'
303
+ * ```
304
+ * to
305
+ * ```
306
+ * format: binary
307
+ * ```
308
+ * in `type: string` schemas.
309
+ * Warn if schema has a `format` already and it is not `binary`.
310
+ */
311
+ convertJsonSchemaContentMediaType() {
312
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
313
+ if (
314
+ schema.hasOwnProperty("type") &&
315
+ schema["type"] === "string" &&
316
+ schema.hasOwnProperty("contentMediaType") &&
317
+ schema["contentMediaType"] === "application/octet-stream"
318
+ ) {
319
+ if (schema.hasOwnProperty("format")) {
320
+ if (schema["format"] === "binary") {
321
+ this.log(
322
+ `Deleted schema contentMediaType: application/octet-stream (leaving format: binary)`,
323
+ );
324
+ delete schema["contentMediaType"];
325
+ } else {
326
+ this.error(
327
+ `Unable to down-convert schema with contentMediaType: application/octet-stream to format: binary because the schema already has a format (${schema["format"]})`,
328
+ );
329
+ }
330
+ } else {
331
+ delete schema["contentMediaType"];
332
+ schema["format"] = "binary";
333
+ this.log(
334
+ `Converted schema contentMediaType: application/octet-stream to format: binary`,
335
+ );
336
+ }
337
+ }
338
+ return this.walkNestedSchemaObjects(schema, schemaVisitor);
339
+ };
340
+ visitSchemaObjects(this.openapi30, schemaVisitor);
341
+ }
342
+
343
+ /**
344
+ * Convert
345
+ * ```
346
+ * contentEncoding: base64
347
+ * ```
348
+ * to
349
+ * ```
350
+ * format: byte
351
+ * ```
352
+ * in `type: string` schemas. It is an error if the schema has a `format` already
353
+ * and it is not `byte`.
354
+ */
355
+ convertJsonSchemaContentEncoding() {
356
+ const schemaVisitor: SchemaVisitor = (schema: any): SchemaObject => {
357
+ if (
358
+ schema.hasOwnProperty("type") &&
359
+ schema["type"] === "string" &&
360
+ schema.hasOwnProperty("contentEncoding")
361
+ ) {
362
+ if (schema["contentEncoding"] === "base64") {
363
+ if (schema.hasOwnProperty("format")) {
364
+ if (schema["format"] === "byte") {
365
+ this.log(
366
+ `Deleted schema contentEncoding: base64 (leaving format: byte)`,
367
+ );
368
+ delete schema["contentEncoding"];
369
+ } else {
370
+ this.error(
371
+ `Unable to down-convert schema contentEncoding: base64 to format: byte because the schema already has a format (${schema["format"]})`,
372
+ );
373
+ }
374
+ } else {
375
+ delete schema["contentEncoding"];
376
+ schema["format"] = "byte";
377
+ this.log(
378
+ `Converted schema: 'contentEncoding: base64' to 'format: byte'`,
379
+ );
380
+ }
381
+ } else {
382
+ this.error(
383
+ `Unable to down-convert contentEncoding: ${schema["contentEncoding"]}`,
384
+ );
385
+ }
386
+ }
387
+ return this.walkNestedSchemaObjects(schema, schemaVisitor);
388
+ };
389
+ visitSchemaObjects(this.openapi30, schemaVisitor);
390
+ }
391
+
392
+ private json(x: any) {
393
+ return JSON.stringify(x, null, 2);
394
+ }
395
+
396
+ /**
397
+ * OpenAPI 3.1 defines a new `openIdConnect` security scheme.
398
+ * Down-convert the scheme to `oauth2` / authorization code flow.
399
+ * Collect all the scopes used in any security requirements within
400
+ * operations and add them to the scheme. Also define the
401
+ * URLs to the `authorizationUrl` and `tokenUrl` of `oauth2`.
402
+ */
403
+ convertSecuritySchemes() {
404
+ const oauth2Scopes = (schemeName: string): object => {
405
+ const scopes = {} as any;
406
+ const paths: any = this.openapi30?.paths ?? {};
407
+ for (const path in paths) {
408
+ for (const op in paths[path]) {
409
+ if (op === "parameters") {
410
+ continue;
411
+ }
412
+ const operation = paths[path][op];
413
+ const sec = operation?.security as object[];
414
+ sec.forEach((s: any) => {
415
+ const requirement = s?.[schemeName] as string[];
416
+ if (requirement) {
417
+ requirement.forEach((scope) => {
418
+ scopes[scope] =
419
+ this.scopeDescriptions?.[scope] ??
420
+ `TODO: describe the '${scope}' scope`;
421
+ });
422
+ }
423
+ });
424
+ }
425
+ }
426
+ return scopes;
427
+ };
428
+ const schemes =
429
+ (this.openapi30?.components as any)?.["securitySchemes"] ?? {};
430
+ for (const schemeName in schemes) {
431
+ const scheme = schemes[schemeName];
432
+ const type = scheme.type;
433
+ if (type === "openIdConnect") {
434
+ this.log(
435
+ `Converting openIdConnect security scheme to oauth2/authorizationCode`,
436
+ );
437
+ scheme.type = "oauth2";
438
+ const openIdConnectUrl = scheme.openIdConnectUrl;
439
+ scheme.description = `OAuth2 Authorization Code Flow. The client may
440
+ GET the OpenID Connect configuration JSON from \`${openIdConnectUrl}\`
441
+ to get the correct \`authorizationUrl\` and \`tokenUrl\`.`;
442
+ delete scheme.openIdConnectUrl;
443
+ const scopes = oauth2Scopes(schemeName);
444
+ scheme.flows = {
445
+ authorizationCode: {
446
+ // TODO: add options for these URLs
447
+ authorizationUrl: this.authorizationUrl,
448
+ tokenUrl: this.tokenUrl,
449
+ scopes: scopes,
450
+ },
451
+ };
452
+ }
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Find remaining OpenAPI 3.0 [Reference Objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#referenceObject)
458
+ * and down convert them to [JSON Reference](https://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03) objects
459
+ * with _only_ a `$ref` property.
460
+ */
461
+ simplifyNonSchemaRef() {
462
+ visitRefObjects(this.openapi30, (node: any): JsonNode => {
463
+ if (Object.keys(node).length === 1) {
464
+ return node;
465
+ } else {
466
+ this.log(
467
+ `Down convert reference object to JSON Reference:\n${JSON.stringify(node, null, 3)}`,
468
+ );
469
+ Object.keys(node)
470
+ .filter((key) => key !== "$ref")
471
+ .forEach((key) => delete node[key]);
472
+ return node;
473
+ }
474
+ });
475
+ }
476
+
477
+ removeLicenseIdentifier() {
478
+ if ((this.openapi30 as any)?.["info"]?.["license"]?.["identifier"]) {
479
+ this.log(
480
+ `Removed info.license.identifier: ${(this.openapi30 as any)["info"]["license"]["identifier"]}`,
481
+ );
482
+ delete (this.openapi30 as any)["info"]["license"]["identifier"];
483
+ }
484
+ }
485
+
486
+ // This transformation ends up breaking openapi-generator
487
+ // SDK gen (typescript-axios, typescript-angular)
488
+ // so it is disabled unless the `allOfTransform` option is `true`.
489
+
490
+ convertSchemaRef() {
491
+ /**
492
+ * In a JSON Schema, replace `{ blah blah, $ref: "uri"}`
493
+ * with `{ blah blah, allOf: [ $ref: "uri" ]}`
494
+ * @param object an object that may contain JSON schemas (directly
495
+ * or in sub-objects)
496
+ */
497
+ const simplifyRefObjectsInSchemas = (
498
+ object: SchemaObject,
499
+ ): SchemaObject => {
500
+ return visitRefObjects(object, (node: any): JsonNode => {
501
+ if (Object.keys(node).length === 1) {
502
+ return node;
503
+ } else {
504
+ this.log(
505
+ `Converting JSON Schema $ref ${this.json(node)} to allOf: [ $ref ]`,
506
+ );
507
+ node["allOf"] = [{ $ref: node.$ref }];
508
+ delete node.$ref;
509
+ return node;
510
+ }
511
+ });
512
+ };
513
+
514
+ if (this.allOfTransform) {
515
+ visitSchemaObjects(
516
+ this.openapi30,
517
+ (schema: SchemaObject): SchemaObject => {
518
+ return simplifyRefObjectsInSchemas(schema);
519
+ },
520
+ );
521
+ }
522
+ }
523
+
524
+ public static deepClone(obj: object): object {
525
+ return JSON.parse(JSON.stringify(obj)); // kinda simple way to clone, but it works...
526
+ }
527
+ }