diff --git a/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.test.ts b/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.test.ts index b27047678..3c186dd5f 100644 --- a/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.test.ts +++ b/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.test.ts @@ -6,6 +6,7 @@ import { CamelCatalogService } from './camel-catalog.service'; import { CatalogKind } from '../../catalog-kind'; import { ICamelComponentDefinition } from '../../camel-components-catalog'; import { ICamelProcessorDefinition } from '../../camel-processors-catalog'; +import { CamelComponentSchemaService } from './support/camel-component-schema.service'; describe('AbstractCamelVisualEntity', () => { let abstractVisualEntity: CamelRouteVisualEntity; @@ -52,4 +53,21 @@ describe('AbstractCamelVisualEntity', () => { expect(result).toEqual('1 required parameter is not yet configured: [ uri ]'); }); }); + + describe('updateModel', () => { + it('should update the model with the new value', () => { + const newUri = 'timer:MyTimer'; + abstractVisualEntity.updateModel('from', { uri: newUri }); + + expect(abstractVisualEntity.route.from.uri).toEqual(newUri); + }); + + it('should delegate the serialization to the `CamelComponentSchemaService`', () => { + const newUri = 'timer:MyTimer'; + const spy = jest.spyOn(CamelComponentSchemaService, 'getUriSerializedDefinition'); + abstractVisualEntity.updateModel('from', { uri: newUri }); + + expect(spy).toHaveBeenCalledWith('from', { uri: newUri }); + }); + }); }); diff --git a/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts b/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts index ff1c991ab..d1a7a47a5 100644 --- a/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/abstract-camel-visual-entity.ts @@ -69,8 +69,9 @@ export abstract class AbstractCamelVisualEntity implements BaseVisualCamelEntity updateModel(path: string | undefined, value: unknown): void { if (!path) return; + const updatedValue = CamelComponentSchemaService.getUriSerializedDefinition(path, value); - setValue(this.route, path, value); + setValue(this.route, path, updatedValue); } /** diff --git a/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts b/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts index 1a199973b..26367d776 100644 --- a/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts +++ b/packages/ui/src/models/visualization/flows/kamelet-visual-entity.ts @@ -45,8 +45,6 @@ export class KameletVisualEntity extends AbstractCamelVisualEntity { } updateModel(path: string | undefined, value: Record): void { - if (!path) return; - if (path === ROOT_PATH) { updateKameletFromCustomSchema(this.kamelet, value); return; diff --git a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts index 5e7e2b944..ee0778691 100644 --- a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts +++ b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.test.ts @@ -6,7 +6,7 @@ import { CamelComponentSchemaService } from './camel-component-schema.service'; import { ICamelComponentDefinition } from '../../../camel-components-catalog'; import { ICamelProcessorDefinition } from '../../../camel-processors-catalog'; import { IKameletDefinition } from '../../../kamelets-catalog'; -import { ROOT_PATH } from '../../../../utils'; +import { CamelUriHelper, ROOT_PATH } from '../../../../utils'; describe('CamelComponentSchemaService', () => { let path: string; @@ -463,6 +463,52 @@ describe('CamelComponentSchemaService', () => { }); }); + describe('getUriSerializedDefinition', () => { + it('should return the same parameters if the definition is not a component', () => { + const definition = { log: { message: 'Hello World' } }; + const result = CamelComponentSchemaService.getUriSerializedDefinition('from', definition); + + expect(result).toEqual(definition); + }); + + it('should return the same parameters if the component is not found', () => { + const definition = { uri: 'unknown-component' }; + const result = CamelComponentSchemaService.getUriSerializedDefinition('from', definition); + + expect(result).toEqual(definition); + }); + + it('should query the catalog service and generate the required parameters array', () => { + const definition = { uri: 'log', parameters: { message: 'Hello World' } }; + const catalogServiceSpy = jest.spyOn(CamelCatalogService, 'getCatalogLookup'); + const camelUriHelperSpy = jest.spyOn(CamelUriHelper, 'getUriStringFromParameters'); + + CamelComponentSchemaService.getUriSerializedDefinition('from', definition); + + expect(catalogServiceSpy).toHaveBeenCalledWith('log'); + expect(camelUriHelperSpy).toHaveBeenCalledWith(definition.uri, 'log:loggerName', definition.parameters, { + requiredParameters: ['loggerName'], + defaultValues: { + groupActiveOnly: 'true', + level: 'INFO', + maxChars: 10000, + showBody: true, + showBodyType: true, + showCachedStreams: true, + skipBodyLineSeparator: true, + style: 'Default', + }, + }); + }); + + it('should return the serialized definition', () => { + const definition = { uri: 'timer', parameters: { timerName: 'MyTimer', delay: '1000', repeatCount: 10 } }; + const result = CamelComponentSchemaService.getUriSerializedDefinition('from', definition); + + expect(result).toEqual({ uri: 'timer:MyTimer', parameters: { delay: '1000', repeatCount: 10 } }); + }); + }); + describe('getComponentNameFromUri', () => { it('should return undefined if the uri is empty', () => { const componentName = CamelComponentSchemaService.getComponentNameFromUri(''); diff --git a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts index 23592f26b..0bdc778b0 100644 --- a/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts +++ b/packages/ui/src/models/visualization/flows/support/camel-component-schema.service.ts @@ -1,6 +1,6 @@ import { ProcessorDefinition } from '@kaoto-next/camel-catalog/types'; import cloneDeep from 'lodash/cloneDeep'; -import { CamelUriHelper, ROOT_PATH, isDefined } from '../../../../utils'; +import { CamelUriHelper, ParsedParameters, ROOT_PATH, isDefined } from '../../../../utils'; import { ICamelComponentDefinition } from '../../../camel-components-catalog'; import { CatalogKind } from '../../../catalog-kind'; import { IKameletDefinition } from '../../../kamelets-catalog'; @@ -171,6 +171,43 @@ export class CamelComponentSchemaService { return ''; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static getUriSerializedDefinition(path: string, definition: any): ParsedParameters | undefined { + const camelElementLookup = this.getCamelComponentLookup(path, definition); + if (camelElementLookup.componentName === undefined) { + return definition; + } + + const catalogLookup = CamelCatalogService.getCatalogLookup(camelElementLookup.componentName); + if ( + catalogLookup.catalogKind === CatalogKind.Component && + catalogLookup.definition?.component.syntax !== undefined + ) { + const requiredParameters: string[] = []; + const defaultValues: ParsedParameters = {}; + if (catalogLookup.definition?.properties !== undefined) { + Object.entries(catalogLookup.definition.properties).forEach(([key, value]) => { + if (value.required) requiredParameters.push(key); + if (value.defaultValue) defaultValues[key] = value.defaultValue; + }); + } + + const result = CamelUriHelper.getUriStringFromParameters( + definition.uri, + catalogLookup.definition.component.syntax, + definition.parameters, + { + requiredParameters, + defaultValues, + }, + ); + + return Object.assign({}, definition, { uri: result.uri, parameters: result.parameters }); + } + + return definition; + } + /** * If the processor is a `from` or `to` processor, we need to extract the component name from the uri property * and return both the processor name and the underlying component name to build the combined schema diff --git a/packages/ui/src/utils/camel-uri-helper.test.ts b/packages/ui/src/utils/camel-uri-helper.test.ts index 7ed1e74c2..0cb21aece 100644 --- a/packages/ui/src/utils/camel-uri-helper.test.ts +++ b/packages/ui/src/utils/camel-uri-helper.test.ts @@ -1,4 +1,4 @@ -import { CamelUriHelper } from './camel-uri-helper'; +import { CamelUriHelper, ParsedParameters } from './camel-uri-helper'; describe('CamelUriHelper', () => { describe('getUriString', () => { @@ -62,6 +62,7 @@ describe('CamelUriHelper', () => { syntax: 'aws2-eventbridge://eventbusNameOrArn', uri: 'aws2-eventbridge://arn:aws:iam::123456789012:user/johndoe', result: { eventbusNameOrArn: 'arn:aws:iam::123456789012:user/johndoe' }, + requiredParameters: ['eventbusNameOrArn'], }, { syntax: 'jms:destinationType:destinationName', @@ -81,6 +82,42 @@ describe('CamelUriHelper', () => { result: { destinationName: 'myQueue' }, requiredParameters: ['destinationName'], }, + { + syntax: 'http://httpUri', + uri: 'http://helloworld.io/api/greetings/{header.name}', + requiredParameters: ['httpUri'], + result: { httpUri: 'helloworld.io/api/greetings/{header.name}' }, + }, + { + syntax: 'https://httpUri', + uri: 'https://helloworld.io/api/greetings/{header.name}', + requiredParameters: ['httpUri'], + result: { httpUri: 'helloworld.io/api/greetings/{header.name}' }, + }, + { + syntax: 'ftp:host:port/directoryName', + uri: 'ftp:localhost:21/a/nested/directory', + requiredParameters: ['host'], + result: { host: 'localhost', port: 21, directoryName: 'a/nested/directory' }, + }, + { + syntax: 'rest-openapi:specificationUri#operationId', + uri: 'rest-openapi:afile-openapi.json#myOperationId', + requiredParameters: ['operationId'], + result: { specificationUri: 'afile-openapi.json', operationId: 'myOperationId' }, + }, + { + syntax: 'rest:method:path:uriTemplate', + uri: 'rest:::{header.name}', + requiredParameters: ['method', 'path'], + result: { uriTemplate: '{header.name}' }, + }, + { + syntax: 'rest:method:path:uriTemplate', + uri: 'rest:options:myPath:', + requiredParameters: ['method', 'path'], + result: { method: 'options', path: 'myPath' }, + }, ])( 'for an URI: `$uri`, using the syntax: `$syntax`, should return `$result`', ({ syntax, uri, result, requiredParameters }) => { @@ -102,4 +139,171 @@ describe('CamelUriHelper', () => { expect(CamelUriHelper.getParametersFromQueryString(queryString)).toEqual(result); }); }); + + describe('getUriStringFromParameters', () => { + it.each([ + { uri: 'log', syntax: 'log', parameters: {}, result: { uri: 'log', parameters: {} } }, + { + uri: 'timer', + syntax: 'timer:timerName', + parameters: undefined, + result: { uri: 'timer', parameters: undefined }, + }, + { + uri: 'timer', + syntax: 'timer:timerName', + parameters: null, + result: { uri: 'timer', parameters: null }, + }, + { + uri: 'timer', + syntax: 'timer:timerName', + parameters: {}, + result: { uri: 'timer', parameters: {} }, + }, + { + uri: 'timer:myTimer', + syntax: 'timer:timerName', + parameters: { timerName: 'myTimer' }, + result: { uri: 'timer:myTimer', parameters: {} }, + }, + { + uri: 'timer:myTimer', + syntax: 'timer:timerName', + parameters: { timerName: 'myTimer', groupDelay: 1000, groupSize: 5 }, + result: { uri: 'timer:myTimer', parameters: { groupDelay: 1000, groupSize: 5 } }, + }, + { uri: 'as2', syntax: 'as2:apiName/methodName', parameters: {}, result: { uri: 'as2:/', parameters: {} } }, + { + uri: 'as2', + syntax: 'activemq:destinationType:destinationName', + parameters: { destinationType: 'queue', destinationName: 'myQueue' }, + result: { uri: 'activemq:queue:myQueue', parameters: {} }, + }, + { + uri: 'as2:CLIENT/GET', + syntax: 'as2:apiName/methodName', + parameters: { + apiName: 'CLIENT', + methodName: 'GET', + }, + result: { uri: 'as2:CLIENT/GET', parameters: {} }, + }, + { + uri: 'atmosphere-websocket', + syntax: 'atmosphere-websocket:servicePath', + parameters: { servicePath: '//localhost:8080/echo' }, + result: { uri: 'atmosphere-websocket://localhost:8080/echo', parameters: {} }, + }, + { + uri: 'avro', + syntax: 'avro:transport:host:port/messageName', + parameters: { transport: 'netty', host: 'localhost', port: 41414, messageName: 'foo' }, + result: { uri: 'avro:netty:localhost:41414/foo', parameters: {} }, + }, + { + uri: 'avro', + syntax: 'avro:transport:host:port/messageName', + parameters: {}, + result: { uri: 'avro:::/', parameters: {} }, + }, + { + uri: 'aws2-eventbridge', + syntax: 'aws2-eventbridge://eventbusNameOrArn', + parameters: { eventbusNameOrArn: 'arn:aws:iam::123456789012:user/johndoe' }, + requiredParameters: ['eventbusNameOrArn'], + result: { uri: 'aws2-eventbridge://arn:aws:iam::123456789012:user/johndoe', parameters: {} }, + }, + { + uri: 'aws2-eventbridge://arn:aws:iam::123456789012:user/johndoe', + syntax: 'aws2-eventbridge://eventbusNameOrArn', + parameters: { eventbusNameOrArn: 'arn:aws:iam::123456789012:user/johndoe' }, + requiredParameters: ['eventbusNameOrArn'], + result: { uri: 'aws2-eventbridge://arn:aws:iam::123456789012:user/johndoe', parameters: {} }, + }, + { + uri: 'jms', + syntax: 'jms:destinationType:destinationName', + parameters: { destinationType: 'queue', destinationName: 'myQueue' }, + requiredParameters: ['destinationName'], + defaultValues: { destinationType: 'queue' }, + result: { uri: 'jms:queue:myQueue', parameters: {} }, + }, + { + uri: 'jms', + syntax: 'jms:destinationType:destinationName', + parameters: { destinationName: 'myQueue' }, + requiredParameters: ['destinationName'], + defaultValues: { destinationType: 'queue' }, + result: { uri: 'jms:queue:myQueue', parameters: {} }, + }, + { + uri: 'jms:myQueue', + syntax: 'jms:destinationType:destinationName', + parameters: { destinationName: 'myQueue' }, + requiredParameters: ['destinationName'], + defaultValues: { destinationType: 'queue' }, + result: { uri: 'jms:queue:myQueue', parameters: {} }, + }, + { + uri: 'http', + syntax: 'http://httpUri', + parameters: { httpUri: 'helloworld.io/api/greetings/{header.name}' }, + requiredParameters: ['httpUri'], + result: { uri: 'http://helloworld.io/api/greetings/{header.name}', parameters: {} }, + }, + { + uri: 'https', + syntax: 'https://httpUri', + parameters: { httpUri: 'helloworld.io/api/greetings/{header.name}' }, + requiredParameters: ['httpUri'], + result: { uri: 'https://helloworld.io/api/greetings/{header.name}', parameters: {} }, + }, + { + uri: 'https', + syntax: 'https://httpUri', + parameters: { httpUri: 'https://helloworld.io/api/greetings/{header.name}' }, + requiredParameters: ['httpUri'], + result: { uri: 'https://helloworld.io/api/greetings/{header.name}', parameters: {} }, + }, + { + uri: 'ftp', + syntax: 'ftp:host:port/directoryName', + parameters: { host: 'localhost', port: 21, directoryName: 'a/nested/directory' }, + requiredParameters: ['host'], + result: { uri: 'ftp:localhost:21/a/nested/directory', parameters: {} }, + }, + { + uri: 'rest-openapi', + syntax: 'rest-openapi:specificationUri#operationId', + parameters: { specificationUri: 'afile-openapi.json', operationId: 'myOperationId' }, + requiredParameters: ['operationId'], + result: { uri: 'rest-openapi:afile-openapi.json#myOperationId', parameters: {} }, + }, + { + uri: 'rest', + syntax: 'rest:method:path:uriTemplate', + parameters: { uriTemplate: '{header.name}' }, + requiredParameters: ['method', 'path'], + result: { uri: 'rest:::{header.name}', parameters: {} }, + }, + { + uri: 'rest', + syntax: 'rest:method:path:uriTemplate', + parameters: { method: 'options', path: 'myPath' }, + requiredParameters: ['method', 'path'], + result: { uri: 'rest:options:myPath:', parameters: {} }, + }, + ])( + 'should return `$result` for `$parameters`', + ({ uri, syntax, parameters, requiredParameters, defaultValues, result }) => { + expect( + CamelUriHelper.getUriStringFromParameters(uri, syntax, parameters as unknown as ParsedParameters, { + requiredParameters, + defaultValues, + }), + ).toEqual(result); + }, + ); + }); }); diff --git a/packages/ui/src/utils/camel-uri-helper.ts b/packages/ui/src/utils/camel-uri-helper.ts index 96c17fe9d..25e50d37a 100644 --- a/packages/ui/src/utils/camel-uri-helper.ts +++ b/packages/ui/src/utils/camel-uri-helper.ts @@ -1,13 +1,18 @@ import get from 'lodash/get'; import { getParsedValue } from './get-parsed-value'; +import { isDefined } from './is-defined'; -type ParsedParameters = Record; +export type ParsedParameters = Record; /** * Helper class for working with Camel URIs */ export class CamelUriHelper { - private static readonly URI_SEPARATORS_REGEX = /:|(\/\/)|\//g; + private static readonly URI_SEPARATORS_REGEX = /:|(\/\/)|#|\//g; + private static readonly KNOWN_URI_MAP: Record = { + 'http://httpUri': 'http://', + 'https://httpUri': 'https://', + }; static getUriString(value: T | undefined | null): string | undefined { /** For string-based processor definitions, we can return the definition itself */ @@ -34,20 +39,17 @@ export class CamelUriHelper { /** If `:` is not present in the syntax, we can return an empty object since there's nothing to parse */ if (!uriSyntax || !uriString || !uriSyntax.includes(':')) return {}; - /** Remove the scheme from the URI syntax: 'avro:transport:host:port/messageName' => 'transport:host:port/messageName' */ - const syntaxWithoutScheme = uriSyntax.substring(uriSyntax.indexOf(':') + 1); - /** Validate that the actual URI contains the correct schema, otherwise return empty object since we could be validating the wrong URI */ if (!uriString.startsWith(uriSyntax.substring(0, uriSyntax.indexOf(':')))) return {}; - /** Remove the scheme from the URI string: 'avro:netty:localhost:41414/foo' => 'netty:localhost:41414/foo' */ - const uriWithoutScheme = uriString.substring(uriSyntax.indexOf(':') + 1); - /** Prepare options */ const requiredParameters = options?.requiredParameters ?? []; /** Holder for parsed parameters */ const parameters: ParsedParameters = {}; + const syntaxWithoutScheme = this.getSyntaxWithoutSchema(uriSyntax).syntax; + const uriWithoutScheme = this.getUriWithoutScheme(uriString, uriSyntax); + /** * Retrieve the delimiters from the syntax by matching the delimiters * Example: 'transport:host:port/messageName' => [':', ':', '/'] @@ -85,7 +87,13 @@ export class CamelUriHelper { } keys.forEach((key, index) => { - const parsedValue = getParsedValue(values[index]); + let parsedValue = getParsedValue(values[index]); + const isLastItem = index === keys.length - 1; + /** If the values length is greater than the keys length, we need to join the remaining values, f.i. ftp component */ + if (isLastItem && values.length > keys.length) { + parsedValue = getParsedValue(values.slice(index).join(delimiters[index - 1])); + } + if (key !== '' && parsedValue !== '') { parameters[key] = parsedValue; } @@ -108,4 +116,101 @@ export class CamelUriHelper { }, {} as ParsedParameters) ?? {} ); } + + /** + * Write the appropriate parameters in the URI string, and + * and keep the parameters that are not present in the URI syntax + */ + static getUriStringFromParameters( + originalUri: string, + uriSyntax: string, + parameters?: ParsedParameters, + options?: { requiredParameters?: string[]; defaultValues?: ParsedParameters }, + ): { uri: string; parameters: ParsedParameters | undefined } { + const { schema, syntax: syntaxWithoutScheme } = this.getSyntaxWithoutSchema(uriSyntax); + /** Prepare options */ + const requiredParameters = options?.requiredParameters ?? []; + const defaultValues = options?.defaultValues ?? {}; + + /** + * Retrieve the delimiters from the syntax by matching the delimiters + * Example: 'transport:host:port/messageName' => [':', ':', '/'] + */ + const delimiters = syntaxWithoutScheme.match(this.URI_SEPARATORS_REGEX); + this.URI_SEPARATORS_REGEX.lastIndex = 0; + + /** If the syntax does not contain any delimiters, we can return the URI string as is */ + if ( + syntaxWithoutScheme === '' || + (delimiters === null && parameters?.[syntaxWithoutScheme] === undefined) || + !isDefined(parameters) + ) { + return { uri: originalUri, parameters }; + } else if (delimiters === null) { + const value = parameters?.[syntaxWithoutScheme] ?? defaultValues[syntaxWithoutScheme] ?? ''; + const uri = `${schema}:${this.cleanUriParts(value.toString(), uriSyntax)}`; + const filteredParameters = this.filterParameters(parameters, [syntaxWithoutScheme]); + return { uri, parameters: filteredParameters }; + } + + /** Otherwise, we create a RegExp using the delimiters found [':', ':', '/'] */ + const delimitersRegex = new RegExp(delimiters.join('|'), 'g'); + + /** + * Splitting the syntax string using the delimiters + * keys: [ 'transport', 'host', 'port', 'messageName' ] + */ + const keys = syntaxWithoutScheme.split(delimitersRegex); + const uri = keys.reduce((uri, key, index) => { + let previousDelimiter = ':'; + + if (index > 0) { + const isRequiredParameter = requiredParameters.includes(key); + const isUriDelimiterTerminated = uri.endsWith(delimiters[index - 1]); + const isLastKey = index === keys.length - 1; + const shouldAddSeparator = + isRequiredParameter && !isUriDelimiterTerminated && !isLastKey && !isDefined(parameters[keys[index - 1]]); + previousDelimiter = shouldAddSeparator ? ':' : ''; + } + + const value = parameters[key] ?? defaultValues[key] ?? ''; + const cleanValue = this.cleanUriParts(value.toString(), uriSyntax); + const nextDelimiter = delimiters[index] ?? ''; + + return `${uri}${previousDelimiter}${cleanValue}${nextDelimiter}`; + }, schema); + + const filteredParameters = this.filterParameters(parameters, keys); + return { uri, parameters: filteredParameters }; + } + + /** + * Remove the scheme from the URI syntax: + * 'avro:transport:host:port/messageName' => { schema: 'avro', syntax: 'transport:host:port/messageName' } */ + private static getSyntaxWithoutSchema(uriSyntax: string): { schema: string; syntax: string } { + const splitIndex = uriSyntax.indexOf(':'); + const schema = uriSyntax.substring(0, splitIndex === -1 ? uriSyntax.length : splitIndex); + const syntax = splitIndex === -1 ? '' : uriSyntax.substring(splitIndex + 1); + return { schema, syntax }; + } + + /** Remove the scheme from the URI string: 'avro:netty:localhost:41414/foo' => 'netty:localhost:41414/foo' */ + private static getUriWithoutScheme(uriString: string, uriSyntax: string): string { + return uriString.substring(uriSyntax.indexOf(':') + 1); + } + + /** Remove parts from URI for known components, particularly for URL-relate components */ + private static cleanUriParts(uri: string, syntax: string): string { + return uri.replace(this.KNOWN_URI_MAP[syntax], ''); + } + + /** Return a new object ignoring the parameters from the `keys` array*/ + private static filterParameters(parameters: ParsedParameters, keys: string[]): ParsedParameters { + return Object.keys(parameters).reduce((acc, parameterKey) => { + if (!keys.includes(parameterKey)) { + acc[parameterKey] = parameters[parameterKey]; + } + return acc; + }, {} as ParsedParameters); + } }