From 847af29bc9102a97331ae0c6d8cc8d57fdf802d9 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Mon, 17 Jun 2024 01:50:07 +0530 Subject: [PATCH 01/15] added the conversion of openapi,info,servers,tags,externalDocs,security --- src/convert.ts | 34 ++++++++++--- src/interfaces.ts | 4 +- src/openapi.ts | 120 ++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 23 +++++---- 4 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 src/openapi.ts diff --git a/src/convert.ts b/src/convert.ts index b7d77eb6..76321703 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -3,25 +3,42 @@ import { dump } from 'js-yaml'; import { converters as firstConverters } from "./first-version"; import { converters as secondConverters } from "./second-version"; import { converters as thirdConverters } from "./third-version"; +import { converters as openapiConverters } from "./openapi"; import { serializeInput } from "./utils"; -import type { AsyncAPIDocument, ConvertVersion, ConvertOptions, ConvertFunction } from './interfaces'; +import type { AsyncAPIDocument, ConvertVersion, ConvertOptions, ConvertFunction, ConvertOpenAPIFunction, OpenAPIDocument } from './interfaces'; /** * Value for key (version) represents the function which converts specification from previous version to the given as key. */ -const converters: Record = { +const converters: Record = { ...firstConverters, ...secondConverters, ...thirdConverters, + ...openapiConverters, }; +console.log(converters) const conversionVersions = Object.keys(converters); -export function convert(asyncapi: string, version?: ConvertVersion, options?: ConvertOptions): string; -export function convert(asyncapi: AsyncAPIDocument, version?: ConvertVersion, options?: ConvertOptions): AsyncAPIDocument; -export function convert(asyncapi: string | AsyncAPIDocument, version: ConvertVersion = '2.6.0', options: ConvertOptions = {}): string | AsyncAPIDocument { - const { format, document } = serializeInput(asyncapi); +function convertOpenAPIToAsyncAPI(openapiDocument: OpenAPIDocument | AsyncAPIDocument, options: ConvertOptions): AsyncAPIDocument { + const openapiToAsyncapiConverter = converters['openapi']; + if (openapiToAsyncapiConverter) { + return openapiToAsyncapiConverter(openapiDocument as any, options) as AsyncAPIDocument; + } else { + throw new Error(`Unsupported OpenAPI version. This converter only supports OpenAPI 3.0.`); + } +} + +// export function convert(asyncapi: string, version?: ConvertVersion, options?: ConvertOptions): string; +// export function convert(asyncapi: AsyncAPIDocument, version?: ConvertVersion, options?: ConvertOptions): AsyncAPIDocument; +export function convert(input: string | AsyncAPIDocument | OpenAPIDocument, version: ConvertVersion , options: ConvertOptions= {}): string | AsyncAPIDocument | OpenAPIDocument { + const { format, document } = serializeInput(input); + + if ('openapi' in document) { + let convertedAsyncAPI = convertOpenAPIToAsyncAPI(document, options); + return format === 'yaml' ? dump(convertedAsyncAPI, { skipInvalid: true }) : convertedAsyncAPI; + } const asyncapiVersion = document.asyncapi; let fromVersion = conversionVersions.indexOf(asyncapiVersion); @@ -42,7 +59,10 @@ export function convert(asyncapi: string | AsyncAPIDocument, version: ConvertVer let converted = document; for (let i = fromVersion; i <= toVersion; i++) { const v = conversionVersions[i] as ConvertVersion; - converted = converters[v](converted, options); + const convertFunction = converters[v]; + converted = ('asyncapi' in converted) + ? (convertFunction as ConvertFunction)(converted as AsyncAPIDocument, options) + : (convertFunction as ConvertOpenAPIFunction)(converted as OpenAPIDocument, options); } if (format === 'yaml') { diff --git a/src/interfaces.ts b/src/interfaces.ts index 31aa98a3..3588488b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -2,7 +2,8 @@ * PUBLIC TYPES */ export type AsyncAPIDocument = { asyncapi: string } & Record; -export type ConvertVersion = '1.1.0' | '1.2.0' | '2.0.0-rc1' | '2.0.0-rc2' | '2.0.0' | '2.1.0' | '2.2.0' | '2.3.0' | '2.4.0' | '2.5.0' | '2.6.0' | '3.0.0'; +export type OpenAPIDocument = { openapi: string } & Record; +export type ConvertVersion = '1.1.0' | '1.2.0' | '2.0.0-rc1' | '2.0.0-rc2' | '2.0.0' | '2.1.0' | '2.2.0' | '2.3.0' | '2.4.0' | '2.5.0' | '2.6.0' | '3.0.0' | 'openapi'; export type ConvertV2ToV3Options = { idGenerator?: (data: { asyncapi: AsyncAPIDocument, kind: 'channel' | 'operation' | 'message', key: string | number | undefined, path: Array, object: any, parentId?: string }) => string, pointOfView?: 'application' | 'client', @@ -18,4 +19,5 @@ export type ConvertOptions = { * PRIVATE TYPES */ export type ConvertFunction = (asyncapi: AsyncAPIDocument, options: ConvertOptions) => AsyncAPIDocument; +export type ConvertOpenAPIFunction = (openapi: OpenAPIDocument, options: ConvertOptions) => AsyncAPIDocument; diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 00000000..ef048e3f --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,120 @@ +import { sortObjectKeys, isRefObject, isPlainObject } from "./utils"; +import { AsyncAPIDocument, ConvertOpenAPIFunction, ConvertOptions, OpenAPIDocument } from "./interfaces"; + +export const converters: Record = { + 'openapi': from_openapi_to_asyncapi, +} + +function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options: ConvertOptions): AsyncAPIDocument{ + convertName(openapi) + + convertInfoObject(openapi); + if (isPlainObject(openapi.servers)) { + openapi.servers = convertServerObjects(openapi.servers, openapi); + } + + return sortObjectKeys( + openapi, + ['asyncapi', 'info', 'defaultContentType', 'servers', 'channels', 'operations', 'components'] + ) +} + +function convertName(openapi: OpenAPIDocument): AsyncAPIDocument { + let { openapi: version } = openapi; + openapi.asyncapi = version; + delete (openapi as any).openapi;; + + return sortObjectKeys( + openapi, + ['asyncapi', 'info', 'defaultContentType', 'servers', 'channels', 'operations', 'components'] + ) +} + +function convertInfoObject(openapi: OpenAPIDocument) { + if(openapi.tags) { + openapi.info.tags = openapi.tags; + delete openapi.tags; + } + + if(openapi.externalDocs) { + openapi.info.externalDocs = openapi.externalDocs; + delete openapi.externalDocs; + } + + return (openapi.info = sortObjectKeys(openapi.info, [ + "title", + "version", + "description", + "termsOfService", + "contact", + "license", + "tags", + "externalDocs", + ])); + } + +function convertServerObjects(servers: Record, openapi: OpenAPIDocument) { + console.log("security",openapi.security) + const newServers: Record = {}; + const security: Record = openapi.security; + Object.entries(servers).forEach(([serverName, server]: [string, any]) => { + if (isRefObject(server)) { + newServers[serverName] = server; + return; + } + + const { host, pathname, protocol } = resolveServerUrl(server.url); + server.host = host; + if (pathname !== undefined) { + server.pathname = pathname; + } + + if (protocol !== undefined && server.protocol === undefined) { + server.protocol = protocol; + } + delete server.url; + + if (security) { + server.security = security; + delete openapi.security; + } + + newServers[serverName] = sortObjectKeys( + server, + ['host', 'pathname', 'protocol', 'protocolVersion', 'title', 'summary', 'description', 'variables', 'security', 'tags', 'externalDocs', 'bindings'], + ); + }); + + return newServers +} + +function resolveServerUrl(url: string): { + host: string; + pathname: string | undefined; + protocol: string | undefined; +} { + let [maybeProtocol, maybeHost] = url.split("://"); + console.log("maybeProtocol", maybeProtocol); + if (!maybeHost) { + maybeHost = maybeProtocol; + } + const [host, ...pathnames] = maybeHost.split("/"); + console.log("pathname1", pathnames); + if (pathnames.length) { + return { + host, + pathname: `/${pathnames.join("/")}`, + protocol: maybeProtocol, + }; + } + return { host, pathname: undefined, protocol: maybeProtocol }; +} + +function convertSecurity(security: Record) { + if(security.type === 'oauth2' && security.flows.authorizationCode.scopes) { + const availableScopes = security.flows.authorizationCode.scopes; + security.flows.authorizationCode.availableScopes = availableScopes; + delete security.flows.authorizationCode.scopes; + } + return security; +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index cde51507..8970ef45 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,8 @@ import { load } from 'js-yaml'; -import type { AsyncAPIDocument } from "./interfaces"; +import type { AsyncAPIDocument, OpenAPIDocument } from "./interfaces"; -export function serializeInput(document: string | AsyncAPIDocument): { format: 'json' | 'yaml', document: AsyncAPIDocument } | never { +export function serializeInput(document: string | AsyncAPIDocument | OpenAPIDocument): { format: 'json' | 'yaml', document: AsyncAPIDocument | OpenAPIDocument } | never { let triedConvertToYaml = false; try { if (typeof document === 'object') { @@ -14,10 +14,17 @@ export function serializeInput(document: string | AsyncAPIDocument): { format: ' const maybeJSON = JSON.parse(document); if (typeof maybeJSON === 'object') { - return { - format: 'json', - document: maybeJSON, - }; + if ('openapi' in maybeJSON) { + return { + format: 'json', + document: maybeJSON, + }; + } else { + return { + format: 'json', + document: maybeJSON, + }; + } } triedConvertToYaml = true; // NOSONAR @@ -25,7 +32,7 @@ export function serializeInput(document: string | AsyncAPIDocument): { format: ' // but if it's `string` then we have option that it can be YAML but it doesn't have to be return { format: 'yaml', - document: load(document) as AsyncAPIDocument, + document: load(document) as AsyncAPIDocument | OpenAPIDocument, }; } catch (e) { try { @@ -36,7 +43,7 @@ export function serializeInput(document: string | AsyncAPIDocument): { format: ' // try to parse (again) YAML, because the text itself may not have a JSON representation and cannot be represented as a JSON object/string return { format: 'yaml', - document: load(document as string) as AsyncAPIDocument, + document: load(document as string) as AsyncAPIDocument | OpenAPIDocument, }; } catch (err) { throw new Error('AsyncAPI document must be a valid JSON or YAML document.'); From e258575936157792b8eb25956d2c18ed9ba66f16 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Mon, 17 Jun 2024 02:35:11 +0530 Subject: [PATCH 02/15] added initial test case --- src/convert.ts | 6 +-- test/input/openapi/no-channel-operation.yml | 42 +++++++++++++++++++ test/openapi-to-asyncapi.spec.ts | 14 +++++++ .../no-channel-parameter.yml | 25 +++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 test/input/openapi/no-channel-operation.yml create mode 100644 test/openapi-to-asyncapi.spec.ts create mode 100644 test/output/openapi_to_asyncapi/no-channel-parameter.yml diff --git a/src/convert.ts b/src/convert.ts index 76321703..5eb275d5 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -18,7 +18,7 @@ const converters: Record = { ...thirdConverters, ...openapiConverters, }; -console.log(converters) + const conversionVersions = Object.keys(converters); function convertOpenAPIToAsyncAPI(openapiDocument: OpenAPIDocument | AsyncAPIDocument, options: ConvertOptions): AsyncAPIDocument { @@ -30,8 +30,8 @@ function convertOpenAPIToAsyncAPI(openapiDocument: OpenAPIDocument | AsyncAPIDoc } } -// export function convert(asyncapi: string, version?: ConvertVersion, options?: ConvertOptions): string; -// export function convert(asyncapi: AsyncAPIDocument, version?: ConvertVersion, options?: ConvertOptions): AsyncAPIDocument; +// export function convert(input: string, version?: ConvertVersion, options?: ConvertOptions): string; +// export function convert(input: AsyncAPIDocument, version?: ConvertVersion, options?: ConvertOptions): AsyncAPIDocument; export function convert(input: string | AsyncAPIDocument | OpenAPIDocument, version: ConvertVersion , options: ConvertOptions= {}): string | AsyncAPIDocument | OpenAPIDocument { const { format, document } = serializeInput(input); diff --git a/test/input/openapi/no-channel-operation.yml b/test/input/openapi/no-channel-operation.yml new file mode 100644 index 00000000..eb188bbe --- /dev/null +++ b/test/input/openapi/no-channel-operation.yml @@ -0,0 +1,42 @@ +openapi: '3.0.0' +info: + title: Sample Pet Store App + description: This is a sample server for a pet store. + termsOfService: http://example.com/terms/ + contact: + name: API Support + url: http://www.example.com/support + email: support@example.com + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.1 + +servers: +- url: https://{username}.gigantic-server.com:{port}/{basePath} + description: The production API server + variables: + username: + default: demo + description: this value is assigned by the service provider, in this example `gigantic-server.com` + port: + enum: + - '8443' + - '443' + default: '8443' + basePath: + default: v2 + +security: + - {} + - petstore_auth: + - write:pets + - read:pets + +tags: + name: pet + description: Pets operations + +externalDocs: + description: Find more info here + url: https://example.com diff --git a/test/openapi-to-asyncapi.spec.ts b/test/openapi-to-asyncapi.spec.ts new file mode 100644 index 00000000..deb34422 --- /dev/null +++ b/test/openapi-to-asyncapi.spec.ts @@ -0,0 +1,14 @@ +import fs from 'fs'; +import path from 'path'; + +import { convert } from '../src/convert'; +import { assertResults } from './helpers'; + +describe("convert() - openapi to asyncapi", () => { + it("should convert the basic structure of openapi to asyncapi", () => { + const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "no-channel-parameter.yml"), "utf8"); + const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "no-channel-parameter.yml"), "utf8"); + const result = convert(input, '3.0.0'); + assertResults(output, result); + }); +}); \ No newline at end of file diff --git a/test/output/openapi_to_asyncapi/no-channel-parameter.yml b/test/output/openapi_to_asyncapi/no-channel-parameter.yml new file mode 100644 index 00000000..60624d01 --- /dev/null +++ b/test/output/openapi_to_asyncapi/no-channel-parameter.yml @@ -0,0 +1,25 @@ +openapi: '3.0.0' +info: + title: Sample Pet Store App + version: 1.0.1 + description: This is a sample server for a pet store. + termsOfService: http://example.com/terms/ + contact: + name: API Support + url: http://www.example.com/support + email: support@example.com + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + tags: + name: pet + description: Pets operations + externalDocs: + description: Find more info here + url: https://example.com + +servers: + # todo + +security: + # todo From b16a8c4d29b16edade874204fab23f130407ae4e Mon Sep 17 00:00:00 2001 From: utnim2 Date: Thu, 27 Jun 2024 14:20:51 +0530 Subject: [PATCH 03/15] refactor the code to use `convertOpenAPI` --- src/convert.ts | 48 +++++++++++-------- src/openapi.ts | 1 - test/helpers.ts | 3 +- test/input/openapi/no-channel-operation.yml | 38 +++++++-------- test/openapi-to-asyncapi.spec.ts | 6 +-- .../no-channel-parameter.yml | 42 ++++++++++++++++ .../no-channel-parameter.yml | 25 ---------- 7 files changed, 93 insertions(+), 70 deletions(-) create mode 100644 test/output/openapi-to-asyncapi/no-channel-parameter.yml delete mode 100644 test/output/openapi_to_asyncapi/no-channel-parameter.yml diff --git a/src/convert.ts b/src/convert.ts index 5eb275d5..d31554ba 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -12,32 +12,21 @@ import type { AsyncAPIDocument, ConvertVersion, ConvertOptions, ConvertFunction, /** * Value for key (version) represents the function which converts specification from previous version to the given as key. */ -const converters: Record = { +const asyncAPIconverters: Record = { ...firstConverters, ...secondConverters, ...thirdConverters, - ...openapiConverters, }; -const conversionVersions = Object.keys(converters); +const conversionVersions = Object.keys(asyncAPIconverters); -function convertOpenAPIToAsyncAPI(openapiDocument: OpenAPIDocument | AsyncAPIDocument, options: ConvertOptions): AsyncAPIDocument { - const openapiToAsyncapiConverter = converters['openapi']; - if (openapiToAsyncapiConverter) { - return openapiToAsyncapiConverter(openapiDocument as any, options) as AsyncAPIDocument; - } else { - throw new Error(`Unsupported OpenAPI version. This converter only supports OpenAPI 3.0.`); - } -} - -// export function convert(input: string, version?: ConvertVersion, options?: ConvertOptions): string; -// export function convert(input: AsyncAPIDocument, version?: ConvertVersion, options?: ConvertOptions): AsyncAPIDocument; -export function convert(input: string | AsyncAPIDocument | OpenAPIDocument, version: ConvertVersion , options: ConvertOptions= {}): string | AsyncAPIDocument | OpenAPIDocument { +export function convert(input: string, version: ConvertVersion, options?: ConvertOptions): string; +export function convert(input: AsyncAPIDocument, version: ConvertVersion, options?: ConvertOptions): AsyncAPIDocument; +export function convert(input: string | AsyncAPIDocument, version: ConvertVersion , options: ConvertOptions= {}): string | AsyncAPIDocument { const { format, document } = serializeInput(input); if ('openapi' in document) { - let convertedAsyncAPI = convertOpenAPIToAsyncAPI(document, options); - return format === 'yaml' ? dump(convertedAsyncAPI, { skipInvalid: true }) : convertedAsyncAPI; + throw new Error('Cannot convert OpenAPI document. Use convertOpenAPI function instead.'); } const asyncapiVersion = document.asyncapi; @@ -59,10 +48,7 @@ export function convert(input: string | AsyncAPIDocument | OpenAPIDocument, vers let converted = document; for (let i = fromVersion; i <= toVersion; i++) { const v = conversionVersions[i] as ConvertVersion; - const convertFunction = converters[v]; - converted = ('asyncapi' in converted) - ? (convertFunction as ConvertFunction)(converted as AsyncAPIDocument, options) - : (convertFunction as ConvertOpenAPIFunction)(converted as OpenAPIDocument, options); + converted = asyncAPIconverters[v](converted, options); } if (format === 'yaml') { @@ -70,3 +56,23 @@ export function convert(input: string | AsyncAPIDocument | OpenAPIDocument, vers } return converted; } + +export function convertOpenAPI(input: string ,options?: ConvertOptions): string; +export function convertOpenAPI(input: OpenAPIDocument ,options?: ConvertOptions): AsyncAPIDocument; +export function convertOpenAPI(input: string | OpenAPIDocument,options: ConvertOptions = {}): string | AsyncAPIDocument { + + const { format, document } = serializeInput(input); + + const openapiToAsyncapiConverter = openapiConverters["openapi"] as ConvertOpenAPIFunction; + + if (!openapiToAsyncapiConverter) { + throw new Error("OpenAPI to AsyncAPI converter is not available."); + } + + const convertedAsyncAPI = openapiToAsyncapiConverter(document as OpenAPIDocument, options); + + if (format === "yaml") { + return dump(convertedAsyncAPI, { skipInvalid: true }); + } + return convertedAsyncAPI; +} diff --git a/src/openapi.ts b/src/openapi.ts index ef048e3f..6b1dd419 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -54,7 +54,6 @@ function convertInfoObject(openapi: OpenAPIDocument) { } function convertServerObjects(servers: Record, openapi: OpenAPIDocument) { - console.log("security",openapi.security) const newServers: Record = {}; const security: Record = openapi.security; Object.entries(servers).forEach(([serverName, server]: [string, any]) => { diff --git a/test/helpers.ts b/test/helpers.ts index 69e7fca6..78163312 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,9 +1,10 @@ /* It is a helper required for testing on windows. It can't be solved by editor configuration and the end line setting because expected result is converted during tests. We need to remove all line breaks from the string + as well as all multiple spaces and trim the string. */ export function removeLineBreaks(str: string) { - return str.replace(/\r?\n|\r/g, '') + return str.replace(/\r?\n|\r/g, '').replace(/\s+/g, ' ').trim(); } export function assertResults(output: string, result: string){ diff --git a/test/input/openapi/no-channel-operation.yml b/test/input/openapi/no-channel-operation.yml index eb188bbe..6ecc2e97 100644 --- a/test/input/openapi/no-channel-operation.yml +++ b/test/input/openapi/no-channel-operation.yml @@ -1,32 +1,32 @@ -openapi: '3.0.0' +openapi: 3.0.0 info: title: Sample Pet Store App description: This is a sample server for a pet store. - termsOfService: http://example.com/terms/ + termsOfService: 'http://example.com/terms/' contact: name: API Support - url: http://www.example.com/support + url: 'http://www.example.com/support' email: support@example.com license: name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' version: 1.0.1 servers: -- url: https://{username}.gigantic-server.com:{port}/{basePath} - description: The production API server - variables: - username: - default: demo - description: this value is assigned by the service provider, in this example `gigantic-server.com` - port: - enum: - - '8443' - - '443' - default: '8443' - basePath: - default: v2 - + - url: 'https://{username}.gigantic-server.com:{port}/{basePath}' + description: The production API server + variables: + username: + default: demo + description: this value is assigned by the service provider, in this example `gigantic-server.com` + port: + enum: + - '8443' + - '443' + default: '8443' + basePath: + default: v2 + security: - {} - petstore_auth: @@ -39,4 +39,4 @@ tags: externalDocs: description: Find more info here - url: https://example.com + url: 'https://example.com' diff --git a/test/openapi-to-asyncapi.spec.ts b/test/openapi-to-asyncapi.spec.ts index deb34422..463ab957 100644 --- a/test/openapi-to-asyncapi.spec.ts +++ b/test/openapi-to-asyncapi.spec.ts @@ -1,14 +1,14 @@ import fs from 'fs'; import path from 'path'; -import { convert } from '../src/convert'; +import { convertOpenAPI } from '../src/convert'; import { assertResults } from './helpers'; describe("convert() - openapi to asyncapi", () => { it("should convert the basic structure of openapi to asyncapi", () => { - const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "no-channel-parameter.yml"), "utf8"); + const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "no-channel-operation.yml"), "utf8"); const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "no-channel-parameter.yml"), "utf8"); - const result = convert(input, '3.0.0'); + const result = convertOpenAPI(input); assertResults(output, result); }); }); \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/no-channel-parameter.yml b/test/output/openapi-to-asyncapi/no-channel-parameter.yml new file mode 100644 index 00000000..8b4f905b --- /dev/null +++ b/test/output/openapi-to-asyncapi/no-channel-parameter.yml @@ -0,0 +1,42 @@ +asyncapi: 3.0.0 +info: + title: Sample Pet Store App + version: 1.0.1 + description: This is a sample server for a pet store. + termsOfService: 'http://example.com/terms/' + contact: + name: API Support + url: 'http://www.example.com/support' + email: support@example.com + license: + name: Apache 2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' + tags: + name: pet + description: Pets operations + externalDocs: + description: Find more info here + url: 'https://example.com' + +servers: + - url: 'https://{username}.gigantic-server.com:{port}/{basePath}' + description: The production API server + variables: + username: + default: demo + description: >- + this value is assigned by the service provider, in this example + `gigantic-server.com` + port: + enum: + - '8443' + - '443' + default: '8443' + basePath: + default: v2 + +security: + - {} + - petstore_auth: + - 'write:pets' + - 'read:pets' diff --git a/test/output/openapi_to_asyncapi/no-channel-parameter.yml b/test/output/openapi_to_asyncapi/no-channel-parameter.yml deleted file mode 100644 index 60624d01..00000000 --- a/test/output/openapi_to_asyncapi/no-channel-parameter.yml +++ /dev/null @@ -1,25 +0,0 @@ -openapi: '3.0.0' -info: - title: Sample Pet Store App - version: 1.0.1 - description: This is a sample server for a pet store. - termsOfService: http://example.com/terms/ - contact: - name: API Support - url: http://www.example.com/support - email: support@example.com - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html - tags: - name: pet - description: Pets operations - externalDocs: - description: Find more info here - url: https://example.com - -servers: - # todo - -security: - # todo From fcd73bd08bd0fe0b333f8850c4e0116908622867 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Mon, 1 Jul 2024 14:41:16 +0530 Subject: [PATCH 04/15] refactor to use empty asyncAPI object and to seperate version of asyncapi and openapi --- src/convert.ts | 12 ++--- src/interfaces.ts | 4 +- src/openapi.ts | 114 +++++++++++++++++++++++----------------------- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/src/convert.ts b/src/convert.ts index d31554ba..ab45e156 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -7,7 +7,7 @@ import { converters as openapiConverters } from "./openapi"; import { serializeInput } from "./utils"; -import type { AsyncAPIDocument, ConvertVersion, ConvertOptions, ConvertFunction, ConvertOpenAPIFunction, OpenAPIDocument } from './interfaces'; +import type { AsyncAPIDocument, AsyncAPIConvertVersion, OpenAPIConvertVersion, ConvertOptions, ConvertFunction, ConvertOpenAPIFunction, OpenAPIDocument } from './interfaces'; /** * Value for key (version) represents the function which converts specification from previous version to the given as key. @@ -20,9 +20,9 @@ const asyncAPIconverters: Record = { const conversionVersions = Object.keys(asyncAPIconverters); -export function convert(input: string, version: ConvertVersion, options?: ConvertOptions): string; -export function convert(input: AsyncAPIDocument, version: ConvertVersion, options?: ConvertOptions): AsyncAPIDocument; -export function convert(input: string | AsyncAPIDocument, version: ConvertVersion , options: ConvertOptions= {}): string | AsyncAPIDocument { +export function convert(input: string, version: AsyncAPIConvertVersion, options?: ConvertOptions): string; +export function convert(input: AsyncAPIDocument, version: AsyncAPIConvertVersion, options?: ConvertOptions): AsyncAPIDocument; +export function convert(input: string | AsyncAPIDocument, version: AsyncAPIConvertVersion , options: ConvertOptions= {}): string | AsyncAPIDocument { const { format, document } = serializeInput(input); if ('openapi' in document) { @@ -47,7 +47,7 @@ export function convert(input: string | AsyncAPIDocument, version: ConvertVersio fromVersion++; let converted = document; for (let i = fromVersion; i <= toVersion; i++) { - const v = conversionVersions[i] as ConvertVersion; + const v = conversionVersions[i] as AsyncAPIConvertVersion; converted = asyncAPIconverters[v](converted, options); } @@ -63,7 +63,7 @@ export function convertOpenAPI(input: string | OpenAPIDocument,options: ConvertO const { format, document } = serializeInput(input); - const openapiToAsyncapiConverter = openapiConverters["openapi"] as ConvertOpenAPIFunction; + const openapiToAsyncapiConverter = openapiConverters["openapi" as OpenAPIConvertVersion] as ConvertOpenAPIFunction; if (!openapiToAsyncapiConverter) { throw new Error("OpenAPI to AsyncAPI converter is not available."); diff --git a/src/interfaces.ts b/src/interfaces.ts index 3588488b..eef7aa47 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -3,7 +3,9 @@ */ export type AsyncAPIDocument = { asyncapi: string } & Record; export type OpenAPIDocument = { openapi: string } & Record; -export type ConvertVersion = '1.1.0' | '1.2.0' | '2.0.0-rc1' | '2.0.0-rc2' | '2.0.0' | '2.1.0' | '2.2.0' | '2.3.0' | '2.4.0' | '2.5.0' | '2.6.0' | '3.0.0' | 'openapi'; +export type AsyncAPIConvertVersion = '1.1.0' | '1.2.0' | '2.0.0-rc1' | '2.0.0-rc2' | '2.0.0' | '2.1.0' | '2.2.0' | '2.3.0' | '2.4.0' | '2.5.0' | '2.6.0' | '3.0.0'; +// for now it is hardcoded to 'openapi' but in the future it can be extended to support multiple versions +export type OpenAPIConvertVersion = 'openapi'; export type ConvertV2ToV3Options = { idGenerator?: (data: { asyncapi: AsyncAPIDocument, kind: 'channel' | 'operation' | 'message', key: string | number | undefined, path: Array, object: any, parentId?: string }) => string, pointOfView?: 'application' | 'client', diff --git a/src/openapi.ts b/src/openapi.ts index 6b1dd419..ab12556f 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -5,43 +5,25 @@ export const converters: Record = { 'openapi': from_openapi_to_asyncapi, } -function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options: ConvertOptions): AsyncAPIDocument{ - convertName(openapi) +function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options?: ConvertOptions): AsyncAPIDocument { + const asyncapi: Partial = { + asyncapi: openapi.openapi, + info: convertInfoObject(openapi.info, openapi), + servers: isPlainObject(openapi.servers[0]) ? convertServerObjects(openapi.servers, openapi) : undefined, + }; - convertInfoObject(openapi); - if (isPlainObject(openapi.servers)) { - openapi.servers = convertServerObjects(openapi.servers, openapi); - } - - return sortObjectKeys( - openapi, - ['asyncapi', 'info', 'defaultContentType', 'servers', 'channels', 'operations', 'components'] - ) -} - -function convertName(openapi: OpenAPIDocument): AsyncAPIDocument { - let { openapi: version } = openapi; - openapi.asyncapi = version; - delete (openapi as any).openapi;; - - return sortObjectKeys( - openapi, - ['asyncapi', 'info', 'defaultContentType', 'servers', 'channels', 'operations', 'components'] - ) + return sortObjectKeys( + asyncapi as AsyncAPIDocument, + ['asyncapi', 'info', 'defaultContentType', 'servers', 'channels', 'operations', 'components'] + ); } -function convertInfoObject(openapi: OpenAPIDocument) { - if(openapi.tags) { - openapi.info.tags = openapi.tags; - delete openapi.tags; - } - - if(openapi.externalDocs) { - openapi.info.externalDocs = openapi.externalDocs; - delete openapi.externalDocs; - } - - return (openapi.info = sortObjectKeys(openapi.info, [ +function convertInfoObject(info: OpenAPIDocument['info'], openapi: OpenAPIDocument): AsyncAPIDocument['info'] { + return sortObjectKeys({ + ...info, + tags: openapi.tags || undefined, + externalDocs: openapi.externalDocs || undefined, + }, [ "title", "version", "description", @@ -50,10 +32,10 @@ function convertInfoObject(openapi: OpenAPIDocument) { "license", "tags", "externalDocs", - ])); - } + ]); +} -function convertServerObjects(servers: Record, openapi: OpenAPIDocument) { +function convertServerObjects(servers: Record, openapi: OpenAPIDocument): AsyncAPIDocument['servers'] { const newServers: Record = {}; const security: Record = openapi.security; Object.entries(servers).forEach(([serverName, server]: [string, any]) => { @@ -77,43 +59,59 @@ function convertServerObjects(servers: Record, openapi: OpenAPIDocu server.security = security; delete openapi.security; } - + newServers[serverName] = sortObjectKeys( server, ['host', 'pathname', 'protocol', 'protocolVersion', 'title', 'summary', 'description', 'variables', 'security', 'tags', 'externalDocs', 'bindings'], ); }); - - return newServers + + return newServers; } function resolveServerUrl(url: string): { host: string; - pathname: string | undefined; - protocol: string | undefined; + pathname?: string; + protocol: string; } { let [maybeProtocol, maybeHost] = url.split("://"); - console.log("maybeProtocol", maybeProtocol); + console.log("maybeProtocol", maybeProtocol, "maybeshost:", maybeHost) if (!maybeHost) { - maybeHost = maybeProtocol; + maybeHost = maybeProtocol; } const [host, ...pathnames] = maybeHost.split("/"); - console.log("pathname1", pathnames); + console.log("host", host, "pathnames", pathnames) + console.log(`/${pathnames.join("/")}`) if (pathnames.length) { - return { - host, - pathname: `/${pathnames.join("/")}`, - protocol: maybeProtocol, - }; + return { + host, + pathname: `/${pathnames.join("/")}`, + protocol: maybeProtocol, + }; } - return { host, pathname: undefined, protocol: maybeProtocol }; + return { host, pathname: undefined , protocol: maybeProtocol }; } -function convertSecurity(security: Record) { - if(security.type === 'oauth2' && security.flows.authorizationCode.scopes) { - const availableScopes = security.flows.authorizationCode.scopes; - security.flows.authorizationCode.availableScopes = availableScopes; - delete security.flows.authorizationCode.scopes; - } - return security; +function convertSecurity(security: Record): Record { + return security.map((securityRequirement: Record) => { + const newSecurityRequirement: Record = {}; + Object.entries(securityRequirement).forEach(([key, value]) => { + if (value.type === 'oauth2' && value.flows.authorizationCode?.scopes) { + newSecurityRequirement[key] = { + ...value, + flows: { + ...value.flows, + authorizationCode: { + ...value.flows.authorizationCode, + availableScopes: value.flows.authorizationCode.scopes, + }, + }, + }; + delete newSecurityRequirement[key].flows.authorizationCode.scopes; + } else { + newSecurityRequirement[key] = value; + } + }); + return newSecurityRequirement; + }); } \ No newline at end of file From 14ba85e650e35ba9724ebbdf357291e2bcc14016 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Thu, 4 Jul 2024 16:52:34 +0530 Subject: [PATCH 05/15] converted skeleton of path operation --- src/openapi.ts | 152 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 3 deletions(-) diff --git a/src/openapi.ts b/src/openapi.ts index ab12556f..c6f0ceb3 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -9,7 +9,9 @@ function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options?: ConvertOpt const asyncapi: Partial = { asyncapi: openapi.openapi, info: convertInfoObject(openapi.info, openapi), - servers: isPlainObject(openapi.servers[0]) ? convertServerObjects(openapi.servers, openapi) : undefined, + servers: isPlainObject(openapi.servers) ? convertServerObjects(openapi.servers, openapi) : undefined, + channels: convertPathsToChannels(openapi.paths), + operations: convertPathsToOperations(openapi.paths, 'server'), }; return sortObjectKeys( @@ -56,7 +58,7 @@ function convertServerObjects(servers: Record, openapi: OpenAPIDocu delete server.url; if (security) { - server.security = security; + server.security = convertSecurity(security); delete openapi.security; } @@ -114,4 +116,148 @@ function convertSecurity(security: Record): Record { }); return newSecurityRequirement; }); -} \ No newline at end of file +} + +function convertPathsToChannels(paths: OpenAPIDocument['paths']): AsyncAPIDocument['channels'] { + const channels: AsyncAPIDocument['channels'] = {}; + + Object.entries(paths).forEach(([path, pathItem]:[any,any]) => { + const channelName = path.replace(/^\//, '').replace(/\//g, '_'); + channels[channelName] = { + address: path, + messages: {}, + parameters: {} + }; + + Object.entries(pathItem).forEach(([method, operation]:[any,any]) => { + if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { + const parameters = convertParameters(pathItem[method].parameters) + channels[channelName].parameters = { ...channels[channelName].parameters, ...parameters }; + + if (operation.responses) { + Object.entries(operation.responses).forEach(([statusCode, response]:[any,any]) => { + const messageName = `${operation.operationId || method}Response${statusCode}`; + channels[channelName].messages[messageName] = { + name: messageName, + payload: response.content?.['application/json']?.schema || {}, + bindings: getMessageBindings(statusCode, parameters.headers) + }; + } + ); + } + } + }); + }); + + return channels; +} +function convertPathsToOperations(paths: OpenAPIDocument['paths'], pointOfView: 'server' | 'client'): AsyncAPIDocument['operations'] { + const operations: AsyncAPIDocument['operations'] = {}; + + Object.entries(paths).forEach(([path, pathItem]:[any,any]) => { + const channelName = path.replace(/^\//, '').replace(/\//g, '_'); + + Object.entries(pathItem).forEach(([method, operation]:[any,any]) => { + if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { + const operationId = operation.operationId || `${method}${channelName}`; + const parameters = convertParameters(pathItem[method].parameters); + operations[operationId] = { + action: pointOfView === 'server' ? 'receive' : 'send', + channel: { + $ref: `#/channels/${channelName}` + }, + summary: operation.summary, + description: operation.description, + tags: operation.tags, + bindings: getOperationBindings(method, parameters?.query) + }; + } + }); + }); + + return operations; +} + +function getOperationBindings(method: string, queryParameters?: Record): Record { + const bindings: Record = { + http: { + bindingVersion: "0.3.0", + }, + }; + + if (method) { + bindings.http.method = method.toUpperCase(); + } + + if (queryParameters && Object.keys(queryParameters).length > 0) { + bindings.http.query = {...queryParameters}; + } + + return bindings; +} + +function getMessageBindings(statusCode?: string, headers?: Record): Record { + const bindings: Record = { + http: { + bindingVersion: "0.3.0", + }, + }; + + if (statusCode) { + bindings.http.statusCode = parseInt(statusCode); + } + + if (headers && Object.keys(headers).length > 0) { + bindings.http.headers = {...headers}; + } + + return bindings; +} + +function getChannelBindings(method: string, header?: Record, query?: Record): Record { + const bindings: Record = { + ws: { + bindingVersion: "0.1.0", + }, + }; + + if (method) { + bindings.http.method = method.toUpperCase(); + } + + if (query && Object.keys(query).length > 0) { + bindings.ws.query = {...query}; + } + + if (header && Object.keys(header).length > 0) { + bindings.ws.header = {...header}; + } + + return bindings; +} + +function convertParameters(parameters: any[]): Record { + const convertedParams: Record = {}; + + if (Array.isArray(parameters)) { + parameters.forEach((param) => { + const paramObj: Record = { + ...(param.description !== undefined && { description: param.description }), + }; + + switch (param.in) { + case 'query': + paramObj.query = param.schema; + break; + case 'header': + paramObj.headers = param.schema; + break; + case 'cookie': + throw new Error('Cookie parameters are not supported in asyncapi'); + } + + Object.assign(convertedParams, paramObj); + }); + } + return convertedParams; +} From bcfa32bb2bb1cd053b381691ea4c9e2de612b47a Mon Sep 17 00:00:00 2001 From: utnim2 Date: Tue, 9 Jul 2024 01:13:25 +0530 Subject: [PATCH 06/15] added components and requestbody logic --- src/openapi.ts | 97 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/src/openapi.ts b/src/openapi.ts index c6f0ceb3..7c8f629d 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -12,6 +12,7 @@ function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options?: ConvertOpt servers: isPlainObject(openapi.servers) ? convertServerObjects(openapi.servers, openapi) : undefined, channels: convertPathsToChannels(openapi.paths), operations: convertPathsToOperations(openapi.paths, 'server'), + components: convertComponents(openapi.components), }; return sortObjectKeys( @@ -125,32 +126,71 @@ function convertPathsToChannels(paths: OpenAPIDocument['paths']): AsyncAPIDocume const channelName = path.replace(/^\//, '').replace(/\//g, '_'); channels[channelName] = { address: path, - messages: {}, - parameters: {} + messages: {} }; Object.entries(pathItem).forEach(([method, operation]:[any,any]) => { if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { - const parameters = convertParameters(pathItem[method].parameters) - channels[channelName].parameters = { ...channels[channelName].parameters, ...parameters }; + const parameters = convertParameters(pathItem[method].parameters); + if(channels[channelName].parameters) { + channels[channelName].parameters = parameters; + } - if (operation.responses) { - Object.entries(operation.responses).forEach(([statusCode, response]:[any,any]) => { - const messageName = `${operation.operationId || method}Response${statusCode}`; + if(isPlainObject(operation.requestBody)) { + const contentTypes = Object.keys(operation.requestBody.content || {}); + contentTypes.forEach((contentType) => { + const messageName = `${operation.operationId || method}Request_${contentType.replace('/', '_')}`; channels[channelName].messages[messageName] = { name: messageName, - payload: response.content?.['application/json']?.schema || {}, - bindings: getMessageBindings(statusCode, parameters.headers) + summary: operation.summary, + description: operation.description, + tags: operation.tags, + externalDocs: operation.externalDocs, + payload: operation.requestBody.content[contentType]?.schema || {}, + bindings: getMessageBindings(undefined, parameters.headers), + contentType: contentType, }; + }) + } + + if (isPlainObject(operation.responses)) { + Object.entries(operation.responses).forEach(([statusCode, response]:[string,any]) => { + const contentTypes = Object.keys(response.content || {}); + if (contentTypes.length === 0) { + const messageName = `${operation.operationId || method}Response${statusCode}`; + channels[channelName].messages[messageName] = { + name: messageName, + description: response.description, + bindings: getMessageBindings(statusCode, parameters?.headers) + }; + } else { + contentTypes.forEach((contentType) => { + const messageName = `${operation.operationId || method}Response${statusCode}_${contentType.replace('/', '_')}`; + channels[channelName].messages[messageName] = { + name: messageName, + summary: operation.summary, + description: response.description, + tags: operation.tags, + externalDocs: operation.externalDocs, + payload: response.content[contentType]?.schema || {}, + bindings: getMessageBindings(statusCode, parameters.headers), + contentType: contentType, + }; + }); + } } ); + } + delete channels[channelName].parameters?.headers; + delete channels[channelName].parameters?.query; } }); }); return channels; } + function convertPathsToOperations(paths: OpenAPIDocument['paths'], pointOfView: 'server' | 'client'): AsyncAPIDocument['operations'] { const operations: AsyncAPIDocument['operations'] = {}; @@ -171,12 +211,13 @@ function convertPathsToOperations(paths: OpenAPIDocument['paths'], pointOfView: tags: operation.tags, bindings: getOperationBindings(method, parameters?.query) }; + } }); }); return operations; -} +}; function getOperationBindings(method: string, queryParameters?: Record): Record { const bindings: Record = { @@ -237,27 +278,49 @@ function getChannelBindings(method: string, header?: Record, query? } function convertParameters(parameters: any[]): Record { - const convertedParams: Record = {}; + const convertedParams: Record = { + query: {}, + headers: {}, + }; if (Array.isArray(parameters)) { parameters.forEach((param) => { - const paramObj: Record = { - ...(param.description !== undefined && { description: param.description }), - }; + + convertedParams[param.name] = {} + convertedParams[param.name].description = param.description; switch (param.in) { case 'query': - paramObj.query = param.schema; + convertedParams.query[param.name] = param.schema; break; case 'header': - paramObj.headers = param.schema; + convertedParams.headers[param.name] = param.schema; break; case 'cookie': throw new Error('Cookie parameters are not supported in asyncapi'); } - Object.assign(convertedParams, paramObj); }); } return convertedParams; } + +function convertComponents(components: OpenAPIDocument['components']): AsyncAPIDocument['components'] { + if (!isPlainObject(components)) { + return; + } + + const asyncComponents: AsyncAPIDocument['components'] = {}; + + if (isPlainObject(components.schemas)) { + asyncComponents.schemas = components.schemas; + } + if (isPlainObject(components.securitySchemes)) { + asyncComponents.securitySchemes = components.securitySchemes; + } + if (isPlainObject(components.parameters)) { + asyncComponents.parameters = components.parameters; + } + + return asyncComponents; +} \ No newline at end of file From bdbac79e2dd79c239b1bbb45364ea958d67899ad Mon Sep 17 00:00:00 2001 From: utnim2 Date: Sun, 21 Jul 2024 10:57:16 +0530 Subject: [PATCH 07/15] updated the test and made some changes --- src/index.ts | 2 +- src/openapi.ts | 33 ++++++++++++------- src/utils.ts | 14 ++++++++ test/input/openapi/no-channel-operation.yml | 7 ---- .../no-channel-parameter.yml | 18 ++++------ 5 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5520a774..808abac0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export { convert } from './convert'; -export type { AsyncAPIDocument, ConvertVersion, ConvertOptions } from './interfaces'; +export type { AsyncAPIDocument, AsyncAPIConvertVersion, OpenAPIConvertVersion, ConvertOptions } from './interfaces'; diff --git a/src/openapi.ts b/src/openapi.ts index 7c8f629d..b7437a63 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,4 +1,4 @@ -import { sortObjectKeys, isRefObject, isPlainObject } from "./utils"; +import { sortObjectKeys, isRefObject, isPlainObject, removeEmptyObjects } from "./utils"; import { AsyncAPIDocument, ConvertOpenAPIFunction, ConvertOptions, OpenAPIDocument } from "./interfaces"; export const converters: Record = { @@ -7,14 +7,16 @@ export const converters: Record = { function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options?: ConvertOptions): AsyncAPIDocument { const asyncapi: Partial = { - asyncapi: openapi.openapi, + asyncapi: '3.0.0', info: convertInfoObject(openapi.info, openapi), - servers: isPlainObject(openapi.servers) ? convertServerObjects(openapi.servers, openapi) : undefined, - channels: convertPathsToChannels(openapi.paths), - operations: convertPathsToOperations(openapi.paths, 'server'), - components: convertComponents(openapi.components), + servers: convertServerObjects(openapi.servers, openapi), + channels: isPlainObject(openapi.paths) ? convertPathsToChannels(openapi.paths) : undefined, + operations: isPlainObject(openapi.paths) ? convertPathsToOperations(openapi.paths, 'server') : undefined, + components: isPlainObject(openapi.components) ? convertComponents(openapi.components): undefined, }; + removeEmptyObjects(asyncapi); + return sortObjectKeys( asyncapi as AsyncAPIDocument, ['asyncapi', 'info', 'defaultContentType', 'servers', 'channels', 'operations', 'components'] @@ -24,8 +26,8 @@ function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options?: ConvertOpt function convertInfoObject(info: OpenAPIDocument['info'], openapi: OpenAPIDocument): AsyncAPIDocument['info'] { return sortObjectKeys({ ...info, - tags: openapi.tags || undefined, - externalDocs: openapi.externalDocs || undefined, + tags: [openapi.tags], + externalDocs: openapi.externalDocs, }, [ "title", "version", @@ -41,7 +43,9 @@ function convertInfoObject(info: OpenAPIDocument['info'], openapi: OpenAPIDocume function convertServerObjects(servers: Record, openapi: OpenAPIDocument): AsyncAPIDocument['servers'] { const newServers: Record = {}; const security: Record = openapi.security; - Object.entries(servers).forEach(([serverName, server]: [string, any]) => { + Object.entries(servers).forEach(([index, server]: [string, any]) => { + + const serverName = generateServerName(server.url); if (isRefObject(server)) { newServers[serverName] = server; return; @@ -72,19 +76,24 @@ function convertServerObjects(servers: Record, openapi: OpenAPIDocu return newServers; } +function generateServerName(url: string): string { + const { host, pathname } = resolveServerUrl(url); + const baseName = host.split('.').slice(-2).join('.'); + const pathSegment = pathname ? pathname.split('/')[1] : ''; + return `${baseName}${pathSegment ? `_${pathSegment}` : ''}`.replace(/[^a-zA-Z0-9_]/g, '_'); +} + function resolveServerUrl(url: string): { host: string; pathname?: string; protocol: string; } { let [maybeProtocol, maybeHost] = url.split("://"); - console.log("maybeProtocol", maybeProtocol, "maybeshost:", maybeHost) if (!maybeHost) { maybeHost = maybeProtocol; } const [host, ...pathnames] = maybeHost.split("/"); - console.log("host", host, "pathnames", pathnames) - console.log(`/${pathnames.join("/")}`) + if (pathnames.length) { return { host, diff --git a/src/utils.ts b/src/utils.ts index 8970ef45..7ef17ae3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -141,3 +141,17 @@ function untilde(str: string) { return sub; }); } + +export function removeEmptyObjects(obj: Record): Record { + Object.keys(obj).forEach(key => { + if (obj[key] && typeof obj[key] === 'object') { + removeEmptyObjects(obj[key]); + if (Object.keys(obj[key]).length === 0) { + delete obj[key]; + } + } else if (obj[key] === undefined) { + delete obj[key]; + } + }); + return obj; +} \ No newline at end of file diff --git a/test/input/openapi/no-channel-operation.yml b/test/input/openapi/no-channel-operation.yml index 6ecc2e97..0f0c4e30 100644 --- a/test/input/openapi/no-channel-operation.yml +++ b/test/input/openapi/no-channel-operation.yml @@ -26,13 +26,6 @@ servers: default: '8443' basePath: default: v2 - -security: - - {} - - petstore_auth: - - write:pets - - read:pets - tags: name: pet description: Pets operations diff --git a/test/output/openapi-to-asyncapi/no-channel-parameter.yml b/test/output/openapi-to-asyncapi/no-channel-parameter.yml index 8b4f905b..1a96efd1 100644 --- a/test/output/openapi-to-asyncapi/no-channel-parameter.yml +++ b/test/output/openapi-to-asyncapi/no-channel-parameter.yml @@ -1,5 +1,5 @@ asyncapi: 3.0.0 -info: +info: title: Sample Pet Store App version: 1.0.1 description: This is a sample server for a pet store. @@ -12,14 +12,16 @@ info: name: Apache 2.0 url: 'https://www.apache.org/licenses/LICENSE-2.0.html' tags: - name: pet - description: Pets operations + - name: pet + description: Pets operations externalDocs: description: Find more info here url: 'https://example.com' - servers: - - url: 'https://{username}.gigantic-server.com:{port}/{basePath}' + gigantic_server_com__port___basePath_: + host: '{username}.gigantic-server.com:{port}' + pathname: '/{basePath}' + protocol: https description: The production API server variables: username: @@ -34,9 +36,3 @@ servers: default: '8443' basePath: default: v2 - -security: - - {} - - petstore_auth: - - 'write:pets' - - 'read:pets' From eb5c980c38dddefefb132a81b3054e78d17546bd Mon Sep 17 00:00:00 2001 From: utnim2 Date: Sat, 27 Jul 2024 11:42:30 +0530 Subject: [PATCH 08/15] refactored the entire `openapi` code --- src/openapi.ts | 556 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 377 insertions(+), 179 deletions(-) diff --git a/src/openapi.ts b/src/openapi.ts index b7437a63..d26019aa 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,20 +1,25 @@ -import { sortObjectKeys, isRefObject, isPlainObject, removeEmptyObjects } from "./utils"; +import { sortObjectKeys, isRefObject, isPlainObject, removeEmptyObjects, createRefObject } from "./utils"; import { AsyncAPIDocument, ConvertOpenAPIFunction, ConvertOptions, OpenAPIDocument } from "./interfaces"; export const converters: Record = { 'openapi': from_openapi_to_asyncapi, } -function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options?: ConvertOptions): AsyncAPIDocument { +function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options: ConvertOptions={}): AsyncAPIDocument { + const perspective = options.v2tov3?.pointOfView === 'client' ? 'client' : 'server'; const asyncapi: Partial = { asyncapi: '3.0.0', info: convertInfoObject(openapi.info, openapi), - servers: convertServerObjects(openapi.servers, openapi), - channels: isPlainObject(openapi.paths) ? convertPathsToChannels(openapi.paths) : undefined, - operations: isPlainObject(openapi.paths) ? convertPathsToOperations(openapi.paths, 'server') : undefined, - components: isPlainObject(openapi.components) ? convertComponents(openapi.components): undefined, + servers: openapi.servers ? convertServerObjects(openapi.servers, openapi) : undefined, + channels: {}, + operations: {}, + components: convertComponents(openapi) }; + const { channels, operations } = convertPaths(openapi.paths, perspective); + asyncapi.channels = channels; + asyncapi.operations = operations; + removeEmptyObjects(asyncapi); return sortObjectKeys( @@ -23,7 +28,27 @@ function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options?: ConvertOpt ); } -function convertInfoObject(info: OpenAPIDocument['info'], openapi: OpenAPIDocument): AsyncAPIDocument['info'] { +interface InfoObject { + title: string; + version: string; + description?: string; + termsOfService?: string; + contact?: ContactObject; + license?: LicenseObject; +} + +interface ContactObject { + name?: string; + url?: string; + email?: string; +} + +interface LicenseObject { + name: string; + url?: string; +} + +function convertInfoObject(info: InfoObject, openapi: OpenAPIDocument): AsyncAPIDocument['info'] { return sortObjectKeys({ ...info, tags: [openapi.tags], @@ -40,7 +65,20 @@ function convertInfoObject(info: OpenAPIDocument['info'], openapi: OpenAPIDocume ]); } -function convertServerObjects(servers: Record, openapi: OpenAPIDocument): AsyncAPIDocument['servers'] { +interface ServerObject { + url: string; + description?: string; + variables?: Record; +} + +interface ServerVariableObject { + enum?: string[]; + default: string; + description?: string; +} + + +function convertServerObjects(servers: ServerVariableObject[], openapi: OpenAPIDocument): AsyncAPIDocument['servers'] { const newServers: Record = {}; const security: Record = openapi.security; Object.entries(servers).forEach(([index, server]: [string, any]) => { @@ -63,7 +101,10 @@ function convertServerObjects(servers: Record, openapi: OpenAPIDocu delete server.url; if (security) { - server.security = convertSecurity(security); + server.security = security.map((securityRequirement: Record) => { + // pass through the security requirement, conversion will happen in components + return securityRequirement; + }); delete openapi.security; } @@ -104,232 +145,389 @@ function resolveServerUrl(url: string): { return { host, pathname: undefined , protocol: maybeProtocol }; } -function convertSecurity(security: Record): Record { - return security.map((securityRequirement: Record) => { - const newSecurityRequirement: Record = {}; - Object.entries(securityRequirement).forEach(([key, value]) => { - if (value.type === 'oauth2' && value.flows.authorizationCode?.scopes) { - newSecurityRequirement[key] = { - ...value, - flows: { - ...value.flows, - authorizationCode: { - ...value.flows.authorizationCode, - availableScopes: value.flows.authorizationCode.scopes, - }, - }, - }; - delete newSecurityRequirement[key].flows.authorizationCode.scopes; - } else { - newSecurityRequirement[key] = value; - } - }); - return newSecurityRequirement; - }); -} - -function convertPathsToChannels(paths: OpenAPIDocument['paths']): AsyncAPIDocument['channels'] { +function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | 'server'): { + channels: AsyncAPIDocument['channels'], + operations: AsyncAPIDocument['operations'] +} { const channels: AsyncAPIDocument['channels'] = {}; + const operations: AsyncAPIDocument['operations'] = {}; - Object.entries(paths).forEach(([path, pathItem]:[any,any]) => { - const channelName = path.replace(/^\//, '').replace(/\//g, '_'); + for (const [path, pathItemOrRef] of Object.entries(paths)) { + if (!isPlainObject(pathItemOrRef)) continue; + + const pathItem = isRefObject(pathItemOrRef) ? pathItemOrRef : pathItemOrRef as any; + const channelName = path.replace(/^\//, '').replace(/\//g, '_') || 'root'; channels[channelName] = { address: path, - messages: {} + messages: {}, + parameters: convertPathParameters(pathItem.parameters) }; - Object.entries(pathItem).forEach(([method, operation]:[any,any]) => { - if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { - const parameters = convertParameters(pathItem[method].parameters); - if(channels[channelName].parameters) { - channels[channelName].parameters = parameters; + for (const [method, operation] of Object.entries(pathItem)) { + if (['get', 'post', 'put', 'delete', 'patch'].includes(method) && isPlainObject(operation)) { + const operationObject = operation as any; + const operationId = operationObject.operationId || `${method}${channelName}`; + + // Convert request body to message + if (operationObject.requestBody) { + const requestMessages = convertRequestBodyToMessages(operationObject.requestBody, operationId, method); + Object.assign(channels[channelName].messages, requestMessages); } - if(isPlainObject(operation.requestBody)) { - const contentTypes = Object.keys(operation.requestBody.content || {}); - contentTypes.forEach((contentType) => { - const messageName = `${operation.operationId || method}Request_${contentType.replace('/', '_')}`; - channels[channelName].messages[messageName] = { - name: messageName, - summary: operation.summary, - description: operation.description, - tags: operation.tags, - externalDocs: operation.externalDocs, - payload: operation.requestBody.content[contentType]?.schema || {}, - bindings: getMessageBindings(undefined, parameters.headers), - contentType: contentType, - }; - }) + // Convert responses to messages + if (operationObject.responses) { + const responseMessages = convertResponsesToMessages(operationObject.responses, operationId, method); + Object.assign(channels[channelName].messages, responseMessages); } - if (isPlainObject(operation.responses)) { - Object.entries(operation.responses).forEach(([statusCode, response]:[string,any]) => { - const contentTypes = Object.keys(response.content || {}); - if (contentTypes.length === 0) { - const messageName = `${operation.operationId || method}Response${statusCode}`; - channels[channelName].messages[messageName] = { - name: messageName, - description: response.description, - bindings: getMessageBindings(statusCode, parameters?.headers) - }; - } else { - contentTypes.forEach((contentType) => { - const messageName = `${operation.operationId || method}Response${statusCode}_${contentType.replace('/', '_')}`; - channels[channelName].messages[messageName] = { - name: messageName, - summary: operation.summary, - description: response.description, - tags: operation.tags, - externalDocs: operation.externalDocs, - payload: response.content[contentType]?.schema || {}, - bindings: getMessageBindings(statusCode, parameters.headers), - contentType: contentType, - }; - }); + // Create operation + operations[operationId] = { + action: perspective === 'client' ? 'send' : 'receive', + channel: createRefObject('channels', channelName), + summary: operationObject.summary, + description: operationObject.description, + tags: operationObject.tags?.map((tag: string) => ({ name: tag })), + bindings: { + http: { + method: method.toUpperCase(), } - } - ); + }, + messages: Object.keys(channels[channelName].messages) + .filter(messageName => messageName.startsWith(operationId)) + .map(messageName => createRefObject('channels', channelName, 'messages', messageName)) + }; + // Convert parameters + if (operationObject.parameters) { + const params = convertOperationParameters(operationObject.parameters); + if (Object.keys(params).length > 0) { + channels[channelName].parameters = { + ...channels[channelName].parameters, + ...params + }; + } } - delete channels[channelName].parameters?.headers; - delete channels[channelName].parameters?.query; } - }); - }); + } + + removeEmptyObjects(channels[channelName]); + } - return channels; + return { channels, operations }; } -function convertPathsToOperations(paths: OpenAPIDocument['paths'], pointOfView: 'server' | 'client'): AsyncAPIDocument['operations'] { - const operations: AsyncAPIDocument['operations'] = {}; +function convertPathParameters(parameters: any[] = []): Record { + const convertedParams: Record = {}; + + parameters.forEach(param => { + if (param.in === 'path') { + convertedParams[param.name] = { + description: param.description, + schema: convertSchema(param.schema) + }; + } + }); - Object.entries(paths).forEach(([path, pathItem]:[any,any]) => { - const channelName = path.replace(/^\//, '').replace(/\//g, '_'); + return convertedParams; +} - Object.entries(pathItem).forEach(([method, operation]:[any,any]) => { - if (['get', 'post', 'put', 'delete', 'patch'].includes(method)) { - const operationId = operation.operationId || `${method}${channelName}`; - const parameters = convertParameters(pathItem[method].parameters); - operations[operationId] = { - action: pointOfView === 'server' ? 'receive' : 'send', - channel: { - $ref: `#/channels/${channelName}` - }, - summary: operation.summary, - description: operation.description, - tags: operation.tags, - bindings: getOperationBindings(method, parameters?.query) - }; +function convertOperationParameters(parameters: any[]): Record { + const convertedParams: Record = {}; + + parameters.forEach(param => { + if (param.in === 'query') { + convertedParams[param.name] = { + description: param.description, + schema: convertSchema(param.schema) + }; + } + }); - } + return convertedParams; +} + +function convertRequestBodyToMessages(requestBody: any, operationId: string, method: string): Record { + const messages: Record = {}; + + if (isPlainObject(requestBody.content)) { + Object.entries(requestBody.content).forEach(([contentType, mediaType]: [string, any]) => { + const messageName = `${operationId}Request`; + messages[messageName] = { + name: messageName, + title: `${method.toUpperCase()} request`, + contentType: contentType, + payload: convertSchema(mediaType.schema), + summary: requestBody.description, + }; }); + } + + return messages; +} + +function convertResponsesToMessages(responses: Record, operationId: string, method: string): Record { + const messages: Record = {}; + + Object.entries(responses).forEach(([statusCode, response]) => { + if (isPlainObject(response.content)) { + Object.entries(response.content).forEach(([contentType, mediaType]: [string, any]) => { + const messageName = `${operationId}Response${statusCode}`; + messages[messageName] = { + name: messageName, + title: `${method.toUpperCase()} response ${statusCode}`, + contentType: contentType, + payload: convertSchema(mediaType.schema), + summary: response.description, + headers: response.headers ? convertHeadersToSchema(response.headers) : undefined, + }; + }); + } else { + const messageName = `${operationId}Response${statusCode}`; + messages[messageName] = { + name: messageName, + title: `${method.toUpperCase()} response ${statusCode}`, + summary: response.description, + }; + } }); - return operations; -}; + return messages; +} -function getOperationBindings(method: string, queryParameters?: Record): Record { - const bindings: Record = { - http: { - bindingVersion: "0.3.0", - }, - }; +function convertComponents(openapi: OpenAPIDocument): AsyncAPIDocument['components'] { + const asyncComponents: AsyncAPIDocument['components'] = {}; + + if (openapi.components) { + if (openapi.components.schemas) { + asyncComponents.schemas = convertSchemas(openapi.components.schemas); + } - if (method) { - bindings.http.method = method.toUpperCase(); + if (openapi.components.securitySchemes) { + asyncComponents.securitySchemes = convertSecuritySchemes(openapi.components.securitySchemes); + } + + if (openapi.components.parameters) { + asyncComponents.parameters = convertParameters(openapi.components.parameters); + } + + if (openapi.components.responses) { + asyncComponents.messages = convertComponentResponsesToMessages(openapi.components.responses); + } + + if (openapi.components.requestBodies) { + asyncComponents.messageTraits = convertRequestBodiesToMessageTraits(openapi.components.requestBodies); + } + + if (openapi.components.headers) { + asyncComponents.messageTraits = { + ...(asyncComponents.messageTraits || {}), + ...convertHeadersToMessageTraits(openapi.components.headers) + }; + } + + if (openapi.components.examples) { + asyncComponents.examples = openapi.components.examples; + } } - if (queryParameters && Object.keys(queryParameters).length > 0) { - bindings.http.query = {...queryParameters}; + return removeEmptyObjects(asyncComponents); +} + +function convertSchemas(schemas: Record): Record { + const convertedSchemas: Record = {}; + + for (const [name, schema] of Object.entries(schemas)) { + convertedSchemas[name] = convertSchema(schema); } - return bindings; + return convertedSchemas; } -function getMessageBindings(statusCode?: string, headers?: Record): Record { - const bindings: Record = { - http: { - bindingVersion: "0.3.0", - }, - }; +function convertSchema(schema: any): any { + if (isRefObject(schema)) { + return schema; + } + + const convertedSchema: any = { ...schema }; - if (statusCode) { - bindings.http.statusCode = parseInt(statusCode); + if (schema.properties) { + convertedSchema.properties = {}; + for (const [propName, propSchema] of Object.entries(schema.properties)) { + convertedSchema.properties[propName] = convertSchema(propSchema); + } + } + + if (schema.items) { + convertedSchema.items = convertSchema(schema.items); } - if (headers && Object.keys(headers).length > 0) { - bindings.http.headers = {...headers}; + ['allOf', 'anyOf', 'oneOf'].forEach(key => { + if (schema[key]) { + convertedSchema[key] = schema[key].map(convertSchema); + } + }); + + // Handle formats + if (schema.format === 'date-time') { + convertedSchema.format = 'date-time'; + } else if (schema.format === 'byte' || schema.format === 'binary') { + delete convertedSchema.format; } - return bindings; + return convertedSchema; +} + +interface SecuritySchemeObject { + type: string; + description?: string; + name?: string; + in?: string; + scheme?: string; + bearerFormat?: string; + flows?: Record; + openIdConnectUrl?: string; +} + +interface OAuthFlowObject { + authorizationUrl?: string; + tokenUrl?: string; + refreshUrl?: string; + scopes?: Record; +} + +function convertSecuritySchemes(securitySchemes: Record): Record { + const convertedSchemes: Record = {}; + + for (const [name, scheme] of Object.entries(securitySchemes)) { + convertedSchemes[name] = convertSecurityScheme(scheme); + } + + return convertedSchemes; } -function getChannelBindings(method: string, header?: Record, query?: Record): Record { - const bindings: Record = { - ws: { - bindingVersion: "0.1.0", - }, - }; - if (method) { - bindings.http.method = method.toUpperCase(); + +function convertSecurityScheme(scheme: SecuritySchemeObject): any { + const convertedScheme: any = { ...scheme }; + + if (scheme.type === 'oauth2' && scheme.flows) { + convertedScheme.flows = {}; + for (const [flowType, flow] of Object.entries(scheme.flows)) { + if (isPlainObject(flow)) { + convertedScheme.flows[flowType] = convertOAuthFlow(flow); + } + } } - if (query && Object.keys(query).length > 0) { - bindings.ws.query = {...query}; + if (scheme.type === 'http') { + if (scheme.scheme === 'basic') { + convertedScheme.type = 'userPassword'; + } else if (scheme.scheme === 'bearer') { + convertedScheme.type = 'httpBearerToken'; + } } - if (header && Object.keys(header).length > 0) { - bindings.ws.header = {...header}; + if (scheme.type === 'apiKey') { + convertedScheme.type = 'httpApiKey'; } - return bindings; + return convertedScheme; } -function convertParameters(parameters: any[]): Record { - const convertedParams: Record = { - query: {}, - headers: {}, +function convertOAuthFlow(flow: OAuthFlowObject): any { + return { + ...flow, + availableScopes: flow.scopes || {}, + scopes: undefined, }; - - if (Array.isArray(parameters)) { - parameters.forEach((param) => { - - convertedParams[param.name] = {} - convertedParams[param.name].description = param.description; - - switch (param.in) { - case 'query': - convertedParams.query[param.name] = param.schema; - break; - case 'header': - convertedParams.headers[param.name] = param.schema; - break; - case 'cookie': - throw new Error('Cookie parameters are not supported in asyncapi'); - } +} - }); +function convertParameters(parameters: Record): Record { + const convertedParameters: Record = {}; + + for (const [name, parameter] of Object.entries(parameters)) { + if (parameter.in === 'query' || parameter.in === 'path') { + convertedParameters[name] = { + ...parameter, + schema: parameter.schema ? convertSchema(parameter.schema) : undefined, + }; + delete convertedParameters[name].in; + } } - return convertedParams; + + return convertedParameters; } -function convertComponents(components: OpenAPIDocument['components']): AsyncAPIDocument['components'] { - if (!isPlainObject(components)) { - return; +function convertComponentResponsesToMessages(responses: Record): Record { + const messages: Record = {}; + + for (const [name, response] of Object.entries(responses)) { + if (isPlainObject(response.content)) { + Object.entries(response.content).forEach(([contentType, mediaType]: [string, any]) => { + messages[name] = { + name: name, + contentType: contentType, + payload: convertSchema(mediaType.schema), + summary: response.description, + headers: response.headers ? convertHeadersToSchema(response.headers) : undefined, + }; + }); + } else { + messages[name] = { + name: name, + summary: response.description, + }; + } } - const asyncComponents: AsyncAPIDocument['components'] = {}; + return messages; +} - if (isPlainObject(components.schemas)) { - asyncComponents.schemas = components.schemas; +function convertRequestBodiesToMessageTraits(requestBodies: Record): Record { + const messageTraits: Record = {}; + + for (const [name, requestBody] of Object.entries(requestBodies)) { + if (requestBody.content) { + const contentType = Object.keys(requestBody.content)[0]; + messageTraits[name] = { + name: name, + summary: requestBody.description, + contentType: contentType, + payload: convertSchema(requestBody.content[contentType].schema), + }; + } } - if (isPlainObject(components.securitySchemes)) { - asyncComponents.securitySchemes = components.securitySchemes; + + return messageTraits; +} + +function convertHeadersToMessageTraits(headers: Record): Record { + const messageTraits: Record = {}; + + for (const [name, header] of Object.entries(headers)) { + messageTraits[`Header${name}`] = { + headers: { + type: 'object', + properties: { + [name]: convertSchema(header.schema), + }, + required: [name], + }, + }; } - if (isPlainObject(components.parameters)) { - asyncComponents.parameters = components.parameters; + + return messageTraits; +} + +function convertHeadersToSchema(headers: Record): any { + const properties: Record = {}; + + for (const [name, header] of Object.entries(headers)) { + properties[name] = convertSchema(header.schema); } - - return asyncComponents; + + return { + type: 'object', + properties, + }; } \ No newline at end of file From e1b4c99800c3366c85ed3b50d2e2bc09a0288761 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Sat, 27 Jul 2024 13:10:07 +0530 Subject: [PATCH 09/15] added all the test cases and fix some bugs --- src/openapi.ts | 153 +++++++++++------- test/input/openapi/callbacks_and_contents.yml | 137 ++++++++++++++++ .../input/openapi/components_and_security.yml | 97 +++++++++++ .../input/openapi/operation_and_parameter.yml | 125 ++++++++++++++ test/openapi-to-asyncapi.spec.ts | 18 +++ .../callbacks_and_contents.yml | 153 ++++++++++++++++++ .../components_and_security.yml | 107 ++++++++++++ .../operation_and_parameter.yml | 141 ++++++++++++++++ 8 files changed, 871 insertions(+), 60 deletions(-) create mode 100644 test/input/openapi/callbacks_and_contents.yml create mode 100644 test/input/openapi/components_and_security.yml create mode 100644 test/input/openapi/operation_and_parameter.yml create mode 100644 test/output/openapi-to-asyncapi/callbacks_and_contents.yml create mode 100644 test/output/openapi-to-asyncapi/components_and_security.yml create mode 100644 test/output/openapi-to-asyncapi/operation_and_parameter.yml diff --git a/src/openapi.ts b/src/openapi.ts index d26019aa..f90a8a7f 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -220,11 +220,8 @@ function convertPathParameters(parameters: any[] = []): Record { const convertedParams: Record = {}; parameters.forEach(param => { - if (param.in === 'path') { - convertedParams[param.name] = { - description: param.description, - schema: convertSchema(param.schema) - }; + if (!isRefObject(param) && param.in === 'path') { + convertedParams[param.name] = convertParameter(param); } }); @@ -235,17 +232,56 @@ function convertOperationParameters(parameters: any[]): Record { const convertedParams: Record = {}; parameters.forEach(param => { - if (param.in === 'query') { - convertedParams[param.name] = { - description: param.description, - schema: convertSchema(param.schema) - }; + if (!isRefObject(param) && param.in === 'query') { + convertedParams[param.name] = convertParameter(param); } }); return convertedParams; } +function convertParameter(param: any): any { + const convertedParam: any = { + description: param.description + }; + + if (param.schema) { + if (!isRefObject(param.schema)) { + if (param.schema.enum) { + convertedParam.enum = param.schema.enum; + } + if (param.schema.default !== undefined) { + convertedParam.default = param.schema.default; + } + } + } + + if (param.examples) { + convertedParam.examples = Object.values(param.examples).map((example:any) => + isRefObject(example) ? example : example.value + ); + } + + // the location based on the parameter's 'in' property + switch (param.in) { + case 'query': + case 'header': + convertedParam.location = `$message.header#/${param.name}`; + break; + case 'path': + case 'cookie': + // For path and cookie parameters, we' have put them in the payload + // as AsyncAPI doesn't have a direct equivalent + convertedParam.location = `$message.payload#/${param.name}`; + break; + default: + // If 'in' is not recognized, default to payload + convertedParam.location = `$message.payload#/${param.name}`; + } + + return convertedParam; +} + function convertRequestBodyToMessages(requestBody: any, operationId: string, method: string): Record { const messages: Record = {}; @@ -307,7 +343,14 @@ function convertComponents(openapi: OpenAPIDocument): AsyncAPIDocument['componen } if (openapi.components.parameters) { - asyncComponents.parameters = convertParameters(openapi.components.parameters); + asyncComponents.parameters = {}; + for (const [name, param] of Object.entries(openapi.components.parameters)) { + if (!isRefObject(param)) { + asyncComponents.parameters[name] = convertParameter(param); + } else { + asyncComponents.parameters[name] = param; + } + } } if (openapi.components.responses) { @@ -405,57 +448,42 @@ function convertSecuritySchemes(securitySchemes: Record): Record): Record { - const convertedParameters: Record = {}; - - for (const [name, parameter] of Object.entries(parameters)) { - if (parameter.in === 'query' || parameter.in === 'path') { - convertedParameters[name] = { - ...parameter, - schema: parameter.schema ? convertSchema(parameter.schema) : undefined, - }; - delete convertedParameters[name].in; + } else if (scheme.type === 'http') { + convertedScheme.scheme = scheme.scheme; + if (scheme.scheme === 'bearer') { + convertedScheme.bearerFormat = scheme.bearerFormat; } + } else if (scheme.type === 'apiKey') { + convertedScheme.in = scheme.in; + convertedScheme.name = scheme.name; } - return convertedParameters; + return convertedScheme; } function convertComponentResponsesToMessages(responses: Record): Record { @@ -483,18 +511,23 @@ function convertComponentResponsesToMessages(responses: Record): Re return messages; } -function convertRequestBodiesToMessageTraits(requestBodies: Record): Record { +function convertRequestBodiesToMessageTraits(requestBodies: Record): Record { const messageTraits: Record = {}; - for (const [name, requestBody] of Object.entries(requestBodies)) { - if (requestBody.content) { - const contentType = Object.keys(requestBody.content)[0]; + for (const [name, requestBodyOrRef] of Object.entries(requestBodies)) { + if (!isRefObject(requestBodyOrRef) && requestBodyOrRef.content) { + const contentType = Object.keys(requestBodyOrRef.content)[0]; messageTraits[name] = { name: name, - summary: requestBody.description, contentType: contentType, - payload: convertSchema(requestBody.content[contentType].schema), + description: requestBodyOrRef.description, }; + + if (requestBodyOrRef.content[contentType].schema && + requestBodyOrRef.content[contentType].schema.properties && + requestBodyOrRef.content[contentType].schema.properties.headers) { + messageTraits[name].headers = requestBodyOrRef.content[contentType].schema.properties.headers; + } } } diff --git a/test/input/openapi/callbacks_and_contents.yml b/test/input/openapi/callbacks_and_contents.yml new file mode 100644 index 00000000..a9e12038 --- /dev/null +++ b/test/input/openapi/callbacks_and_contents.yml @@ -0,0 +1,137 @@ +openapi: 3.0.0 +info: + title: Callbacks, Links, and Content Types API + version: 1.0.0 + description: An API showcasing callbacks, links, and various content types +servers: + - url: https://api.example.com/v1 +paths: + /webhooks: + post: + summary: Subscribe to webhook + operationId: subscribeWebhook + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + callbackUrl: + type: string + format: uri + responses: + '201': + description: Subscription created + callbacks: + onEvent: + '{$request.body#/callbackUrl}': + post: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + eventType: + type: string + eventData: + type: object + responses: + '200': + description: Webhook processed + /users/{userId}: + get: + summary: Get a user + operationId: getUser + parameters: + - in: path + name: userId + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/User' + links: + userPosts: + operationId: getUserPosts + parameters: + userId: '$response.body#/id' + /users/{userId}/posts: + get: + summary: Get user posts + operationId: getUserPosts + parameters: + - in: path + name: userId + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + /upload: + post: + summary: Upload a file + operationId: uploadFile + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Successful upload + content: + application/json: + schema: + type: object + properties: + fileId: + type: string + /stream: + get: + summary: Get a data stream + operationId: getStream + responses: + '200': + description: Successful response + content: + application/octet-stream: + schema: + type: string + format: binary +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + Post: + type: object + properties: + id: + type: string + title: + type: string + content: + type: string \ No newline at end of file diff --git a/test/input/openapi/components_and_security.yml b/test/input/openapi/components_and_security.yml new file mode 100644 index 00000000..b88359ac --- /dev/null +++ b/test/input/openapi/components_and_security.yml @@ -0,0 +1,97 @@ +openapi: 3.0.0 +info: + title: Components and Security API + version: 1.0.0 + description: An API showcasing various components and security schemes +servers: + - url: https://api.example.com/v1 +paths: + /secure: + get: + summary: Secure endpoint + security: + - bearerAuth: [] + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/SecureResponse' + /oauth: + get: + summary: OAuth protected endpoint + security: + - oAuth2: + - read + - write + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthResponse' +components: + schemas: + SecureResponse: + type: object + properties: + message: + type: string + OAuthResponse: + type: object + properties: + data: + type: string + Error: + type: object + properties: + code: + type: integer + message: + type: string + securitySchemes: + bearerAuth: + type: http + scheme: bearer + oAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + write: Write access + parameters: + limitParam: + in: query + name: limit + schema: + type: integer + required: false + description: Maximum number of items to return + responses: + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + requestBodies: + ItemInput: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + headers: + X-Rate-Limit: + schema: + type: integer + description: Calls per hour allowed by the user \ No newline at end of file diff --git a/test/input/openapi/operation_and_parameter.yml b/test/input/openapi/operation_and_parameter.yml new file mode 100644 index 00000000..329d23fc --- /dev/null +++ b/test/input/openapi/operation_and_parameter.yml @@ -0,0 +1,125 @@ +openapi: 3.0.0 +info: + title: Operations and Parameters API + version: 1.0.0 + description: An API showcasing various operations and parameter types +servers: + - url: https://api.example.com/v1 +paths: + /items: + get: + summary: List items + operationId: listItems + parameters: + - in: query + name: limit + schema: + type: integer + required: false + description: Maximum number of items to return + - in: query + name: offset + schema: + type: integer + required: false + description: Number of items to skip + - in: header + name: X-API-Key + schema: + type: string + required: true + description: API Key for authentication + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Item' + post: + summary: Create an item + operationId: createItem + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ItemInput' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + /items/{itemId}: + get: + summary: Get an item + operationId: getItem + parameters: + - in: path + name: itemId + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + put: + summary: Update an item + operationId: updateItem + parameters: + - in: path + name: itemId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ItemInput' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + delete: + summary: Delete an item + operationId: deleteItem + parameters: + - in: path + name: itemId + required: true + schema: + type: string + responses: + '204': + description: Successful response +components: + schemas: + Item: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + ItemInput: + type: object + properties: + name: + type: string + description: + type: string \ No newline at end of file diff --git a/test/openapi-to-asyncapi.spec.ts b/test/openapi-to-asyncapi.spec.ts index 463ab957..5bba8eed 100644 --- a/test/openapi-to-asyncapi.spec.ts +++ b/test/openapi-to-asyncapi.spec.ts @@ -11,4 +11,22 @@ describe("convert() - openapi to asyncapi", () => { const result = convertOpenAPI(input); assertResults(output, result); }); + it("should convert the openapi operation and parameter keywoards to asyncapi", () => { + const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "operation_and_parameter.yml"), "utf8"); + const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "operation_and_parameter.yml"), "utf8"); + const result = convertOpenAPI(input); + assertResults(output, result); + }); + it("should convert the openapi components and securitySchemes keywoards to asyncapi", () => { + const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "components_and_security.yml"), "utf8"); + const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "components_and_security.yml"), "utf8"); + const result = convertOpenAPI(input); + assertResults(output, result); + }); + it("should convert the openapi contents and callbacks keywoards to asyncapi", () => { + const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "callbacks_and_contents.yml"), "utf8"); + const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "callbacks_and_contents.yml"), "utf8"); + const result = convertOpenAPI(input); + assertResults(output, result); + }); }); \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml new file mode 100644 index 00000000..b336fec9 --- /dev/null +++ b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml @@ -0,0 +1,153 @@ +asyncapi: 3.0.0 +info: + title: 'Callbacks, Links, and Content Types API' + version: 1.0.0 + description: 'An API showcasing callbacks, links, and various content types' +servers: + example_com_v1: + host: api.example.com + pathname: /v1 + protocol: https +channels: + webhooks: + address: /webhooks + messages: + subscribeWebhookRequest: + name: subscribeWebhookRequest + title: POST request + contentType: application/json + payload: + type: object + properties: + callbackUrl: + type: string + format: uri + subscribeWebhookResponse201: + name: subscribeWebhookResponse201 + title: POST response 201 + summary: Subscription created + 'users_{userId}': + address: '/users/{userId}' + messages: + getUserResponse200: + name: getUserResponse200 + title: GET response 200 + contentType: application/json + payload: + $ref: '#/components/schemas/User' + summary: Successful response + 'users_{userId}_posts': + address: '/users/{userId}/posts' + messages: + getUserPostsResponse200: + name: getUserPostsResponse200 + title: GET response 200 + contentType: application/json + payload: + type: array + items: + $ref: '#/components/schemas/Post' + summary: Successful response + upload: + address: /upload + messages: + uploadFileRequest: + name: uploadFileRequest + title: POST request + contentType: multipart/form-data + payload: + type: object + properties: + file: + type: string + uploadFileResponse200: + name: uploadFileResponse200 + title: POST response 200 + contentType: application/json + payload: + type: object + properties: + fileId: + type: string + summary: Successful upload + stream: + address: /stream + messages: + getStreamResponse200: + name: getStreamResponse200 + title: GET response 200 + contentType: application/octet-stream + payload: + type: string + summary: Successful response +operations: + subscribeWebhook: + action: receive + channel: + $ref: '#/channels/webhooks' + summary: Subscribe to webhook + bindings: + http: + method: POST + messages: + - $ref: '#/channels/webhooks/messages/subscribeWebhookRequest' + - $ref: '#/channels/webhooks/messages/subscribeWebhookResponse201' + getUser: + action: receive + channel: + $ref: '#/channels/users_{userId}' + summary: Get a user + bindings: + http: + method: GET + messages: + - $ref: '#/channels/users_{userId}/messages/getUserResponse200' + getUserPosts: + action: receive + channel: + $ref: '#/channels/users_{userId}_posts' + summary: Get user posts + bindings: + http: + method: GET + messages: + - $ref: '#/channels/users_{userId}_posts/messages/getUserPostsResponse200' + uploadFile: + action: receive + channel: + $ref: '#/channels/upload' + summary: Upload a file + bindings: + http: + method: POST + messages: + - $ref: '#/channels/upload/messages/uploadFileRequest' + - $ref: '#/channels/upload/messages/uploadFileResponse200' + getStream: + action: receive + channel: + $ref: '#/channels/stream' + summary: Get a data stream + bindings: + http: + method: GET + messages: + - $ref: '#/channels/stream/messages/getStreamResponse200' +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + Post: + type: object + properties: + id: + type: string + title: + type: string + content: + type: string \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/components_and_security.yml b/test/output/openapi-to-asyncapi/components_and_security.yml new file mode 100644 index 00000000..8086cb04 --- /dev/null +++ b/test/output/openapi-to-asyncapi/components_and_security.yml @@ -0,0 +1,107 @@ +asyncapi: 3.0.0 +info: + title: Components and Security API + version: 1.0.0 + description: An API showcasing various components and security schemes +servers: + example_com_v1: + host: api.example.com + pathname: /v1 + protocol: https +channels: + secure: + address: /secure + messages: + getsecureResponse200: + name: getsecureResponse200 + title: GET response 200 + contentType: application/json + payload: + $ref: '#/components/schemas/SecureResponse' + summary: Successful response + oauth: + address: /oauth + messages: + getoauthResponse200: + name: getoauthResponse200 + title: GET response 200 + contentType: application/json + payload: + $ref: '#/components/schemas/OAuthResponse' + summary: Successful response +operations: + getsecure: + action: receive + channel: + $ref: '#/channels/secure' + summary: Secure endpoint + bindings: + http: + method: GET + messages: + - $ref: '#/channels/secure/messages/getsecureResponse200' + getoauth: + action: receive + channel: + $ref: '#/channels/oauth' + summary: OAuth protected endpoint + bindings: + http: + method: GET + messages: + - $ref: '#/channels/oauth/messages/getoauthResponse200' +components: + schemas: + SecureResponse: + type: object + properties: + message: + type: string + OAuthResponse: + type: object + properties: + data: + type: string + Error: + type: object + properties: + code: + type: integer + message: + type: string + securitySchemes: + bearerAuth: + type: http + scheme: bearer + oAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: 'https://example.com/oauth/authorize' + tokenUrl: 'https://example.com/oauth/token' + availableScopes: + read: Read access + write: Write access + parameters: + limitParam: + description: Maximum number of items to return + location: $message.header#/limit + messages: + NotFound: + name: NotFound + contentType: application/json + payload: + $ref: '#/components/schemas/Error' + summary: Resource not found + messageTraits: + ItemInput: + name: ItemInput + contentType: application/json + HeaderX-Rate-Limit: + headers: + type: object + properties: + X-Rate-Limit: + type: integer + required: + - X-Rate-Limit \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/operation_and_parameter.yml b/test/output/openapi-to-asyncapi/operation_and_parameter.yml new file mode 100644 index 00000000..bdcb9204 --- /dev/null +++ b/test/output/openapi-to-asyncapi/operation_and_parameter.yml @@ -0,0 +1,141 @@ +asyncapi: 3.0.0 +info: + title: Operations and Parameters API + version: 1.0.0 + description: An API showcasing various operations and parameter types +servers: + example_com_v1: + host: api.example.com + pathname: /v1 + protocol: https +channels: + items: + address: /items + messages: + listItemsResponse200: + name: listItemsResponse200 + title: GET response 200 + contentType: application/json + payload: + type: array + items: + $ref: '#/components/schemas/Item' + summary: Successful response + createItemRequest: + name: createItemRequest + title: POST request + contentType: application/json + payload: + $ref: '#/components/schemas/ItemInput' + createItemResponse201: + name: createItemResponse201 + title: POST response 201 + contentType: application/json + payload: + $ref: '#/components/schemas/Item' + summary: Created + parameters: + limit: + description: Maximum number of items to return + location: $message.header#/limit + offset: + description: Number of items to skip + location: $message.header#/offset + 'items_{itemId}': + address: '/items/{itemId}' + messages: + getItemResponse200: + name: getItemResponse200 + title: GET response 200 + contentType: application/json + payload: + $ref: '#/components/schemas/Item' + summary: Successful response + updateItemRequest: + name: updateItemRequest + title: PUT request + contentType: application/json + payload: + $ref: '#/components/schemas/ItemInput' + updateItemResponse200: + name: updateItemResponse200 + title: PUT response 200 + contentType: application/json + payload: + $ref: '#/components/schemas/Item' + summary: Successful response + deleteItemResponse204: + name: deleteItemResponse204 + title: DELETE response 204 + summary: Successful response +operations: + listItems: + action: receive + channel: + $ref: '#/channels/items' + summary: List items + bindings: + http: + method: GET + messages: + - $ref: '#/channels/items/messages/listItemsResponse200' + createItem: + action: receive + channel: + $ref: '#/channels/items' + summary: Create an item + bindings: + http: + method: POST + messages: + - $ref: '#/channels/items/messages/createItemRequest' + - $ref: '#/channels/items/messages/createItemResponse201' + getItem: + action: receive + channel: + $ref: '#/channels/items_{itemId}' + summary: Get an item + bindings: + http: + method: GET + messages: + - $ref: '#/channels/items_{itemId}/messages/getItemResponse200' + updateItem: + action: receive + channel: + $ref: '#/channels/items_{itemId}' + summary: Update an item + bindings: + http: + method: PUT + messages: + - $ref: '#/channels/items_{itemId}/messages/updateItemRequest' + - $ref: '#/channels/items_{itemId}/messages/updateItemResponse200' + deleteItem: + action: receive + channel: + $ref: '#/channels/items_{itemId}' + summary: Delete an item + bindings: + http: + method: DELETE + messages: + - $ref: '#/channels/items_{itemId}/messages/deleteItemResponse204' +components: + schemas: + Item: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + ItemInput: + type: object + properties: + name: + type: string + description: + type: string \ No newline at end of file From 0133eca9290fd1f0b0f19855daa4245b2e87e05e Mon Sep 17 00:00:00 2001 From: utnim2 Date: Sat, 27 Jul 2024 13:20:25 +0530 Subject: [PATCH 10/15] added an if check for paths --- src/openapi.ts | 110 +++++++++++++++++++++++++------------------------ 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/src/openapi.ts b/src/openapi.ts index f90a8a7f..fc882f9b 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -152,65 +152,67 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' const channels: AsyncAPIDocument['channels'] = {}; const operations: AsyncAPIDocument['operations'] = {}; - for (const [path, pathItemOrRef] of Object.entries(paths)) { - if (!isPlainObject(pathItemOrRef)) continue; - - const pathItem = isRefObject(pathItemOrRef) ? pathItemOrRef : pathItemOrRef as any; - const channelName = path.replace(/^\//, '').replace(/\//g, '_') || 'root'; - channels[channelName] = { - address: path, - messages: {}, - parameters: convertPathParameters(pathItem.parameters) - }; - - for (const [method, operation] of Object.entries(pathItem)) { - if (['get', 'post', 'put', 'delete', 'patch'].includes(method) && isPlainObject(operation)) { - const operationObject = operation as any; - const operationId = operationObject.operationId || `${method}${channelName}`; - - // Convert request body to message - if (operationObject.requestBody) { - const requestMessages = convertRequestBodyToMessages(operationObject.requestBody, operationId, method); - Object.assign(channels[channelName].messages, requestMessages); - } - - // Convert responses to messages - if (operationObject.responses) { - const responseMessages = convertResponsesToMessages(operationObject.responses, operationId, method); - Object.assign(channels[channelName].messages, responseMessages); - } - - // Create operation - operations[operationId] = { - action: perspective === 'client' ? 'send' : 'receive', - channel: createRefObject('channels', channelName), - summary: operationObject.summary, - description: operationObject.description, - tags: operationObject.tags?.map((tag: string) => ({ name: tag })), - bindings: { - http: { - method: method.toUpperCase(), + if(paths) { + for (const [path, pathItemOrRef] of Object.entries(paths)) { + if (!isPlainObject(pathItemOrRef)) continue; + + const pathItem = isRefObject(pathItemOrRef) ? pathItemOrRef : pathItemOrRef as any; + const channelName = path.replace(/^\//, '').replace(/\//g, '_') || 'root'; + channels[channelName] = { + address: path, + messages: {}, + parameters: convertPathParameters(pathItem.parameters) + }; + + for (const [method, operation] of Object.entries(pathItem)) { + if (['get', 'post', 'put', 'delete', 'patch'].includes(method) && isPlainObject(operation)) { + const operationObject = operation as any; + const operationId = operationObject.operationId || `${method}${channelName}`; + + // Convert request body to message + if (operationObject.requestBody) { + const requestMessages = convertRequestBodyToMessages(operationObject.requestBody, operationId, method); + Object.assign(channels[channelName].messages, requestMessages); + } + + // Convert responses to messages + if (operationObject.responses) { + const responseMessages = convertResponsesToMessages(operationObject.responses, operationId, method); + Object.assign(channels[channelName].messages, responseMessages); + } + + // Create operation + operations[operationId] = { + action: perspective === 'client' ? 'send' : 'receive', + channel: createRefObject('channels', channelName), + summary: operationObject.summary, + description: operationObject.description, + tags: operationObject.tags?.map((tag: string) => ({ name: tag })), + bindings: { + http: { + method: method.toUpperCase(), + } + }, + messages: Object.keys(channels[channelName].messages) + .filter(messageName => messageName.startsWith(operationId)) + .map(messageName => createRefObject('channels', channelName, 'messages', messageName)) + }; + + // Convert parameters + if (operationObject.parameters) { + const params = convertOperationParameters(operationObject.parameters); + if (Object.keys(params).length > 0) { + channels[channelName].parameters = { + ...channels[channelName].parameters, + ...params + }; } - }, - messages: Object.keys(channels[channelName].messages) - .filter(messageName => messageName.startsWith(operationId)) - .map(messageName => createRefObject('channels', channelName, 'messages', messageName)) - }; - - // Convert parameters - if (operationObject.parameters) { - const params = convertOperationParameters(operationObject.parameters); - if (Object.keys(params).length > 0) { - channels[channelName].parameters = { - ...channels[channelName].parameters, - ...params - }; } } } + + removeEmptyObjects(channels[channelName]); } - - removeEmptyObjects(channels[channelName]); } return { channels, operations }; From 7a5ee2a6e030f3384602a9a29f03297e09539153 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Sun, 28 Jul 2024 12:41:05 +0530 Subject: [PATCH 11/15] added the `perspective` option test and added reply in operation --- src/convert.ts | 12 +- src/interfaces.ts | 11 +- src/openapi.ts | 151 ++++++++++++---- test/openapi-to-asyncapi.spec.ts | 16 +- .../callbacks_and_contents.yml | 65 ++++--- .../components_and_security.yml | 48 ++++-- .../operation_and_parameter.yml | 65 ++++--- .../operation_and_parameter_client.yml | 162 ++++++++++++++++++ 8 files changed, 422 insertions(+), 108 deletions(-) create mode 100644 test/output/openapi-to-asyncapi/operation_and_parameter_client.yml diff --git a/src/convert.ts b/src/convert.ts index ab45e156..a69595b4 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -7,7 +7,7 @@ import { converters as openapiConverters } from "./openapi"; import { serializeInput } from "./utils"; -import type { AsyncAPIDocument, AsyncAPIConvertVersion, OpenAPIConvertVersion, ConvertOptions, ConvertFunction, ConvertOpenAPIFunction, OpenAPIDocument } from './interfaces'; +import type { AsyncAPIDocument, AsyncAPIConvertVersion, OpenAPIConvertVersion, ConvertOptions, ConvertFunction, ConvertOpenAPIFunction, OpenAPIDocument, OpenAPIToAsyncAPIOptions } from './interfaces'; /** * Value for key (version) represents the function which converts specification from previous version to the given as key. @@ -57,13 +57,15 @@ export function convert(input: string | AsyncAPIDocument, version: AsyncAPIConve return converted; } -export function convertOpenAPI(input: string ,options?: ConvertOptions): string; -export function convertOpenAPI(input: OpenAPIDocument ,options?: ConvertOptions): AsyncAPIDocument; -export function convertOpenAPI(input: string | OpenAPIDocument,options: ConvertOptions = {}): string | AsyncAPIDocument { +export function convertOpenAPI(input: string ,version: OpenAPIConvertVersion,options?: OpenAPIToAsyncAPIOptions): string; +export function convertOpenAPI(input: OpenAPIDocument, version: OpenAPIConvertVersion ,options?: OpenAPIToAsyncAPIOptions): AsyncAPIDocument; +export function convertOpenAPI(input: string | OpenAPIDocument, version: OpenAPIConvertVersion, options: OpenAPIToAsyncAPIOptions = {}): string | AsyncAPIDocument { const { format, document } = serializeInput(input); + const openApiVersion = document.openapi; + const converterVersion = openApiVersion === '3.0.0' ? '3.0.0' : openApiVersion; - const openapiToAsyncapiConverter = openapiConverters["openapi" as OpenAPIConvertVersion] as ConvertOpenAPIFunction; + const openapiToAsyncapiConverter = openapiConverters[converterVersion as OpenAPIConvertVersion] as ConvertOpenAPIFunction; if (!openapiToAsyncapiConverter) { throw new Error("OpenAPI to AsyncAPI converter is not available."); diff --git a/src/interfaces.ts b/src/interfaces.ts index eef7aa47..779c6a53 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -4,8 +4,8 @@ export type AsyncAPIDocument = { asyncapi: string } & Record; export type OpenAPIDocument = { openapi: string } & Record; export type AsyncAPIConvertVersion = '1.1.0' | '1.2.0' | '2.0.0-rc1' | '2.0.0-rc2' | '2.0.0' | '2.1.0' | '2.2.0' | '2.3.0' | '2.4.0' | '2.5.0' | '2.6.0' | '3.0.0'; -// for now it is hardcoded to 'openapi' but in the future it can be extended to support multiple versions -export type OpenAPIConvertVersion = 'openapi'; + +export type OpenAPIConvertVersion = '3.0.0'; export type ConvertV2ToV3Options = { idGenerator?: (data: { asyncapi: AsyncAPIDocument, kind: 'channel' | 'operation' | 'message', key: string | number | undefined, path: Array, object: any, parentId?: string }) => string, pointOfView?: 'application' | 'client', @@ -13,13 +13,18 @@ export type ConvertV2ToV3Options = { convertServerComponents?: boolean; convertChannelComponents?: boolean; } + +export type OpenAPIToAsyncAPIOptions = { + perspective?: 'client' | 'server'; +}; export type ConvertOptions = { v2tov3?: ConvertV2ToV3Options; + openAPIToAsyncAPI?: OpenAPIToAsyncAPIOptions; } /** * PRIVATE TYPES */ export type ConvertFunction = (asyncapi: AsyncAPIDocument, options: ConvertOptions) => AsyncAPIDocument; -export type ConvertOpenAPIFunction = (openapi: OpenAPIDocument, options: ConvertOptions) => AsyncAPIDocument; +export type ConvertOpenAPIFunction = (openapi: OpenAPIDocument, options: OpenAPIToAsyncAPIOptions) => AsyncAPIDocument; diff --git a/src/openapi.ts b/src/openapi.ts index fc882f9b..15388359 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,12 +1,18 @@ import { sortObjectKeys, isRefObject, isPlainObject, removeEmptyObjects, createRefObject } from "./utils"; -import { AsyncAPIDocument, ConvertOpenAPIFunction, ConvertOptions, OpenAPIDocument } from "./interfaces"; +import { AsyncAPIDocument, ConvertOpenAPIFunction, OpenAPIToAsyncAPIOptions, OpenAPIDocument } from "./interfaces"; export const converters: Record = { - 'openapi': from_openapi_to_asyncapi, + '3.0.0': from_openapi_to_asyncapi, } -function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options: ConvertOptions={}): AsyncAPIDocument { - const perspective = options.v2tov3?.pointOfView === 'client' ? 'client' : 'server'; +/** + * Converts an OpenAPI document to an AsyncAPI document. + * @param {OpenAPIDocument} openapi - The OpenAPI document to convert. + * @param {ConvertOptions} options - Conversion options. + * @returns {AsyncAPIDocument} The converted AsyncAPI document. + */ +function from_openapi_to_asyncapi(openapi: OpenAPIDocument, options: OpenAPIToAsyncAPIOptions = {}): AsyncAPIDocument { + const perspective = options.perspective || 'server'; const asyncapi: Partial = { asyncapi: '3.0.0', info: convertInfoObject(openapi.info, openapi), @@ -48,6 +54,12 @@ interface LicenseObject { url?: string; } +/** + * Converts openAPI info objects to asyncAPI info objects. + * @param info - The openAPI info object to convert. + * @param openapi - The complete openAPI document. + * @returns openAPI info object + */ function convertInfoObject(info: InfoObject, openapi: OpenAPIDocument): AsyncAPIDocument['info'] { return sortObjectKeys({ ...info, @@ -77,11 +89,16 @@ interface ServerVariableObject { description?: string; } - +/** + * Converts OpenAPI server objects to AsyncAPI server objects. + * @param {ServerObject[]} servers - The OpenAPI server objects to convert. + * @param {OpenAPIDocument} openapi - The complete OpenAPI document. + * @returns {AsyncAPIDocument['servers']} The converted AsyncAPI server objects. + */ function convertServerObjects(servers: ServerVariableObject[], openapi: OpenAPIDocument): AsyncAPIDocument['servers'] { const newServers: Record = {}; const security: Record = openapi.security; - Object.entries(servers).forEach(([index, server]: [string, any]) => { + servers.forEach((server: any) => { const serverName = generateServerName(server.url); if (isRefObject(server)) { @@ -145,6 +162,12 @@ function resolveServerUrl(url: string): { return { host, pathname: undefined , protocol: maybeProtocol }; } +/** + * Converts OpenAPI paths to AsyncAPI channels and operations. + * @param {Record} paths - The OpenAPI paths object. + * @param {'client' | 'server'} perspective - The perspective of the conversion (client or server). + * @returns {{ channels: AsyncAPIDocument['channels'], operations: AsyncAPIDocument['operations'] }} + */ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | 'server'): { channels: AsyncAPIDocument['channels'], operations: AsyncAPIDocument['operations'] @@ -165,7 +188,7 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' }; for (const [method, operation] of Object.entries(pathItem)) { - if (['get', 'post', 'put', 'delete', 'patch'].includes(method) && isPlainObject(operation)) { + if (['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'].includes(method) && isPlainObject(operation)) { const operationObject = operation as any; const operationId = operationObject.operationId || `${method}${channelName}`; @@ -193,10 +216,20 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' method: method.toUpperCase(), } }, - messages: Object.keys(channels[channelName].messages) - .filter(messageName => messageName.startsWith(operationId)) - .map(messageName => createRefObject('channels', channelName, 'messages', messageName)) + messages: operationObject.requestBody + ? [createRefObject('channels', channelName, 'messages', `${operationId}Request`)] + : [], + }; + + // Add reply section if there are responses + if (operationObject.responses && Object.keys(operationObject.responses).length > 0) { + operations[operationId].reply = { + channel: createRefObject('channels', channelName), + messages: Object.entries(operationObject.responses).map(([statusCode, response]) => + createRefObject('channels', channelName, 'messages', `${operationId}Response${statusCode}`) + ) }; + } // Convert parameters if (operationObject.parameters) { @@ -218,6 +251,11 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' return { channels, operations }; } +/** + * Converts OpenAPI path parameters to AsyncAPI channel parameters. + * @param {any[]} parameters - The OpenAPI path parameters. + * @returns {Record} The converted AsyncAPI channel parameters. + */ function convertPathParameters(parameters: any[] = []): Record { const convertedParams: Record = {}; @@ -230,6 +268,11 @@ function convertPathParameters(parameters: any[] = []): Record { return convertedParams; } +/** + * Converts OpenAPI operatiion parameters to AsyncAPI operation parameters. + * @param {any[]} parameters - The OpenAPI operation parameters. + * @returns {Record} The converted AsyncAPI operation parameters. + */ function convertOperationParameters(parameters: any[]): Record { const convertedParams: Record = {}; @@ -242,6 +285,11 @@ function convertOperationParameters(parameters: any[]): Record { return convertedParams; } +/** + * Converts an OpenAPI Parameter Object to an AsyncAPI Parameter Object. + * @param {ParameterObject} param - The OpenAPI Parameter Object. + * @returns {any} The converted AsyncAPI Parameter Object. + */ function convertParameter(param: any): any { const convertedParam: any = { description: param.description @@ -268,13 +316,11 @@ function convertParameter(param: any): any { switch (param.in) { case 'query': case 'header': + case 'cookie': convertedParam.location = `$message.header#/${param.name}`; break; case 'path': - case 'cookie': - // For path and cookie parameters, we' have put them in the payload - // as AsyncAPI doesn't have a direct equivalent - convertedParam.location = `$message.payload#/${param.name}`; + // Path parameters are part of the channel address break; default: // If 'in' is not recognized, default to payload @@ -303,6 +349,13 @@ function convertRequestBodyToMessages(requestBody: any, operationId: string, met return messages; } +/** + * Converts OpenAPI Response Objects to AsyncAPI Message Objects. + * @param {ResponsesObject} responses - The OpenAPI Response Objects to convert. + * @param {string} operationId - The ID of the operation these responses belong to. + * @param {string} method - The HTTP method of the operation. + * @returns {Record} A record of converted AsyncAPI Message Objects. + */ function convertResponsesToMessages(responses: Record, operationId: string, method: string): Record { const messages: Record = {}; @@ -332,6 +385,11 @@ function convertResponsesToMessages(responses: Record, operationId: return messages; } +/** + * Converts OpenAPI Components Object to AsyncAPI Components Object. + * @param {OpenAPIDocument} openapi - The complete OpenAPI document. + * @returns {AsyncAPIDocument['components']} The converted AsyncAPI Components Object. + */ function convertComponents(openapi: OpenAPIDocument): AsyncAPIDocument['components'] { const asyncComponents: AsyncAPIDocument['components'] = {}; @@ -378,16 +436,29 @@ function convertComponents(openapi: OpenAPIDocument): AsyncAPIDocument['componen return removeEmptyObjects(asyncComponents); } +/** + * Converts OpenAPI Schema Objects to AsyncAPI Schema Objects. + * @param {Record} schemas - The OpenAPI Schema Objects to convert. + * @returns {Record} The converted AsyncAPI Schema Objects. + */ function convertSchemas(schemas: Record): Record { const convertedSchemas: Record = {}; for (const [name, schema] of Object.entries(schemas)) { - convertedSchemas[name] = convertSchema(schema); + convertedSchemas[name] = { + schemaFormat: 'application/vnd.oai.openapi;version=3.0.0', + schema: schema + }; } return convertedSchemas; } +/** + * Converts a single OpenAPI Schema Object to an AsyncAPI Schema Object. + * @param {any} schema - The OpenAPI Schema Object to convert. + * @returns {any} The converted AsyncAPI Schema Object. + */ function convertSchema(schema: any): any { if (isRefObject(schema)) { return schema; @@ -422,24 +493,11 @@ function convertSchema(schema: any): any { return convertedSchema; } -interface SecuritySchemeObject { - type: string; - description?: string; - name?: string; - in?: string; - scheme?: string; - bearerFormat?: string; - flows?: Record; - openIdConnectUrl?: string; -} - -interface OAuthFlowObject { - authorizationUrl?: string; - tokenUrl?: string; - refreshUrl?: string; - scopes?: Record; -} - +/** + * Converts a single OpenAPI Security Scheme Object to an AsyncAPI Security Scheme Object. + * @param {Record} scheme - The OpenAPI Security Scheme Object to convert. + * @returns {Record} The converted AsyncAPI Security Scheme Object. + */ function convertSecuritySchemes(securitySchemes: Record): Record { const convertedSchemes: Record = {}; @@ -450,7 +508,12 @@ function convertSecuritySchemes(securitySchemes: Record): Record} The converted AsyncAPI Security Scheme Object. + */ +function convertSecurityScheme(scheme: any): Record { const convertedScheme: any = { type: scheme.type, description: scheme.description @@ -488,6 +551,11 @@ function convertSecurityScheme(scheme: any): any { return convertedScheme; } +/** + * Converts OpenAPI Response Objects from the components section to AsyncAPI Message Objects. + * @param {Record} responses - The OpenAPI Response Objects to convert. + * @returns {Record} A record of converted AsyncAPI Message Objects. + */ function convertComponentResponsesToMessages(responses: Record): Record { const messages: Record = {}; @@ -513,6 +581,11 @@ function convertComponentResponsesToMessages(responses: Record): Re return messages; } +/** + * Converts OpenAPI Request Body Objects from the components section to AsyncAPI Message Trait Objects. + * @param {Record} requestBodies - The OpenAPI Request Body Objects to convert. + * @returns {Record} A record of converted AsyncAPI Message Trait Objects. + */ function convertRequestBodiesToMessageTraits(requestBodies: Record): Record { const messageTraits: Record = {}; @@ -536,6 +609,11 @@ function convertRequestBodiesToMessageTraits(requestBodies: Record): return messageTraits; } +/** + * Converts OpenAPI Header Objects from the components section to AsyncAPI Message Trait Objects. + * @param {Record} headers - The OpenAPI Header Objects to convert. + * @returns {Record} A record of converted AsyncAPI Message Trait Objects. + */ function convertHeadersToMessageTraits(headers: Record): Record { const messageTraits: Record = {}; @@ -554,6 +632,11 @@ function convertHeadersToMessageTraits(headers: Record): Record} headers - The OpenAPI Header Objects to convert. + * @returns {SchemaObject} An AsyncAPI Schema Object representing the headers. + */ function convertHeadersToSchema(headers: Record): any { const properties: Record = {}; diff --git a/test/openapi-to-asyncapi.spec.ts b/test/openapi-to-asyncapi.spec.ts index 5bba8eed..8b2b5a4f 100644 --- a/test/openapi-to-asyncapi.spec.ts +++ b/test/openapi-to-asyncapi.spec.ts @@ -3,30 +3,38 @@ import path from 'path'; import { convertOpenAPI } from '../src/convert'; import { assertResults } from './helpers'; +import { OpenAPIToAsyncAPIOptions } from '../src/interfaces' describe("convert() - openapi to asyncapi", () => { it("should convert the basic structure of openapi to asyncapi", () => { const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "no-channel-operation.yml"), "utf8"); const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "no-channel-parameter.yml"), "utf8"); - const result = convertOpenAPI(input); + const result = convertOpenAPI(input, '3.0.0'); assertResults(output, result); }); it("should convert the openapi operation and parameter keywoards to asyncapi", () => { const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "operation_and_parameter.yml"), "utf8"); const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "operation_and_parameter.yml"), "utf8"); - const result = convertOpenAPI(input); + const result = convertOpenAPI(input, '3.0.0'); assertResults(output, result); }); it("should convert the openapi components and securitySchemes keywoards to asyncapi", () => { const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "components_and_security.yml"), "utf8"); const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "components_and_security.yml"), "utf8"); - const result = convertOpenAPI(input); + const result = convertOpenAPI(input, '3.0.0'); assertResults(output, result); }); it("should convert the openapi contents and callbacks keywoards to asyncapi", () => { const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "callbacks_and_contents.yml"), "utf8"); const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "callbacks_and_contents.yml"), "utf8"); - const result = convertOpenAPI(input); + const result = convertOpenAPI(input, '3.0.0'); + assertResults(output, result); + }); + it("should convert with 'server' perspective", () => { + const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "operation_and_parameter.yml"), "utf8"); + const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "operation_and_parameter_client.yml"), "utf8"); + const options: OpenAPIToAsyncAPIOptions = { perspective: 'client' }; + const result = convertOpenAPI(input, '3.0.0', options); assertResults(output, result); }); }); \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml index b336fec9..1fbae299 100644 --- a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml +++ b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml @@ -91,7 +91,11 @@ operations: method: POST messages: - $ref: '#/channels/webhooks/messages/subscribeWebhookRequest' - - $ref: '#/channels/webhooks/messages/subscribeWebhookResponse201' + reply: + channel: + $ref: '#/channels/webhooks' + messages: + - $ref: '#/channels/webhooks/messages/subscribeWebhookResponse201' getUser: action: receive channel: @@ -100,8 +104,11 @@ operations: bindings: http: method: GET - messages: - - $ref: '#/channels/users_{userId}/messages/getUserResponse200' + reply: + channel: + $ref: '#/channels/users_{userId}' + messages: + - $ref: '#/channels/users_{userId}/messages/getUserResponse200' getUserPosts: action: receive channel: @@ -110,8 +117,11 @@ operations: bindings: http: method: GET - messages: - - $ref: '#/channels/users_{userId}_posts/messages/getUserPostsResponse200' + reply: + channel: + $ref: '#/channels/users_{userId}_posts' + messages: + - $ref: '#/channels/users_{userId}_posts/messages/getUserPostsResponse200' uploadFile: action: receive channel: @@ -122,7 +132,11 @@ operations: method: POST messages: - $ref: '#/channels/upload/messages/uploadFileRequest' - - $ref: '#/channels/upload/messages/uploadFileResponse200' + reply: + channel: + $ref: '#/channels/upload' + messages: + - $ref: '#/channels/upload/messages/uploadFileResponse200' getStream: action: receive channel: @@ -131,23 +145,30 @@ operations: bindings: http: method: GET - messages: - - $ref: '#/channels/stream/messages/getStreamResponse200' + reply: + channel: + $ref: '#/channels/stream' + messages: + - $ref: '#/channels/stream/messages/getStreamResponse200' components: schemas: User: - type: object - properties: - id: - type: string - name: - type: string + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + id: + type: string + name: + type: string Post: - type: object - properties: - id: - type: string - title: - type: string - content: - type: string \ No newline at end of file + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + id: + type: string + title: + type: string + content: + type: string \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/components_and_security.yml b/test/output/openapi-to-asyncapi/components_and_security.yml index 8086cb04..7cc9d246 100644 --- a/test/output/openapi-to-asyncapi/components_and_security.yml +++ b/test/output/openapi-to-asyncapi/components_and_security.yml @@ -38,8 +38,11 @@ operations: bindings: http: method: GET - messages: - - $ref: '#/channels/secure/messages/getsecureResponse200' + reply: + channel: + $ref: '#/channels/secure' + messages: + - $ref: '#/channels/secure/messages/getsecureResponse200' getoauth: action: receive channel: @@ -48,27 +51,36 @@ operations: bindings: http: method: GET - messages: - - $ref: '#/channels/oauth/messages/getoauthResponse200' + reply: + channel: + $ref: '#/channels/oauth' + messages: + - $ref: '#/channels/oauth/messages/getoauthResponse200' components: schemas: SecureResponse: - type: object - properties: - message: - type: string + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + message: + type: string OAuthResponse: - type: object - properties: - data: - type: string + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + data: + type: string Error: - type: object - properties: - code: - type: integer - message: - type: string + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + code: + type: integer + message: + type: string securitySchemes: bearerAuth: type: http diff --git a/test/output/openapi-to-asyncapi/operation_and_parameter.yml b/test/output/openapi-to-asyncapi/operation_and_parameter.yml index bdcb9204..4bb5964b 100644 --- a/test/output/openapi-to-asyncapi/operation_and_parameter.yml +++ b/test/output/openapi-to-asyncapi/operation_and_parameter.yml @@ -77,8 +77,11 @@ operations: bindings: http: method: GET - messages: - - $ref: '#/channels/items/messages/listItemsResponse200' + reply: + channel: + $ref: '#/channels/items' + messages: + - $ref: '#/channels/items/messages/listItemsResponse200' createItem: action: receive channel: @@ -89,7 +92,11 @@ operations: method: POST messages: - $ref: '#/channels/items/messages/createItemRequest' - - $ref: '#/channels/items/messages/createItemResponse201' + reply: + channel: + $ref: '#/channels/items' + messages: + - $ref: '#/channels/items/messages/createItemResponse201' getItem: action: receive channel: @@ -98,8 +105,11 @@ operations: bindings: http: method: GET - messages: - - $ref: '#/channels/items_{itemId}/messages/getItemResponse200' + reply: + channel: + $ref: '#/channels/items_{itemId}' + messages: + - $ref: '#/channels/items_{itemId}/messages/getItemResponse200' updateItem: action: receive channel: @@ -110,7 +120,11 @@ operations: method: PUT messages: - $ref: '#/channels/items_{itemId}/messages/updateItemRequest' - - $ref: '#/channels/items_{itemId}/messages/updateItemResponse200' + reply: + channel: + $ref: '#/channels/items_{itemId}' + messages: + - $ref: '#/channels/items_{itemId}/messages/updateItemResponse200' deleteItem: action: receive channel: @@ -119,23 +133,30 @@ operations: bindings: http: method: DELETE - messages: - - $ref: '#/channels/items_{itemId}/messages/deleteItemResponse204' + reply: + channel: + $ref: '#/channels/items_{itemId}' + messages: + - $ref: '#/channels/items_{itemId}/messages/deleteItemResponse204' components: schemas: Item: - type: object - properties: - id: - type: string - name: - type: string - description: - type: string + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string ItemInput: - type: object - properties: - name: - type: string - description: - type: string \ No newline at end of file + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + name: + type: string + description: + type: string \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml b/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml new file mode 100644 index 00000000..abb7c86c --- /dev/null +++ b/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml @@ -0,0 +1,162 @@ +asyncapi: 3.0.0 +info: + title: Operations and Parameters API + version: 1.0.0 + description: An API showcasing various operations and parameter types +servers: + example_com_v1: + host: api.example.com + pathname: /v1 + protocol: https +channels: + items: + address: /items + messages: + listItemsResponse200: + name: listItemsResponse200 + title: GET response 200 + contentType: application/json + payload: + type: array + items: + $ref: '#/components/schemas/Item' + summary: Successful response + createItemRequest: + name: createItemRequest + title: POST request + contentType: application/json + payload: + $ref: '#/components/schemas/ItemInput' + createItemResponse201: + name: createItemResponse201 + title: POST response 201 + contentType: application/json + payload: + $ref: '#/components/schemas/Item' + summary: Created + parameters: + limit: + description: Maximum number of items to return + location: $message.header#/limit + offset: + description: Number of items to skip + location: $message.header#/offset + 'items_{itemId}': + address: '/items/{itemId}' + messages: + getItemResponse200: + name: getItemResponse200 + title: GET response 200 + contentType: application/json + payload: + $ref: '#/components/schemas/Item' + summary: Successful response + updateItemRequest: + name: updateItemRequest + title: PUT request + contentType: application/json + payload: + $ref: '#/components/schemas/ItemInput' + updateItemResponse200: + name: updateItemResponse200 + title: PUT response 200 + contentType: application/json + payload: + $ref: '#/components/schemas/Item' + summary: Successful response + deleteItemResponse204: + name: deleteItemResponse204 + title: DELETE response 204 + summary: Successful response +operations: + listItems: + action: send + channel: + $ref: '#/channels/items' + summary: List items + bindings: + http: + method: GET + reply: + channel: + $ref: '#/channels/items' + messages: + - $ref: '#/channels/items/messages/listItemsResponse200' + createItem: + action: send + channel: + $ref: '#/channels/items' + summary: Create an item + bindings: + http: + method: POST + messages: + - $ref: '#/channels/items/messages/createItemRequest' + reply: + channel: + $ref: '#/channels/items' + messages: + - $ref: '#/channels/items/messages/createItemResponse201' + getItem: + action: send + channel: + $ref: '#/channels/items_{itemId}' + summary: Get an item + bindings: + http: + method: GET + reply: + channel: + $ref: '#/channels/items_{itemId}' + messages: + - $ref: '#/channels/items_{itemId}/messages/getItemResponse200' + updateItem: + action: send + channel: + $ref: '#/channels/items_{itemId}' + summary: Update an item + bindings: + http: + method: PUT + messages: + - $ref: '#/channels/items_{itemId}/messages/updateItemRequest' + reply: + channel: + $ref: '#/channels/items_{itemId}' + messages: + - $ref: '#/channels/items_{itemId}/messages/updateItemResponse200' + deleteItem: + action: send + channel: + $ref: '#/channels/items_{itemId}' + summary: Delete an item + bindings: + http: + method: DELETE + reply: + channel: + $ref: '#/channels/items_{itemId}' + messages: + - $ref: '#/channels/items_{itemId}/messages/deleteItemResponse204' +components: + schemas: + Item: + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + ItemInput: + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + name: + type: string + description: + type: string From 6b0da2e7346ce047cba4ccd831ce79fd2e5d1277 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Sun, 28 Jul 2024 18:46:34 +0530 Subject: [PATCH 12/15] added the reviewed changes --- src/convert.ts | 2 +- src/openapi.ts | 92 +++++++------------ .../callbacks_and_contents.yml | 2 + 3 files changed, 34 insertions(+), 62 deletions(-) diff --git a/src/convert.ts b/src/convert.ts index a69595b4..51dc4167 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -68,7 +68,7 @@ export function convertOpenAPI(input: string | OpenAPIDocument, version: OpenAPI const openapiToAsyncapiConverter = openapiConverters[converterVersion as OpenAPIConvertVersion] as ConvertOpenAPIFunction; if (!openapiToAsyncapiConverter) { - throw new Error("OpenAPI to AsyncAPI converter is not available."); + throw new Error(`We are not able to convert OpenAPI ${converterVersion} to AsyncAPI, please raise a feature request.`); } const convertedAsyncAPI = openapiToAsyncapiConverter(document as OpenAPIDocument, options); diff --git a/src/openapi.ts b/src/openapi.ts index 15388359..c2dd45e5 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -191,19 +191,7 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' if (['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'trace'].includes(method) && isPlainObject(operation)) { const operationObject = operation as any; const operationId = operationObject.operationId || `${method}${channelName}`; - - // Convert request body to message - if (operationObject.requestBody) { - const requestMessages = convertRequestBodyToMessages(operationObject.requestBody, operationId, method); - Object.assign(channels[channelName].messages, requestMessages); - } - - // Convert responses to messages - if (operationObject.responses) { - const responseMessages = convertResponsesToMessages(operationObject.responses, operationId, method); - Object.assign(channels[channelName].messages, responseMessages); - } - + // Create operation operations[operationId] = { action: perspective === 'client' ? 'send' : 'receive', @@ -216,10 +204,29 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' method: method.toUpperCase(), } }, - messages: operationObject.requestBody - ? [createRefObject('channels', channelName, 'messages', `${operationId}Request`)] - : [], + messages: [] }; + + // Convert request body to message + if (operationObject.requestBody) { + const requestMessages = convertRequestBodyToMessages(operationObject.requestBody, operationId, method); + Object.assign(channels[channelName].messages, requestMessages); + operations[operationId].messages.push(...Object.keys(requestMessages).map(msgName => + createRefObject('channels', channelName, 'messages', msgName) + )); + } + + // Convert responses to messages + if (operationObject.responses) { + const responseMessages = convertResponsesToMessages(operationObject.responses, operationId, method); + Object.assign(channels[channelName].messages, responseMessages); + operations[operationId].reply = { + channel: createRefObject('channels', channelName), + messages: Object.keys(responseMessages).map(msgName => + createRefObject('channels', channelName, 'messages', msgName) + ) + }; + } // Add reply section if there are responses if (operationObject.responses && Object.keys(operationObject.responses).length > 0) { @@ -340,7 +347,7 @@ function convertRequestBodyToMessages(requestBody: any, operationId: string, met name: messageName, title: `${method.toUpperCase()} request`, contentType: contentType, - payload: convertSchema(mediaType.schema), + payload: mediaType.schema, summary: requestBody.description, }; }); @@ -367,7 +374,7 @@ function convertResponsesToMessages(responses: Record, operationId: name: messageName, title: `${method.toUpperCase()} response ${statusCode}`, contentType: contentType, - payload: convertSchema(mediaType.schema), + payload: mediaType.schema, summary: response.description, headers: response.headers ? convertHeadersToSchema(response.headers) : undefined, }; @@ -454,45 +461,6 @@ function convertSchemas(schemas: Record): Record { return convertedSchemas; } -/** - * Converts a single OpenAPI Schema Object to an AsyncAPI Schema Object. - * @param {any} schema - The OpenAPI Schema Object to convert. - * @returns {any} The converted AsyncAPI Schema Object. - */ -function convertSchema(schema: any): any { - if (isRefObject(schema)) { - return schema; - } - - const convertedSchema: any = { ...schema }; - - if (schema.properties) { - convertedSchema.properties = {}; - for (const [propName, propSchema] of Object.entries(schema.properties)) { - convertedSchema.properties[propName] = convertSchema(propSchema); - } - } - - if (schema.items) { - convertedSchema.items = convertSchema(schema.items); - } - - ['allOf', 'anyOf', 'oneOf'].forEach(key => { - if (schema[key]) { - convertedSchema[key] = schema[key].map(convertSchema); - } - }); - - // Handle formats - if (schema.format === 'date-time') { - convertedSchema.format = 'date-time'; - } else if (schema.format === 'byte' || schema.format === 'binary') { - delete convertedSchema.format; - } - - return convertedSchema; -} - /** * Converts a single OpenAPI Security Scheme Object to an AsyncAPI Security Scheme Object. * @param {Record} scheme - The OpenAPI Security Scheme Object to convert. @@ -565,7 +533,7 @@ function convertComponentResponsesToMessages(responses: Record): Re messages[name] = { name: name, contentType: contentType, - payload: convertSchema(mediaType.schema), + payload: mediaType.schema, summary: response.description, headers: response.headers ? convertHeadersToSchema(response.headers) : undefined, }; @@ -622,7 +590,7 @@ function convertHeadersToMessageTraits(headers: Record): Record): Record): any { const properties: Record = {}; - for (const [name, header] of Object.entries(headers)) { - properties[name] = convertSchema(header.schema); + for (const [name, headerOrRef] of Object.entries(headers)) { + if (!isRefObject(headerOrRef)) { + properties[name] = headerOrRef.schema || {}; + } } return { diff --git a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml index 1fbae299..92797610 100644 --- a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml +++ b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml @@ -60,6 +60,7 @@ channels: properties: file: type: string + format: binary uploadFileResponse200: name: uploadFileResponse200 title: POST response 200 @@ -79,6 +80,7 @@ channels: contentType: application/octet-stream payload: type: string + format: binary summary: Successful response operations: subscribeWebhook: From 0f891a6198897e140bc5250e4c0f3f40e69912a5 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Sun, 28 Jul 2024 19:10:58 +0530 Subject: [PATCH 13/15] added the README.md --- README.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39831bcf..9c84e71c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AsyncAPI Converter -Convert [AsyncAPI](https://asyncapi.com) documents older to newer versions. +Convert [AsyncAPI](https://asyncapi.com) documents older to newer versions and you can also convert OpenAPI documents to AsyncAPI documents. [![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors-) @@ -17,6 +17,7 @@ Convert [AsyncAPI](https://asyncapi.com) documents older to newer versions. * [In TS](#in-ts) - [Conversion 2.x.x to 3.x.x](#conversion-2xx-to-3xx) - [Known missing features](#known-missing-features) +- [OpenAPI 3.0 to AsyncAPI 3.0 Conversion](#openapi-30-to-asyncapi-30-conversion) - [Development](#development) - [Contribution](#contribution) - [Contributors ✨](#contributors-%E2%9C%A8) @@ -194,6 +195,45 @@ Conversion to version `3.x.x` from `2.x.x` has several assumptions that should b examples: ["test"] ``` +### OpenAPI 3.0 to AsyncAPI 3.0 Conversion + +The converter now supports transformation from OpenAPI 3.0 to AsyncAPI 3.0. This feature enables easy transition of existing OpenAPI 3.0 documents to AsyncAPI 3.0. + +To use this new conversion feature: + +```js +const fs = require('fs'); +const { convert } = require('@asyncapi/converter') + +try { + const openapi = fs.readFileSync('openapi.yml', 'utf-8') + const asyncapi = convert(openapi, '3.0.0', { from: 'openapi' }); + console.log(asyncapi); +} catch (e) { + console.error(e); +} +``` + +When converting from OpenAPI to AsyncAPI you can now specify the perspective of the conversion using the `perspective` option. This allows you to choose whether the conversion should be from an application or client point of view + +```js +const { convert } = require('@asyncapi/converter') + +try { + const asyncapi2 = fs.readFileSync('asyncapi2.yml', 'utf-8') + const asyncapi3 = convert(asyncapi2, '3.0.0', { openAPIToAsyncAPI: { perspective: 'client' } }); + console.log(asyncapi3); +} catch (e) { + console.error(e); +} +``` + +The perspective option can be set to either 'server' (default) or 'client'. + +- With `server` perspective: `action` becomes `receive` + +- With `client` perspective: `action` becomes `send` + ## Development 1. Setup project by installing dependencies `npm install` From b8061b5c6bcb4ed75630a166c37e219878770c97 Mon Sep 17 00:00:00 2001 From: utnim2 Date: Thu, 1 Aug 2024 01:23:38 +0530 Subject: [PATCH 14/15] added the reviewed changes except fromm payloa schema --- README.md | 4 ++ src/openapi.ts | 70 ++++++++++++++----- test/openapi-to-asyncapi.spec.ts | 2 +- .../callbacks_and_contents.yml | 6 ++ .../operation_and_parameter.yml | 3 + .../operation_and_parameter_client.yml | 5 +- 6 files changed, 69 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9c84e71c..9837e46a 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,10 @@ The perspective option can be set to either 'server' (default) or 'client'. - With `client` perspective: `action` becomes `send` +#### Limitations + +- External to internal references: The converter does not support scenarios where an external schema file references internal components of the AsyncAPI document. In such cases, manual adjustment of the converted document may be necessary. + ## Development 1. Setup project by installing dependencies `npm install` diff --git a/src/openapi.ts b/src/openapi.ts index c2dd45e5..1678f543 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,4 +1,4 @@ -import { sortObjectKeys, isRefObject, isPlainObject, removeEmptyObjects, createRefObject } from "./utils"; +import { sortObjectKeys, isRefObject, isPlainObject, removeEmptyObjects, createRefObject, isRemoteRef } from "./utils"; import { AsyncAPIDocument, ConvertOpenAPIFunction, OpenAPIToAsyncAPIOptions, OpenAPIDocument } from "./interfaces"; export const converters: Record = { @@ -134,6 +134,11 @@ function convertServerObjects(servers: ServerVariableObject[], openapi: OpenAPID return newServers; } +/** + * Generates a server name based on the server URL. + * @param {string} url - The server URL. + * @returns {string} The generated server name. + */ function generateServerName(url: string): string { const { host, pathname } = resolveServerUrl(url); const baseName = host.split('.').slice(-2).join('.'); @@ -184,7 +189,7 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' channels[channelName] = { address: path, messages: {}, - parameters: convertPathParameters(pathItem.parameters) + parameters: convertPathParameters(path, pathItem.parameters) }; for (const [method, operation] of Object.entries(pathItem)) { @@ -263,12 +268,20 @@ function convertPaths(paths: OpenAPIDocument['paths'], perspective: 'client' | ' * @param {any[]} parameters - The OpenAPI path parameters. * @returns {Record} The converted AsyncAPI channel parameters. */ -function convertPathParameters(parameters: any[] = []): Record { +function convertPathParameters( path:string, parameters: any[] = []): Record { const convertedParams: Record = {}; + + const paramNames = path.match(/\{([^}]+)\}/g)?.map(param => param.slice(1, -1)) || []; - parameters.forEach(param => { - if (!isRefObject(param) && param.in === 'path') { - convertedParams[param.name] = convertParameter(param); + paramNames.forEach(paramName => { + const param = parameters.find(p => p.name === paramName && p.in === 'path'); + if (param) { + convertedParams[paramName] = convertParameter(param); + } else { + // If the parameter is not defined in the OpenAPI spec, create a default one + convertedParams[paramName] = { + description: `Path parameter ${paramName}`, + }; } }); @@ -299,17 +312,19 @@ function convertOperationParameters(parameters: any[]): Record { */ function convertParameter(param: any): any { const convertedParam: any = { - description: param.description + description: param.description, }; - if (param.schema) { - if (!isRefObject(param.schema)) { - if (param.schema.enum) { - convertedParam.enum = param.schema.enum; - } - if (param.schema.default !== undefined) { - convertedParam.default = param.schema.default; - } + if (param.required) { + convertedParam.required = param.required; + } + + if (param.schema && !isRefObject(param.schema)) { + if (param.schema.enum) { + convertedParam.enum = param.schema.enum; + } + if (param.schema.default !== undefined) { + convertedParam.default = param.schema.default; } } @@ -443,6 +458,26 @@ function convertComponents(openapi: OpenAPIDocument): AsyncAPIDocument['componen return removeEmptyObjects(asyncComponents); } +/** + * converts openAPI schema object to multiformat/schema object + * @param schema openAPI schema object + * @returns multiformat/schema object + */ +function convertSchema(schema: any): any { + if (isRefObject(schema)) { + // Check if it's an external reference + if (schema.$ref.startsWith('./') || schema.$ref.startsWith('http')) { + return schema; + } + return schema; + } + + return { + schemaFormat: 'application/vnd.oai.openapi;version=3.0.0', + schema: schema + }; +} + /** * Converts OpenAPI Schema Objects to AsyncAPI Schema Objects. * @param {Record} schemas - The OpenAPI Schema Objects to convert. @@ -452,10 +487,7 @@ function convertSchemas(schemas: Record): Record { const convertedSchemas: Record = {}; for (const [name, schema] of Object.entries(schemas)) { - convertedSchemas[name] = { - schemaFormat: 'application/vnd.oai.openapi;version=3.0.0', - schema: schema - }; + convertedSchemas[name] = convertSchema(schema); } return convertedSchemas; diff --git a/test/openapi-to-asyncapi.spec.ts b/test/openapi-to-asyncapi.spec.ts index 8b2b5a4f..b4636417 100644 --- a/test/openapi-to-asyncapi.spec.ts +++ b/test/openapi-to-asyncapi.spec.ts @@ -30,7 +30,7 @@ describe("convert() - openapi to asyncapi", () => { const result = convertOpenAPI(input, '3.0.0'); assertResults(output, result); }); - it("should convert with 'server' perspective", () => { + it("should convert with 'client' perspective", () => { const input = fs.readFileSync(path.resolve(__dirname, "input", "openapi", "operation_and_parameter.yml"), "utf8"); const output = fs.readFileSync(path.resolve(__dirname, "output", "openapi-to-asyncapi", "operation_and_parameter_client.yml"), "utf8"); const options: OpenAPIToAsyncAPIOptions = { perspective: 'client' }; diff --git a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml index 92797610..d0a96513 100644 --- a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml +++ b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml @@ -36,6 +36,9 @@ channels: payload: $ref: '#/components/schemas/User' summary: Successful response + parameters: + userId: + description: Path parameter userId 'users_{userId}_posts': address: '/users/{userId}/posts' messages: @@ -48,6 +51,9 @@ channels: items: $ref: '#/components/schemas/Post' summary: Successful response + parameters: + userId: + description: Path parameter userId upload: address: /upload messages: diff --git a/test/output/openapi-to-asyncapi/operation_and_parameter.yml b/test/output/openapi-to-asyncapi/operation_and_parameter.yml index 4bb5964b..f57dcce8 100644 --- a/test/output/openapi-to-asyncapi/operation_and_parameter.yml +++ b/test/output/openapi-to-asyncapi/operation_and_parameter.yml @@ -68,6 +68,9 @@ channels: name: deleteItemResponse204 title: DELETE response 204 summary: Successful response + parameters: + itemId: + description: Path parameter itemId operations: listItems: action: receive diff --git a/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml b/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml index abb7c86c..824593c3 100644 --- a/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml +++ b/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml @@ -68,6 +68,9 @@ channels: name: deleteItemResponse204 title: DELETE response 204 summary: Successful response + parameters: + itemId: + description: Path parameter itemId operations: listItems: action: send @@ -159,4 +162,4 @@ components: name: type: string description: - type: string + type: string \ No newline at end of file From a5d010c2e9f47da02aa4dec627502e965e64f88e Mon Sep 17 00:00:00 2001 From: utnim2 Date: Fri, 2 Aug 2024 00:38:56 +0530 Subject: [PATCH 15/15] added test case for external ref and converted the payload schema --- README.md | 2 +- src/convert.ts | 2 +- src/openapi.ts | 12 +++-- test/helpers.ts | 3 +- test/input/openapi/external_reference.yml | 14 ++++++ .../callbacks_and_contents.yml | 48 +++++++++++-------- .../external_reference.yml | 30 ++++++++++++ .../operation_and_parameter.yml | 8 ++-- .../operation_and_parameter_client.yml | 8 ++-- 9 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 test/input/openapi/external_reference.yml create mode 100644 test/output/openapi-to-asyncapi/external_reference.yml diff --git a/README.md b/README.md index 9837e46a..49f15848 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ The perspective option can be set to either 'server' (default) or 'client'. #### Limitations -- External to internal references: The converter does not support scenarios where an external schema file references internal components of the AsyncAPI document. In such cases, manual adjustment of the converted document may be necessary. +- External to internal references: The converter does not support scenarios where an external schema file references internal components of the OpenAPI document. In such cases, manual adjustment of the converted document may be necessary. ## Development diff --git a/src/convert.ts b/src/convert.ts index 51dc4167..9a6ea63d 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -63,7 +63,7 @@ export function convertOpenAPI(input: string | OpenAPIDocument, version: OpenAPI const { format, document } = serializeInput(input); const openApiVersion = document.openapi; - const converterVersion = openApiVersion === '3.0.0' ? '3.0.0' : openApiVersion; + const converterVersion = openApiVersion; const openapiToAsyncapiConverter = openapiConverters[converterVersion as OpenAPIConvertVersion] as ConvertOpenAPIFunction; diff --git a/src/openapi.ts b/src/openapi.ts index 1678f543..a8afb3aa 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -362,7 +362,7 @@ function convertRequestBodyToMessages(requestBody: any, operationId: string, met name: messageName, title: `${method.toUpperCase()} request`, contentType: contentType, - payload: mediaType.schema, + payload: convertSchema(mediaType.schema), summary: requestBody.description, }; }); @@ -389,7 +389,7 @@ function convertResponsesToMessages(responses: Record, operationId: name: messageName, title: `${method.toUpperCase()} response ${statusCode}`, contentType: contentType, - payload: mediaType.schema, + payload: convertSchema(mediaType.schema), summary: response.description, headers: response.headers ? convertHeadersToSchema(response.headers) : undefined, }; @@ -467,7 +467,11 @@ function convertSchema(schema: any): any { if (isRefObject(schema)) { // Check if it's an external reference if (schema.$ref.startsWith('./') || schema.$ref.startsWith('http')) { - return schema; + // Convert external references to multi-format schema objects + return { + schemaFormat: 'application/vnd.oai.openapi;version=3.0.0', + schema: schema + }; } return schema; } @@ -565,7 +569,7 @@ function convertComponentResponsesToMessages(responses: Record): Re messages[name] = { name: name, contentType: contentType, - payload: mediaType.schema, + payload: convertSchema(mediaType.schema), summary: response.description, headers: response.headers ? convertHeadersToSchema(response.headers) : undefined, }; diff --git a/test/helpers.ts b/test/helpers.ts index 78163312..de31b908 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -10,8 +10,7 @@ export function removeLineBreaks(str: string) { export function assertResults(output: string, result: string){ try{ expect(removeLineBreaks(output)).toEqual(removeLineBreaks(result)); - }catch(e) { - console.log(result) + } catch(e) { throw e; } } diff --git a/test/input/openapi/external_reference.yml b/test/input/openapi/external_reference.yml new file mode 100644 index 00000000..e6e24bbe --- /dev/null +++ b/test/input/openapi/external_reference.yml @@ -0,0 +1,14 @@ +openapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +paths: + /test: + get: + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: './external.json' \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml index d0a96513..ec7814c9 100644 --- a/test/output/openapi-to-asyncapi/callbacks_and_contents.yml +++ b/test/output/openapi-to-asyncapi/callbacks_and_contents.yml @@ -17,11 +17,13 @@ channels: title: POST request contentType: application/json payload: - type: object - properties: - callbackUrl: - type: string - format: uri + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + callbackUrl: + type: string + format: uri subscribeWebhookResponse201: name: subscribeWebhookResponse201 title: POST response 201 @@ -47,9 +49,11 @@ channels: title: GET response 200 contentType: application/json payload: - type: array - items: - $ref: '#/components/schemas/Post' + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: array + items: + $ref: '#/components/schemas/Post' summary: Successful response parameters: userId: @@ -62,20 +66,24 @@ channels: title: POST request contentType: multipart/form-data payload: - type: object - properties: - file: - type: string - format: binary + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + file: + type: string + format: binary uploadFileResponse200: name: uploadFileResponse200 title: POST response 200 contentType: application/json payload: - type: object - properties: - fileId: - type: string + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: object + properties: + fileId: + type: string summary: Successful upload stream: address: /stream @@ -85,8 +93,10 @@ channels: title: GET response 200 contentType: application/octet-stream payload: - type: string - format: binary + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: string + format: binary summary: Successful response operations: subscribeWebhook: diff --git a/test/output/openapi-to-asyncapi/external_reference.yml b/test/output/openapi-to-asyncapi/external_reference.yml new file mode 100644 index 00000000..5066a2fe --- /dev/null +++ b/test/output/openapi-to-asyncapi/external_reference.yml @@ -0,0 +1,30 @@ +asyncapi: 3.0.0 +info: + title: Test API + version: 1.0.0 +channels: + test: + address: /test + messages: + gettestResponse200: + name: gettestResponse200 + title: GET response 200 + contentType: application/json + payload: + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + $ref: ./external.json + summary: Successful response +operations: + gettest: + action: receive + channel: + $ref: '#/channels/test' + bindings: + http: + method: GET + reply: + channel: + $ref: '#/channels/test' + messages: + - $ref: '#/channels/test/messages/gettestResponse200' \ No newline at end of file diff --git a/test/output/openapi-to-asyncapi/operation_and_parameter.yml b/test/output/openapi-to-asyncapi/operation_and_parameter.yml index f57dcce8..55532975 100644 --- a/test/output/openapi-to-asyncapi/operation_and_parameter.yml +++ b/test/output/openapi-to-asyncapi/operation_and_parameter.yml @@ -17,9 +17,11 @@ channels: title: GET response 200 contentType: application/json payload: - type: array - items: - $ref: '#/components/schemas/Item' + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: array + items: + $ref: '#/components/schemas/Item' summary: Successful response createItemRequest: name: createItemRequest diff --git a/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml b/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml index 824593c3..3e3f1678 100644 --- a/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml +++ b/test/output/openapi-to-asyncapi/operation_and_parameter_client.yml @@ -17,9 +17,11 @@ channels: title: GET response 200 contentType: application/json payload: - type: array - items: - $ref: '#/components/schemas/Item' + schemaFormat: application/vnd.oai.openapi;version=3.0.0 + schema: + type: array + items: + $ref: '#/components/schemas/Item' summary: Successful response createItemRequest: name: createItemRequest