diff --git a/src/ComponentProvider.ts b/src/ComponentProvider.ts index 98072e6a..cd053b55 100644 --- a/src/ComponentProvider.ts +++ b/src/ComponentProvider.ts @@ -16,20 +16,38 @@ export const toLodashPath = (jsonPointer: string): string => { const tokens = jsonPointer.split('/') // Unescape the special characters and transform into Lodash path - return tokens - .map((token) => { - // Replace tilde representations - token = token.replace(/~1/g, '/') - token = token.replace(/~0/g, '~') + const pathParts: string[] = [] + for (const rawToken of tokens) { + // Replace tilde representations (JSON Pointer escaping) + let token = rawToken.replace(/~1/g, '/') + token = token.replace(/~0/g, '~') - // Check if token can be treated as an array index (non-negative integer) - if (/^\d+$/.test(token)) { - return `[${token}]` + // Check if token can be treated as an array index (non-negative integer) + if (/^\d+$/.test(token)) { + pathParts.push(`[${token}]`) + } else if (token.includes('.')) { + // If the token contains a dot, use bracket notation to prevent lodash + // from interpreting it as a path separator (e.g., channel names like "user.fifo") + pathParts.push(`['${token}']`) + } else { + pathParts.push(token) + } + } + + // Join parts, handling bracket notation properly + return pathParts + .map((part, index) => { + // Array indices and bracket notation should not have a preceding dot + if (part.startsWith('[')) { + return part + } + // First element should not have a preceding dot + if (index === 0) { + return part } - // For nested properties, use dot notation - return token + return `.${part}` }) - .join('.') + .join('') } export const parseComponentsFromPath = ( diff --git a/src/Optimizer.ts b/src/Optimizer.ts index c78c0f09..0f151000 100644 --- a/src/Optimizer.ts +++ b/src/Optimizer.ts @@ -17,7 +17,7 @@ import YAML from 'js-yaml' import merge from 'merge-deep' import * as _ from 'lodash' import { getOptimizableComponents } from './ComponentProvider' -import { filterReportElements, hasParent, sortReportElements, toJS } from './Utils' +import { filterReportElements, hasParent, sortReportElements, toJS, lodashPathToJsonPointer } from './Utils' import Debug from 'debug' export enum Action { @@ -161,7 +161,7 @@ export class Optimizer { case Action.Move: _.set(this.outputObject, change.target as string, _.get(this.outputObject, change.path)) _.set(this.outputObject, change.path, { - $ref: `#/${change.target?.replace(/\./g, '/')}`, + $ref: `#/${lodashPathToJsonPointer(change.target as string)}`, }) debug('moved %s to %s', change.path, change.target) break @@ -169,7 +169,7 @@ export class Optimizer { case Action.Reuse: if (_.get(this.outputObject, change.target as string)) { _.set(this.outputObject, change.path, { - $ref: `#/${change.target?.replace(/\./g, '/')}`, + $ref: `#/${lodashPathToJsonPointer(change.target as string)}`, }) } debug('%s reused %s', change.path, change.target) diff --git a/src/Reporters/moveAllToComponents.ts b/src/Reporters/moveAllToComponents.ts index 1116f941..6f256489 100644 --- a/src/Reporters/moveAllToComponents.ts +++ b/src/Reporters/moveAllToComponents.ts @@ -1,5 +1,5 @@ import { Action } from '../Optimizer' -import { createReport, isEqual, isInComponents, getComponentName } from '../Utils' +import { createReport, isEqual, isInComponents, getComponentName, toLodashPathSegment } from '../Utils' import { OptimizableComponent, OptimizableComponentGroup, ReportElement, Reporter } from 'types' import Debug from 'debug' const debug = Debug('reporter:moveAllToComponents') @@ -23,7 +23,10 @@ const findAllComponents = ( )[0] if (!existingResult) { const componentName = getComponentName(component) - const target = `components.${optimizableComponentGroup.type}.${componentName}` + // Use bracket notation if the component name contains a dot to prevent lodash from + // interpreting it as nested properties (e.g., "user.fifo" should not become user: { fifo: ... }) + const componentNameSegment = toLodashPathSegment(componentName) + const target = `components.${optimizableComponentGroup.type}${componentNameSegment.startsWith('[') ? '' : '.'}${componentNameSegment}` resultElements.push({ path: component.path, action: Action.Move, diff --git a/src/Reporters/moveDuplicatesToComponents.ts b/src/Reporters/moveDuplicatesToComponents.ts index a260b2aa..a4140302 100644 --- a/src/Reporters/moveDuplicatesToComponents.ts +++ b/src/Reporters/moveDuplicatesToComponents.ts @@ -1,5 +1,5 @@ import { Action } from '../Optimizer' -import { createReport, isEqual, isInComponents, getComponentName } from '../Utils' +import { createReport, isEqual, isInComponents, getComponentName, toLodashPathSegment } from '../Utils' import { OptimizableComponent, OptimizableComponentGroup, ReportElement, Reporter } from 'types' import Debug from 'debug' const debug = Debug('reporter:moveDuplicatesToComponents') @@ -8,6 +8,40 @@ const debug = Debug('reporter:moveDuplicatesToComponents') * @param optimizableComponentGroup all AsyncAPI Specification-valid components that you want to analyze for duplicates. * @returns A list of optimization report elements. */ +const buildTarget = (groupType: string, componentName: string): string => { + const componentNameSegment = toLodashPathSegment(componentName) + return `components.${groupType}${componentNameSegment.startsWith('[') ? '' : '.'}${componentNameSegment}` +} + +const handleDuplicate = ( + resultElements: ReportElement[], + groupType: string, + component: OptimizableComponent, + compareComponent: OptimizableComponent +): void => { + const existingResult = resultElements.find((reportElement) => component.path === reportElement.path) + if (!existingResult) { + const target = buildTarget(groupType, getComponentName(component)) + resultElements.push({ + path: component.path, + action: Action.Move, + target, + }) + resultElements.push({ + path: compareComponent.path, + action: Action.Reuse, + target, + }) + return + } + + resultElements.push({ + path: component.path, + action: Action.Reuse, + target: existingResult.target, + }) +} + const findDuplicateComponents = ( optimizableComponentGroup: OptimizableComponentGroup ): ReportElement[] => { @@ -19,31 +53,8 @@ const findDuplicateComponents = ( for (const [index, component] of outsideComponentsSection.entries()) { for (const compareComponent of outsideComponentsSection.slice(index + 1)) { - if (isEqual(component.component, compareComponent.component, false)) { - const existingResult = resultElements.filter( - (reportElement) => component.path === reportElement.path - )[0] - if (!existingResult) { - const componentName = getComponentName(component) - const target = `components.${optimizableComponentGroup.type}.${componentName}` - resultElements.push({ - path: component.path, - action: Action.Move, - target, - }) - resultElements.push({ - path: compareComponent.path, - action: Action.Reuse, - target, - }) - } else { - resultElements.push({ - path: component.path, - action: Action.Reuse, - target: existingResult.target, - }) - } - } + if (!isEqual(component.component, compareComponent.component, false)) continue + handleDuplicate(resultElements, optimizableComponentGroup.type, component, compareComponent) } } debug( diff --git a/src/Utils/Helpers.ts b/src/Utils/Helpers.ts index 9752a736..7251a546 100644 --- a/src/Utils/Helpers.ts +++ b/src/Utils/Helpers.ts @@ -159,14 +159,65 @@ const toJS = (asyncapiYAMLorJSON: any): any => { ) } +/** + * Converts a lodash path (potentially with bracket notation) to an array of path segments. + * e.g., "channels['user.fifo'].publish.message" → ["channels", "user.fifo", "publish", "message"] + */ +const lodashPathToSegments = (lodashPath: string): string[] => { + // lodash already supports parsing bracket notation and numeric indices safely. + // Examples: + // - _.toPath("channels['user.fifo'].publish") => ["channels","user.fifo","publish"] + // - _.toPath("channels.myChannel.messages[0]") => ["channels","myChannel","messages","0"] + return _.toPath(lodashPath).map((segment) => String(segment)) +} + const getComponentName = (component: OptimizableComponent): string => { let componentName if (component.component['x-origin']) { componentName = String(component.component['x-origin']).split('/').reverse()[0] } else { - componentName = String(component.path).split('.').reverse()[0] + // Use lodashPathToSegments to properly handle bracket notation paths + // e.g., "operations['user/deleteAccount.subscribe']" should return "user/deleteAccount.subscribe" + const segments = lodashPathToSegments(component.path) + componentName = segments[segments.length - 1] } return componentName } -export { compareComponents, isEqual, isInComponents, isInChannels, toJS, getComponentName } +/** + * Converts a property name to a lodash path segment. + * Uses bracket notation if the name contains a dot to prevent lodash from + * interpreting it as nested properties. + * e.g., "user.fifo" → "['user.fifo']", "simple" → "simple" + */ +const toLodashPathSegment = (name: string): string => { + if (name.includes('.')) { + return `['${name}']` + } + return name +} + +/** + * Escapes special characters for JSON Pointer format. + * According to RFC 6901: + * - '~' must be escaped as '~0' + * - '/' must be escaped as '~1' + */ +const escapeJsonPointerSegment = (segment: string): string => { + return segment.replace(/~/g, '~0').replace(/\//g, '~1') +} + +/** + * Converts a lodash path (potentially with bracket notation) to a JSON Pointer format. + * This handles paths like: channels['user.fifo'].publish.message + * And converts them to: channels/user.fifo/publish/message + * + * Special characters in segment values are escaped according to RFC 6901: + * - '~' → '~0' + * - '/' → '~1' + */ +const lodashPathToJsonPointer = (lodashPath: string): string => { + return lodashPathToSegments(lodashPath).map(escapeJsonPointerSegment).join('/') +} + +export { compareComponents, isEqual, isInComponents, isInChannels, toJS, getComponentName, lodashPathToJsonPointer, toLodashPathSegment } diff --git a/test/Reporters/Reporters.spec.ts b/test/Reporters/Reporters.spec.ts index 69fe2fee..e4bec686 100644 --- a/test/Reporters/Reporters.spec.ts +++ b/test/Reporters/Reporters.spec.ts @@ -86,9 +86,9 @@ const moveAllToComponentsExpectedResult: any[] = [ target: 'components.schemas.payload', }, { - path: 'operations.user/deleteAccount.subscribe', + path: 'operations[\'user/deleteAccount.subscribe\']', action: 'move', - target: 'components.operations.subscribe', + target: 'components.operations[\'user/deleteAccount.subscribe\']', }, ] const moveDuplicatesToComponentsExpectedResult: any[] = [ diff --git a/test/fixtures.ts b/test/fixtures.ts index 71387d6c..93cbb7fa 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -393,14 +393,7 @@ channels: $ref: '#/components/channels/deleteAccount' operations: user/deleteAccount.subscribe: - action: send - channel: - $ref: '#/channels/deleteAccount' - messages: - - $ref: '#/channels/deleteAccount/messages/deleteUser' - user/deleteAccount: - subscribe: - $ref: '#/components/operations/subscribe' + $ref: '#/components/operations/user~1deleteAccount.subscribe' components: schemas: canBeReused: @@ -434,7 +427,13 @@ components: duped2: payload: $ref: '#/components/schemas/payload' - operations: {} + operations: + user/deleteAccount.subscribe: + action: send + channel: + $ref: '#/channels/deleteAccount' + messages: + - $ref: '#/channels/deleteAccount/messages/deleteUser' channels: withDuplicatedMessage1FromXOrigin: x-origin: ./messages.yaml#/withDuplicatedMessage1FromXOrigin @@ -475,7 +474,7 @@ components: $ref: '#/components/messages/DeleteUser'` // eslint-disable-next-line quotes -export const outputJSON_mATCTrue_mDTCFalse_schemaFalse = `{"asyncapi":"3.0.0","info":{"title":"Untidy AsyncAPI file","version":"1.0.0","description":"This file contains duplicate and unused messages across the file and is used to test the optimizer."},"channels":{"withDuplicatedMessage1":{"$ref":"#/components/channels/withDuplicatedMessage1FromXOrigin"},"withDuplicatedMessage2":{"$ref":"#/components/channels/withDuplicatedMessage2"},"withFullFormMessage":{"$ref":"#/components/channels/withFullFormMessage"},"UserSignedUp1":{"$ref":"#/components/channels/UserSignedUp1"},"UserSignedUp2":{"$ref":"#/components/channels/UserSignedUp2"},"deleteAccount":{"$ref":"#/components/channels/deleteAccount"}},"operations":{"user/deleteAccount.subscribe":{"action":"send","channel":{"$ref":"#/channels/deleteAccount"},"messages":[{"$ref":"#/channels/deleteAccount/messages/deleteUser"}]},"user/deleteAccount":{"subscribe":{"$ref":"#/components/operations/subscribe"}}},"components":{"schemas":{"canBeReused":{"type":"object","description":"I can be reused."},"payload":{"type":"object","description":"I am duplicated"}},"messages":{"DeleteUser":{"payload":{"type":"string","description":"userId of the user that is going to be deleted"}},"UserSignedUp":{"payload":{"type":"object","properties":{"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}}}},"canBeReused":{"payload":{"$ref":"#/components/schemas/canBeReused"}},"duped1":{"payload":{"$ref":"#/components/schemas/payload"}},"duped2":{"payload":{"$ref":"#/components/schemas/payload"}}},"operations":{},"channels":{"withDuplicatedMessage1FromXOrigin":{"x-origin":"./messages.yaml#/withDuplicatedMessage1FromXOrigin","address":"user/signedup","messages":{"duped1":{"$ref":"#/components/messages/duped1"}}},"withDuplicatedMessage2":{"address":"user/signedup","messages":{"duped2":{"$ref":"#/components/messages/duped2"}}},"withFullFormMessage":{"address":"user/signedup","messages":{"canBeReused":{"$ref":"#/components/messages/canBeReused"}}},"UserSignedUp1":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp","payload":{"properties":{"displayName":{"$ref":"#/components/schemas/displayName"},"email":{"$ref":"#/components/schemas/email"}}}}}},"UserSignedUp2":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp"}}},"deleteAccount":{"address":"user/deleteAccount","messages":{"deleteUser":{"$ref":"#/components/messages/DeleteUser"}}}}}}` +export const outputJSON_mATCTrue_mDTCFalse_schemaFalse = `{"asyncapi":"3.0.0","info":{"title":"Untidy AsyncAPI file","version":"1.0.0","description":"This file contains duplicate and unused messages across the file and is used to test the optimizer."},"channels":{"withDuplicatedMessage1":{"$ref":"#/components/channels/withDuplicatedMessage1FromXOrigin"},"withDuplicatedMessage2":{"$ref":"#/components/channels/withDuplicatedMessage2"},"withFullFormMessage":{"$ref":"#/components/channels/withFullFormMessage"},"UserSignedUp1":{"$ref":"#/components/channels/UserSignedUp1"},"UserSignedUp2":{"$ref":"#/components/channels/UserSignedUp2"},"deleteAccount":{"$ref":"#/components/channels/deleteAccount"}},"operations":{"user/deleteAccount.subscribe":{"$ref":"#/components/operations/user~1deleteAccount.subscribe"}},"components":{"schemas":{"canBeReused":{"type":"object","description":"I can be reused."},"payload":{"type":"object","description":"I am duplicated"}},"messages":{"DeleteUser":{"payload":{"type":"string","description":"userId of the user that is going to be deleted"}},"UserSignedUp":{"payload":{"type":"object","properties":{"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}}}},"canBeReused":{"payload":{"$ref":"#/components/schemas/canBeReused"}},"duped1":{"payload":{"$ref":"#/components/schemas/payload"}},"duped2":{"payload":{"$ref":"#/components/schemas/payload"}}},"operations":{"user/deleteAccount.subscribe":{"action":"send","channel":{"$ref":"#/channels/deleteAccount"},"messages":[{"$ref":"#/channels/deleteAccount/messages/deleteUser"}]}},"channels":{"withDuplicatedMessage1FromXOrigin":{"x-origin":"./messages.yaml#/withDuplicatedMessage1FromXOrigin","address":"user/signedup","messages":{"duped1":{"$ref":"#/components/messages/duped1"}}},"withDuplicatedMessage2":{"address":"user/signedup","messages":{"duped2":{"$ref":"#/components/messages/duped2"}}},"withFullFormMessage":{"address":"user/signedup","messages":{"canBeReused":{"$ref":"#/components/messages/canBeReused"}}},"UserSignedUp1":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp","payload":{"properties":{"displayName":{"$ref":"#/components/schemas/displayName"},"email":{"$ref":"#/components/schemas/email"}}}}}},"UserSignedUp2":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp"}}},"deleteAccount":{"address":"user/deleteAccount","messages":{"deleteUser":{"$ref":"#/components/messages/DeleteUser"}}}}}}` export const outputYAML_mATCFalse_mDTCTrue_schemaTrue = `asyncapi: 3.0.0 info: @@ -576,14 +575,7 @@ channels: $ref: '#/components/channels/deleteAccount' operations: user/deleteAccount.subscribe: - action: send - channel: - $ref: '#/channels/deleteAccount' - messages: - - $ref: '#/channels/deleteAccount/messages/deleteUser' - user/deleteAccount: - subscribe: - $ref: '#/components/operations/subscribe' + $ref: '#/components/operations/user~1deleteAccount.subscribe' components: schemas: canBeReused: @@ -617,7 +609,13 @@ components: payload: type: object description: I am duplicated - operations: {} + operations: + user/deleteAccount.subscribe: + action: send + channel: + $ref: '#/channels/deleteAccount' + messages: + - $ref: '#/channels/deleteAccount/messages/deleteUser' channels: withDuplicatedMessage1FromXOrigin: x-origin: ./messages.yaml#/withDuplicatedMessage1FromXOrigin @@ -652,4 +650,4 @@ components: $ref: '#/components/messages/DeleteUser'` // eslint-disable-next-line quotes -export const outputJSON_mATCTrue_mDTCFalse_schemaTrue = `{"asyncapi":"3.0.0","info":{"title":"Untidy AsyncAPI file","version":"1.0.0","description":"This file contains duplicate and unused messages across the file and is used to test the optimizer."},"channels":{"withDuplicatedMessage1":{"$ref":"#/components/channels/withDuplicatedMessage1FromXOrigin"},"withDuplicatedMessage2":{"$ref":"#/components/channels/withDuplicatedMessage2"},"withFullFormMessage":{"$ref":"#/components/channels/withFullFormMessage"},"UserSignedUp1":{"$ref":"#/components/channels/UserSignedUp1"},"UserSignedUp2":{"$ref":"#/components/channels/UserSignedUp2"},"deleteAccount":{"$ref":"#/components/channels/deleteAccount"}},"operations":{"user/deleteAccount.subscribe":{"action":"send","channel":{"$ref":"#/channels/deleteAccount"},"messages":[{"$ref":"#/channels/deleteAccount/messages/deleteUser"}]},"user/deleteAccount":{"subscribe":{"$ref":"#/components/operations/subscribe"}}},"components":{"schemas":{"canBeReused":{"type":"object","description":"I can be reused."}},"messages":{"DeleteUser":{"payload":{"type":"string","description":"userId of the user that is going to be deleted"}},"UserSignedUp":{"payload":{"type":"object","properties":{"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}}}},"canBeReused":{"payload":{"type":"object","description":"I can be reused."}},"duped1":{"payload":{"type":"object","description":"I am duplicated"}},"duped2":{"payload":{"type":"object","description":"I am duplicated"}}},"operations":{},"channels":{"withDuplicatedMessage1FromXOrigin":{"x-origin":"./messages.yaml#/withDuplicatedMessage1FromXOrigin","address":"user/signedup","messages":{"duped1":{"$ref":"#/components/messages/duped1"}}},"withDuplicatedMessage2":{"address":"user/signedup","messages":{"duped2":{"$ref":"#/components/messages/duped2"}}},"withFullFormMessage":{"address":"user/signedup","messages":{"canBeReused":{"$ref":"#/components/messages/canBeReused"}}},"UserSignedUp1":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp"}}},"UserSignedUp2":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp"}}},"deleteAccount":{"address":"user/deleteAccount","messages":{"deleteUser":{"$ref":"#/components/messages/DeleteUser"}}}}}}` +export const outputJSON_mATCTrue_mDTCFalse_schemaTrue = `{"asyncapi":"3.0.0","info":{"title":"Untidy AsyncAPI file","version":"1.0.0","description":"This file contains duplicate and unused messages across the file and is used to test the optimizer."},"channels":{"withDuplicatedMessage1":{"$ref":"#/components/channels/withDuplicatedMessage1FromXOrigin"},"withDuplicatedMessage2":{"$ref":"#/components/channels/withDuplicatedMessage2"},"withFullFormMessage":{"$ref":"#/components/channels/withFullFormMessage"},"UserSignedUp1":{"$ref":"#/components/channels/UserSignedUp1"},"UserSignedUp2":{"$ref":"#/components/channels/UserSignedUp2"},"deleteAccount":{"$ref":"#/components/channels/deleteAccount"}},"operations":{"user/deleteAccount.subscribe":{"$ref":"#/components/operations/user~1deleteAccount.subscribe"}},"components":{"schemas":{"canBeReused":{"type":"object","description":"I can be reused."}},"messages":{"DeleteUser":{"payload":{"type":"string","description":"userId of the user that is going to be deleted"}},"UserSignedUp":{"payload":{"type":"object","properties":{"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}}}},"canBeReused":{"payload":{"type":"object","description":"I can be reused."}},"duped1":{"payload":{"type":"object","description":"I am duplicated"}},"duped2":{"payload":{"type":"object","description":"I am duplicated"}}},"operations":{"user/deleteAccount.subscribe":{"action":"send","channel":{"$ref":"#/channels/deleteAccount"},"messages":[{"$ref":"#/channels/deleteAccount/messages/deleteUser"}]}},"channels":{"withDuplicatedMessage1FromXOrigin":{"x-origin":"./messages.yaml#/withDuplicatedMessage1FromXOrigin","address":"user/signedup","messages":{"duped1":{"$ref":"#/components/messages/duped1"}}},"withDuplicatedMessage2":{"address":"user/signedup","messages":{"duped2":{"$ref":"#/components/messages/duped2"}}},"withFullFormMessage":{"address":"user/signedup","messages":{"canBeReused":{"$ref":"#/components/messages/canBeReused"}}},"UserSignedUp1":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp"}}},"UserSignedUp2":{"address":"user/signedup","messages":{"myMessage":{"$ref":"#/components/messages/UserSignedUp"}}},"deleteAccount":{"address":"user/deleteAccount","messages":{"deleteUser":{"$ref":"#/components/messages/DeleteUser"}}}}}}`