diff --git a/README.md b/README.md index b83d0363..808bad14 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Convert [AsyncAPI](https://asyncapi.com) documents older to newer versions. * [From CLI](#from-cli) * [In JS](#in-js) * [In TS](#in-ts) +- [Conversion 2.x.x to 3.x.x](#conversion-2xx-to-3xx) - [Known missing features](#known-missing-features) - [Development](#development) - [Contribution](#contribution) @@ -91,6 +92,75 @@ try { } ``` +## Conversion 2.x.x to 3.x.x + +> **NOTE**: This feature is still WIP, and is until the final release of `3.0.0`. + +Conversion to version `3.x.x` from `2.x.x` has several assumptions that should be know before converting: + +- The input must be valid AsyncAPI document. +- External references are not resolved and converted, they remain untouched, even if they are incorrect. +- In version `3.0.0`, the channel identifier is no longer its address, but due to the difficulty of defining a unique identifier, we still treat the address as an identifier. If there is a need to assign an identifier other than an address, an `x-channelId` extension should be defined at the level of the given channel. + + ```yaml + # 2.x.x + channels: + users/signup: + x-channelId: 'userSignUp' + ... + users/logout: + ... + + # 3.0.0 + channels: + userSignUp: + ... + users/logout: + ... + ``` + +- The `publish` operation is treated as a `receive` action, and `subscribe` is treated as a `send` action. Conversion by default is embraced from the application perspective. If you want to change this logic, you need to specify `v2tov3.pointOfView` configuration as `client`. +- If the operation does not have an `operationId` field defined, the unique identifier of the operation will be defined as a combination of the identifier of the channel on which the operation was defined + the type of operation, `publish` or `subscribe`. Identical situation is with messages. However, here the priority is the `messageId` field and then the concatenation `{publish|subscribe}.messages.{optional index of oneOf messages}`. + + ```yaml + # 2.x.x + channels: + users/signup: + publish: + message: + ... + subscribe: + operationId: 'userSignUpEvent' + message: + oneOf: + - messageId: 'userSignUpEventMessage' + ... + - ... + + + # 3.0.0 + channels: + users/signup: + messages: + publish.message: + ... + userSignUpEventMessage: + ... + userSignUpEvent.message.1: + ... + operations: + users/signup.publish: + action: receive + ... + userSignUpEvent: + action: send + ... + ``` + +- Security requirements that use scopes are defined in the appropriate places inline, the rest as a reference to the `components.securitySchemes` objects. +- If servers are defined at the channel level, they are converted as references to the corresponding objects defined in the `servers` field. +- Channels and servers defined in components are also converted (unless configured otherwise). + ## Known missing features * When converting from 1.x to 2.x, Streaming APIs (those using `stream` instead of `topics` or `events`) are converted correctly but information about framing type and delimiter is missing until a [protocolInfo](https://github.com/asyncapi/extensions-catalog/issues/1) for that purpose is created. diff --git a/src/convert.ts b/src/convert.ts index ea92d0ab..b7d77eb6 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -2,6 +2,7 @@ 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 { serializeInput } from "./utils"; @@ -12,7 +13,8 @@ import type { AsyncAPIDocument, ConvertVersion, ConvertOptions, ConvertFunction */ const converters: Record = { ...firstConverters, - ...secondConverters + ...secondConverters, + ...thirdConverters, }; const conversionVersions = Object.keys(converters); diff --git a/src/interfaces.ts b/src/interfaces.ts index 297933a2..31aa98a3 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -2,10 +2,20 @@ * 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'; -export type ConvertOptions = { } +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 ConvertV2ToV3Options = { + idGenerator?: (data: { asyncapi: AsyncAPIDocument, kind: 'channel' | 'operation' | 'message', key: string | number | undefined, path: Array, object: any, parentId?: string }) => string, + pointOfView?: 'application' | 'client', + useChannelIdExtension?: boolean; + convertServerComponents?: boolean; + convertChannelComponents?: boolean; +} +export type ConvertOptions = { + v2tov3?: ConvertV2ToV3Options; +} /** * PRIVATE TYPES */ export type ConvertFunction = (asyncapi: AsyncAPIDocument, options: ConvertOptions) => AsyncAPIDocument; + diff --git a/src/third-version.ts b/src/third-version.ts new file mode 100644 index 00000000..5327e384 --- /dev/null +++ b/src/third-version.ts @@ -0,0 +1,557 @@ +import { isPlainObject, createRefObject, isRefObject, sortObjectKeys, getValueByRef, getValueByPath, createRefPath } from './utils'; +import type { AsyncAPIDocument, ConvertOptions, ConvertV2ToV3Options, ConvertFunction } from './interfaces'; + +export const converters: Record = { + '3.0.0': from__2_6_0__to__3_0_0, +} + +type RequiredConvertV2ToV3Options = Required; +type ConvertContext = { + refs: Map; +}; + +function from__2_6_0__to__3_0_0(asyncapi: AsyncAPIDocument, options: ConvertOptions): AsyncAPIDocument { + asyncapi.asyncapi = '3.0.0'; + + const v2tov3Options: RequiredConvertV2ToV3Options = { + pointOfView: 'application', + useChannelIdExtension: true, + convertServerComponents: true, + convertChannelComponents: true, + ...(options.v2tov3 ?? {}), + } as RequiredConvertV2ToV3Options; + v2tov3Options.idGenerator = v2tov3Options.idGenerator || idGeneratorFactory(v2tov3Options); + const context: ConvertContext = { + refs: new Map(), + } + + convertInfoObject(asyncapi, context); + if (isPlainObject(asyncapi.servers)) { + asyncapi.servers = convertServerObjects(asyncapi.servers, asyncapi); + } + if (isPlainObject(asyncapi.channels)) { + asyncapi.channels = convertChannelObjects(asyncapi.channels, asyncapi, v2tov3Options, context); + } + convertComponents(asyncapi, v2tov3Options, context); + replaceDeepRefs(asyncapi, context.refs, '', asyncapi); + + return sortObjectKeys( + asyncapi, + ['asyncapi', 'id', 'info', 'defaultContentType', 'servers', 'channels', 'operations', 'components'] + ); +} + +/** + * Moving Tags and ExternalDocs to the Info Object. + */ +function convertInfoObject(asyncapi: AsyncAPIDocument, context: ConvertContext) { + if (asyncapi.tags) { + asyncapi.info.tags = asyncapi.tags; + context.refs.set(createRefPath('tags'), createRefPath('info', 'tags')); + delete asyncapi.tags; + } + + if (asyncapi.externalDocs) { + asyncapi.info.externalDocs = asyncapi.externalDocs; + context.refs.set(createRefPath('externalDocs'), createRefPath('info', 'externalDocs')); + delete asyncapi.externalDocs; + } + + asyncapi.info = sortObjectKeys( + asyncapi.info, + ['title', 'version', 'description', 'termsOfService', 'contact', 'license', 'tags', 'externalDocs'], + ); +} + +/** + * Split `url` field to the `host` and `pathname` (optional) fields. + * Unify referencing mechanism in security field. + */ +function convertServerObjects(servers: Record, asyncapi: AsyncAPIDocument) { + const newServers: Record = {}; + 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; + } + // Dont overwrite anything + if(protocol !== undefined && server.protocol === undefined) { + server.protocol = protocol; + } + delete server.url; + + if (server.security) { + server.security = convertSecurityObject(server.security, asyncapi); + } + + newServers[serverName] = sortObjectKeys( + server, + ['host', 'pathname', 'protocol', 'protocolVersion', 'title', 'summary', 'description', 'variables', 'security', 'tags', 'externalDocs', 'bindings'], + ); + }); + return newServers; +} + +type ToStandaloneOperationData = { + kind: 'subscribe' | 'publish', + channel: any, + asyncapi: any, + operations: any, + context:any, + inComponents: boolean, + channelId: string, + channelAddress: string, + options: any, + oldPath: string[] +} +/** + * Convert operation part of channels into standalone operations + */ +function toStandaloneOperation(data: ToStandaloneOperationData): Record { + const {kind, channel, asyncapi, operations, context, inComponents, channelId, channelAddress, options, oldPath} = data; + let operation = channel[kind]; + const operationPath = inComponents ? ['components', 'operations'] : ['operations']; + if (isPlainObject(operation)) { + const { operationId, operation: newOperation, messages } = convertOperationObject({ asyncapi, kind, channel, channelId, oldChannelId: channelAddress, operation, inComponents }, options, context); + if (operation.security) { + newOperation.security = convertSecurityObject(operation.security, asyncapi); + } + operationPath.push(operationId); + + context.refs.set(createRefPath(...oldPath, kind), createRefPath(...operationPath)); + operations[operationId] = newOperation; + delete channel[kind]; + return messages ?? {}; + } + return {}; +} + +/** + * Split Channel Objects to the Channel Objects and Operation Objects. + */ +function convertChannelObjects(channels: Record, asyncapi: AsyncAPIDocument, options: RequiredConvertV2ToV3Options, context: ConvertContext, inComponents: boolean = false) { + const newChannels: Record = {}; + Object.entries(channels).forEach(([channelAddress, channel]) => { + const oldPath = inComponents ? ['components', 'channels', channelAddress] : ['channels', channelAddress]; + const channelId = options.idGenerator({ asyncapi, kind: 'channel', key: channelAddress, path: oldPath, object: channel }); + const newPath = inComponents ? ['components', 'channels', channelId] : ['channels', channelId]; + context.refs.set(createRefPath(...oldPath), createRefPath(...newPath)); + + if (isRefObject(channel)) { + newChannels[channelId] = channel; + return; + } + + // assign address + channel.address = channelAddress; + // change the Server names to the Refs + const servers = channel.servers; + if (Array.isArray(servers)) { + channel.servers = servers.map((serverName: string) => createRefObject('servers', serverName)); + } + + const operations: Record = {}; + // serialize publish and subscribe Operation Objects to standalone object + const publishMessages = toStandaloneOperation({kind: 'publish', channel, asyncapi, operations, context, inComponents, channelId, channelAddress, options, oldPath}); + const subscribeMessages = toStandaloneOperation({kind: 'subscribe', channel, asyncapi, operations, context, inComponents, channelId, channelAddress, options, oldPath}); + + if (publishMessages || subscribeMessages) { + const allOperationMessages = { + ...publishMessages, + ...subscribeMessages, + } + channel.messages = convertMessages({ + messages: allOperationMessages + }); + } + + setOperationsOnRoot({operations, inComponents, asyncapi, oldPath}); + newChannels[channelId] = sortObjectKeys( + channel, + ['address', 'messages', 'title', 'summary', 'description', 'servers', 'parameters', 'tags', 'externalDocs', 'bindings'], + ); + }); + return newChannels; +} + +type SetOperationsOnRootData = { + operations: any, + inComponents: boolean, + asyncapi: AsyncAPIDocument, + oldPath: string[] +} +/** + * Assign the operations to the root AsyncAPI object. + */ +function setOperationsOnRoot(data: SetOperationsOnRootData){ + const {operations, inComponents, asyncapi, oldPath} = data; + if (Object.keys(operations)) { + if (inComponents) { + const components = asyncapi.components = asyncapi.components ?? {}; + components.operations = { ...components.operations ?? {}, ...operations }; + + // if given component is used in the `channels` object then create references for operations in the `operations` object + if (channelIsUsed(asyncapi.channels ?? {}, oldPath)) { + const referencedOperations = Object.keys(operations).reduce((acc, current) => { + acc[current] = createRefObject('components', 'operations', current); + return acc; + }, {} as Record); + asyncapi.operations = { ...asyncapi.operations ?? {}, ...referencedOperations }; + } + } else { + asyncapi.operations = { ...asyncapi.operations ?? {}, ...operations }; + } + } +} + +type ConvertOperationObjectData = { + asyncapi: AsyncAPIDocument; + operation: any; + channel: any; + channelId: string; + oldChannelId: string; + kind: 'publish' | 'subscribe'; + inComponents: boolean; +} +/** + * Points to the connected channel and split messages for channel + */ +function convertOperationObject(data: ConvertOperationObjectData, options: RequiredConvertV2ToV3Options, context: ConvertContext): { operationId: string, operation: any, messages?: Record } { + const { asyncapi, channelId, oldChannelId, kind, inComponents } = data; + const operation = { ...data.operation }; + + const oldChannelPath = ['channels', oldChannelId]; + if (inComponents) { + oldChannelPath.unshift('components'); + } + const newChannelPath = ['channels', channelId]; + if (inComponents) { + newChannelPath.unshift('components'); + } + + const operationId = options.idGenerator({ asyncapi, kind: 'operation', key: kind, path: oldChannelPath, object: data.operation, parentId: channelId }); + operation.channel = createRefObject(...newChannelPath); + try { + delete operation.operationId; + } catch(err) {} + + + const isPublish = kind === 'publish'; + if (options.pointOfView === 'application') { + operation.action = isPublish ? 'receive' : 'send'; + } else { + operation.action = isPublish ? 'send' : 'receive'; + } + + const message = operation.message; + let serializedMessages: Record = {}; + if (message) { + delete operation.message; + + const oldMessagePath = ['channels', oldChannelId, kind, 'message']; + const newMessagePath = ['channels', channelId, 'messages']; + if (inComponents) { + oldMessagePath.unshift('components'); + newMessagePath.unshift('components'); + } + serializedMessages = moveMessagesFromOperation(message, newMessagePath, oldMessagePath, asyncapi, options, context, operationId); + applyMessageRefsToOperation(serializedMessages, newMessagePath, operation); + } + + const sortedOperation = sortObjectKeys( + operation, + ['action', 'channel', 'title', 'summary', 'description', 'security', 'tags', 'externalDocs', 'bindings', 'traits'], + ); + + return { operationId, operation: sortedOperation, messages: serializedMessages }; +} +/** + * Remove all messages under operations and return an object of them. + */ +function moveMessagesFromOperation(message: any, newMessagePath: string[], oldMessagePath: string[], asyncapi: any, options: any, context: any, operationId: string): Record { + if (Array.isArray(message.oneOf)) { + //Message oneOf no longer exists, it's implicit by having multiple entires in the message object. + return message.oneOf.reduce((acc: Record, current: any, index: number) => { + const messagePath = [...oldMessagePath, 'oneOf', index]; + const messageId = options.idGenerator({ asyncapi, kind: 'message', key: index, path: messagePath, object: current, parentId: operationId }); + context.refs.set(createRefPath(...messagePath), createRefPath(...newMessagePath, messageId)); + acc[messageId] = current; + return acc; + }, {}); + } else { + const messageId = options.idGenerator({ asyncapi, kind: 'message', key: 'message', path: oldMessagePath, object: message, parentId: operationId }); + context.refs.set(createRefPath(...oldMessagePath), createRefPath(...newMessagePath, messageId)); + return { [messageId]: message }; + } +} + +/** + * Add references of messages to operations. + */ +function applyMessageRefsToOperation(serializedMessages: Record, newMessagePath: string[], operation: any) { + if (Object.keys(serializedMessages ?? {}).length > 0 ) { + const newOperationMessages: Array = []; + Object.entries(serializedMessages).forEach(([messageId, messageValue]) => { + if (isRefObject(messageValue)) { + // shallow copy of JS reference + newOperationMessages.push({ ...messageValue }); + } else { + const messagePath = [...newMessagePath, messageId]; + newOperationMessages.push(createRefObject(...messagePath)); + } + }); + operation.messages = newOperationMessages; + } +} + +type ConvertMessagesObjectData = { + messages: Record +} +/** + * Convert messages that use custom schema format into schema union. + */ +function convertMessages(data: ConvertMessagesObjectData): Record{ + const messages = {...data.messages}; + // Convert schema formats to union schemas + Object.entries(messages).forEach(([_, message]) => { + if(message.schemaFormat !== undefined) { + const payloadSchema = message.payload; + message.payload = { + schemaFormat: message.schemaFormat, + schema: payloadSchema + } + delete message.schemaFormat; + } + }); + return messages; +} + +/** + * Convert `channels`, `servers` and `securitySchemes` in components. + */ +function convertComponents(asyncapi: AsyncAPIDocument, options: RequiredConvertV2ToV3Options, context: ConvertContext) { + const components = asyncapi.components; + if (!isPlainObject(components)) { + return; + } + + if (options.convertServerComponents && isPlainObject(components.servers)) { + components.servers = convertServerObjects(components.servers, asyncapi); + } + if (options.convertChannelComponents && isPlainObject(components.channels)) { + components.channels = convertChannelObjects(components.channels, asyncapi, options, context, true); + } + if (isPlainObject(components.securitySchemes)) { + components.securitySchemes = convertSecuritySchemes(components.securitySchemes); + } + + if (isPlainObject(components.messages)) { + components.messages = convertMessages({ + messages: components.messages + }); + } +} + +/** + * Convert `channels`, `servers` and `securitySchemes` in components. + */ +function convertSecuritySchemes(securitySchemes: Record): Record { + const newSecuritySchemes: Record = {}; + Object.entries(securitySchemes).forEach(([name, scheme]) => { + newSecuritySchemes[name] = convertSecuritySchemeObject(scheme); + }); + return newSecuritySchemes; +} + +/** + * Unify referencing mechanism in security field + */ +function convertSecurityObject(security: Array>>, asyncapi: AsyncAPIDocument) { + const newSecurity: Array = []; + security.forEach(securityItem => { + Object.entries(securityItem).forEach(([securityName, scopes]) => { + // without scopes - use ref + if (!scopes.length) { + newSecurity.push(createRefObject('components', 'securitySchemes', securityName)) + return; + } + + // create new security scheme in the components/securitySchemes with appropriate scopes + const securityScheme = getValueByPath(asyncapi, ['components', 'securitySchemes', securityName]); + // handle logic only on `oauth2` and `openIdConnect` security mechanism + if (securityScheme.type === 'oauth2' || securityScheme.type === 'openIdConnect') { + const newSecurityScheme = convertSecuritySchemeObject(securityScheme); + newSecurity.push({ + ...newSecurityScheme, + scopes: [...scopes], + }); + } + }); + }); + return newSecurity; +} + +const flowKinds = ['implicit', 'password', 'clientCredentials', 'authorizationCode']; +/** + * Convert security scheme object to new from v3 version (flow.[x].scopes -> flow.[x].availableScopes). + */ +function convertSecuritySchemeObject(original: any) { + const securityScheme = JSON.parse(JSON.stringify(original)); + if (securityScheme.flows) { + flowKinds.forEach(flow => { + const flowScheme = securityScheme.flows[flow]; + if (flowScheme?.scopes) { + flowScheme.availableScopes = flowScheme.scopes; + delete flowScheme.scopes; + } + }); + } + return securityScheme; +} + +/** + * Split `url` to the `host` and `pathname` (optional) fields. + * + * This function takes care of https://github.com/asyncapi/spec/pull/888 + */ +function resolveServerUrl(url: string): { host: string, pathname: string | undefined, protocol: string | undefined } { + let [maybeProtocol, maybeHost] = url.split('://'); + if (!maybeHost) { + maybeHost = maybeProtocol; + } + + const [host, ...pathnames] = maybeHost.split('/'); + if (pathnames.length) { + return { host, pathname: `/${pathnames.join('/')}`, protocol: maybeProtocol }; + } + return { host, pathname: undefined, protocol: maybeProtocol }; +} + +/** + * Check if given channel (based on path) is used in the `channels` object. + */ +function channelIsUsed(channels: Record, path: Array): boolean { + for (const channel of Object.values(channels)) { + if (isRefObject(channel) && createRefPath(...path) === channel.$ref) { + return true; + } + } + return false; +} + +/** + * Replace all deep local references with the new beginning of ref (when object is moved to another place). + */ +function replaceDeepRefs(value: any, refs: ConvertContext['refs'], key: string | number, parent: any): void { + if (key === '$ref' && typeof value === 'string') { + const newRef = replaceRef(value, refs); + if (typeof newRef === 'string') { + parent[key] = newRef; + } + return; + } + + if (Array.isArray(value)) { + return value.forEach((item, idx) => replaceDeepRefs(item, refs, idx, value)); + } + + if (value && typeof value === 'object') { + for (const objKey in value) { + replaceDeepRefs(value[objKey], refs, objKey, value); + } + } +} + +function replaceRef(ref: string, refs: ConvertContext['refs']): string | undefined { + const allowed: string[] = []; + refs.forEach((_, key) => { + // few refs can be allowed + if (ref.startsWith(key)) { + allowed.push(key); + } + }); + + // find the longest one + allowed.sort((a, b) => a.length - b.length); + const from = allowed.pop(); + if (!from) { + return; + } + + const toReplace = refs.get(from); + if (toReplace) { + return ref.replace(from, toReplace); + } +} + +/** + * Default function to generate ids for objects. + */ +function idGeneratorFactory(options: ConvertV2ToV3Options): ConvertV2ToV3Options['idGenerator'] { + const useChannelIdExtension = options.useChannelIdExtension; + return (data: Parameters>[0]): string => { + const { asyncapi, kind, object, key, parentId } = data; + + switch (kind) { + case 'channel': + return generateIdForChannel(object, key, useChannelIdExtension); + case 'operation': { + const oldOperationId = object.operationId; + const operationId = oldOperationId || (parentId ? `${parentId}.${key}` : kind); + return operationId; + }; + case 'message': + return generateIdForMessage(object, asyncapi, parentId, key); + default: return ''; + } + }; +} +function generateIdForChannel(object: any, key: any, useChannelIdExtension: boolean | undefined) { + if (isRefObject(object)) { + const id = key as string; + return id; + } + + const channel = object; + let channelId: string; + if (useChannelIdExtension) { + channelId = channel['x-channelId'] || key as string; + } else { + channelId = key as string; + } + return channelId; +} + +function generateIdForMessage(object: any, asyncapi: any, parentId: string | undefined, key: any) { + if (isRefObject(object)) { + const possibleMessage = getValueByRef(asyncapi, object.$ref); + if (possibleMessage?.messageId) { + const messageId = possibleMessage.messageId; + return messageId; + } + } + + const messageId = object.messageId; + if (messageId) { + return messageId; + } + + let operationKind: string; + const splitParentId = parentId!.split('.'); + if (splitParentId.length === 1) { + operationKind = parentId as string; + } else { + operationKind = splitParentId.pop() as string; + } + + if (typeof key === 'number') { + return `${operationKind}.message.${key}`; + } + return `${operationKind}.message`; +} \ No newline at end of file diff --git a/test/helpers.ts b/test/helpers.ts index 469ccb9a..69e7fca6 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -7,5 +7,10 @@ export function removeLineBreaks(str: string) { } export function assertResults(output: string, result: string){ - expect(removeLineBreaks(output)).toEqual(removeLineBreaks(result)); + try{ + expect(removeLineBreaks(output)).toEqual(removeLineBreaks(result)); + }catch(e) { + console.log(result) + throw e; + } } diff --git a/test/input/2.6.0/for-3.0.0-with-custom-schema-format.yml b/test/input/2.6.0/for-3.0.0-with-custom-schema-format.yml new file mode 100644 index 00000000..55a3c9f5 --- /dev/null +++ b/test/input/2.6.0/for-3.0.0-with-custom-schema-format.yml @@ -0,0 +1,70 @@ +asyncapi: 2.6.0 +id: 'urn:example:com:smartylighting:streetlights:server' + +info: + title: AsyncAPI Sample App + version: 1.0.1 + description: This is a sample app. + termsOfService: https://asyncapi.com/terms/ + contact: + name: API Support + url: https://www.asyncapi.com/support + email: support@asyncapi.org + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +tags: + - name: e-commerce + - name: another-tag + description: Description... +externalDocs: + description: Find more info here + url: https://www.asyncapi.com + +defaultContentType: application/json + +channels: + 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured': + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + publish: + operationId: lightMeasured + message: + schemaFormat: 'application/vnd.apache.avro;version=1.9.0' + payload: # The following is an Avro schema in YAML format (JSON format is also supported) + type: record + name: User + namespace: com.company + doc: User information + fields: + - name: displayName + type: string + - name: email + type: string + - name: age + type: int + +components: + messages: + lightMeasured: + schemaFormat: 'application/vnd.apache.avro;version=1.9.0' + payload: # The following is an Avro schema in YAML format (JSON format is also supported) + type: record + name: User + namespace: com.company + doc: User information + fields: + - name: displayName + type: string + - name: email + type: string + - name: age + type: int + + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string \ No newline at end of file diff --git a/test/output/3.0.0/from-2.6.0-with-custom-schema-format.yml b/test/output/3.0.0/from-2.6.0-with-custom-schema-format.yml new file mode 100644 index 00000000..cdb93a02 --- /dev/null +++ b/test/output/3.0.0/from-2.6.0-with-custom-schema-format.yml @@ -0,0 +1,75 @@ +asyncapi: 3.0.0 +id: 'urn:example:com:smartylighting:streetlights:server' +info: + title: AsyncAPI Sample App + version: 1.0.1 + description: This is a sample app. + termsOfService: 'https://asyncapi.com/terms/' + contact: + name: API Support + url: 'https://www.asyncapi.com/support' + email: support@asyncapi.org + license: + name: Apache 2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' + tags: + - name: e-commerce + - name: another-tag + description: Description... + externalDocs: + description: Find more info here + url: 'https://www.asyncapi.com' +defaultContentType: application/json +channels: + 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured': + address: 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured' + messages: + lightMeasured.message: + payload: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + schema: + type: record + name: User + namespace: com.company + doc: User information + fields: + - name: displayName + type: string + - name: email + type: string + - name: age + type: int + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' +operations: + lightMeasured: + action: receive + channel: + $ref: >- + #/channels/smartylighting~1streetlights~11~10~1event~1{streetlightId}~1lighting~1measured + messages: + - $ref: >- + #/channels/smartylighting~1streetlights~11~10~1event~1{streetlightId}~1lighting~1measured/messages/lightMeasured.message +components: + messages: + lightMeasured: + payload: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + schema: + type: record + name: User + namespace: com.company + doc: User information + fields: + - name: displayName + type: string + - name: email + type: string + - name: age + type: int + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string \ No newline at end of file diff --git a/test/output/3.0.0/from-2.6.0-with-deep-local-references.yml b/test/output/3.0.0/from-2.6.0-with-deep-local-references.yml new file mode 100644 index 00000000..900fdf50 --- /dev/null +++ b/test/output/3.0.0/from-2.6.0-with-deep-local-references.yml @@ -0,0 +1,112 @@ +asyncapi: 3.0.0 +info: + title: AsyncAPI Sample App + version: 1.0.1 +channels: + lightingMeasured: + address: lightingMeasured + messages: + lightMeasured.message: + payload: + type: object + properties: + someProperty: + type: string + circularProperty: + $ref: >- + #/channels/lightingMeasured/messages/lightMeasured.message/payload + turnOn: + address: turnOn + messages: + publish.message: + $ref: '#/components/messages/lightMeasured' + subscribe.message.0: + $ref: '#/components/messages/turnOnOff' + customMessageId: + messageId: customMessageId + payload: + type: object + properties: + someProperty: + type: string + circularProperty: + $ref: '#/channels/turnOn/messages/customMessageId/payload' +operations: + lightMeasured: + action: receive + channel: + $ref: '#/channels/lightingMeasured' + messages: + - $ref: '#/channels/lightingMeasured/messages/lightMeasured.message' + turnOn.publish: + action: receive + channel: + $ref: '#/channels/turnOn' + messages: + - $ref: '#/components/messages/lightMeasured' + turnOn.subscribe: + action: send + channel: + $ref: '#/channels/turnOn' + messages: + - $ref: '#/components/messages/turnOnOff' + - $ref: '#/channels/turnOn/messages/customMessageId' +components: + channels: + someChannel: + address: someChannel + messages: + publish.message: + $ref: '#/components/messages/lightMeasured' + subscribe.message.0: + $ref: '#/components/messages/turnOnOff' + customMessageId: + messageId: customMessageId + payload: + type: object + properties: + someProperty: + type: string + circularProperty: + $ref: >- + #/components/channels/someChannel/messages/customMessageId/payload + messages: + turnOnOff: + summary: Command a particular streetlight to turn the lights on or off. + payload: + $ref: '#/components/schemas/turnOnOffPayload' + schemas: + turnOnOffPayload: + type: object + properties: + command: + type: string + enum: + - 'on' + - 'off' + description: Whether to turn on or off the light. + sentAt: + $ref: '#/components/schemas/sentAt' + sentAt: + type: string + format: date-time + description: Date and time when the message was sent. + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string + operations: + someChannel.publish: + action: receive + channel: + $ref: '#/components/channels/someChannel' + messages: + - $ref: '#/components/messages/lightMeasured' + someChannel.subscribe: + action: send + channel: + $ref: '#/components/channels/someChannel' + messages: + - $ref: '#/components/messages/turnOnOff' + - $ref: '#/components/channels/someChannel/messages/customMessageId' diff --git a/test/output/3.0.0/from-2.6.0-with-servers-and-channels-components.yml b/test/output/3.0.0/from-2.6.0-with-servers-and-channels-components.yml new file mode 100644 index 00000000..d98eb2e3 --- /dev/null +++ b/test/output/3.0.0/from-2.6.0-with-servers-and-channels-components.yml @@ -0,0 +1,188 @@ +asyncapi: 3.0.0 +info: + title: AsyncAPI Sample App + version: 1.0.1 + description: This is a sample app. +servers: + default: + host: 'api.streetlights.smartylighting.com:{port}' + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - $ref: '#/components/securitySchemes/apiKey' + - type: oauth2 + flows: + implicit: + authorizationUrl: 'https://example.com/api/oauth/dialog' + availableScopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + scopes: + - 'write:pets' + referenced: + $ref: '#/components/servers/withProtocol' +channels: + 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured': + address: 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured' + messages: + lightMeasured.message: + payload: + type: object + servers: + - $ref: '#/servers/production' + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + 'smartylighting/streetlights/1/0/action/{streetlightId}/turn/on': + $ref: '#/components/channels/usedChannel' +operations: + lightMeasured: + action: receive + channel: + $ref: >- + #/channels/smartylighting~1streetlights~11~10~1event~1{streetlightId}~1lighting~1measured + messages: + - $ref: >- + #/channels/smartylighting~1streetlights~11~10~1event~1{streetlightId}~1lighting~1measured/messages/lightMeasured.message + usedChannel.publish: + $ref: '#/components/operations/usedChannel.publish' + usedChannel.subscribe: + $ref: '#/components/operations/usedChannel.subscribe' +components: + servers: + production: + host: 'api.streetlights.smartylighting.com:{port}' + pathname: /some/path + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - $ref: '#/components/securitySchemes/apiKey' + withProtocol: + host: 'api.streetlights.smartylighting.com:{port}' + pathname: /some/path + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - $ref: '#/components/securitySchemes/apiKey' + channels: + usedChannel: + address: usedChannel + messages: + publish.message: + $ref: '#/components/messages/lightMeasured' + subscribe.message.0: + $ref: '#/components/messages/turnOnOff' + customMessageId: + messageId: customMessageId + payload: + type: object + subscribe.message.2: + payload: + type: object + subscribe.message.3: + $ref: 'https://example.com/message' + servers: + - $ref: '#/servers/default' + - $ref: '#/servers/production' + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + unusedChannel: + address: unusedChannel + messages: + dimLight.message: + $ref: '#/components/messages/dimLight' + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + messages: + lightMeasured: + summary: >- + Inform about environmental lighting conditions for a particular + streetlight. + payload: + $ref: '#/components/schemas/lightMeasuredPayload' + turnOnOff: + summary: Command a particular streetlight to turn the lights on or off. + payload: + $ref: '#/components/schemas/turnOnOffPayload' + dimLight: + summary: Command a particular streetlight to dim the lights. + payload: + $ref: '#/components/schemas/dimLightPayload' + schemas: + sentAt: + type: string + format: date-time + description: Date and time when the message was sent. + securitySchemes: + apiKey: + type: apiKey + in: user + description: Provide your API key as the user and leave the password empty. + flows: + type: oauth2 + flows: + implicit: + authorizationUrl: 'https://example.com/api/oauth/dialog' + availableScopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string + operations: + usedChannel.publish: + action: receive + channel: + $ref: '#/components/channels/usedChannel' + messages: + - $ref: '#/components/messages/lightMeasured' + usedChannel.subscribe: + action: send + channel: + $ref: '#/components/channels/usedChannel' + messages: + - $ref: '#/components/messages/turnOnOff' + - $ref: '#/components/channels/usedChannel/messages/customMessageId' + - $ref: '#/components/channels/usedChannel/messages/subscribe.message.2' + - $ref: 'https://example.com/message' + dimLight: + action: send + channel: + $ref: '#/components/channels/unusedChannel' + security: + - type: oauth2 + flows: + implicit: + authorizationUrl: 'https://example.com/api/oauth/dialog' + availableScopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + scopes: + - 'write:pets' + messages: + - $ref: '#/components/messages/dimLight' \ No newline at end of file diff --git a/test/output/3.0.0/from-2.6.0.yml b/test/output/3.0.0/from-2.6.0.yml new file mode 100644 index 00000000..ad5aac63 --- /dev/null +++ b/test/output/3.0.0/from-2.6.0.yml @@ -0,0 +1,260 @@ +asyncapi: 3.0.0 +id: 'urn:example:com:smartylighting:streetlights:server' +info: + title: AsyncAPI Sample App + version: 1.0.1 + description: This is a sample app. + termsOfService: 'https://asyncapi.com/terms/' + contact: + name: API Support + url: 'https://www.asyncapi.com/support' + email: support@asyncapi.org + license: + name: Apache 2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' + tags: + - name: e-commerce + - name: another-tag + description: Description... + externalDocs: + description: Find more info here + url: 'https://www.asyncapi.com' +defaultContentType: application/json +servers: + default: + host: 'api.streetlights.smartylighting.com:{port}' + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - $ref: '#/components/securitySchemes/apiKey' + - type: oauth2 + flows: + implicit: + authorizationUrl: 'https://example.com/api/oauth/dialog' + availableScopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + scopes: + - 'write:pets' + - type: openIdConnect + openIdConnectUrl: openIdConnectUrl + scopes: + - 'some:scope:1' + - 'some:scope:2' + production: + host: 'api.streetlights.smartylighting.com:{port}' + pathname: /some/path + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - $ref: '#/components/securitySchemes/apiKey' + withProtocol: + host: 'api.streetlights.smartylighting.com:{port}' + pathname: /some/path + protocol: mqtt + description: Test broker + variables: + port: + description: Secure connection (TLS) is available through port 8883. + default: '1883' + enum: + - '1883' + - '8883' + security: + - $ref: '#/components/securitySchemes/apiKey' +channels: + 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured': + address: 'smartylighting/streetlights/1/0/event/{streetlightId}/lighting/measured' + messages: + lightMeasured.message: + payload: + type: object + servers: + - $ref: '#/servers/production' + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + 'smartylighting/streetlights/1/0/action/{streetlightId}/turn/on': + address: 'smartylighting/streetlights/1/0/action/{streetlightId}/turn/on' + messages: + publish.message: + $ref: '#/components/messages/lightMeasured' + subscribe.message.0: + $ref: '#/components/messages/turnOnOff' + customMessageId: + messageId: customMessageId + payload: + type: object + subscribe.message.2: + payload: + type: object + subscribe.message.3: + $ref: 'https://example.com/message' + servers: + - $ref: '#/servers/default' + - $ref: '#/servers/production' + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + customChannelId: + address: 'smartylighting/streetlights/1/0/action/{streetlightId}/turn/off' + messages: + turnOnOff.message: + $ref: '#/components/messages/turnOnOff' + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' + x-channelId: customChannelId + 'smartylighting/streetlights/1/0/action/{streetlightId}/dim': + address: 'smartylighting/streetlights/1/0/action/{streetlightId}/dim' + messages: + dimLight.message: + $ref: '#/components/messages/dimLight' + parameters: + streetlightId: + $ref: '#/components/parameters/streetlightId' +operations: + lightMeasured: + action: receive + channel: + $ref: >- + #/channels/smartylighting~1streetlights~11~10~1event~1{streetlightId}~1lighting~1measured + messages: + - $ref: >- + #/channels/smartylighting~1streetlights~11~10~1event~1{streetlightId}~1lighting~1measured/messages/lightMeasured.message + 'smartylighting/streetlights/1/0/action/{streetlightId}/turn/on.publish': + action: receive + channel: + $ref: >- + #/channels/smartylighting~1streetlights~11~10~1action~1{streetlightId}~1turn~1on + messages: + - $ref: '#/components/messages/lightMeasured' + 'smartylighting/streetlights/1/0/action/{streetlightId}/turn/on.subscribe': + action: send + channel: + $ref: >- + #/channels/smartylighting~1streetlights~11~10~1action~1{streetlightId}~1turn~1on + messages: + - $ref: '#/components/messages/turnOnOff' + - $ref: >- + #/channels/smartylighting~1streetlights~11~10~1action~1{streetlightId}~1turn~1on/messages/customMessageId + - $ref: >- + #/channels/smartylighting~1streetlights~11~10~1action~1{streetlightId}~1turn~1on/messages/subscribe.message.2 + - $ref: 'https://example.com/message' + turnOnOff: + action: send + channel: + $ref: '#/channels/customChannelId' + messages: + - $ref: '#/components/messages/turnOnOff' + dimLight: + action: send + channel: + $ref: >- + #/channels/smartylighting~1streetlights~11~10~1action~1{streetlightId}~1dim + security: + - type: oauth2 + flows: + implicit: + authorizationUrl: 'https://example.com/api/oauth/dialog' + availableScopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + scopes: + - 'write:pets' + messages: + - $ref: '#/components/messages/dimLight' +components: + messages: + lightMeasured: + summary: >- + Inform about environmental lighting conditions for a particular + streetlight. + payload: + $ref: '#/components/schemas/lightMeasuredPayload' + turnOnOff: + summary: Command a particular streetlight to turn the lights on or off. + payload: + $ref: '#/components/schemas/turnOnOffPayload' + dimLight: + summary: Command a particular streetlight to dim the lights. + payload: + $ref: '#/components/schemas/dimLightPayload' + schemas: + lightMeasuredPayload: + type: object + properties: + lumens: + type: integer + minimum: 0 + description: Light intensity measured in lumens. + sentAt: + $ref: '#/components/schemas/sentAt' + turnOnOffPayload: + type: object + properties: + command: + type: string + enum: + - 'on' + - 'off' + description: Whether to turn on or off the light. + sentAt: + $ref: '#/components/schemas/sentAt' + dimLightPayload: + type: object + properties: + percentage: + type: integer + description: Percentage to which the light should be dimmed to. + minimum: 0 + maximum: 100 + sentAt: + $ref: '#/components/schemas/sentAt' + sentAt: + type: string + format: date-time + description: Date and time when the message was sent. + securitySchemes: + apiKey: + type: apiKey + in: user + description: Provide your API key as the user and leave the password empty. + flows: + type: oauth2 + flows: + implicit: + authorizationUrl: 'https://example.com/api/oauth/dialog' + availableScopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + openIdConnect: + type: openIdConnect + openIdConnectUrl: openIdConnectUrl + unusedFlows: + type: oauth2 + flows: + implicit: + authorizationUrl: 'https://example.com/api/oauth/dialog' + availableScopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + parameters: + streetlightId: + description: The ID of the streetlight. + schema: + type: string diff --git a/test/second-to-third-version.spec.ts b/test/second-to-third-version.spec.ts new file mode 100644 index 00000000..5304e262 --- /dev/null +++ b/test/second-to-third-version.spec.ts @@ -0,0 +1,35 @@ +import fs from 'fs'; +import path from 'path'; + +import { convert } from '../src/convert'; +import { assertResults } from './helpers'; + +describe('convert() - 2.X.X to 3.X.X versions', () => { + it('should convert from 2.6.0 to 3.0.0', () => { + const input = fs.readFileSync(path.resolve(__dirname, 'input', '2.6.0', 'for-3.0.0.yml'), 'utf8'); + const output = fs.readFileSync(path.resolve(__dirname, 'output', '3.0.0', 'from-2.6.0.yml'), 'utf8'); + const result = convert(input, '3.0.0'); + assertResults(output, result); + }); + + it('should convert from 2.6.0 to 3.0.0 (with used channel components)', () => { + const input = fs.readFileSync(path.resolve(__dirname, 'input', '2.6.0', 'for-3.0.0-with-servers-and-channels-components.yml'), 'utf8'); + const output = fs.readFileSync(path.resolve(__dirname, 'output', '3.0.0', 'from-2.6.0-with-servers-and-channels-components.yml'), 'utf8'); + const result = convert(input, '3.0.0'); + assertResults(output, result); + }); + + it('should convert from 2.6.0 to 3.0.0 (with deep local references)', () => { + const input = fs.readFileSync(path.resolve(__dirname, 'input', '2.6.0', 'for-3.0.0-with-deep-local-references.yml'), 'utf8'); + const output = fs.readFileSync(path.resolve(__dirname, 'output', '3.0.0', 'from-2.6.0-with-deep-local-references.yml'), 'utf8'); + const result = convert(input, '3.0.0'); + assertResults(output, result); + }); + + it('should convert from 2.6.0 to 3.0.0 (with custom schema formats)', () => { + const input = fs.readFileSync(path.resolve(__dirname, 'input', '2.6.0', 'for-3.0.0-with-custom-schema-format.yml'), 'utf8'); + const output = fs.readFileSync(path.resolve(__dirname, 'output', '3.0.0', 'from-2.6.0-with-custom-schema-format.yml'), 'utf8'); + const result = convert(input, '3.0.0'); + assertResults(output, result); + }); +});