diff --git a/portals/devportal/src/main/webapp/source/src/app/data/defaultTheme.js b/portals/devportal/src/main/webapp/source/src/app/data/defaultTheme.js index daccc562f65..d38cf17044c 100755 --- a/portals/devportal/src/main/webapp/source/src/app/data/defaultTheme.js +++ b/portals/devportal/src/main/webapp/source/src/app/data/defaultTheme.js @@ -114,6 +114,8 @@ const DefaultConfigurations = { head: '#785446', sub: '#38a169', pub: '#4299e1', + receive: '#38a169', + send: '#4299e1', }, operationChipColor: { query: '#b3e6fe', diff --git a/portals/publisher/src/main/webapp/site/public/locales/en.json b/portals/publisher/src/main/webapp/site/public/locales/en.json index c6b785a08b8..878a2a59eb5 100644 --- a/portals/publisher/src/main/webapp/site/public/locales/en.json +++ b/portals/publisher/src/main/webapp/site/public/locales/en.json @@ -434,6 +434,10 @@ "Apis.Details.Components.SOAP.To.REST.transformation.text": "Transformation Configurations", "Apis.Details.Components.async.api.add.property.description.helper.text": "Enter property description", "Apis.Details.Components.async.api.add.property.description.text": "Description", + "Apis.Details.Components.async.api.add.property.message.name": "Message", + "Apis.Details.Components.async.api.add.property.message.name.helper": "Enter message name", + "Apis.Details.Components.async.api.add.property.operation": "Operation", + "Apis.Details.Components.async.api.add.property.operation.helper": "Enter operation name", "Apis.Details.Components.async.api.add.property.select.data.type": "Select the data type", "Apis.Details.Configurartion.components.QueryAnalysis": "Query Analysis", "Apis.Details.Configurartion.components.QueryAnalysis.cancle.btn": "Cancel", @@ -510,6 +514,7 @@ "Apis.Details.Configuration.RuntimeConfigurationWebSocket.topic.header": "Runtime Configurations", "Apis.Details.Configuration.RuntimeConfigurationWebsocket.RuntimeConfiguration.emptySchemes": "Please select at least one API security method.", "Apis.Details.Configuration.Topic.already.exist.error": "Operation(s) already exist!", + "Apis.Details.Configuration.Topic.already.operation.exist.error": "Operation \"{opName}\" already exists for {verb} on {channel}", "Apis.Details.Configuration.Topic.already.opreation.verb.exist.error": "Operation already exist with {data_target} and {current_Verb}", "Apis.Details.Configuration.Topic.update.api.error": "Error while updating the API", "Apis.Details.Configuration.Topic.update.definition.error": "Error while updating the definition", @@ -1585,6 +1590,7 @@ "Apis.Details.Resources.Components.Operations.tooltip.delete.selections": "Mark all for delete", "Apis.Details.Resources.Components.async.api.description": "Description", "Apis.Details.Resources.Components.async.api.description.title": "Description", + "Apis.Details.Resources.Components.async.api.operations.title": "Operations", "Apis.Details.Resources.Components.operation.async.api.payload.properties": "Payload Properties", "Apis.Details.Resources.Components.operationComponents.parameters.async.api.topic": "Topic Parameters", "Apis.Details.Resources.Operation.Components.Description": "Description", @@ -1604,8 +1610,14 @@ "Apis.Details.Resources.Resources.something.went.wrong.while.updating.the.api": "Error occurred while updating API", "Apis.Details.Resources.components.APIRateLimiting.rate.limiting.level": "Rate limiting level", "Apis.Details.Resources.components.AddOperation.add.tooltip": "Add new operation", + "Apis.Details.Resources.components.AddOperation.channel.address": "Enter Channel Address", + "Apis.Details.Resources.components.AddOperation.channel.address.label": "Channel Address", "Apis.Details.Resources.components.AddOperation.clear.inputs.tooltip": "Clear inputs", "Apis.Details.Resources.components.AddOperation.http.verb": "HTTP Verb", + "Apis.Details.Resources.components.AddOperation.operation.name.helper": "Enter Operation Name", + "Apis.Details.Resources.components.AddOperation.operation.name.label": "Operation Name", + "Apis.Details.Resources.components.AddOperation.operation.name.placeholder": "Enter Operation Name", + "Apis.Details.Resources.components.AddOperation.operation.name.cannot.be.empty.warning": "Operation name can't be empty", "Apis.Details.Resources.components.AddOperation.operation.target.cannot.contains.white.spaces": "Operation target cannot contains white spaces", "Apis.Details.Resources.components.AddOperation.operation.target.or.verb.cannot.be.empty.warning": "Operation target or operation verb(s) can't be empty", "Apis.Details.Resources.components.AddOperation.operation.target.topic.name": "Enter topic name", @@ -1619,6 +1631,11 @@ "Apis.Details.Resources.components.AsyncOperation.no.security": "No security", "Apis.Details.Resources.components.AsyncOperation.security.enabled": "Security enabled", "Apis.Details.Resources.components.AsyncOperation.security.operation": "Security", + "Apis.Details.Resources.components.AsyncOperation.delete": "Delete", + "Apis.Details.Resources.components.AsyncOperation.no.operations": "No operations", + "Apis.Details.Resources.components.AsyncOperation.no.security": "No security", + "Apis.Details.Resources.components.AsyncOperation.operations.count.label": "Operations ({count})", + "Apis.Details.Resources.components.AsyncOperation.security.enabled": "Security enabled", "Apis.Details.Resources.components.Operation.Delete": "Delete", "Apis.Details.Resources.components.Operation.Name": "Name", "Apis.Details.Resources.components.Operation.Schema": "Schema", @@ -1643,6 +1660,7 @@ "Apis.Details.Resources.components.operationComponents.AddParameter.enter.parameter.name": "Enter Parameter Name", "Apis.Details.Resources.components.operationComponents.AddParameter.parameter.name.already.exists": "Parameter name already exists", "Apis.Details.Resources.components.operationComponents.AddParameter.type": "Parameter Type", + "Apis.Details.Resources.components.operationComponents.AddParameter.operation.not.found": "Operation \"{operation}\" does not exist", "Apis.Details.Resources.components.operationComponents.EditParameter.close": "Close", "Apis.Details.Resources.components.operationComponents.EditParameter.data.format": "Data Format", "Apis.Details.Resources.components.operationComponents.EditParameter.data.type": "Data Type", @@ -1837,7 +1855,9 @@ "Apis.Details.Topics.components.operationComponents.ListPayloadProps.actions": "Actions", "Apis.Details.Topics.components.operationComponents.ListPayloadProps.data.type": "Data Type", "Apis.Details.Topics.components.operationComponents.ListPayloadProps.description": "Description", + "Apis.Details.Topics.components.operationComponents.ListPayloadProps.message": "Message", "Apis.Details.Topics.components.operationComponents.ListPayloadProps.name": "Name", + "Apis.Details.Topics.components.operationComponents.ListPayloadProps.operation": "Operation", "Apis.Details.Topics.components.operationComponents.OperationGovernance.Security.tooltip": "This will enable/disable Application Level securities defined in the Runtime Configurations page.", "Apis.Details.Topics.components.operationComponents.OperationGovernance.no.scopes.available": "No scopes available", "Apis.Details.Topics.components.operationComponents.OperationGovernance.operation.scope.create.new.scope": "Create New Scope", diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Configuration/Topics.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Configuration/Topics.jsx index 1ab467ff622..1dbf960cd61 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Configuration/Topics.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Configuration/Topics.jsx @@ -66,6 +66,7 @@ export default function Topics(props) { const [securityDefScopes, setSecurityDefScopes] = useState({}); const isAsyncAPI = api.type === 'WEBSUB' || api.type === 'WS' || api.type === 'SSE'; const [markedOperations, setSelectedOperation] = useState({}); + const [isAsyncV3, setIsAsyncV3] = useState(false); const intl = useIntl(); /** @@ -83,6 +84,329 @@ export default function Topics(props) { return target; } + function extractPayloadProperties(resolvedPayload, opName, msgKey) { + if (!resolvedPayload?.properties) return {}; + return Object.fromEntries( + Object.entries(resolvedPayload.properties).map(([propName, propVal]) => { + const compositeKey = `${msgKey}__${propName}`; + return [compositeKey, { + ...propVal, + 'x-operation': opName, + 'x-message': msgKey, + 'x-prop-name': propName, + }]; + }), + ); + } + + // Extracts and flattens payload properties from all messages of a single AsyncAPI v3 operation. + function extractMessageProperties(spec, opObj, opName) { + const chRef = opObj.channel?.$ref; + const chKey = chRef?.replace('#/channels/', ''); + const channelSpecObj = chKey && spec.channels[chKey]; + + if (!channelSpecObj?.messages) return {}; + + const allProperties = {}; + opObj.messages.forEach((msgRef) => { + const msgKey = msgRef.$ref?.split('/messages/')[1]; + if (!msgKey) return; + + const channelMsg = channelSpecObj.messages[msgKey]; + if (!channelMsg?.$ref) return; + + const componentMsg = getRefTarget(spec, channelMsg.$ref); + if (!componentMsg) return; + + const resolvedPayload = componentMsg.payload?.$ref + ? getRefTarget(spec, componentMsg.payload.$ref) + : componentMsg.payload; + + Object.assign(allProperties, extractPayloadProperties(resolvedPayload, opName, msgKey)); + }); + return allProperties; + } + + /** + * Builds a UI-friendly channel map from an AsyncAPI v3 spec. + */ + function buildChannelMap(spec) { + const channelMap = {}; + const channelIndex = {}; + + if (spec.channels) { + Object.entries(spec.channels).forEach(([key, channelObj]) => { + const address = channelObj.address || `/${key}`; + channelIndex[`#/channels/${key}`] = address; + channelIndex[`#/channels/${key.replace(/\//g, '~1')}`] = address; + channelMap[address] = channelMap[address] || { + parameters: channelObj.parameters || {}, + ...(channelObj.description && { description: channelObj.description }), + }; + }); + } + if (spec.operations) { + Object.entries(spec.operations).forEach(([opName, opObj]) => { + const { action } = opObj; + const channelRef = opObj.channel?.$ref; + const channelAddress = channelRef ? channelIndex[channelRef] || channelRef : null; + + if (!channelAddress) return; + + if (!channelMap[channelAddress]) { + channelMap[channelAddress] = { parameters: [] }; + } + if (!channelMap[channelAddress][action]) { + channelMap[channelAddress][action] = { 'x-operations': [], 'x-auth-type': 'Any' }; + } + + channelMap[channelAddress][action]['x-operations'].push(opName); + + if (opObj['x-auth-type']) channelMap[channelAddress][action]['x-auth-type'] = opObj['x-auth-type']; + if (opObj['x-uri-mapping']) channelMap[channelAddress][action]['x-uri-mapping'] = + opObj['x-uri-mapping']; + if (opObj['x-scopes']) channelMap[channelAddress][action]['x-scopes'] = opObj['x-scopes']; + if (opObj.messages) { + const allProperties = extractMessageProperties(spec, opObj, opName); + if (Object.keys(allProperties).length > 0) { + const existing = channelMap[channelAddress][action].message; + channelMap[channelAddress][action].message = { + payload: { + type: 'object', + properties: { + ...existing?.payload?.properties, + ...allProperties, + }, + }, + }; + } + } + }); + } + return channelMap; + } + + /** + * Returns updated components with the new message schema and message ref added. + */ + function buildUpdatedComponents(currentComponents, msgName, msgProps) { + return { + ...currentComponents, + schemas: { + ...currentComponents.schemas, + [msgName]: { type: 'object', properties: msgProps }, + }, + messages: { + ...currentComponents.messages, + [msgName]: { payload: { $ref: `#/components/schemas/${msgName}` } }, + }, + }; + } + + /** + * Returns updated channels with the new message ref added to the given channel. + */ + function buildUpdatedChannels(currentChannels, channelName, msgName) { + return { + ...currentChannels, + [channelName]: { + ...currentChannels[channelName], + messages: { + ...currentChannels[channelName]?.messages, + [msgName]: { $ref: `#/components/messages/${msgName}` }, + }, + }, + }; + } + + function groupPayloadProperties(properties) { + const byMessage = {}; + + Object.entries(properties).forEach(([compositeKey, propVal]) => { + const msgName = propVal['x-message'] || propVal['x-operation'] || '__default__'; + + if (!byMessage[msgName]) { + byMessage[msgName] = { opName: propVal['x-operation'], props: {} }; + } + + const originalPropName = propVal['x-prop-name'] || compositeKey; + // const { 'x-operation': _, 'x-message': __, 'x-prop-name': ___, ...cleanProp } = propVal; + const { + 'x-operation': xOperation, + 'x-message': xMessage, + 'x-prop-name': xPropName, + ...cleanProp + } = propVal; + + byMessage[msgName].props[originalPropName] = cleanProp; + }); + + return byMessage; + } + + function wireOperationMessage(opName, channelName, msgName, newOperations) { + if (!opName || opName === '__default__' || !newOperations[opName]) return; + + const ref = { $ref: `#/channels/${channelName}/messages/${msgName}` }; + + if (!newOperations[opName].messages.some((m) => m.$ref === ref.$ref)) { + newOperations[opName].messages.push(ref); + } + } + + function processMessageGroups(byMessage, channelName, newComponents, newChannels, newOperations) { + let updatedComponents = { ...newComponents }; + let updatedChannels = { ...newChannels }; + + Object.entries(byMessage).forEach(([msgName, { opName, props: msgProps }]) => { + updatedComponents = buildUpdatedComponents(updatedComponents, msgName, msgProps); + updatedChannels = buildUpdatedChannels(updatedChannels, channelName, msgName); + + wireOperationMessage(opName, channelName, msgName, newOperations); + }); + + return { updatedComponents, updatedChannels }; + } + + function wirePayloadProperties(verbInfo, channelName, newComponents, newChannels, newOperations) { + if (!verbInfo.message?.payload?.properties) { + return { updatedComponents: newComponents, updatedChannels: newChannels }; + } + + const byMessage = groupPayloadProperties(verbInfo.message.payload.properties); + + return processMessageGroups( + byMessage, + channelName, + newComponents, + newChannels, + newOperations, + ); + } + + function createOperationEntry(opName, action, channelName, verbInfo, originalSpec) { + // const { messages: _, ...restOfOriginal } = originalSpec.operations?.[opName] || {}; + const { messages, ...restOfOriginal } = originalSpec.operations?.[opName] || {}; + + return { + ...restOfOriginal, + action, + channel: { $ref: `#/channels/${channelName}` }, + 'x-auth-type': verbInfo['x-auth-type'], + ...(verbInfo['x-uri-mapping'] && { 'x-uri-mapping': verbInfo['x-uri-mapping'] }), + ...(verbInfo['x-scopes'] && { 'x-scopes': verbInfo['x-scopes'] }), + messages: [], + }; + } + + function resolveChannel(channelAddress, channelObj, originalSpec, newChannels, addressToName) { + const existingChannelName = addressToName[channelAddress]; + + if (existingChannelName) { + return { + channelName: existingChannelName, + newChannels: { + ...newChannels, + [existingChannelName]: { + ...newChannels[existingChannelName], + ...(channelObj.description && { description: channelObj.description }), + }, + }, + }; + } + + const baseChannel = channelAddress.split('/').find(s => s.length > 0) || 'channel'; + const randomId = crypto.randomUUID().split('-')[0]; + const channelName = `${baseChannel}_${randomId}`; + const parametersArray = channelObj.parameters || []; + + return { + channelName, + newChannels: { + ...newChannels, + [channelName]: { + address: channelAddress, + ...(Object.keys(parametersArray).length > 0 && { parameters: parametersArray }), + }, + }, + }; + } + + /** + * Helper to process specific verb actions (send/receive) for a channel. + * This flattens the nesting from rebuildOperations. + */ + function processVerbActions(context) { + const { + verbInfo, action, channelName, + originalSpec, newOperations, newChannels, newComponents + } = context; + + // Level 4: Extracting the operations loop into a named helper or keeping it flat here + const opList = verbInfo['x-operations'] || []; + opList.forEach((opName) => { + newOperations[opName] = createOperationEntry( + opName, + action, + channelName, + verbInfo, + originalSpec, + ); + }); + + return wirePayloadProperties( + verbInfo, + channelName, + newComponents, + newChannels, + newOperations, + ); + } + + function rebuildOperations(channelMap, originalSpec) { + const newOperations = {}; + let newChannels = { ...originalSpec.channels }; + let newComponents = { messages: {} }; + + const addressToName = {}; + if (originalSpec.channels) { + Object.entries(originalSpec.channels).forEach(([channelName, channelObj]) => { + addressToName[channelObj.address] = channelName; + }); + } + + Object.entries(channelMap).forEach(([channelAddress, channelObj]) => { + const resolved = resolveChannel( + channelAddress, + channelObj, + originalSpec, + newChannels, + addressToName, + ); + + const { channelName } = resolved; + newChannels = resolved.newChannels; + + ['send', 'receive'].forEach((action) => { + const verbInfo = channelObj[action]; + if (!verbInfo) return; + const result = processVerbActions({ + verbInfo, + action, + channelName, + originalSpec, + newOperations, + newChannels, + newComponents, + }); + + newComponents = result.updatedComponents; + newChannels = result.updatedChannels; + }); + }); + return { newOperations, newChannels, newComponents }; + } + /** * * @param {*} spec @@ -160,7 +484,7 @@ export default function Topics(props) { switch (action) { case 'init': setSelectedOperation({}); - return data || asyncAPISpec.channels; + return data || (isAsyncV3 ? {} : asyncAPISpec.channels); case 'toggleSecurityStatus': setSelectedOperation({}); return Object.entries(currentOperations).reduce((channelAcc, [channelKey, channelObj]) => { @@ -188,6 +512,18 @@ export default function Topics(props) { }, }; case 'authType': + if (isAsyncV3) { + return { + ...currentOperations, + [target]: { + ...currentOperations[target], + [verb]: { + ...currentOperations[target][verb], + 'x-auth-type': value ? 'Any' : 'None', + }, + }, + }; + } updatedOperation['x-auth-type'] = value ? 'Any' : 'None'; return { ...currentOperations, @@ -202,20 +538,46 @@ export default function Topics(props) { // eslint-disable-next-line no-case-declarations let alreadyExistCount = 0; for (let currentVerb of data.verbs) { - currentVerb = verbMap[currentVerb]; - if (addedOperations[data.target][currentVerb]) { - const message = intl.formatMessage( - { - id: 'Apis.Details.Configuration.Topic.already.opreation.verb.exist.error', - defaultMessage: 'Operation already exist with {data_target} and {current_Verb}', - }, - { data_target: data.target, current_Verb: currentVerb }, - ); - Alert.warning(message); - console.warn(message); - alreadyExistCount++; + if (isAsyncV3) { + const ACTION_ALIASES = { pub: 'send', sub: 'receive' }; + const normalizedVerb = ACTION_ALIASES[currentVerb.toLowerCase()] + || currentVerb.toLowerCase(); + const opName = data.operationName; + if (!addedOperations[data.target][normalizedVerb]) { + addedOperations[data.target][normalizedVerb] = { + 'x-operations': [], + 'x-auth-type': 'Any', + }; + } + const existingOps = addedOperations[data.target][normalizedVerb]['x-operations']; + if (opName && existingOps.includes(opName)) { + Alert.warning(intl.formatMessage( + { + id: 'Apis.Details.Configuration.Topic.already.operation.exist.error', + defaultMessage: 'Operation "{opName}" already exists for {verb} on {channel}', + }, + { opName, verb: normalizedVerb, channel: data.target }, + )); + alreadyExistCount++; + } else if (opName) { + addedOperations[data.target][normalizedVerb]['x-operations'].push(opName); + } } else { - addedOperations[data.target][currentVerb] = { }; + currentVerb = verbMap[currentVerb]; + if (addedOperations[data.target][currentVerb]) { + const message = intl.formatMessage( + { + id: 'Apis.Details.Configuration.Topic.already.opreation.verb.exist.error', + defaultMessage: 'Operation already exist with {data_target} and {current_Verb}', + }, + { data_target: data.target, current_Verb: currentVerb }, + ); + Alert.warning(message); + console.warn(message); + alreadyExistCount++; + } else { + addedOperations[data.target][currentVerb] = { }; + } } } if (alreadyExistCount === data.verbs.length) { @@ -234,20 +596,53 @@ export default function Topics(props) { ...currentOperations, [target]: { ...currentOperations[target], parameters: updatedOperation.parameters }, }; - case 'addPayloadProperty': + case 'deleteNamedOperation': { + const channelCopy = cloneDeep(currentOperations[target]); + if (channelCopy && channelCopy[verb] && channelCopy[verb]['x-operations']) { + channelCopy[verb]['x-operations'] = channelCopy[verb]['x-operations'].filter((n) => n !== value); + } + return { ...currentOperations, [target]: channelCopy }; + } + case 'addPayloadProperty': { updatedOperation[verb].message = updatedOperation[verb].message || { }; updatedOperation[verb].message.payload = updatedOperation[verb].message.payload || { }; updatedOperation[verb].message.payload.type = 'object'; updatedOperation[verb].message.payload.properties = updatedOperation[verb].message.payload.properties || { }; - updatedOperation[verb].message.payload.properties[value.name] = { + const propKey = isAsyncV3 + ? `${value.message}__${value.name}` + : value.name; + updatedOperation[verb].message.payload.properties[propKey] = { description: value.description, type: value.type, + ...(isAsyncV3 && { + 'x-operation': value.operation, + 'x-message': value.message, + 'x-prop-name': value.name, + }), }; break; - case 'deletePayloadProperty': - delete updatedOperation[verb].message.payload.properties[value]; - break; + } + case 'deletePayloadProperty': { + const existingProps = updatedOperation[verb]?.message?.payload?.properties || {}; + const { [value]: _removed, ...remainingProps } = existingProps; + return { + ...currentOperations, + [target]: { + ...currentOperations[target], + [verb]: { + ...currentOperations[target][verb], + message: { + ...currentOperations[target][verb]?.message, + payload: { + ...currentOperations[target][verb]?.message?.payload, + properties: remainingProps, + }, + }, + }, + }, + }; + } case 'payloadProperty': updatedOperation[verb].message.payload.properties[value.name] = value; break; @@ -338,6 +733,14 @@ export default function Topics(props) { || {}; spec.components.securitySchemes.oauth2.flows.implicit.scopes = spec.components.securitySchemes.oauth2.flows .implicit.scopes || {}; + if (isAsyncV3) { + // v3 — use availableScopes + spec.components.securitySchemes.oauth2.flows.implicit.availableScopes = + spec.components.securitySchemes.oauth2.flows.implicit.availableScopes || {}; + } else { + spec.components.securitySchemes.oauth2.flows.implicit.scopes = + spec.components.securitySchemes.oauth2.flows.implicit.scopes || {}; + } /* eslint-enable no-param-reassign */ } @@ -347,7 +750,9 @@ export default function Topics(props) { */ function setSecurityDefScopesFromSpec(spec) { verifySecurityScheme(spec); - setSecurityDefScopes(cloneDeep(spec.components.securitySchemes.oauth2.flows.implicit.scopes)); + const implicitFlow = spec.components.securitySchemes.oauth2.flows.implicit; + const scopeSource = isAsyncV3 ? implicitFlow.availableScopes : implicitFlow.scopes; + setSecurityDefScopes(cloneDeep(scopeSource)); } /** @@ -355,7 +760,12 @@ export default function Topics(props) { */ function setSpecScopesFromSecurityDefScopes() { verifySecurityScheme(asyncAPISpec); - asyncAPISpec.components.securitySchemes.oauth2.flows.implicit.scopes = securityDefScopes; + if (isAsyncV3) { + // v3 uses availableScopes, not scopes + asyncAPISpec.components.securitySchemes.oauth2.flows.implicit.availableScopes = securityDefScopes; + } else { + asyncAPISpec.components.securitySchemes.oauth2.flows.implicit.scopes = securityDefScopes; + } } /** @@ -364,10 +774,18 @@ export default function Topics(props) { * @returns {null} */ function resolveAndUpdateSpec(rawSpec) { - const resolvedChannels = resolveSpec(rawSpec, rawSpec); - const resolvedSpec = { ...rawSpec, channels: resolvedChannels.channels }; - operationsDispatcher({ action: 'init', data: resolvedSpec.channels }); - setAsyncAPISpec(resolvedSpec); + const asyncSpecVersion = rawSpec?.asyncapi || '2.0.0'; + const asyncv3 = Number.parseInt(asyncSpecVersion.split('.')[0], 10) >= 3; + if (asyncv3) { + const channelData = buildChannelMap(rawSpec); + operationsDispatcher({ action: 'init', data: channelData }); + setAsyncAPISpec(rawSpec); + } else { + const resolvedChannels = resolveSpec(rawSpec, rawSpec); + const resolvedSpec = { ...rawSpec, channels: resolvedChannels.channels }; + operationsDispatcher({ action: 'init', data: resolvedSpec.channels }); + setAsyncAPISpec(resolvedSpec); + } setSecurityDefScopesFromSpec(rawSpec); } @@ -444,7 +862,10 @@ export default function Topics(props) { for (const [target, verbs] of Object.entries(markedOperations)) { for (const verb of Object.keys(verbs)) { delete copyOfOperations[target][verb]; - if (!copyOfOperations[target].publish && !copyOfOperations[target].subscribe) { + const channelEmpty = isAsyncV3 + ? !copyOfOperations[target].send && !copyOfOperations[target].receive + : !copyOfOperations[target].publish && !copyOfOperations[target].subscribe; + if (channelEmpty) { delete copyOfOperations[target]; } } @@ -452,6 +873,29 @@ export default function Topics(props) { updateSecurityDefinition(copyOfOperations); setSpecScopesFromSecurityDefScopes(); + + let specToSave; + if (isAsyncV3) { + const { newOperations, newChannels, newComponents } = rebuildOperations(copyOfOperations, asyncAPISpec); + specToSave = { + ...asyncAPISpec, + channels: newChannels, + operations: newOperations, + components: { + ...asyncAPISpec.components, + schemas: { + ...(asyncAPISpec.components?.schemas || {}), + ...newComponents.schemas, + }, + messages: { + ...(asyncAPISpec.components?.messages || {}), + ...newComponents.messages, + }, + }, + }; + } else { + specToSave = { ...asyncAPISpec, channels: copyOfOperations }; + } if (websubSubscriptionConfiguration !== api.websubSubscriptionConfiguration) { return updateAPI({ websubSubscriptionConfiguration }) .catch((error) => { @@ -461,10 +905,10 @@ export default function Topics(props) { defaultMessage: 'Error while updating the API', })); }) - .then(() => updateAsyncAPIDefinition({ ...asyncAPISpec, channels: copyOfOperations })); - } else { - return updateAsyncAPIDefinition({ ...asyncAPISpec, channels: copyOfOperations }); + .then(() => updateAsyncAPIDefinition(specToSave)); } + return updateAsyncAPIDefinition(specToSave); + } useEffect(() => { @@ -490,9 +934,10 @@ export default function Topics(props) { }, []); useEffect(() => { - // Update the Swagger spec object when API object gets changed api.getAsyncAPIDefinition() .then((response) => { + const asyncSpecVersion = response.body?.asyncapi || '2.0.0'; + setIsAsyncV3(Number.parseInt(asyncSpecVersion.split('.')[0], 10) >= 3); resolveAndUpdateSpec(response.body); }) .catch((error) => { @@ -536,7 +981,8 @@ export default function Topics(props) { {!isRestricted(['apim:api_create'], api) && !disableAddOperation && (api.gatewayVendor === 'wso2') && ( - + )} @@ -558,44 +1004,29 @@ export default function Topics(props) { spacing={1} alignItems='stretch' > - {operation.subscribe && ( - - - - )} - {operation.publish && ( - - - - )} + {(isAsyncV3 ? ['send', 'receive'] : ['subscribe', 'publish']).map((verb) => ( + operation[verb] && ( + + + + ) + ))} )) diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Policies/PoliciesSection.tsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Policies/PoliciesSection.tsx index 8ca82cd837c..645a62ad991 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Policies/PoliciesSection.tsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Policies/PoliciesSection.tsx @@ -77,6 +77,11 @@ const PoliciesSection: FC = ({ const borderColor = ''; + // Derive async api version from spec directly + const isAsyncV3 = openAPISpec?.asyncapi + ? Number.parseInt(openAPISpec.asyncapi.split('.')[0], 10) >= 3 + : false; + return ( {isAPILevelTabSelected ? ( @@ -108,9 +113,13 @@ const PoliciesSection: FC = ({ {api.type === 'WS' ? ( Object.entries(openAPISpec.channels).map(([channelName, channelItem]) => { - const operation = (channelItem as { subscribe?: any, publish?: any }).subscribe - ?? (channelItem as { publish?: any }).publish; - + const operation = isAsyncV3 + ? channelItem + : (channelItem as { subscribe?: any, publish?: any }).subscribe + ?? (channelItem as { publish?: any }).publish; + const target = isAsyncV3 + ? (channelItem as { address?: string }).address ?? channelName + : channelName; return ( @@ -127,7 +136,7 @@ const PoliciesSection: FC = ({ className={classes.gridItem} > { setLabelWidth(inputLabel.current.offsetWidth); }, []); @@ -209,6 +214,14 @@ function AddOperation(props) { })); return; } + // For AsyncAPI v3, operationName is required + if (isAsyncV3 && !newOperations.operationName) { + Alert.warning(intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.operation.name.cannot.be.empty.warning', + defaultMessage: "Operation name can't be empty", + })); + return; + } if (api && api.type && api.type.toLowerCase() === 'websub' && APIValidation.websubOperationTarget.validate(newOperations.target).error !== null) { Alert.warning(intl.formatMessage({ @@ -227,10 +240,48 @@ function AddOperation(props) { operationsDispatcher({ action: 'add', data: newOperations }); clearInputs(); } + const getOperationLabel = () => { + if (isAsyncV3 && isAsyncAPI) { + return intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.channel.address.label', + defaultMessage: 'Channel Address', + }); + } + if (isAsyncAPI) { + return intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.operation.target.topic.name.label', + defaultMessage: 'Topic Name', + }); + } + return intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.operation.target.uri.pattern.label', + defaultMessage: 'URI Pattern', + }); + }; + const getOperationPlaceholder = () => { + if (isAsyncV3 && isAsyncAPI) { + return intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.channel.address', + defaultMessage: 'Enter Channel Address', + }); + } + if (isAsyncAPI) { + return intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.operation.target.topic.name', + defaultMessage: 'Enter topic name', + }); + } + return intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.operation.target.uri.pattern', + defaultMessage: 'Enter URI pattern', + }); + }; + + return ( - + {isAsyncAPI && ( @@ -247,52 +298,71 @@ function AddOperation(props) { )} - - + {isAsyncV3 ? ( + + ) : ( + + )} {newOperations.verbs.includes('options') && ( // TODO: Add i18n to tooltip text ~tmkb @@ -316,53 +386,64 @@ function AddOperation(props) { - + newOperationsDispatcher({ type: name, - value: !isWebSub && !value.startsWith('/') ? `/${value}` : value, + value: !isWebSub && !isAsyncAPI && !value.startsWith('/') ? `/${value}` : value, })} - placeholder={isAsyncAPI ? intl.formatMessage({ - id: 'Apis.Details.Resources.components.AddOperation.operation.target.topic.name', - defaultMessage: 'Enter topic name', - }) : intl.formatMessage({ - id: 'Apis.Details.Resources.components.AddOperation.operation.target.uri.pattern', - defaultMessage: 'Enter URI pattern', - })} - helperText={newOperations.error || (isAsyncAPI ? intl.formatMessage({ - id: 'Apis.Details.Resources.components.AddOperation.operation.target.topic.name', - defaultMessage: 'Enter topic name', - }) : intl.formatMessage({ - id: 'Apis.Details.Resources.components.AddOperation.operation.target.uri.pattern', - defaultMessage: 'Enter URI pattern', - }))} + placeholder={getOperationPlaceholder()} + helperText={newOperations.error || getOperationPlaceholder()} fullWidth margin='dense' variant='outlined' - InputLabelProps={{ - shrink: true, - }} + InputLabelProps={{ shrink: true }} onKeyPress={(event) => { if (event.key === 'Enter') { - // key code 13 is for `Enter` key - event.preventDefault(); // To prevent form submissions + event.preventDefault(); addOperation(); } }} /> + {isAsyncV3 && isAsyncAPI && ( + + newOperationsDispatcher({ type: name, value })} + placeholder={intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.operation.name.placeholder', + defaultMessage: 'Enter Operation Name', + })} + helperText={intl.formatMessage({ + id: 'Apis.Details.Resources.components.AddOperation.operation.name.helper', + defaultMessage: 'Enter Operation Name', + })} + fullWidth + margin='dense' + variant='outlined' + InputLabelProps={{ shrink: true }} + onKeyPress={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addOperation(); + } + }} + /> + + )} { }; }); +const HeaderContent = ({ isAsyncV3, ...props }) => { + const { + verb, trimmedVerb, target, backgroundColor, + isUsedInAPIProduct, disableDelete, markAsDelete, + disableUpdate, toggleDelete, operation, namedOperations + } = props; + + const deleteButton = !(disableDelete || markAsDelete) && ( + +
+ + + +
+
+ ); + + const isSecure = operation['x-auth-type']?.toLowerCase() !== 'none'; + const securityIcon = ( + + + {isSecure ? : } + + + ); + + if (isAsyncV3) { + return ( + + + + + + {target} + + + + {c} }} /> + + + + {deleteButton} + {securityIcon} + + + ); + } + + return ( + + + + + + {target} + + + {deleteButton} + {securityIcon} + + + ); +}; + /** * * Handle the operation UI @@ -96,15 +172,30 @@ function AsyncOperation(props) { target, verb, sharedScopes, - componentValidator + componentValidator, + isAsyncV3, } = props; - const trimmedVerb = verb === 'publish' || verb === 'subscribe' ? verb.substr(0, 3) : verb; + const trimmedVerb = (!isAsyncV3 && (verb === 'publish' || verb === 'subscribe')) + ? verb.substr(0, 3) + : verb; + const theme = useTheme(); - const backgroundColor = theme.custom.resourceChipColors[trimmedVerb]; + const backgroundColor = theme.custom.resourceChipColors[trimmedVerb] || theme.palette.primary.main; const [isExpanded, setIsExpanded] = useState(false); - const isUsedInAPIProduct = false; + + // v3 only + const namedOperations = isAsyncV3 + ? (operation[verb]?.['x-operations'] || []) + : []; + + function handleDeleteNamedOperation(opName) { + operationsDispatcher({ + action: 'deleteNamedOperation', + data: { target, verb, value: opName }, + }); + } /** * @@ -158,101 +249,24 @@ function AsyncOperation(props) { classes={{ content: classes.contentNoMargin }} sx={highlight ? { backgroundColor: Utils.hexToRGBA(backgroundColor, 0.1) } : { }} > - - - - - - - {target} - - - - {!(disableDelete || markAsDelete) && ( - - ) - : ( - - ) - } - aria-label={( - - )} - > -
- - - -
-
- )} - - ) - : ( - - ) - } - aria-label={( - - )} - > - - {(!operation['x-auth-type'] || operation['x-auth-type'].toLowerCase() !== 'none') - ? - : } - - -
-
+ + {isAsyncV3 && ( + + )} {(api.gatewayVendor === 'wso2') && ( <> @@ -315,6 +331,7 @@ AsyncOperation.defaultProps = { disableDelete: false, onMarkAsDelete: () => {}, markAsDelete: false, + isAsyncV3: false, }; AsyncOperation.propTypes = { api: PropTypes.shape({ scopes: PropTypes.arrayOf(PropTypes.shape({})), resourcePolicies: PropTypes.shape({}) }) @@ -327,6 +344,7 @@ AsyncOperation.propTypes = { operation: PropTypes.shape({ 'x-wso2-new': PropTypes.bool, summary: PropTypes.string, + 'x-auth-type': PropTypes.string, }).isRequired, target: PropTypes.string.isRequired, verb: PropTypes.string.isRequired, @@ -335,6 +353,7 @@ AsyncOperation.propTypes = { sharedScopes: PropTypes.arrayOf(PropTypes.shape({})).isRequired, highlight: PropTypes.bool, disableUpdate: PropTypes.bool, + isAsyncV3: PropTypes.bool, }; export default React.memo(AsyncOperation); diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/AddPayloadProperty.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/AddPayloadProperty.jsx index 9f9bf0ef55e..c168b2ce004 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/AddPayloadProperty.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/AddPayloadProperty.jsx @@ -30,6 +30,7 @@ import Button from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import ClearIcon from '@mui/icons-material/Clear'; import Tooltip from '@mui/material/Tooltip'; +import Alert from 'AppComponents/Shared/Alert'; import { isRestricted } from 'AppData/AuthManager'; const PREFIX = 'AddPayloadProperty'; @@ -65,7 +66,7 @@ const StyledGrid = styled(Grid)(() => ({ */ function AddPayloadProperty(props) { const { - operationsDispatcher, target, verb, intl + operationsDispatcher, target, verb, intl, namedOperations, isAsyncV3, } = props; /** @@ -82,11 +83,20 @@ function AddPayloadProperty(props) { case 'type': nextState[type] = value; break; + // V3 only fields + case 'operation': + if (isAsyncV3) nextState[type] = value; + break; + case 'message': + if (isAsyncV3) nextState[type] = value; + break; case 'clear': return { name: '', description: '', type: '', + operation: '', + message: '', }; default: return nextState; @@ -116,6 +126,32 @@ function AddPayloadProperty(props) { * Add new property */ function addNewProperty() { + if (isAsyncV3) { + if (!property.operation) { + Alert.error(intl.formatMessage({ + id: 'Apis.Details.Resources.components.operationComponents.AddParameter.operation.required', + defaultMessage: 'Operation name is required', + })); + return; + } + if (!property.message) { + Alert.error(intl.formatMessage({ + id: 'Apis.Details.Resources.components.operationComponents.AddParameter.message.required', + defaultMessage: 'Message name is required', + })); + return; + } + if (!namedOperations.includes(property.operation)) { + Alert.error(intl.formatMessage( + { + id: 'Apis.Details.Resources.components.operationComponents.AddParameter.operation.not.found', + defaultMessage: 'Operation "{operation}" does not exist', + }, + { operation: property.operation }, + )); + return; + } + } operationsDispatcher({ action: 'addPayloadProperty', data: { @@ -127,6 +163,55 @@ function AddPayloadProperty(props) { return ( + {isAsyncV3 && ( + <> + + newPropertyDispatcher({ type: name, value })} + helperText={intl.formatMessage({ + id: 'Apis.Details.Components.async.api.add.property.operation.helper', + defaultMessage: 'Enter operation name', + })} + error={property.operation && !namedOperations.includes(property.operation)} + margin='dense' + variant='outlined' + onKeyPress={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addNewProperty(); + } + }} + /> + + + newPropertyDispatcher({ type: name, value })} + helperText={intl.formatMessage({ + id: 'Apis.Details.Components.async.api.add.property.message.name.helper', + defaultMessage: 'Enter message name', + })} + margin='dense' + variant='outlined' + /> + + + )} - + + + + + + + + + + e.stopPropagation()} + sx={{ + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + maxHeight: '150px', + overflowY: 'auto', + minWidth: '220px', + backgroundColor: 'background.paper', + // force scroll containment + display: 'block', + }} + > + + {operations && operations.length > 0 ? ( + operations.map((opName) => ( + + } + aria-label={( + + )} + > +
+ handleDeleteClick(e, opName)} + aria-label='delete' + size='small'> + + +
+
+ ) + } + > + + + )) + ) : ( + + + )} + primaryTypographyProps={{ color: 'text.secondary', variant: 'body2' }} + /> + + )} + +
+
+ + + + ); +} + +Asyncv3OperationsList.propTypes = { + operations: PropTypes.arrayOf(PropTypes.string), + onDeleteOperation: PropTypes.func, + disableDelete: PropTypes.bool, +}; +Asyncv3OperationsList.defaultProps = { + operations: [], + onDeleteOperation: null, + disableDelete: false, +}; + +export default Asyncv3OperationsList; diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/ListParameters.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/ListParameters.jsx index bad1c3d6f0c..c8aaf173249 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/ListParameters.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/ListParameters.jsx @@ -112,7 +112,7 @@ export default function ListParameters(props) { return ( {k} - {v.schema.type} + {v.schema?.type || v.type || 'string'} {v.description} )} - +
+ {isAsyncV3 && ( + <> + + + + + + + + )} { return ( + {isAsyncV3 && ( + <> + {v['x-operation'] || '—'} + {v['x-message'] || '—'} + + )} {k} {v.type} {v.description} @@ -163,6 +194,7 @@ export default function ListPayloadProperties(props) { ListPayloadProperties.defaultProps = { disableUpdate: false, disableForSolace: false, + isAsyncV3: false, }; ListPayloadProperties.propTypes = { operation: PropTypes.shape({}).isRequired, @@ -173,4 +205,5 @@ ListPayloadProperties.propTypes = { disableUpdate: PropTypes.bool, resolvedSpec: PropTypes.shape({}).isRequired, disableForSolace: PropTypes.bool, + isAsyncV3: PropTypes.bool, }; diff --git a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/PayloadProperties.jsx b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/PayloadProperties.jsx index 38bd81a295c..5def993d1bb 100644 --- a/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/PayloadProperties.jsx +++ b/portals/publisher/src/main/webapp/source/src/app/components/Apis/Details/Resources/components/operationComponents/asyncapi/PayloadProperties.jsx @@ -38,7 +38,7 @@ import ListPayloadProperties from './ListPayloadProperties'; */ export default function PayloadProperties(props) { const { - operation, operationsDispatcher, target, verb, disableUpdate, disableForSolace, + operation, operationsDispatcher, target, verb, disableUpdate, disableForSolace, namedOperations, isAsyncV3 } = props; return ( <> @@ -60,6 +60,8 @@ export default function PayloadProperties(props) { verb={verb} operationsDispatcher={operationsDispatcher} operation={operation} + namedOperations={namedOperations} + isAsyncV3={isAsyncV3} /> ) )} @@ -73,6 +75,7 @@ export default function PayloadProperties(props) { operationsDispatcher={operationsDispatcher} operation={operation} disableForSolace={disableForSolace} + isAsyncV3={isAsyncV3} /> @@ -88,9 +91,13 @@ PayloadProperties.propTypes = { disableUpdate: PropTypes.bool, resolvedSpec: PropTypes.shape({}).isRequired, disableForSolace: PropTypes.bool, + namedOperations: PropTypes.arrayOf(PropTypes.string), + isAsyncV3: PropTypes.bool, }; PayloadProperties.defaultProps = { disableUpdate: false, disableForSolace: false, + namedOperations: [], + isAsyncV3: false, }; diff --git a/portals/publisher/src/main/webapp/source/src/app/data/defaultTheme.js b/portals/publisher/src/main/webapp/source/src/app/data/defaultTheme.js index e7f9231342d..7247323aec0 100644 --- a/portals/publisher/src/main/webapp/source/src/app/data/defaultTheme.js +++ b/portals/publisher/src/main/webapp/source/src/app/data/defaultTheme.js @@ -156,6 +156,8 @@ export default { sub: '#38a169', pub: '#4299e1', tool: '#f2d4a7', + receive: '#38a169', + send: '#4299e1', }, operationChipColor: { query: '#b3e6fe',