From 6db40e6df191236c3b57a31f8e1d77656ba46665 Mon Sep 17 00:00:00 2001 From: David Biesack Date: Wed, 17 Jan 2024 11:22:46 -0500 Subject: [PATCH 1/7] WIP --- README.md | 98 ++++++++++++++++++++++++++++++---- package-lock.json | 4 +- package.json | 2 +- src/cli.ts | 2 + src/converter.ts | 118 ++++++++++++++++++++++++++++++++++++++--- test/converter.spec.ts | 51 ++++++++++++++++++ test/data/openapi.yaml | 35 +++++++++--- 7 files changed, 281 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index af1311b..b174d6d 100644 --- a/README.md +++ b/README.md @@ -170,8 +170,8 @@ mySchema: This also applies to the schema used in parameters or in `requestBody` objects and in responses. -**Note** This transformation is disabled by default because it breaks `openapi-generator` 5.4 in -cases where the referenced schema is an array. +**Note** This transformation is disabled by default because it breaks +`openapi-generator` 5.4 in cases where the referenced schema is an array. It generates Typescript types for such cases as ```typescript @@ -223,7 +223,7 @@ components: const: '1.0.0' ``` - becomes +becomes ```yaml components: @@ -235,7 +235,7 @@ components: - '1.0.0' ``` -### Convert type arrays to nullable +### ⤓ Convert type arrays to nullable If a schema has a type array of exactly two values, and one of them is the string `'null'`, the type is converted to the non-null string item, @@ -302,11 +302,11 @@ be possible (`properties`, `allOf` etc.) (Contributions welcome.) -### Remove `unevaluatedProperties` +### ⤓ Remove `unevaluatedProperties` The tool removes the `unevaluatedProperties` value, introduced in later versions of JSON Schema, -as this is not supported in OAS 3.0 JSON Schema Draft 4 +as this is not supported in JSON Schema Draft 4 used in OAS 3.0. ```yaml @@ -331,16 +331,92 @@ becomes ... ``` -### Remove schema `$id` and `$schema` +### ⤓ Remove schema `$id` and `$schema` The tool removes any `$id` or `$schema` keywords that may appear inside schema objects. +### ⤓ Convert `$comment` to `x-comment` + +JSON Schema introduced `$comment` in schemas in 2020-12. +Since OAS 3.0 uses JSON Schema Draft 4, and some tools +will flag `$comment` as invalid, this tool removes these comments. + +An earlier version of the tool comverted `$comment` to `x-comment` +However, other tools which do not allow `$comment` may not not support +`x-comment` + +use the `--convert-schema-comments` option or set +`ConverterOptions.convertSchemaComments` +to `true` +in the `Converter` constructor +to requst conversion of +`$comment` to `x-comment` rather than deleting `$comment`. + +### Convert `contentEncoding: base64` to `format: byte` + +JSON Schema Draft 7 uses `contentEncoding` to specify +[the encoding of non-JSON string content] +(https://json-schema.org/understanding-json-schema/reference/non_json_data). +Draft 7 supports `format: byte`. + +This tool converts `type: string` schemas as follows: + + + + + + + + + + + + + + + + + + +
OAS 3.1 schemaOAS 3.0 schema
+
+type: string
+contentEncoding: base64
+
+
+
+type: string
+format: byte
+
+
+
+type: string
+contentMediaType: 'application/octet-string'
+
+
+
+type: string
+format: binary
+
+
+ + ## Unsupported down conversions +The tool does not support the following situations. +Contributions welcome! + * `openapi-down-convert` does not convert `exclusiveMinimum` and `exclusiveMaximum` -as defined in JSON Schema 2012-12; these handled differently in JSON Schema Draft 4 -used in OAS 3.0. Contributions welcome! -* Webhooks are not addressed. Contributions welcome! + as defined in JSON Schema 2012-12; these handled differently in JSON Schema Draft 4 + used in OAS 3.0. +* Webhooks are not addressed. * The tool only supports self-contained documents. It does not follow or resolve -external `$ref` documents embedded in the source document. + external `$ref` documents embedded in the source document. +* Request body and response body `content` object transformations, such as + reversing `content: { 'application/octet-stream': {} }` + described in [Migrating from OpenAPI 3.0 to 3.1.0](https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0) +* Converting other `contentEncoding` values (`7bit`, `8bit`, `binary`, + `quoted-printable`, `base16`, `base32`) (Note: `contentEncoding: base64` is supported by + converting to `format: byte` as listed above.) +* Converting `contentMediaType: 'type/subtype` to `media: { type: 'type/subtype'}` for non-JSON data. diff --git a/package-lock.json b/package-lock.json index de0dd4d..0049b85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apiture/openapi-down-convert", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@apiture/openapi-down-convert", - "version": "0.9.0", + "version": "0.10.0", "license": "ISC", "dependencies": { "commander": "^9.4.1", diff --git a/package.json b/package.json index 31cc22e..cde1fa7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apiture/openapi-down-convert", - "version": "0.9.0", + "version": "0.10.0", "description": "Tool to down convert OpenAPI 3.1 to OpenAPI 3.0", "main": "lib/src/index.js", "bin": { diff --git a/src/cli.ts b/src/cli.ts index 68967f8..bf3c2fe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,6 +25,7 @@ async function main(args: string[] = process.argv) { .option('--oidc-to-oauth2 ', 'Convert openIdConnect security to oauth2 to allow scope definition') .option('-s, --scopes ', 'Alias for --oidc-to-oauth2') .option('-v, --verbose', 'Verbose output') + .option('--convert-schema-comments', 'Convert $comment to x-comment instead of deleting $comment values') .parse(args); const opts = cli.opts(); const sourceFileName: string = opts.input || 'openapi.yaml'; @@ -37,6 +38,7 @@ async function main(args: string[] = process.argv) { authorizationUrl: opts.authorizationUrl, tokenUrl: opts.tokenUrl, scopeDescriptionFile: opts.scopes, + convertSchemaComments: opts.convertSchemaComments, }; const converter = new Converter(source, cOpts); try { diff --git a/src/converter.ts b/src/converter.ts index 94597f1..4a54a5c 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -45,6 +45,12 @@ export interface ConverterOptions { * `{ scope1: "description of scope1", ... }` */ scopeDescriptionFile?: string; + /** Earlier versions of the tool converted $comment to x-comment + * in JSON Schemas. The tool now deleted $comment values. + * Use this option to preserve the conversion and not delete + * comments. + */ + convertSchemaComments?: boolean; } export class Converter { @@ -56,6 +62,7 @@ export class Converter { /** The tokenUrl for openIdConnect -> oauth2 transformation */ private tokenUrl: string; private scopeDescriptions = undefined; + private convertSchemaComments = false; /** * Construct a new Converter @@ -69,6 +76,7 @@ export class Converter { this.authorizationUrl = options?.authorizationUrl || 'https://www.example.com/oauth2/authorize'; this.tokenUrl = options?.tokenUrl || 'https://www.example.com/oauth2/token'; this.loadScopeDescriptions(options?.scopeDescriptionFile); + this.convertSchemaComments = options?.convertSchemaComments; } /** Load the scopes.yaml file and save in this.scopeDescriptions @@ -117,11 +125,17 @@ export class Converter { this.convertSecuritySchemes(); } this.convertJsonSchemaExamples(); + this.convertJsonSchemaContentEncoding(); + this.convertJsonSchemaContentMediaType(); this.convertJsonSchemaComments(); this.convertConstToEnum(); this.convertNullableTypeArray(); this.removeUnsupportedSchemaKeywords(); - this.renameSchema$comment(); + if (this.convertSchemaComments) { + this.renameSchema$comment(); + } else { + this.deleteSchema$comment(); + } return this.openapi30; } @@ -170,9 +184,7 @@ export class Converter { * Replace all `$comment` with `x-comment` */ convertJsonSchemaComments() { - const schemaVisitor: SchemaVisitor = - (schema: SchemaObject): SchemaObject => - { + const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { if (schema.hasOwnProperty('$comment')) { schema['x-comment'] = schema['$comment']; delete schema['$comment']; @@ -246,9 +258,7 @@ export class Converter { } renameSchema$comment() { - const schemaVisitor: SchemaVisitor = - (schema: SchemaObject): SchemaObject => - { + const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { if (schema.hasOwnProperty('$comment')) { schema['x-comment'] = schema['$comment']; delete schema['$comment']; @@ -259,6 +269,100 @@ export class Converter { visitSchemaObjects(this.openapi30, schemaVisitor); } + private deleteSchema$comment() { + const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { + if (schema.hasOwnProperty('$comment')) { + const comment = schema['$comment']; + delete schema['$comment']; + this.log(`schema $comment deleted: ${comment}`); + } + return this.walkNestedSchemaObjects(schema, schemaVisitor); + }; + visitSchemaObjects(this.openapi30, schemaVisitor); + } + + /** + * Convert + * ``` + * contentMediaType: 'application/octet-string' + * ``` + * to + * ``` + * format: binary + * ``` + * in `type: string` schemas. + * Warn if schema has a `format` already and it is not `binary`. + */ + convertJsonSchemaContentMediaType() {{ + const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { + if ( + schema.hasOwnProperty('type') && + schema['type'] === 'string' && + schema.hasOwnProperty('contentMediaType') && + schema['contentMediaType'] === 'application/octet-string' + ) { + if (schema.hasOwnProperty('format')) { + if (schema['format'] === 'binary') { + this.log(`Deleted schema contentMediaType: application/octet-string (leaving format: binary)`); + delete schema['contentMediaType']; + } else { + this.warn( + `Could not convert schema contentMediaType: application/octet-string to format: binary because the schema already has a format (${schema['format']})`, + ); + } + } else { + delete schema['contentMediaType']; + schema['format'] = 'binary' + this.log(`Converted schema contentMediaType: application/octet-string to format: binary`); + } + } + return this.walkNestedSchemaObjects(schema, schemaVisitor); + }; + visitSchemaObjects(this.openapi30, schemaVisitor); + } + } + + /** + * Convert + * ``` + * contentEncoding: base64 + * ``` + * to + * ``` + * format: byte + * ``` + * in `type: string` schemas. Warn if schema has a `format` already. + * and it is not `byte` + */ + convertJsonSchemaContentEncoding() { + const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { + if (schema.hasOwnProperty('type') && + schema['type'] === 'string' && + schema.hasOwnProperty('contentEncoding') + ) { + if (schema['contentEncoding'] === 'base64') { + if (schema.hasOwnProperty('format')) { + if (schema['format'] === 'byte') { + this.log(`Deleted schema contentEncoding: base64 (leaving format: byte)`); + delete schema['contentEncoding']; + } else { + this.warn( + `Could not convert schema contentEncoding: base64 to format: byte because the schema already has a format (${schema['format']})`, + ); + } + } else { + delete schema['contentEncoding']; + schema['format'] = 'byte'; + this.log(` converted schema: contentEncoding: base64 to format: byte`); + } + } else { + this.warn(`Unable to down-convert contentEncoding: ${schema['contentEncoding']}`) + } + return this.walkNestedSchemaObjects(schema, schemaVisitor); + } + } + visitSchemaObjects(this.openapi30, schemaVisitor); + } private json(x) { return JSON.stringify(x, null, 2); diff --git a/test/converter.spec.ts b/test/converter.spec.ts index 0946d08..77483c2 100644 --- a/test/converter.spec.ts +++ b/test/converter.spec.ts @@ -433,6 +433,57 @@ describe('resolver test suite', () => { }, }, }; + const converter = new Converter(input, { verbose: true, convertSchemaComments: true }); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + done(); + }); + + + test('Delete $comment (not convert to x-comment)', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + a: { + type: 'object', + $comment: 'a comment on schema a', + properties: { + b: { + type: 'object', + $comment: 'A comment on a.b', + properties: { + s: { + type: 'string', + $comment: 'A comment on a.b.s', + }, + }, + }, + }, + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + a: { + type: 'object', + properties: { + b: { + type: 'object', + properties: { + s: { + type: 'string' + }, + }, + }, + }, + }, + }, + }, + }; const converter = new Converter(input, { verbose: true }); const converted: any = converter.convert(); expect(converted).toEqual(expected); diff --git a/test/data/openapi.yaml b/test/data/openapi.yaml index 913a772..cc418af 100644 --- a/test/data/openapi.yaml +++ b/test/data/openapi.yaml @@ -427,12 +427,31 @@ components: format: date-time readOnly: true example: '2021-10-30T19:06:04.250Z' - - resourceTitle: - title: Resource Title - description: A Title for a business object + $comment: >- + minlength is for no milliseconds, such as + '2021-10-30T19:06:00Z' + maxLength is for '.' plus up to 9 digits for milliseconds, + such as '2021-10-30T19:06:04.999000999Z' + base64EncodedText: + type: string + contentEncoding: base64 + base64EncodedTextWihFormat: + type: string + format: byte + contentEncoding: base64 + base64EncodedTextWihUnexpectedFormat: + type: string + format: text + contentEncoding: base64 + binaryEncodedData: type: string - maxLength: 80 - x-comment: >- - this maxLength must match the maxLength of - `title` in the `resourcePatch` schema. + contentEncoding: application/octet-stream + binaryEncodedDataWIthFormat: + type: string + format: binary + contentEncoding: application/octet-stream + binaryEncodedDataWIthUnexpectedFormat: + type: string + format: base32 + contentEncoding: application/octet-stream + From e1958c3a2e8fb485d1568dc9af80ed2db42a3442 Mon Sep 17 00:00:00 2001 From: David Biesack Date: Thu, 18 Jan 2024 11:11:50 -0500 Subject: [PATCH 2/7] WIP - tests passed but need to add new tests for new conversions --- src/RefVisitor.ts | 3 ++- src/converter.ts | 23 ++--------------------- test/converter.spec.ts | 3 +-- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/src/RefVisitor.ts b/src/RefVisitor.ts index be891a0..f600b5a 100644 --- a/src/RefVisitor.ts +++ b/src/RefVisitor.ts @@ -62,7 +62,8 @@ export function visitSchemaObjects(node: any, schemaCallback: SchemaVisitor): an if (schemas != null && typeof schemas === 'object') { for (const schemaName in schemas) { const schema = schemas[schemaName]; - schemas[schemaName] = schemaCallback(schema); + const newSchema = schemaCallback(schema); + schemas[schemaName] = newSchema; } } } diff --git a/src/converter.ts b/src/converter.ts index 4a54a5c..0776e08 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -127,7 +127,6 @@ export class Converter { this.convertJsonSchemaExamples(); this.convertJsonSchemaContentEncoding(); this.convertJsonSchemaContentMediaType(); - this.convertJsonSchemaComments(); this.convertConstToEnum(); this.convertNullableTypeArray(); this.removeUnsupportedSchemaKeywords(); @@ -178,23 +177,6 @@ export class Converter { visitSchemaObjects(this.openapi30, schemaVisitor); } - /** - * OpenAPI 3.1 uses JSON Schema 2020-12 which allows schema `$comment`; - * OpenAPI 3.0 uses JSON Scheme Draft 7 does not allow it. - * Replace all `$comment` with `x-comment` - */ - convertJsonSchemaComments() { - const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { - if (schema.hasOwnProperty('$comment')) { - schema['x-comment'] = schema['$comment']; - delete schema['$comment']; - this.log(`schema $comment renamed to x-comment`); - } - return this.walkNestedSchemaObjects(schema, schemaVisitor); - }; - visitSchemaObjects(this.openapi30, schemaVisitor); - } - private walkNestedSchemaObjects(schema, schemaVisitor) { for (const key in schema) { const subSchema = schema[key]; @@ -293,7 +275,7 @@ export class Converter { * in `type: string` schemas. * Warn if schema has a `format` already and it is not `binary`. */ - convertJsonSchemaContentMediaType() {{ + convertJsonSchemaContentMediaType() { const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => { if ( schema.hasOwnProperty('type') && @@ -320,7 +302,6 @@ export class Converter { }; visitSchemaObjects(this.openapi30, schemaVisitor); } - } /** * Convert @@ -358,8 +339,8 @@ export class Converter { } else { this.warn(`Unable to down-convert contentEncoding: ${schema['contentEncoding']}`) } - return this.walkNestedSchemaObjects(schema, schemaVisitor); } + return this.walkNestedSchemaObjects(schema, schemaVisitor); } visitSchemaObjects(this.openapi30, schemaVisitor); } diff --git a/test/converter.spec.ts b/test/converter.spec.ts index 77483c2..d29d8f1 100644 --- a/test/converter.spec.ts +++ b/test/converter.spec.ts @@ -295,7 +295,7 @@ describe('resolver test suite', () => { }, }, }; - const converter = new Converter(input, {}); + const converter = new Converter(input, {convertSchemaComments: true}); const converted: any = converter.convert(); const a = converted.components.schemas.a; @@ -439,7 +439,6 @@ describe('resolver test suite', () => { done(); }); - test('Delete $comment (not convert to x-comment)', (done) => { const input = { openapi: '3.1.0', From 47a77582a6bff622cfa22bce9c07be9358de7bf1 Mon Sep 17 00:00:00 2001 From: David Biesack Date: Thu, 18 Jan 2024 14:58:57 -0500 Subject: [PATCH 3/7] use application/octet-stream not application/octet-string. complete unit tests --- README.md | 4 +- src/converter.ts | 10 +- test/converter.spec.ts | 462 +++++++++++++++++++++++++++++------------ 3 files changed, 332 insertions(+), 144 deletions(-) diff --git a/README.md b/README.md index b174d6d..07e4fbe 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,7 @@ use the `--convert-schema-comments` option or set `ConverterOptions.convertSchemaComments` to `true` in the `Converter` constructor -to requst conversion of +to requst conversion of `$comment` to `x-comment` rather than deleting `$comment`. ### Convert `contentEncoding: base64` to `format: byte` @@ -389,7 +389,7 @@ format: byte
 type: string
-contentMediaType: 'application/octet-string'
+contentMediaType: 'application/octet-stream'
 
diff --git a/src/converter.ts b/src/converter.ts index 0776e08..2427885 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -266,7 +266,7 @@ export class Converter { /** * Convert * ``` - * contentMediaType: 'application/octet-string' + * contentMediaType: 'application/octet-stream' * ``` * to * ``` @@ -281,21 +281,21 @@ export class Converter { schema.hasOwnProperty('type') && schema['type'] === 'string' && schema.hasOwnProperty('contentMediaType') && - schema['contentMediaType'] === 'application/octet-string' + schema['contentMediaType'] === 'application/octet-stream' ) { if (schema.hasOwnProperty('format')) { if (schema['format'] === 'binary') { - this.log(`Deleted schema contentMediaType: application/octet-string (leaving format: binary)`); + this.log(`Deleted schema contentMediaType: application/octet-stream (leaving format: binary)`); delete schema['contentMediaType']; } else { this.warn( - `Could not convert schema contentMediaType: application/octet-string to format: binary because the schema already has a format (${schema['format']})`, + `Could not convert schema contentMediaType: application/octet-stream to format: binary because the schema already has a format (${schema['format']})`, ); } } else { delete schema['contentMediaType']; schema['format'] = 'binary' - this.log(`Converted schema contentMediaType: application/octet-string to format: binary`); + this.log(`Converted schema contentMediaType: application/octet-stream to format: binary`); } } return this.walkNestedSchemaObjects(schema, schemaVisitor); diff --git a/test/converter.spec.ts b/test/converter.spec.ts index d29d8f1..b798565 100644 --- a/test/converter.spec.ts +++ b/test/converter.spec.ts @@ -1,4 +1,4 @@ -/* eslint-disable prefer-destructuring */ +/*eslint-disable prefer-destructuring */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, expect, test } from '@jest/globals'; @@ -291,11 +291,11 @@ describe('resolver test suite', () => { a: { type: 'string', $comment: 'This is a comment.', - } + }, }, }, }; - const converter = new Converter(input, {convertSchemaComments: true}); + const converter = new Converter(input, { convertSchemaComments: true }); const converted: any = converter.convert(); const a = converted.components.schemas.a; @@ -355,139 +355,139 @@ describe('resolver test suite', () => { done(); }); - test('Remove $id and $schema keywords', (done) => { - // const sourceFileName = path.join(__dirname, 'data/root.yaml'); // __dirname is the test dir - const input = { - openapi: '3.1.0', - components: { - schemas: { - a: { - $id: 'http://www.example.com/schemas/a', - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'string', - }, - }, - }, - }; - const expected = { - openapi: '3.0.3', - components: { - schemas: { - a: { - type: 'string', - }, - }, - }, - }; - const converter = new Converter(input, { verbose: true }); - const converted: any = converter.convert(); - expect(converted).toEqual(expected); - done(); - }); + test('Remove $id and $schema keywords', (done) => { + // const sourceFileName = path.join(__dirname, 'data/root.yaml'); // __dirname is the test dir + const input = { + openapi: '3.1.0', + components: { + schemas: { + a: { + $id: 'http://www.example.com/schemas/a', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'string', + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + a: { + type: 'string', + }, + }, + }, + }; + const converter = new Converter(input, { verbose: true }); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + done(); + }); - test('Rename $comment to x-comment', (done) => { - const input = { - openapi: '3.1.0', - components: { - schemas: { - a: { - type: 'object', - $comment: 'a comment on schema a', - properties: { - b: { - type: 'object', - $comment: 'A comment on a.b', - properties: { - s: { - type: 'string', - $comment: 'A comment on a.b.s', - }, - }, - }, - }, - }, - }, - }, - }; - const expected = { - openapi: '3.0.3', - components: { - schemas: { - a: { - type: 'object', - 'x-comment': 'a comment on schema a', - properties: { - b: { - type: 'object', + test('Rename $comment to x-comment', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + a: { + type: 'object', + $comment: 'a comment on schema a', + properties: { + b: { + type: 'object', + $comment: 'A comment on a.b', + properties: { + s: { + type: 'string', + $comment: 'A comment on a.b.s', + }, + }, + }, + }, + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + a: { + type: 'object', + 'x-comment': 'a comment on schema a', + properties: { + b: { + type: 'object', - 'x-comment': 'A comment on a.b', - properties: { - s: { - type: 'string', - 'x-comment': 'A comment on a.b.s', - }, - }, - }, - }, - }, - }, - }, - }; - const converter = new Converter(input, { verbose: true, convertSchemaComments: true }); - const converted: any = converter.convert(); - expect(converted).toEqual(expected); - done(); - }); + 'x-comment': 'A comment on a.b', + properties: { + s: { + type: 'string', + 'x-comment': 'A comment on a.b.s', + }, + }, + }, + }, + }, + }, + }, + }; + const converter = new Converter(input, { verbose: true, convertSchemaComments: true }); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + done(); + }); - test('Delete $comment (not convert to x-comment)', (done) => { - const input = { - openapi: '3.1.0', - components: { - schemas: { - a: { - type: 'object', - $comment: 'a comment on schema a', - properties: { - b: { - type: 'object', - $comment: 'A comment on a.b', - properties: { - s: { - type: 'string', - $comment: 'A comment on a.b.s', - }, - }, - }, - }, - }, - }, - }, - }; - const expected = { - openapi: '3.0.3', - components: { - schemas: { - a: { - type: 'object', - properties: { - b: { - type: 'object', - properties: { - s: { - type: 'string' - }, - }, - }, - }, - }, - }, - }, - }; - const converter = new Converter(input, { verbose: true }); - const converted: any = converter.convert(); - expect(converted).toEqual(expected); - done(); - }); + test('Delete $comment (not convert to x-comment)', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + a: { + type: 'object', + $comment: 'a comment on schema a', + properties: { + b: { + type: 'object', + $comment: 'A comment on a.b', + properties: { + s: { + type: 'string', + $comment: 'A comment on a.b.s', + }, + }, + }, + }, + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + a: { + type: 'object', + properties: { + b: { + type: 'object', + properties: { + s: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }; + const converter = new Converter(input, { verbose: true }); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + done(); + }); test('Convert nullable type array', (done) => { // const sourceFileName = path.join(__dirname, 'data/root.yaml'); // __dirname is the test dir @@ -608,19 +608,19 @@ describe('resolver test suite', () => { test('Remove info.license.identifier', (done) => { // const sourceFileName = path.join(__dirname, 'data/root.yaml'); // __dirname is the test dir const input = { - openapi: "3.1.0", + openapi: '3.1.0', info: { license: { name: 'MIT', - identifier: 'MIT' + identifier: 'MIT', }, }, }; const expected = { - openapi: "3.0.3", + openapi: '3.0.3', info: { license: { - name: 'MIT' + name: 'MIT', }, }, }; @@ -649,3 +649,191 @@ describe('resolver test suite', () => { done(); }); }); + +test('binary encoded data with existing binary format', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + format: 'binary', + contentEncoding: 'base64', + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + format: 'binary', + contentEncoding: 'base64', + }, + }, + }, + }; + const converter = new Converter(input); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + // TODO how to check that Converter logged to console.warn ? + done(); +}); + +test('binary encoded data with byte format', (done) => { + // const sourceFileName = path.join(__dirname, 'data/root.yaml'); // __dirname is the test dir + const input = { + openapi: '3.1.0', + components: { + schemas: { + binaryEncodedDataWithByteFormat: { + type: 'string', + format: 'byte', + contentEncoding: 'base64', + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + binaryEncodedDataWithByteFormat: { + type: 'string', + format: 'byte', + }, + }, + }, + }; + const converter = new Converter(input); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + done(); +}); + +test('binary encoded data with no existing format', (done) => { + // const sourceFileName = path.join(__dirname, 'data/root.yaml'); // __dirname is the test dir + const input = { + openapi: '3.1.0', + components: { + schemas: { + binaryEncodedDataWithNoFormat: { + type: 'string', + contentEncoding: 'base64', + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + binaryEncodedDataWithNoFormat: { + type: 'string', + format: 'byte', + }, + }, + }, + }; + const converter = new Converter(input); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + done(); +}); + +test('contentMediaType with existing binary format', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + contentMediaType: 'application/octet-stream', + format: 'binary' + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + format: 'binary', + }, + }, + }, + }; + const converter = new Converter(input); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + // TODO how to check that Converter logged to console.warn ? + done(); +}); + + +test('contentMediaType with no existing format', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + contentMediaType: 'application/octet-stream', + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + format: 'binary', + }, + }, + }, + }; + const converter = new Converter(input); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + // TODO how to check that Converter logged to console.warn ? + done(); +}); + +test('contentMediaType with existing unexpected format', (done) => { + const input = { + openapi: '3.1.0', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + contentMediaType: 'application/octet-stream', + format: 'byte' + }, + }, + }, + }; + const expected = { + openapi: '3.0.3', + components: { + schemas: { + binaryEncodedDataWithExistingBinaryFormat: { + type: 'string', + contentMediaType: 'application/octet-stream', + format: 'byte' + }, + }, + }, + }; + const converter = new Converter(input); + const converted: any = converter.convert(); + expect(converted).toEqual(expected); + // TODO how to check that Converter logged to console.warn ? + done(); +}); + From ddf6d7f38c20037a3f842c08fe8a11e053a96c5f Mon Sep 17 00:00:00 2001 From: David Biesack Date: Thu, 18 Jan 2024 15:26:32 -0500 Subject: [PATCH 4/7] update CLI with note on non-zero exist codes. Throw Error if cannot convert --- README.md | 25 ++++++++++++++------- src/converter.ts | 49 ++++++++++++++++++++++++++---------------- test/converter.spec.ts | 47 +++++++++++++++------------------------- test/data/openapi.yaml | 25 +-------------------- 4 files changed, 66 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 07e4fbe..fc16a6d 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ Options: --tokenUrl The tokenUrl for openIdConnect -> oauth2 transformation -d, --delete-examples-with-id If set, delete any JSON Schema examples that have an `id` property --oidc-to-oath2 Convert openIdConnect security to oath2. + --convertJsonComments if used, convert `$comment` in JSON schemas + to `x-comment`. I omitted, simply delete + all `$comment` in JSON schemas. -s, --scopes If set, this JSON/YAML file describes the OpenID scopes. This is an alias for --oidc-to-oath2 -v, --verbose Verbose output @@ -79,7 +82,13 @@ Options: -h, --help display help for command ``` -The verbose mode logs the changes to standard error output stream. +The verbose mode logs all changes to standard error output stream. + +The tool returns a 0 status code upon success or a non-zero status code +if it finds constructs that cannot be down-converted, such as +using `contentMediaType: application/octet-stream` with a `format` +other than `binary`, or if a schema has `contentEncoding: base64` +and has an existing `format` that is not already `base64`. The tool only supports local file-based documents, not URLs. Download such files to convert: @@ -342,12 +351,12 @@ JSON Schema introduced `$comment` in schemas in 2020-12. Since OAS 3.0 uses JSON Schema Draft 4, and some tools will flag `$comment` as invalid, this tool removes these comments. -An earlier version of the tool comverted `$comment` to `x-comment` +An earlier version of the tool converted `$comment` to `x-comment` However, other tools which do not allow `$comment` may not not support -`x-comment` +`x-comment` either. -use the `--convert-schema-comments` option or set -`ConverterOptions.convertSchemaComments` +Use the `--convert-schema-comments` CLI option or set +`convertSchemaComments` to `true` in the `Converter` constructor to requst conversion of @@ -355,10 +364,10 @@ to requst conversion of ### Convert `contentEncoding: base64` to `format: byte` -JSON Schema Draft 7 uses `contentEncoding` to specify +JSON Schema Draft 7 and later uses `contentEncoding` to specify [the encoding of non-JSON string content] (https://json-schema.org/understanding-json-schema/reference/non_json_data). -Draft 7 supports `format: byte`. +Draft 4 supports `format: byte` for `Base64` encoded strings. This tool converts `type: string` schemas as follows: @@ -369,7 +378,7 @@ This tool converts `type: string` schemas as follows: OAS 3.1 schema OAS 3.0 schema - +2
diff --git a/src/converter.ts b/src/converter.ts
index 2427885..87360e1 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -63,6 +63,7 @@ export class Converter {
   private tokenUrl: string;
   private scopeDescriptions = undefined;
   private convertSchemaComments = false;
+  private returnCode = 0;
 
   /**
    * Construct a new Converter
@@ -111,6 +112,19 @@ export class Converter {
     console.warn(...message);
   }
 
+  /**
+   * Log an rror message to console.error stream. Prefix the message string with `Error: `
+   * if it does not already have that text. Increments the `returnCode`.
+   * @param message parameters for console.warn
+   */
+  private error(...message) {
+    if (!message[0].startsWith('Error')) {
+      message[0] = `Error: ${message[0]}`;
+    }
+    this.returnCode++;
+    console.error(...message);
+  }
+
   /**
    * Convert the OpenAPI document to 3.0
    * @returns the converted document. The input is not modified.
@@ -135,6 +149,8 @@ export class Converter {
     } else {
       this.deleteSchema$comment();
     }
+    if (this.returnCode > 0)
+      throw new Error('Cannot down convert this OpenAPI definition.');
     return this.openapi30;
   }
 
@@ -285,16 +301,16 @@ export class Converter {
       ) {
         if (schema.hasOwnProperty('format')) {
           if (schema['format'] === 'binary') {
-             this.log(`Deleted schema contentMediaType: application/octet-stream (leaving format: binary)`);
-             delete schema['contentMediaType'];
+            this.log(`Deleted schema contentMediaType: application/octet-stream (leaving format: binary)`);
+            delete schema['contentMediaType'];
           } else {
-            this.warn(
-              `Could not convert schema contentMediaType: application/octet-stream to format: binary because the schema already has a format (${schema['format']})`,
-          );
+            this.error(
+              `Unable to down-convert schema with contentMediaType: application/octet-stream to format: binary because the schema already has a format (${schema['format']})`,
+            );
           }
         } else {
           delete schema['contentMediaType'];
-          schema['format'] = 'binary'
+          schema['format'] = 'binary';
           this.log(`Converted schema contentMediaType: application/octet-stream to format: binary`);
         }
       }
@@ -317,31 +333,28 @@ export class Converter {
    */
   convertJsonSchemaContentEncoding() {
     const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => {
-      if (schema.hasOwnProperty('type') &&
-          schema['type'] === 'string' &&
-          schema.hasOwnProperty('contentEncoding')
-          ) {
+      if (schema.hasOwnProperty('type') && schema['type'] === 'string' && schema.hasOwnProperty('contentEncoding')) {
         if (schema['contentEncoding'] === 'base64') {
           if (schema.hasOwnProperty('format')) {
             if (schema['format'] === 'byte') {
               this.log(`Deleted schema contentEncoding: base64 (leaving format: byte)`);
               delete schema['contentEncoding'];
             } else {
-              this.warn(
-                `Could not convert schema contentEncoding: base64 to format: byte because the schema already has a format (${schema['format']})`,
+              this.error(
+                `Unable to down-convert schema contentEncoding: base64 to format: byte because the schema already has a format (${schema['format']})`,
               );
             }
           } else {
             delete schema['contentEncoding'];
             schema['format'] = 'byte';
-             this.log(` converted schema: contentEncoding: base64 to format: byte`);
+            this.log(` converted schema: contentEncoding: base64 to format: byte`);
           }
-      } else {
-        this.warn(`Unable to down-convert contentEncoding: ${schema['contentEncoding']}`)
+        } else {
+          this.error(`Unable to down-convert contentEncoding: ${schema['contentEncoding']}`);
+        }
       }
-    }
-    return this.walkNestedSchemaObjects(schema, schemaVisitor);
-  }
+      return this.walkNestedSchemaObjects(schema, schemaVisitor);
+    };
     visitSchemaObjects(this.openapi30, schemaVisitor);
   }
 
diff --git a/test/converter.spec.ts b/test/converter.spec.ts
index b798565..ccf0ae7 100644
--- a/test/converter.spec.ts
+++ b/test/converter.spec.ts
@@ -663,22 +663,15 @@ test('binary encoded data with existing binary format', (done) => {
       },
     },
   };
-  const expected = {
-    openapi: '3.0.3',
-    components: {
-      schemas: {
-        binaryEncodedDataWithExistingBinaryFormat: {
-          type: 'string',
-          format: 'binary',
-          contentEncoding: 'base64',
-        },
-      },
-    },
-  };
   const converter = new Converter(input);
-  const converted: any = converter.convert();
-  expect(converted).toEqual(expected);
-  // TODO how to check that Converter logged to console.warn ?
+  let caught = false;
+  try {
+      converter.convert();
+  } catch (e) {
+    caught = true;
+  }
+  expect(caught).toBeTruthy()
+  // TODO how to check that Converter logged a specific note?
   done();
 });
 
@@ -818,21 +811,15 @@ test('contentMediaType with existing unexpected format', (done) => {
       },
     },
   };
-  const expected = {
-    openapi: '3.0.3',
-    components: {
-      schemas: {
-        binaryEncodedDataWithExistingBinaryFormat: {
-          type: 'string',
-          contentMediaType: 'application/octet-stream',
-          format: 'byte'
-        },
-      },
-    },
-  };
-  const converter = new Converter(input);
-  const converted: any = converter.convert();
-  expect(converted).toEqual(expected);
+
+   const converter = new Converter(input);
+   let caught = false;
+   try {
+     converter.convert();
+   } catch (e) {
+     caught = true;
+   }
+   expect(caught).toBeTruthy();
   // TODO how to check that Converter logged to console.warn ?
   done();
 });
diff --git a/test/data/openapi.yaml b/test/data/openapi.yaml
index cc418af..8a4fd62 100644
--- a/test/data/openapi.yaml
+++ b/test/data/openapi.yaml
@@ -431,27 +431,4 @@ components:
         minlength is for no milliseconds, such as
         '2021-10-30T19:06:00Z'
         maxLength is for '.' plus up to 9 digits for milliseconds,
-        such as '2021-10-30T19:06:04.999000999Z'
-    base64EncodedText:
-      type: string
-      contentEncoding: base64
-    base64EncodedTextWihFormat:
-      type: string
-      format: byte
-      contentEncoding: base64
-    base64EncodedTextWihUnexpectedFormat:
-      type: string
-      format: text
-      contentEncoding: base64
-    binaryEncodedData:
-      type: string
-      contentEncoding: application/octet-stream
-    binaryEncodedDataWIthFormat:
-      type: string
-      format: binary
-      contentEncoding: application/octet-stream
-    binaryEncodedDataWIthUnexpectedFormat:
-      type: string
-      format: base32
-      contentEncoding: application/octet-stream
-
+        such as '2021-10-30T19:06:04.999000999Z'
\ No newline at end of file

From de96127f617149c086df3958c3069a9c8eab4676 Mon Sep 17 00:00:00 2001
From: David Biesack 
Date: Thu, 18 Jan 2024 15:28:26 -0500
Subject: [PATCH 5/7] format code

---
 src/converter.ts | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/converter.ts b/src/converter.ts
index 87360e1..07b9bbe 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -149,8 +149,9 @@ export class Converter {
     } else {
       this.deleteSchema$comment();
     }
-    if (this.returnCode > 0)
+    if (this.returnCode > 0) {
       throw new Error('Cannot down convert this OpenAPI definition.');
+    }
     return this.openapi30;
   }
 

From 70bff49f715331039ba0d9ca0c7591b2bb3f58c4 Mon Sep 17 00:00:00 2001
From: David Biesack 
Date: Fri, 19 Jan 2024 09:08:31 -0500
Subject: [PATCH 6/7] Address PR comments

---
 README.md        | 10 ++++++----
 src/converter.ts | 13 +++++++------
 2 files changed, 13 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md
index fc16a6d..f384c94 100644
--- a/README.md
+++ b/README.md
@@ -72,9 +72,11 @@ Options:
   --tokenUrl                   The tokenUrl for openIdConnect -> oauth2 transformation
   -d, --delete-examples-with-id          If set, delete any JSON Schema examples that have an `id` property
   --oidc-to-oath2                Convert openIdConnect security to oath2.
-  --convertJsonComments                  if used, convert `$comment` in JSON schemas
-                                         to `x-comment`. I omitted, simply delete
+  --convertJsonComments                  If used, convert `$comment` in JSON schemas
+                                         to `x-comment`. If omitted, delete
                                          all `$comment` in JSON schemas.
+                                         (Use `--verbose` to log deletion
+                                         to stdout)
   -s, --scopes                   If set, this JSON/YAML file describes the OpenID scopes.
                                          This is an alias for --oidc-to-oath2
   -v, --verbose                          Verbose output
@@ -413,7 +415,7 @@ format: binary
 
 ## Unsupported down conversions
 
-The tool does not support the following situations.
+Curretnly, the tool does not support the following situations.
 Contributions welcome!
 
 * `openapi-down-convert` does not convert `exclusiveMinimum` and `exclusiveMaximum`
@@ -423,7 +425,7 @@ Contributions welcome!
 * The tool only supports self-contained documents. It does not follow or resolve
   external `$ref` documents embedded in the source document.
 * Request body and response body `content` object transformations, such as
-  reversing `content: { 'application/octet-stream': {} }`
+  reversing `content: { 'application/octet-stream': {} }` as
   described in [Migrating from OpenAPI 3.0 to 3.1.0](https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0)
 * Converting other `contentEncoding` values (`7bit`, `8bit`, `binary`,
   `quoted-printable`, `base16`, `base32`) (Note: `contentEncoding: base64` is supported by
diff --git a/src/converter.ts b/src/converter.ts
index 07b9bbe..8a65904 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -113,9 +113,10 @@ export class Converter {
   }
 
   /**
-   * Log an rror message to console.error stream. Prefix the message string with `Error: `
-   * if it does not already have that text. Increments the `returnCode`.
-   * @param message parameters for console.warn
+   * Log an error message to `console.error` stream. Prefix the message string with `Error: `
+   * if it does not already start with `'Error'`. Increments the `returnCode`, causing
+   * the CLI to throw an Error when done.
+   * @param message parameters for `console.error`
    */
   private error(...message) {
     if (!message[0].startsWith('Error')) {
@@ -329,8 +330,8 @@ export class Converter {
    * ```
    * format: byte
    * ```
-   * in `type: string` schemas. Warn if schema has a `format` already.
-   * and it is not `byte`
+   * in `type: string` schemas. It is an error if the schema has a `format` already
+   * and it is not `byte`.
    */
   convertJsonSchemaContentEncoding() {
     const schemaVisitor: SchemaVisitor = (schema: SchemaObject): SchemaObject => {
@@ -348,7 +349,7 @@ export class Converter {
           } else {
             delete schema['contentEncoding'];
             schema['format'] = 'byte';
-            this.log(` converted schema: contentEncoding: base64 to format: byte`);
+            this.log(`Converted schema: 'contentEncoding: base64' to 'format: byte'`);
           }
         } else {
           this.error(`Unable to down-convert contentEncoding: ${schema['contentEncoding']}`);

From 8f3fedab504032d14d28508211f4b7216e864d13 Mon Sep 17 00:00:00 2001
From: David Biesack 
Date: Fri, 19 Jan 2024 09:43:54 -0500
Subject: [PATCH 7/7] Address PR comments

---
 README.md        | 2 +-
 src/converter.ts | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index 43882b9..68f2317 100644
--- a/README.md
+++ b/README.md
@@ -440,7 +440,7 @@ format: binary
 
 ## Unsupported down conversions
 
-Curretnly, the tool does not support the following situations.
+Currently, the tool does not support the following situations.
 Contributions welcome!
 
 * `openapi-down-convert` does not convert `exclusiveMinimum` and `exclusiveMaximum`
diff --git a/src/converter.ts b/src/converter.ts
index 8a65904..959d66d 100644
--- a/src/converter.ts
+++ b/src/converter.ts
@@ -46,7 +46,7 @@ export interface ConverterOptions {
    */
   scopeDescriptionFile?: string;
   /** Earlier versions of the tool converted $comment to x-comment
-   * in JSON Schemas. The tool now deleted $comment values.
+   * in JSON Schemas. The tool now deletes $comment values by default.
    * Use this option to preserve the conversion and not delete
    * comments.
    */