diff --git a/docs/rest-apis/gateway/README.md b/docs/rest-apis/gateway/README.md index 5955513ae..54f70c282 100644 --- a/docs/rest-apis/gateway/README.md +++ b/docs/rest-apis/gateway/README.md @@ -102,9 +102,21 @@ Base URLs: - [Create a new WebSubAPI](websub-api-management.md#create-a-new-websubapi) - [List all WebSubAPIs](websub-api-management.md#list-all-websubapis) +- [Create a new API key for a WebSub API](websub-api-management.md#create-a-new-api-key-for-a-websub-api) +- [Get the list of API keys for a WebSub API](websub-api-management.md#get-the-list-of-api-keys-for-a-websub-api) +- [Regenerate API key for a WebSub API](websub-api-management.md#regenerate-api-key-for-a-websub-api) +- [Update an API key for a WebSub API](websub-api-management.md#update-an-api-key-for-a-websub-api) +- [Revoke an API key for a WebSub API](websub-api-management.md#revoke-an-api-key-for-a-websub-api) - [Get WebSubAPI by id](websub-api-management.md#get-websubapi-by-id) - [Update an existing WebSubAPI](websub-api-management.md#update-an-existing-websubapi) - [Delete a WebSubAPI](websub-api-management.md#delete-a-websubapi) +### [WebBroker API Management](webbroker-api-management.md) + +- [Create a new WebBrokerAPI](webbroker-api-management.md#create-a-new-webbrokerapi) +- [List all WebBrokerAPIs](webbroker-api-management.md#list-all-webbrokerapis) +- [Get WebBrokerAPI by id](webbroker-api-management.md#get-webbrokerapi-by-id) +- [Delete a WebBrokerAPI](webbroker-api-management.md#delete-a-webbrokerapi) + ### [Schemas](schemas.md) diff --git a/docs/rest-apis/gateway/schemas.md b/docs/rest-apis/gateway/schemas.md index 973559447..c1bc42b32 100644 --- a/docs/rest-apis/gateway/schemas.md +++ b/docs/rest-apis/gateway/schemas.md @@ -648,10 +648,38 @@ xor "main": "api.example.com", "sandbox": "sandbox-api.example.com" }, - "channels": [ - { - "name": "issues", - "method": "SUB", + "allChannels": { + "on_subscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_unsubscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_received": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_delivery": { "policies": [ { "name": "cors", @@ -661,15 +689,93 @@ xor } ] } - ], - "policies": [ - { - "name": "cors", - "version": "v1", - "executionCondition": "request.metadata[authenticated] != true", - "params": {} + }, + "channels": { + "property1": { + "on_subscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_unsubscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_received": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_delivery": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } + }, + "property2": { + "on_subscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_unsubscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_received": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_delivery": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } } - ], + }, "deploymentState": "deployed" } @@ -685,8 +791,9 @@ xor |vhosts|object|false|none|Custom virtual hosts/domains for the API| |» main|string|true|none|Custom virtual host/domain for production traffic| |» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| -|channels|[[Channel](#schemachannel)]|true|none|List of channels - Async operations(SUB) for WebSub APIs| -|policies|[[Policy](#schemapolicy)]|false|none|List of API-level policies applied to all operations unless overridden| +|allChannels|[WebSubAllChannelPolicies](#schemawebsuballchannelpolicies)|false|none|Policies applied to all channels, organized by event type.| +|channels|object|false|none|Per-channel configuration keyed by channel name. Each key is a channel name and defines policies applied only to that channel.| +|» **additionalProperties**|[WebSubChannel](#schemawebsubchannel)|false|none|A single channel definition with optional per-channel policy overrides.| |deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment.| #### Enumerated Values @@ -696,6 +803,219 @@ xor |deploymentState|deployed| |deploymentState|undeployed| +

WebSubChannel

+ + + + + + +```json +{ + "on_subscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_unsubscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_received": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_delivery": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } +} + +``` + +A single channel definition with optional per-channel policy overrides. + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|on_subscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|on_unsubscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|on_message_received|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|on_message_delivery|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| + +

WebSubEventPolicies

+ + + + + + +```json +{ + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] +} + +``` + +Policies for a single event type. + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|policies|[[Policy](#schemapolicy)]|false|none|List of policies applied for this event type.| + +

WebSubAllChannelPolicies

+ + + + + + +```json +{ + "on_subscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_unsubscription": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_received": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_message_delivery": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } +} + +``` + +Policies applied to all channels, organized by event type. + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|on_subscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|on_unsubscription|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|on_message_received|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|on_message_delivery|[WebSubEventPolicies](#schemawebsubeventpolicies)|false|none|Policies for a single event type.| + +

WebSubChannelPolicies

+ + + + + + +```json +{ + "on_subscription": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ], + "on_unsubscription": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ], + "on_message_received": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ], + "on_message_delivery": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] +} + +``` + +Policies applied to a specific channel, organized by event type. + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|on_subscription|[[Policy](#schemapolicy)]|false|none|Policies applied when a client subscribes to this channel (e.g., rbac)| +|on_unsubscription|[[Policy](#schemapolicy)]|false|none|Policies applied when a client unsubscribes from this channel| +|on_message_received|[[Policy](#schemapolicy)]|false|none|Policies applied when a message is received for this channel| +|on_message_delivery|[[Policy](#schemapolicy)]|false|none|Policies applied when delivering a message for this channel| +

Channel

@@ -735,6 +1055,592 @@ Channel (topic/event stream) definition for async APIs. |---|---| |method|SUB| +

WebBrokerApiRequest

+ + + + + + +```json +{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "WebBrokerApi", + "metadata": { + "name": "stock-trading-v1.0" + }, + "spec": { + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading/$version", + "receiver": { + "name": "websocket-receiver", + "type": "websocket" + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + }, + "channels": { + "prices": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + } + } + } +} + +``` + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|apiVersion|string|true|none|API specification version| +|kind|string|true|none|API type| +|metadata|[Metadata](#schemametadata)|true|none|none| +|spec|[WebBrokerApiData](#schemawebbrokerapidata)|true|none|none| + +#### Enumerated Values + +|Property|Value| +|---|---| +|apiVersion|gateway.api-platform.wso2.com/v1alpha1| +|kind|WebBrokerApi| + +

WebBrokerApi

+ + + + + + +```json +{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "WebBrokerApi", + "metadata": { + "name": "stock-trading-v1.0" + }, + "spec": { + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading/$version", + "receiver": { + "name": "websocket-receiver", + "type": "websocket" + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + }, + "channels": { + "prices": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + } + } + }, + "status": { + "id": "stock-trading-v1.0", + "state": "deployed", + "createdAt": "2026-04-24T07:21:13Z", + "updatedAt": "2026-04-24T07:21:13Z", + "deployedAt": "2026-04-24T07:21:13Z" + } +} + +``` + +### Properties + +allOf + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|*anonymous*|[WebBrokerApiRequest](#schemawebbrokerapirequest)|false|none|none| + +and + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|*anonymous*|object|false|none|none| +|» status|[ResourceStatus](#schemaresourcestatus)|false|read-only|Server-managed lifecycle fields. Populated on responses.| + +

WebBrokerApiData

+ + + + + + +```json +{ + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading", + "receiver": { + "name": "websocket-receiver", + "type": "websocket", + "properties": {} + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_produce": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_consume": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } + }, + "channels": { + "property1": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_produce": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_consume": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } + }, + "property2": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_produce": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_consume": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } + } + }, + "vhosts": { + "main": "api.example.com", + "sandbox": "sandbox-api.example.com" + }, + "deploymentState": "deployed" +} + +``` + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|displayName|string|true|none|Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed)| +|version|string|true|none|Semantic version of the API| +|context|string|true|none|Base path for all API routes (must start with /, no trailing slash)| +|receiver|[WebBrokerApiReceiver](#schemawebbrokerapireceiver)|true|none|WebSocket receiver configuration| +|broker|[WebBrokerApiBroker](#schemawebbrokerapibroker)|true|none|Message broker driver configuration| +|allChannels|[WebBrokerApiAllChannelPolicies](#schemawebbrokerapiallchannelpolicies)|false|none|Protocol mediation policies applied to all channels| +|channels|object|true|none|Map of WebSocket channels for bidirectional streaming with Kafka (key is channel name)| +|» **additionalProperties**|[WebBrokerApiChannel](#schemawebbrokerapichannel)|false|none|WebSocket channel configuration with Kafka topic mapping| +|vhosts|object|false|none|Custom virtual hosts/domains for the API| +|» main|string|true|none|Custom virtual host/domain for production traffic| +|» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| +|deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration and policies are preserved for potential redeployment.| + +#### Enumerated Values + +|Property|Value| +|---|---| +|deploymentState|deployed| +|deploymentState|undeployed| + +

WebBrokerApiReceiver

+ + + + + + +```json +{ + "name": "websocket-receiver", + "type": "websocket", + "properties": {} +} + +``` + +WebSocket receiver configuration + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|name|string|true|none|Receiver name| +|type|string|true|none|Receiver type| +|properties|object|false|none|Additional receiver properties| + +

WebBrokerApiBroker

+ + + + + + +```json +{ + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } +} + +``` + +Message broker driver configuration + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|name|string|true|none|Broker driver name| +|type|string|true|none|Broker driver type| +|properties|object|true|none|Broker driver properties (e.g., bootstrap servers)| + +

WebBrokerApiAllChannelPolicies

+ + + + + + +```json +{ + "on_connection_init": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_produce": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_consume": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } +} + +``` + +Protocol mediation policies applied to all channels + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|on_connection_init|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|on_produce|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|on_consume|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| + +

WebBrokerApiPolicyGroup

+ + + + + + +```json +{ + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] +} + +``` + +Group of policies + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|policies|[[Policy](#schemapolicy)]|false|none|List of policies to apply| + +

WebBrokerApiChannel

+ + + + + + +```json +{ + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_produce": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + }, + "on_consume": { + "policies": [ + { + "name": "cors", + "version": "v1", + "executionCondition": "request.metadata[authenticated] != true", + "params": {} + } + ] + } +} + +``` + +WebSocket channel configuration with Kafka topic mapping + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|produceTo|[WebBrokerApiProduceConfig](#schemawebbrokerapiproduceconfig)|false|none|Configuration for producing messages from WebSocket to Kafka| +|consumeFrom|[WebBrokerApiConsumeConfig](#schemawebbrokerapiconsumeconfig)|false|none|Configuration for consuming messages from Kafka to WebSocket| +|on_connection_init|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|on_produce|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|on_consume|[WebBrokerApiPolicyGroup](#schemawebbrokerapipolicygroup)|false|none|Group of policies| + +

WebBrokerApiProduceConfig

+ + + + + + +```json +{ + "topic": "stock.prices" +} + +``` + +Configuration for producing messages from WebSocket to Kafka + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|topic|string|true|none|Kafka topic to produce messages to| + +

WebBrokerApiConsumeConfig

+ + + + + + +```json +{ + "topic": "stock.prices" +} + +``` + +Configuration for consuming messages from Kafka to WebSocket + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|topic|string|true|none|Kafka topic to consume messages from| +

APIKeyCreationRequest

diff --git a/docs/rest-apis/gateway/webbroker-api-management.md b/docs/rest-apis/gateway/webbroker-api-management.md new file mode 100644 index 000000000..3fac511e7 --- /dev/null +++ b/docs/rest-apis/gateway/webbroker-api-management.md @@ -0,0 +1,564 @@ +

WebBroker API Management

+ +## Create a new WebBrokerAPI + + + +`POST /webbroker-apis` + +> Code samples + +```shell + +curl -X POST http://localhost:9090/api/management/v0.9/webbroker-apis \ + -u {username}:{password} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d @payload.json + +``` + +Add a new WebBrokerAPI to the Gateway. WebBrokerAPI provides bidirectional streaming between WebSocket clients and Kafka brokers with per-connection isolation. + +> Payload + +```json +{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "WebBrokerApi", + "metadata": { + "name": "stock-trading-v1.0" + }, + "spec": { + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading/$version", + "receiver": { + "name": "websocket-receiver", + "type": "websocket" + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + }, + "channels": { + "prices": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + } + } + } +} +``` + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|[WebBrokerApiRequest](schemas.md#schemawebbrokerapirequest)|true|none| + +> Example responses + +> 201 Response + +```json +{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "WebBrokerApi", + "metadata": { + "name": "stock-trading-v1.0" + }, + "spec": { + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading/$version", + "receiver": { + "name": "websocket-receiver", + "type": "websocket" + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + }, + "channels": { + "prices": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + } + } + }, + "status": { + "id": "stock-trading-v1.0", + "state": "deployed", + "createdAt": "2026-04-24T07:21:13Z", + "updatedAt": "2026-04-24T07:21:13Z", + "deployedAt": "2026-04-24T07:21:13Z" + } +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|WebBrokerAPI created successfully|[WebBrokerApi](schemas.md#schemawebbrokerapi)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Invalid configuration (validation failed)|[ErrorResponse](schemas.md#schemaerrorresponse)| +|409|[Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8)|Conflict - WebBroker API with same name and version already exists|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +## List all WebBrokerAPIs + + + +`GET /webbroker-apis` + +> Code samples + +```shell + +curl -X GET http://localhost:9090/api/management/v0.9/webbroker-apis \ + -u {username}:{password} \ + -H 'Accept: application/json' + +``` + +List WebBrokerAPIs registered in the Gateway, optionally filtered by name, version, or status. + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|displayName|query|string|false|Filter by WebBroker API display name| +|version|query|string|false|Filter by WebBroker API version| +|status|query|string|false|Filter by deployment status| + +#### Enumerated Values + +|Parameter|Value| +|---|---| +|status|deployed| +|status|undeployed| + +> Example responses + +> 200 Response + +```json +{ + "status": "success", + "count": 3, + "apis": [ + { + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "WebBrokerApi", + "metadata": { + "name": "stock-trading-v1.0" + }, + "spec": { + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading/$version", + "receiver": { + "name": "websocket-receiver", + "type": "websocket" + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + }, + "channels": { + "prices": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + } + } + }, + "status": { + "id": "stock-trading-v1.0", + "state": "deployed", + "createdAt": "2026-04-24T07:21:13Z", + "updatedAt": "2026-04-24T07:21:13Z", + "deployedAt": "2026-04-24T07:21:13Z" + } + } + ] +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|List of WebBrokerAPIs|Inline| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +

Response Schema

+ +Status Code **200** + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|» status|string|false|none|none| +|» count|integer|false|none|none| +|» apis|[allOf]|false|none|none| + +*allOf* + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|»» *anonymous*|[WebBrokerApiRequest](schemas.md#schemawebbrokerapirequest)|false|none|none| +|»»» apiVersion|string|true|none|API specification version| +|»»» kind|string|true|none|API type| +|»»» metadata|[Metadata](schemas.md#schemametadata)|true|none|none| +|»»»» name|string|true|none|Unique handle for the resource| +|»»»» labels|object|false|none|Labels are key-value pairs for organizing and selecting APIs. Keys must not contain spaces.| +|»»»»» **additionalProperties**|string|false|none|none| +|»»»» annotations|object|false|none|Annotations are arbitrary non-identifying metadata. Use domain-prefixed keys.| +|»»»»» **additionalProperties**|string|false|none|none| +|»»» spec|[WebBrokerApiData](schemas.md#schemawebbrokerapidata)|true|none|none| +|»»»» displayName|string|true|none|Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed)| +|»»»» version|string|true|none|Semantic version of the API| +|»»»» context|string|true|none|Base path for all API routes (must start with /, no trailing slash)| +|»»»» receiver|[WebBrokerApiReceiver](schemas.md#schemawebbrokerapireceiver)|true|none|WebSocket receiver configuration| +|»»»»» name|string|true|none|Receiver name| +|»»»»» type|string|true|none|Receiver type| +|»»»»» properties|object|false|none|Additional receiver properties| +|»»»» broker|[WebBrokerApiBroker](schemas.md#schemawebbrokerapibroker)|true|none|Message broker driver configuration| +|»»»»» name|string|true|none|Broker driver name| +|»»»»» type|string|true|none|Broker driver type| +|»»»»» properties|object|true|none|Broker driver properties (e.g., bootstrap servers)| +|»»»» allChannels|[WebBrokerApiAllChannelPolicies](schemas.md#schemawebbrokerapiallchannelpolicies)|false|none|Protocol mediation policies applied to all channels| +|»»»»» on_connection_init|[WebBrokerApiPolicyGroup](schemas.md#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|»»»»»» policies|[[Policy](schemas.md#schemapolicy)]|false|none|List of policies to apply| +|»»»»»»» name|string|true|none|Name of the policy| +|»»»»»»» version|string|true|none|Version of the policy. Only major-only version is allowed (e.g., v0, v1). Full semantic version (e.g., v1.0.0) is not accepted and will be rejected. The Gateway Controller resolves the major version to the single matching full version installed in the gateway image.| +|»»»»»»» executionCondition|string|false|none|Expression controlling conditional execution of the policy| +|»»»»»»» params|object|false|none|Arbitrary parameters for the policy (free-form key/value structure)| +|»»»»» on_produce|[WebBrokerApiPolicyGroup](schemas.md#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|»»»»» on_consume|[WebBrokerApiPolicyGroup](schemas.md#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|»»»» channels|object|true|none|Map of WebSocket channels for bidirectional streaming with Kafka (key is channel name)| +|»»»»» **additionalProperties**|[WebBrokerApiChannel](schemas.md#schemawebbrokerapichannel)|false|none|WebSocket channel configuration with Kafka topic mapping| +|»»»»»» produceTo|[WebBrokerApiProduceConfig](schemas.md#schemawebbrokerapiproduceconfig)|false|none|Configuration for producing messages from WebSocket to Kafka| +|»»»»»»» topic|string|true|none|Kafka topic to produce messages to| +|»»»»»» consumeFrom|[WebBrokerApiConsumeConfig](schemas.md#schemawebbrokerapiconsumeconfig)|false|none|Configuration for consuming messages from Kafka to WebSocket| +|»»»»»»» topic|string|true|none|Kafka topic to consume messages from| +|»»»»»» on_connection_init|[WebBrokerApiPolicyGroup](schemas.md#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|»»»»»» on_produce|[WebBrokerApiPolicyGroup](schemas.md#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|»»»»»» on_consume|[WebBrokerApiPolicyGroup](schemas.md#schemawebbrokerapipolicygroup)|false|none|Group of policies| +|»»»» vhosts|object|false|none|Custom virtual hosts/domains for the API| +|»»»»» main|string|true|none|Custom virtual host/domain for production traffic| +|»»»»» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| +|»»»» deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration and policies are preserved for potential redeployment.| + +*and* + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|»» *anonymous*|object|false|none|none| +|»»» status|[ResourceStatus](schemas.md#schemaresourcestatus)|false|read-only|Server-managed lifecycle fields. Populated on responses.| +|»»»» id|string|false|none|Unique identifier assigned by the server (equal to metadata.name)| +|»»»» state|string|false|none|Desired deployment state reported by the server| +|»»»» createdAt|string(date-time)|false|none|Timestamp when the resource was first created (UTC)| +|»»»» updatedAt|string(date-time)|false|none|Timestamp when the resource was last updated (UTC)| +|»»»» deployedAt|string(date-time)|false|none|Timestamp when the resource was last deployed (omitted when undeployed)| + +#### Enumerated Values + +|Property|Value| +|---|---| +|apiVersion|gateway.api-platform.wso2.com/v1alpha1| +|kind|WebBrokerApi| +|deploymentState|deployed| +|deploymentState|undeployed| +|state|deployed| +|state|undeployed| + +## Get WebBrokerAPI by id + + + +`GET /webbroker-apis/{id}` + +> Code samples + +```shell + +curl -X GET http://localhost:9090/api/management/v0.9/webbroker-apis/{id} \ + -u {username}:{password} \ + -H 'Accept: application/json' + +``` + +Get a WebBrokerAPI by its ID. + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|id|path|string|true|Unique public identifier for the WebBroker API.| + +#### Detailed descriptions + +**id**: Unique public identifier for the WebBroker API. + +> Example responses + +> 200 Response + +```json +{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "WebBrokerApi", + "metadata": { + "name": "stock-trading-v1.0" + }, + "spec": { + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading/$version", + "receiver": { + "name": "websocket-receiver", + "type": "websocket" + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka-broker-1:9092", + "kafka-broker-2:9092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + }, + "channels": { + "prices": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "stock.prices" + }, + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + } + } + }, + "status": { + "id": "stock-trading-v1.0", + "state": "deployed", + "createdAt": "2026-04-24T07:21:13Z", + "updatedAt": "2026-04-24T07:21:13Z", + "deployedAt": "2026-04-24T07:21:13Z" + } +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|WebBrokerAPI details|[WebBrokerApi](schemas.md#schemawebbrokerapi)| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|WebBrokerAPI not found|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +## Delete a WebBrokerAPI + + + +`DELETE /webbroker-apis/{id}` + +> Code samples + +```shell + +curl -X DELETE http://localhost:9090/api/management/v0.9/webbroker-apis/{id} \ + -u {username}:{password} \ + -H 'Accept: application/json' + +``` + +Delete a WebBrokerAPI from the Gateway. + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|id|path|string|true|Unique public identifier of the WebBroker API to delete.| + +#### Detailed descriptions + +**id**: Unique public identifier of the WebBroker API to delete. + +> Example responses + +> 200 Response + +```json +{ + "status": "success", + "message": "WebBrokerAPI deleted successfully", + "id": "stock-trading-webbroker-api" +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|WebBrokerAPI deleted successfully|Inline| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|WebBrokerAPI not found|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +

Response Schema

+ +Status Code **200** + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|» status|string|false|none|none| +|» message|string|false|none|none| +|» id|string|false|none|none| diff --git a/docs/rest-apis/gateway/websub-api-management.md b/docs/rest-apis/gateway/websub-api-management.md index fefdba9f1..91cf39fcb 100644 --- a/docs/rest-apis/gateway/websub-api-management.md +++ b/docs/rest-apis/gateway/websub-api-management.md @@ -229,15 +229,22 @@ Status Code **200** |»»»» vhosts|object|false|none|Custom virtual hosts/domains for the API| |»»»»» main|string|true|none|Custom virtual host/domain for production traffic| |»»»»» sandbox|string|false|none|Custom virtual host/domain for sandbox traffic| -|»»»» channels|[[Channel](schemas.md#schemachannel)]|true|none|List of channels - Async operations(SUB) for WebSub APIs| -|»»»»» name|string|true|none|Channel name or topic identifier relative to API context.| -|»»»»» method|string|true|none|Operation method type.| -|»»»»» policies|[[Policy](schemas.md#schemapolicy)]|false|none|List of policies applied only to this channel (overrides or adds to API-level policies)| -|»»»»»» name|string|true|none|Name of the policy| -|»»»»»» version|string|true|none|Version of the policy. Only major-only version is allowed (e.g., v0, v1). Full semantic version (e.g., v1.0.0) is not accepted and will be rejected. The Gateway Controller resolves the major version to the single matching full version installed in the gateway image.| -|»»»»»» executionCondition|string|false|none|Expression controlling conditional execution of the policy| -|»»»»»» params|object|false|none|Arbitrary parameters for the policy (free-form key/value structure)| -|»»»» policies|[[Policy](schemas.md#schemapolicy)]|false|none|List of API-level policies applied to all operations unless overridden| +|»»»» allChannels|[WebSubAllChannelPolicies](schemas.md#schemawebsuballchannelpolicies)|false|none|Policies applied to all channels, organized by event type.| +|»»»»» on_subscription|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|»»»»»» policies|[[Policy](schemas.md#schemapolicy)]|false|none|List of policies applied for this event type.| +|»»»»»»» name|string|true|none|Name of the policy| +|»»»»»»» version|string|true|none|Version of the policy. Only major-only version is allowed (e.g., v0, v1). Full semantic version (e.g., v1.0.0) is not accepted and will be rejected. The Gateway Controller resolves the major version to the single matching full version installed in the gateway image.| +|»»»»»»» executionCondition|string|false|none|Expression controlling conditional execution of the policy| +|»»»»»»» params|object|false|none|Arbitrary parameters for the policy (free-form key/value structure)| +|»»»»» on_unsubscription|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|»»»»» on_message_received|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|»»»»» on_message_delivery|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|»»»» channels|object|false|none|Per-channel configuration keyed by channel name. Each key is a channel name and defines policies applied only to that channel.| +|»»»»» **additionalProperties**|[WebSubChannel](schemas.md#schemawebsubchannel)|false|none|A single channel definition with optional per-channel policy overrides.| +|»»»»»» on_subscription|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|»»»»»» on_unsubscription|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|»»»»»» on_message_received|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| +|»»»»»» on_message_delivery|[WebSubEventPolicies](schemas.md#schemawebsubeventpolicies)|false|none|Policies for a single event type.| |»»»» deploymentState|string|false|none|Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration, API keys, and policies are preserved for potential redeployment.| *and* @@ -258,12 +265,360 @@ Status Code **200** |---|---| |apiVersion|gateway.api-platform.wso2.com/v1alpha1| |kind|WebSubApi| -|method|SUB| |deploymentState|deployed| |deploymentState|undeployed| |state|deployed| |state|undeployed| +## Create a new API key for a WebSub API + + + +`POST /websub-apis/{id}/api-keys` + +> Code samples + +```shell + +curl -X POST http://localhost:9090/api/management/v0.9/websub-apis/{id}/api-keys \ + -u {username}:{password} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d @payload.json + +``` + +Generate a new API key for a WebSub API in the Gateway. The key is a 32-byte random value encoded in hexadecimal, prefixed with `apip_`. Use the API Key policy on the API to validate incoming requests with this key. + +> Payload + +```json +{ + "name": "my-production-key" +} +``` + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|id|path|string|true|Unique public identifier of the WebSub API to generate the key for| +|body|body|[APIKeyCreationRequest](schemas.md#schemaapikeycreationrequest)|true|none| + +> Example responses + +> 201 Response + +```json +{ + "status": "success", + "message": "API key generated successfully", + "remainingApiKeyQuota": 9, + "apiKey": { + "name": "my-production-key", + "displayName": "My Production Key", + "apiKey": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "apiId": "reading-list-api-v1.0", + "status": "active", + "createdAt": "2026-04-01T10:30:00Z", + "createdBy": "admin", + "expiresAt": null, + "source": "local" + } +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|201|[Created](https://tools.ietf.org/html/rfc7231#section-6.3.2)|API key created successfully|[APIKeyCreationResponse](schemas.md#schemaapikeycreationresponse)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Invalid configuration (validation failed)|[ErrorResponse](schemas.md#schemaerrorresponse)| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|WebSub API not found|[ErrorResponse](schemas.md#schemaerrorresponse)| +|409|[Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8)|Conflict (duplicate key or conflicting update)|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +## Get the list of API keys for a WebSub API + + + +`GET /websub-apis/{id}/api-keys` + +> Code samples + +```shell + +curl -X GET http://localhost:9090/api/management/v0.9/websub-apis/{id}/api-keys \ + -u {username}:{password} \ + -H 'Accept: application/json' + +``` + +List all API keys for a WebSub API in the Gateway. + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|id|path|string|true|Unique public identifier of the WebSub API to retrieve the keys for| + +> Example responses + +> 200 Response + +```json +{ + "apiKeys": [ + { + "name": "my-production-key", + "displayName": "My Production Key", + "apiKey": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "apiId": "reading-list-api-v1.0", + "status": "active", + "createdAt": "2026-04-01T10:30:00Z", + "createdBy": "admin", + "expiresAt": null, + "source": "local" + } + ], + "totalCount": 3, + "status": "success" +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|List of API keys|[APIKeyListResponse](schemas.md#schemaapikeylistresponse)| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|WebSub API not found|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +## Regenerate API key for a WebSub API + + + +`POST /websub-apis/{id}/api-keys/{apiKeyName}/regenerate` + +> Code samples + +```shell + +curl -X POST http://localhost:9090/api/management/v0.9/websub-apis/{id}/api-keys/{apiKeyName}/regenerate \ + -u {username}:{password} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d @payload.json + +``` + +Regenerate an existing API key for a WebSub API in the Gateway. The previous key is revoked and replaced with a new 32-byte random value encoded in hexadecimal, prefixed with `apip_`. + +> Payload + +```json +{} +``` + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|id|path|string|true|Unique public identifier of the WebSub API| +|apiKeyName|path|string|true|Name of the API key to regenerate| +|body|body|[APIKeyRegenerationRequest](schemas.md#schemaapikeyregenerationrequest)|true|none| + +> Example responses + +> 200 Response + +```json +{ + "status": "success", + "message": "API key generated successfully", + "remainingApiKeyQuota": 9, + "apiKey": { + "name": "my-production-key", + "displayName": "My Production Key", + "apiKey": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "apiId": "reading-list-api-v1.0", + "status": "active", + "createdAt": "2026-04-01T10:30:00Z", + "createdBy": "admin", + "expiresAt": null, + "source": "local" + } +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|API key rotated successfully|[APIKeyCreationResponse](schemas.md#schemaapikeycreationresponse)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Invalid configuration (validation failed)|[ErrorResponse](schemas.md#schemaerrorresponse)| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|WebSub API or API key not found|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +## Update an API key for a WebSub API + + + +`PUT /websub-apis/{id}/api-keys/{apiKeyName}` + +> Code samples + +```shell + +curl -X PUT http://localhost:9090/api/management/v0.9/websub-apis/{id}/api-keys/{apiKeyName} \ + -u {username}:{password} \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d @payload.json + +``` + +Update an API key with a custom value instead of auto-generating one. + +> Payload + +```json +{ + "name": "my-production-key" +} +``` + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|id|path|string|true|Unique public identifier of the WebSub API| +|apiKeyName|path|string|true|Name of the API key to update| +|body|body|[APIKeyUpdateRequest](schemas.md#schemaapikeyupdaterequest)|true|none| + +> Example responses + +> 200 Response + +```json +{ + "status": "success", + "message": "API key generated successfully", + "remainingApiKeyQuota": 9, + "apiKey": { + "name": "my-production-key", + "displayName": "My Production Key", + "apiKey": "apip_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "apiId": "reading-list-api-v1.0", + "status": "active", + "createdAt": "2026-04-01T10:30:00Z", + "createdBy": "admin", + "expiresAt": null, + "source": "local" + } +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|API key updated successfully|[APIKeyCreationResponse](schemas.md#schemaapikeycreationresponse)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Invalid request (validation failed)|[ErrorResponse](schemas.md#schemaerrorresponse)| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|WebSub API or API key not found|[ErrorResponse](schemas.md#schemaerrorresponse)| +|409|[Conflict](https://tools.ietf.org/html/rfc7231#section-6.5.8)|Conflict (duplicate key or conflicting update)|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + +## Revoke an API key for a WebSub API + + + +`DELETE /websub-apis/{id}/api-keys/{apiKeyName}` + +> Code samples + +```shell + +curl -X DELETE http://localhost:9090/api/management/v0.9/websub-apis/{id}/api-keys/{apiKeyName} \ + -u {username}:{password} \ + -H 'Accept: application/json' + +``` + +Revoke an API key. Once revoked, it can no longer be used to authenticate requests. + +### Authentication + + + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|id|path|string|true|Unique public identifier of the WebSub API| +|apiKeyName|path|string|true|Name of the API key to revoke| + +> Example responses + +> 200 Response + +```json +{ + "status": "success", + "message": "API key revoked successfully" +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|API key revoked successfully|[APIKeyRevocationResponse](schemas.md#schemaapikeyrevocationresponse)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Invalid configuration (validation failed)|[ErrorResponse](schemas.md#schemaerrorresponse)| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|WebSub API or API key not found|[ErrorResponse](schemas.md#schemaerrorresponse)| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[ErrorResponse](schemas.md#schemaerrorresponse)| + ## Get WebSubAPI by id diff --git a/event-gateway/ARCHITECTURE.md b/event-gateway/ARCHITECTURE.md new file mode 100644 index 000000000..859f7b6b4 --- /dev/null +++ b/event-gateway/ARCHITECTURE.md @@ -0,0 +1,1466 @@ +# Event Gateway Architecture: WebSubApi & WebBrokerApi + +This document provides a comprehensive architectural overview of how **WebSubApi** and **WebBrokerApi** are implemented and fit into the Event Gateway. + +## Architecture Overview + +Both API types follow the same **Receiver → Policy Engine → Broker Driver** architecture with protocol-specific implementations. + +--- + +## 1. Spec Submission → Storage (Controller) + +### Entry Point: REST API +``` +POST /websub-apis → CreateWebSubAPI() +POST /webbroker-apis → CreateWebBrokerApi() +``` + +### Flow +1. **Handler** receives YAML/JSON spec via HTTP +2. **DeploymentService.DeployAPIConfiguration()** processes it: + - Parses spec into typed structs (`WebSubAPI` or `WebBrokerApi`) + - Validates structure + - Stores in **SQLite** as `StoredConfig` + - Returns UUID and deployment status + +### Storage Schema +```go +StoredConfig { + UUID: string // Unique ID + Kind: "WebSubApi" | "WebBrokerApi" + DisplayName: string + Configuration: interface{} // Typed as WebSubAPI or WebBrokerApi + DesiredState: "deployed" | "undeployed" + SourceConfiguration: []byte +} +``` + +**Implementation Files:** +- `gateway/gateway-controller/pkg/api/handlers/websub_api_handler.go` +- `gateway/gateway-controller/pkg/api/handlers/webbroker_api_handler.go` +- `gateway/gateway-controller/pkg/api/management/generated.go` (WebSubAPI types) +- `gateway/gateway-controller/pkg/api/management/webbroker_types.go` (WebBrokerApi types) + +--- + +## 2. xDS Translation (Controller) + +### Translator Service +**File:** `gateway/gateway-controller/pkg/policyxds/event_channel_translator.go` + +### Functions +- `TranslateWebSubApisToEventChannelConfigs()` +- `TranslateWebBrokerApisToEventChannelConfigs()` + +### Process +1. Fetches all `StoredConfig` entries by Kind +2. Filters for `DesiredState == "deployed"` +3. Converts each into **EventChannelConfig** xDS resource: + ```json + { + "uuid": "...", + "name": "my-api", + "kind": "WebSubApi" | "WebBrokerApi", + "context": "/api/v1", + "version": "1.0.0", + "channels": [...], // Structure differs by Kind + "policies": {...}, // Structure differs by Kind + "receiver": {...}, // Only for WebBrokerApi + "broker-driver": {...} // Only for WebBrokerApi + } + ``` + +### WebSubApi xDS Structure +```json +{ + "kind": "WebSubApi", + "channels": [ + { + "name": "issues", + "policies": { + "subscribe": [], + "inbound": [], + "outbound": [] + } + } + ], + "policies": { + "subscribe": [], // Hub-level auth + "inbound": [], // Webhook receiver validation + "outbound": [] // Delivery transformation/signing + }, + "receiver": { + "type": "websub" + } +} +``` + +### WebBrokerApi xDS Structure +```json +{ + "kind": "WebBrokerApi", + "channels": { + "/issues": { + "policies": { + "onConnectionInit": { + "request": [], + "response": [] + }, + "on_produce": [], + "on_consume": [] + } + } + }, + "policies": { + "on_connection_init": { + "request": [], + "response": [] + }, + "on_produce": [], + "on_consume": [] + }, + "receiver": { + "name": "ws-receiver", + "type": "websocket" + }, + "broker-driver": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "bootstrap.servers": "kafka:9092" + } + } +} +``` + +4. xDS Server pushes to runtime via gRPC stream + +--- + +## 3. Runtime Processing (Event Gateway) + +### xDS Reception +**File:** `event-gateway/gateway-runtime/internal/xdsclient/handler.go` + +### Handler.HandleResources() +1. Receives EventChannelConfig from xDS stream +2. Deserializes JSON payload into `EventChannelResource` +3. Diffs against previous state (add/remove/update) +4. Calls `addBinding()` or `removeBinding()` + +### Binding Conversion + +#### WebSubApi +```go +// In handler.go: toWebSubApiBinding() +WebSubApiBinding { + Kind: "WebSubApi" + Name: string + Context: string + Version: string + Channels: map[string]string // channel-name → Kafka topic + BrokerDriver: BrokerDriverSpec{Type: "kafka", Config: {...}} + Receiver: ReceiverSpec{Type: "websub"} + Policies: {Subscribe: [], Inbound: [], Outbound: []} + ChannelPolicies: map[string]{Subscribe: []} // Per-channel policies +} +``` + +**Implementation File:** `event-gateway/gateway-runtime/internal/binding/types.go` + +#### WebBrokerApi +```go +// In handler.go: toWebBrokerApiBinding() +WebBrokerApiBinding { + Kind: "WebBrokerApi" + Name: string + Context: string // WebSocket path + Receiver: ReceiverSpec{Name: "ws-receiver", Type: "websocket"} + BrokerDriver: BrokerDriverSpec{Name: "kafka-driver", Type: "kafka", ...} + Policies: { + OnConnectionInit: {Request: [], Response: []}, + OnProduce: [], + OnConsume: [] + } + Channels: map[string]WebBrokerChannelDef{ + "/issues": { + OnConnectionInit: {...}, + OnProduce: [], + OnConsume: [] + } + } +} +``` + +**Implementation File:** `event-gateway/gateway-runtime/internal/binding/types.go` + +--- + +## 4. Component Creation (Runtime) + +### Runtime.AddWebSubApiBinding() + +**File:** `event-gateway/gateway-runtime/internal/runtime/runtime.go` + +``` +┌─────────────────────────────────────────────┐ +│ 1. Build Policy Chains │ +│ - Subscribe chain (hub-level + per-chan) │ +│ - Inbound chain (webhook validation) │ +│ - Outbound chain (delivery transform) │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ 2. Create Broker Driver (Kafka) │ +│ - Topics: one per channel + sync topic │ +│ - Producer for webhook ingress │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ 3. Create WebSub Receiver │ +│ - HTTP handlers: /hub, /webhook-receiver │ +│ - Subscription store (in-memory) │ +│ - Consumer manager (per-callback) │ +│ - Deliverer (async retry logic) │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ 4. Register with Hub │ +│ - ChannelBinding with chain keys │ +│ - Channel → Kafka topic mapping │ +└─────────────────────────────────────────────┘ +``` + +**Implementation:** `event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go` + +### Runtime.AddWebBrokerApiBinding() + +**File:** `event-gateway/gateway-runtime/internal/runtime/runtime.go` + +``` +┌─────────────────────────────────────────────┐ +│ 1. Build Policy Chains │ +│ API-level: │ +│ - onConnectionInit (request + response) │ +│ - onProduce │ +│ - onConsume │ +│ Per-channel: │ +│ - /issues: onProduce + onConsume chains │ +│ - /commits: onProduce + onConsume chains │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ 2. Extract Topics & Create Broker Driver │ +│ - Parse map-topic policies │ +│ - Extract produceTo + consumeFrom topics │ +│ - Create Kafka driver with all topics │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ 3. Create WebSocket Receiver │ +│ - HTTP handler on context path │ +│ - Metadata: channelNames, chainKeys │ +│ - Upgrade logic with X-channel validation │ +└─────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────┐ +│ 4. Register with Hub │ +│ - ChannelChainKeys per channel │ +│ - Enables ProcessByChainKey() │ +└─────────────────────────────────────────────┘ +``` + +**Implementation:** `event-gateway/gateway-runtime/internal/connectors/receiver/websocket/broker_api_connector.go` + +--- + +## 5. Data Flow Architecture + +### WebSubApi Flow (Publisher → Subscribers) + +``` +┌──────────────┐ +│ Publisher │ (HTTP POST webhook) +└──────┬───────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ WebSubReceiver │ +│ /api/v1/webhook-receiver │ +└──────┬───────────────────────────────────┘ + │ connectors.Message + ▼ +┌──────────────────────────────────────────┐ +│ Hub.ProcessInbound() │ +│ - Executes inbound policy chain │ +│ - Validates/transforms webhook payload │ +└──────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ KafkaBrokerDriver.Publish() │ +│ - Writes to topic: api-v1_issues │ +└──────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ ConsumerManager (per-callback consumer) │ +│ - One consumer per active subscription │ +└──────┬───────────────────────────────────┘ + │ connectors.Message + ▼ +┌──────────────────────────────────────────┐ +│ Hub.ProcessOutbound() │ +│ - Executes outbound policy chain │ +│ - Signs/transforms delivery payload │ +└──────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────┐ +│ Subscriber │ (HTTP POST to callback URL) +└──────────────┘ +``` + +**Key Components:** +- **WebSubReceiver:** HTTP endpoint for data ingress and subscription management +- **ConsumerManager:** Creates dedicated Kafka consumer for each active subscription +- **Deliverer:** Handles async delivery with exponential backoff retry + +### WebBrokerApi Flow (Bidirectional WebSocket ↔ Kafka) + +**Note:** This diagram shows WebSocket + Kafka as an example. The architecture supports any receiver type (eg: SSE) with any broker driver (eg: MQTT, RabbitMQ) through the plugin system (see [Extensibility](#12-extensibility--plugin-architecture)). + +``` +┌───────────────┐ +│ WebSocket │ +│ Client │ +└───────┬───────┘ + │ ws://gateway/api?X-channel=/issues + ▼ +┌──────────────────────────────────────────┐ +│ WebBrokerApiReceiver.handleUpgrade() │ +│ 1. Validate X-channel header │ +│ 2. Apply onConnectionInit.request │ +│ 3. Upgrade to WebSocket │ +│ 4. Apply onConnectionInit.response │ +└──────┬───────────────────────────────────┘ + │ + ├─────────── Inbound (client → broker) ──────────┐ + │ │ + ▼ │ +┌──────────────────────────────────────────┐ │ +│ brokerApiConnection.inboundLoop() │ │ +│ - Read WebSocket frames │ │ +└──────┬───────────────────────────────────┘ │ + │ connectors.Message │ + ▼ │ +┌──────────────────────────────────────────┐ │ +│ Hub.ProcessByChainKey(produceChainKey) │ │ +│ - API-level on_produce │ │ +│ - Channel-level on_produce (/issues) │ │ +│ - map-topic policy sets target topic │ │ +└──────┬───────────────────────────────────┘ │ + │ │ + ▼ │ +┌──────────────────────────────────────────┐ │ +│ KafkaBrokerDriver.Publish() │ │ +│ - Writes to: produce_issues │ │ +└──────────────────────────────────────────┘ │ + │ + ├─────────── Outbound (broker → client) ─────────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ Kafka Consumer (unique group per conn) │ +│ - Reads from: consume_issues │ +└──────┬───────────────────────────────────┘ + │ connectors.Message + ▼ +┌──────────────────────────────────────────┐ +│ Hub.ProcessByChainKey(consumeChainKey) │ +│ - API-level on_consume │ +│ - Channel-level on_consume (/issues) │ +└──────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ brokerApiConnection.outboundLoop() │ +│ - Write WebSocket frames │ +└──────────────────────────────────────────┘ +``` + +**Key Components:** +- **brokerApiConnection:** Per-connection state with dedicated Kafka consumer/producer +- **Consumer Group:** `{prefix}-ws-{uuid}` ensures per-connection isolation +- **Bidirectional Channels:** Inbound (client→Kafka) and Outbound (Kafka→client) Go channels + +### WebBrokerApi Connection Architecture (Detailed) + +#### Per-Connection Resources + +Each WebSocket connection creates the following dedicated resources: + +**File:** `event-gateway/gateway-runtime/internal/connectors/receiver/websocket/broker_api_connector.go` + +```go +type brokerApiConnection struct { + connID string // UUID for this connection + ws *websocket.Conn // WebSocket connection + inbound chan *connectors.Message // client → broker (256 buffer) + outbound chan *connectors.Message // broker → client (256 buffer) + kafkaConsumer connectors.Receiver // Dedicated Kafka consumer + cancel context.CancelFunc // Cancel function for cleanup + channelName string // Selected channel from X-channel header + produceChainKey string // Policy chain key for on_produce + consumeChainKey string // Policy chain key for on_consume + produceTopic string // Target Kafka topic for producing + consumeTopic string // Source Kafka topic for consuming +} +``` + +**Created during WebSocket handshake at lines 299-311:** + +```go +connID := uuid.New().String() +conn := &brokerApiConnection{ + connID: connID, + ws: ws, + inbound: make(chan *connectors.Message, 256), // ← PRODUCE Go channel + outbound: make(chan *connectors.Message, 256), // ← CONSUME Go channel + channelName: channelName, // From X-channel header + produceChainKey: produceChainKey, // From channel config + consumeChainKey: consumeChainKey, // From channel config + produceTopic: produceTopic, // From channel config + consumeTopic: consumeTopic, // From channel config +} +``` + +#### Kafka Resource Naming + +**Consumer Group Naming (Per-Connection):** + +```go +// Line 318 +groupID := fmt.Sprintf("%s-ws-%s", e.opts.ConsumerGroupPrefix, connID) +// Example: "event-gateway-ws-a1b2c3d4-e5f6-7890-abcd-ef1234567890" +``` + +**Format:** `{prefix}-ws-{uuid}` +- `prefix` - Configured consumer group prefix (e.g., `event-gateway`) +- `ws` - Indicates WebSocket receiver type +- `uuid` - Unique connection ID + +**Producer (Shared Globally):** +- **One shared Publisher** instance for ALL WebSocket connections +- Created at gateway startup via `NewBrokerDriver()` +- No per-connection naming—all connections use the same producer via `e.brokerDriver.Publish()` + +#### Why Shared Producer vs. Per-Connection Consumer? + +**Consumers MUST be per-connection (stateful):** +- Each consumer maintains unique consumer group membership +- Each tracks its own offset position in topics independently +- Each receives ALL messages from subscribed topics (not distributed) +- Required for per-connection message isolation + +**Producers SHOULD be shared (stateless):** +- Topic specified per `Publish()` call—no per-connection state +- franz-go's `kgo.Client` is thread-safe for concurrent use +- Internal batching: messages from multiple connections batched into single network requests +- Resource efficiency: 1 producer = 3 TCP connections; 1000 producers = 3000+ TCP connections +- Memory efficiency: single set of send buffers (~100KB) vs. per-connection buffers +- Reduced broker load: Kafka handles 1 producer instead of 1000 + +#### Goroutine Architecture + +Each WebSocket connection spawns **three goroutines** (line 350-352): + +```go +go e.readLoop(ctx, conn) // WebSocket → inbound channel +go e.inboundLoop(ctx, conn) // inbound channel → Kafka +go e.outboundLoop(ctx, conn) // outbound channel → WebSocket +``` + +**Plus one additional thread:** +- **Kafka consumer goroutine** (internal to franz-go library) that reads from Kafka and calls the callback + +**Total per connection:** 3 goroutines + 1 Kafka consumer thread + +#### Complete Handshake Sequence + +**1. Extract & Validate X-channel Header (lines 143-183)** +```go +xChannelHeader := r.Header.Get("X-channel") +// Validate channel exists in API definition +``` + +**2. Apply onConnectionInit.request Policies (lines 203-230)** +```go +processed, shortCircuited, err := e.processor.ProcessConnectionInitRequest(...) +if shortCircuited { + http.Error(w, "connection rejected", http.StatusForbidden) + return +} +``` + +**3. Upgrade HTTP → WebSocket (lines 232-240)** +```go +ws, err := upgrader.Upgrade(w, r, nil) +``` + +**4. Apply onConnectionInit.response Policies (lines 244-252)** +```go +e.processor.ProcessConnectionInitResponse(...) +``` + +**5. Extract Channel Configuration (lines 254-282)** +```go +// Get policy chain keys for this channel +produceChainKey = channelChainsMap[channelName]["ProduceKey"] +consumeChainKey = channelChainsMap[channelName]["ConsumeKey"] + +// Get topic mappings +produceTopic = channelTopicsMap[channelName]["produceTo"] +consumeTopic = channelTopicsMap[channelName]["consumeFrom"] +``` + +**6. Create Per-Connection Resources (lines 299-311)** +```go +conn := &brokerApiConnection{ + inbound: make(chan *connectors.Message, 256), + outbound: make(chan *connectors.Message, 256), + // ... other fields +} +``` + +**7. Create & Start Kafka Consumer (lines 318-335)** +```go +consumer, err := e.brokerDriver.Subscribe(groupID, channelTopics, func(ctx context.Context, msg *connectors.Message) error { + // Kafka message → outbound Go channel + conn.outbound <- msg + return nil +}) +consumer.Start(ctx) +``` + +**8. Start Goroutines (lines 350-352)** +```go +go e.readLoop(ctx, conn) +go e.inboundLoop(ctx, conn) +go e.outboundLoop(ctx, conn) +``` + +#### Message Production Flow (Client → Kafka) + +**Path:** `WebSocket client` → `readLoop` → `inbound channel` → `inboundLoop` → `Kafka` + +**Step 1: Read from WebSocket (readLoop, lines 356-394)** +```go +msgType, data, err := conn.ws.ReadMessage() +msg := &connectors.Message{Value: data} +conn.inbound <- msg // Push to inbound Go channel +``` + +**Step 2: Apply onProduce Policies (inboundLoop, lines 403-425)** +```go +processed, shortCircuited, err := e.processor.ProcessByChainKey( + ctx, + e.channel.Name, + conn.produceChainKey, // Channel-specific policy chain + msg +) +``` + +**Step 3: Publish to Kafka (inboundLoop, lines 427-449)** +```go +targetTopic := conn.produceTopic // From channel config +e.brokerDriver.Publish(ctx, targetTopic, processed) +``` + +#### Message Consumption Flow (Kafka → Client) + +**Path:** `Kafka` → `consumer callback` → `outbound channel` → `outboundLoop` → `WebSocket client` + +**Step 1: Kafka Consumer Callback (lines 319-331)** +```go +// Runs in Kafka consumer goroutine (franz-go) +consumer, err := e.brokerDriver.Subscribe(groupID, channelTopics, func(ctx context.Context, msg *connectors.Message) error { + conn.outbound <- msg // Push to outbound Go channel + return nil +}) +``` + +**Step 2: Apply onConsume Policies (outboundLoop, lines 461-479)** +```go +msg := <-conn.outbound // Read from outbound Go channel +processed, shortCircuited, err := e.processor.ProcessByChainKey( + ctx, + e.channel.Name, + conn.consumeChainKey, // Channel-specific policy chain + msg +) +``` + +**Step 3: Write to WebSocket (outboundLoop, lines 487-492)** +```go +conn.ws.WriteMessage(websocket.BinaryMessage, processed.Value) +``` + +#### Connection Teardown + +**Triggered by:** +- Client disconnects +- WebSocket read/write error +- Context cancellation + +**Cleanup sequence (closeConnection, lines 502-528):** +1. Cancel context → stops all goroutines +2. Stop Kafka consumer: `conn.kafkaConsumer.Stop()` +3. Close Go channels: `close(conn.inbound)`, `close(conn.outbound)` +4. Close WebSocket: `conn.ws.Close()` +5. Deregister connection: `delete(e.connections, conn.connID)` + +#### Architecture Diagram + +``` +┌──────────────────┐ +│ WebSocket Client │ +└────────┬─────────┘ + │ X-channel: prices + │ Handshake + ┌────▼────────────────────────────────────────────────────────┐ + │ WebBrokerApiReceiver.handleUpgrade() │ + │ 1. Validate X-channel 2. Auth policies 3. Upgrade │ + └────────────────────────┬───────────────────────────────────┘ + │ Create brokerApiConnection + ┌────────────────────────▼───────────────────────────────────┐ + │ brokerApiConnection │ + │ • connID: "uuid" │ + │ • ws: *websocket.Conn │ + │ • inbound: chan *Message (256) ← Client messages │ + │ • outbound: chan *Message (256) ← Kafka messages │ + │ • kafkaConsumer: group="egw-ws-uuid" │ + │ • channelName: "prices" │ + │ • produceTopic: "stock.prices" │ + │ • consumeTopic: "dummy.prices" │ + └─────────────────────────┬──────────────────────────────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ┌────▼──────┐ ┌────────▼───────┐ ┌──────▼─────────┐ + │ readLoop │ │ inboundLoop │ │ outboundLoop │ + │ goroutine │ │ goroutine │ │ goroutine │ + └─────┬─────┘ └────────┬───────┘ └──────┬─────────┘ + │ │ │ + │ ws.ReadMessage() │ │ ws.WriteMessage() + │ │ │ + ▼ ▼ ▲ + inbound ──────────→ Policies ──→ Kafka │ + channel onProduce Publish │ + │ │ + │ │ + Kafka │ + Consumer Policies + (franz-go) onConsume + callback │ + │ │ + └─────→ outbound + channel + + ┌─────────────────────────────────────────────────────────────┐ + │ Shared Resources │ + │ • KafkaBrokerDriver (one instance globally) │ + │ - Publisher: shared by ALL connections │ + │ - Admin client: topic management │ + └─────────────────────────────────────────────────────────────┘ +``` + +**Key Takeaways:** +- ✅ **2 Go channels** per connection (256-message buffers each) +- ✅ **3 goroutines** per connection (readLoop, inboundLoop, outboundLoop) +- ✅ **1 dedicated Kafka consumer** per connection (unique consumer group) +- ✅ **1 shared Kafka producer** for all connections (global) +- ✅ **Per-channel policy chains** (produceChainKey, consumeChainKey) +- ✅ **Automatic cleanup** on connection close (goroutines, consumer, channels) + +--- + +## 6. Policy Engine Integration + +### Policy Chain Structure +Both API types use the same policy engine with different enforcement points. + +**Key Insight: Policies are fully interchangeable between WebSubApi and WebBrokerApi.** The same policy (e.g., `api-key-auth`, `transform-payload-case`, `map-topic`) can be used in either API type. The policy engine treats all policies identically regardless of which API type invokes them. + +#### What Makes Policies Interchangeable? + +1. **Unified Policy Engine:** Both API types use the same `engine.Engine` instance +2. **Same Execution Method:** Both call `engine.ExecuteRequestHeaderPolicies()` and `engine.ExecuteRequestBodyPolicies()` +3. **Common Message Format:** Both work with `connectors.Message` structure +4. **No API-Type Restrictions:** Policy definitions have no `applyTo` or API-type constraints +5. **Same Results Processing:** Both process `RequestHeaderResult` and `RequestBodyResult` identically + +#### The Only Differences: + +| Aspect | WebSubApi | WebBrokerApi | +|--------|-----------|--------------| +| **Enforcement Point Names** | subscribe, inbound, outbound | onConnectionInit, onProduce, onConsume | +| **Chain Key Format** | `{api}-subscribe`, `{api}-inbound`, `{api}-outbound` | `{api}-on_connection_init_req`, `{api}-on_produce`, etc. | +| **When Applied** | Subscription requests, webhook ingress, callback delivery | WebSocket upgrade, client→broker, broker→client | +| **Hub Method** | `ProcessSubscribe()`, `ProcessInbound()`, `ProcessOutbound()` | `ProcessByChainKey()` with specific chain keys | + +**Example: The Same Policy in Both API Types** + +```yaml +# In WebSubApi - validating inbound webhook data +spec: + receiver: + policies: + - name: json-schema-validator + version: v1 + params: + schema: {...} + +# In WebBrokerApi - validating inbound client messages +spec: + policies: + on_produce: + - name: json-schema-validator + version: v1 + params: + schema: {...} +``` + +Both use the **exact same policy** (`json-schema-validator`), just at different enforcement points! + +#### WebSubApi Policy Enforcement Points + +| Phase | Purpose | When Applied | +|-------|---------|--------------| +| **Subscribe** | Authentication/authorization | During subscription request (POST /hub) | +| **Inbound** | Validate/transform incoming data | When publisher sends webhook data | +| **Outbound** | Sign/transform outgoing delivery | Before delivering to subscriber callback | + +**Implementation:** `event-gateway/gateway-runtime/internal/hub/hub.go` +- `Hub.ProcessSubscribe()` - Subscribe phase +- `Hub.ProcessInbound()` - Inbound phase +- `Hub.ProcessOutbound()` - Outbound phase + +#### WebBrokerApi Policy Enforcement Points + +| Phase | Purpose | When Applied | +|-------|---------|--------------| +| **onConnectionInit (request)** | Authenticate upgrade request | Before WebSocket upgrade | +| **onConnectionInit (response)** | Modify upgrade response | After successful upgrade | +| **onProduce** | Validate/route client messages | Client → Kafka direction | +| **onConsume** | Transform broker messages | Kafka → Client direction | + +**Implementation:** `event-gateway/gateway-runtime/internal/hub/hub.go` +- `Hub.ProcessByChainKey()` - All phases using specific chain keys + +### Hub Orchestration + +The Hub acts as the central message router and policy orchestrator: + +```go +// WebSubApi +msg → hub.engine.ExecuteRequestPolicies(inboundChain) → result + +// WebBrokerApi +msg → hub.engine.ExecuteRequestPolicies(chainKey) → result +``` + +**Chain Building:** Policies are compiled into chains during binding registration: +- API-level policies apply to all channels +- Channel-level policies apply only to specific channels +- Chains are stored with unique keys in the policy engine + +--- + +## 7. Key Differences + +| Aspect | WebSubApi | WebBrokerApi | +|--------|-----------|--------------| +| **Protocol** | HTTP Webhooks (WebSub standard) | Pluggable (WebSocket, SSE, etc.) | +| **Broker** | Pluggable (Kafka default) | Pluggable (Kafka, MQTT, RabbitMQ, etc.) | +| **Direction** | Unidirectional (pub → sub) | Bidirectional (client ↔ broker) | +| **Connection Model** | Request/response per webhook | Persistent connection | +| **Subscription Model** | Hub manages subscriptions | Per-connection isolation | +| **Kafka Consumer** | One per callback URL | One per connection (for Kafka) | +| **Consumer Group** | `{api}-{callback-hash}` | `{prefix}-{protocol}-{uuid}` | +| **Topics** | One per channel + sync topic | Separate produce/consume topics per channel | +| **Receiver Paths** | `/hub`, `/webhook-receiver` | Configurable via `context` field | +| **Policy Points** | 3 (subscribe, inbound, outbound) | 3 (onConnectionInit, onProduce, onConsume) | +| **State Management** | Subscription store + Kafka sync topic | Per-connection state only | +| **Delivery** | Async with retry (exponential backoff) | Synchronous over connection | +| **Use Case** | Event distribution to webhooks | Protocol mediation / real-time streaming | +| **Extensibility** | Fixed to WebSub protocol | Any streaming protocol via receiver plugins | + +**Note:** While WebSubApi is specialized for the WebSub protocol, WebBrokerApi is designed for **protocol mediation** between any streaming protocol and any message broker through its plugin architecture. + +--- + +## 8. Component Lifecycle + +### Deployment Flow +``` +Controller: + 1. Spec submitted via REST API + 2. Stored in SQLite (StoredConfig) + 3. Translated to EventChannelConfig xDS + 4. Pushed to runtime via gRPC stream + +Runtime: + 1. xDS received and deserialized + 2. Binding created (WebSubApiBinding or WebBrokerApiBinding) + 3. Components instantiated: + - Policy chains built + - Broker driver created + - Receiver created + 4. Registered with Hub + 5. HTTP handlers activated +``` + +### Teardown Flow +``` +Controller: + 1. API deleted or undeployed + 2. xDS deletion marker sent to runtime + +Runtime: + 1. xDS delete received + 2. Hub deregisters binding + 3. Receiver stops (closes connections) + 4. Broker driver closes + 5. Kafka consumers cleanup + 6. HTTP handlers removed +``` + +### Dynamic Updates +The xDS-based architecture enables zero-downtime updates: +- New configuration → new binding created +- Old binding continues serving existing connections +- New connections use new binding +- Old binding removed when connections drain + +--- + +## 9. Configuration Examples + +### WebSubApi Configuration + +```yaml +apiVersion: wso2.com/v1 +kind: WebSubApi +metadata: + name: github-events +spec: + context: /github/webhooks + version: v1 + hub: + policies: + - name: api-key-auth + version: v1 + params: + in: header + name: X-API-Key + channels: + - name: issues + policies: + - name: json-schema-validator + version: v1 + params: + schema: {...} + - name: pull_requests + receiver: + policies: + - name: hmac-signature-validator + version: v1 + params: + algorithm: sha256 + header: X-Hub-Signature-256 + delivery: + policies: + - name: hmac-signature-signer + version: v1 + params: + algorithm: sha256 + header: X-Hub-Signature +``` + +**Resulting Topics:** +- `github-webhooks-v1_issues` - Issue events +- `github-webhooks-v1_pull_requests` - PR events +- `_sync_github-webhooks-v1` - Subscription state sync + +### WebBrokerApi Configuration + +```yaml +apiVersion: wso2.com/v1 +kind: WebBrokerApi +metadata: + name: realtime-events +spec: + context: /ws/events + version: v1 + receiver: + name: ws-receiver + type: websocket + broker-driver: + name: kafka-driver + type: kafka + properties: + bootstrap.servers: kafka:9092 + policies: + on_connection_init: + request: + - name: api-key-auth + version: v1 + params: + in: header + name: X-API-Key + on_produce: [] + on_consume: [] + channels: + "/issues": + policies: + onConnectionInit: + request: [] + response: [] + on_produce: + - name: map-topic + version: v1 + params: + mode: produceTo + topic: produce_issues + on_consume: + - name: map-topic + version: v1 + params: + mode: consumeFrom + topic: consume_issues +``` + +**Resulting Topics:** +- `produce_issues` - Client writes +- `consume_issues` - Client reads + +**WebSocket Connection:** +```bash +websocat --header "X-API-Key: secret" --header "X-channel: /issues" ws://gateway:8081/ws/events/v1 +``` + +--- + +## 10. Implementation File Reference + +### Controller (Gateway Controller) + +| Component | File Path | +|-----------|-----------| +| WebSubApi Types | `gateway/gateway-controller/pkg/api/management/generated.go` | +| WebBrokerApi Types | `gateway/gateway-controller/pkg/api/management/webbroker_types.go` | +| WebSubApi Handler | `gateway/gateway-controller/pkg/api/handlers/websub_api_handler.go` | +| WebBrokerApi Handler | `gateway/gateway-controller/pkg/api/handlers/webbroker_api_handler.go` | +| xDS Translator | `gateway/gateway-controller/pkg/policyxds/event_channel_translator.go` | +| Storage | `gateway/gateway-controller/pkg/storage/` | + +### Runtime (Event Gateway) + +| Component | File Path | +|-----------|-----------| +| Binding Types | `event-gateway/gateway-runtime/internal/binding/types.go` | +| xDS Handler | `event-gateway/gateway-runtime/internal/xdsclient/handler.go` | +| Runtime Core | `event-gateway/gateway-runtime/internal/runtime/runtime.go` | +| Hub | `event-gateway/gateway-runtime/internal/hub/hub.go` | +| WebSub Receiver | `event-gateway/gateway-runtime/internal/connectors/receiver/websub/connector.go` | +| WebBroker Receiver | `event-gateway/gateway-runtime/internal/connectors/receiver/websocket/broker_api_connector.go` | +| Kafka Driver | `event-gateway/gateway-runtime/internal/connectors/brokerdriver/kafka/endpoint.go` | +| Policy Engine | `gateway/gateway-runtime/policy-engine/` | + +--- + +--- + +## 11. Policy Interchangeability + +### Are Policies the Same Between WebSubApi and WebBrokerApi? + +**Yes! Policies are fully interchangeable.** A policy that works in WebSubApi will work in WebBrokerApi and vice versa. The policy engine treats all policies identically regardless of which API type invokes them. + +### How Policy Execution Works (Unified for Both API Types) + +Both WebSubApi and WebBrokerApi use the **exact same policy engine** (`engine.Engine`) and follow the same execution flow: + +```go +// In Hub.go - Same for both API types +msg → engine.ExecuteRequestHeaderPolicies(chainKey) → RequestHeaderResult + → engine.ExecuteRequestBodyPolicies(chainKey) → RequestBodyResult + → Apply results to msg +``` + +**Implementation:** +- **WebSubApi:** Calls `Hub.ProcessInbound()`, `Hub.ProcessOutbound()`, `Hub.ProcessSubscribe()` +- **WebBrokerApi:** Calls `Hub.ProcessByChainKey()` with specific chain keys + +Both ultimately call the same engine methods: +- `engine.ExecuteRequestHeaderPolicies()` +- `engine.ExecuteRequestBodyPolicies()` + +### What Makes Them Interchangeable? + +| Component | WebSubApi | WebBrokerApi | Same? | +|-----------|-----------|--------------|-------| +| **Policy Engine** | `engine.Engine` | `engine.Engine` | ✅ Same instance | +| **Message Format** | `connectors.Message` | `connectors.Message` | ✅ Same struct | +| **Policy Registry** | `registry.PolicyRegistry` | `registry.PolicyRegistry` | ✅ Same registry | +| **Execution Methods** | `ExecuteRequest*Policies()` | `ExecuteRequest*Policies()` | ✅ Same methods | +| **Result Types** | `RequestHeaderResult`, `RequestBodyResult` | `RequestHeaderResult`, `RequestBodyResult` | ✅ Same types | +| **Policy Definitions** | `policy-definition.yaml` | `policy-definition.yaml` | ✅ Same format | +| **Policy Factories** | `policy.PolicyFactory` | `policy.PolicyFactory` | ✅ Same interface | + +### The Only Difference: Enforcement Point Naming + +The **only** difference is the naming convention for enforcement points: + +| WebSubApi Enforcement Point | WebBrokerApi Enforcement Point | Purpose | +|----------------------------|-------------------------------|---------| +| `on_subscription` | `on_connection_init.request` | Authenticate/authorize initial request | +| N/A | `on_connection_init.response` | Modify initial response | +| `on_message_received` (inbound) | `on_produce` | Validate/transform messages going TO broker | +| `on_message_delivery` (outbound) | `on_consume` | Validate/transform messages FROM broker | +| `on_unsubscription` | N/A | Handle unsubscription (WebSub-specific) | + +### Example: Using the Same Policies in Both API Types + +#### Authentication Policy Example + +**WebSubApi:** +```yaml +apiVersion: wso2.com/v1 +kind: WebSubApi +metadata: + name: secure-webhooks +spec: + hub: + policies: + - name: api-key-auth # ← Same policy + version: v1 + params: + in: header + name: X-API-Key +``` + +**WebBrokerApi:** +```yaml +apiVersion: wso2.com/v1 +kind: WebBrokerApi +metadata: + name: secure-websocket +spec: + policies: + on_connection_init: + request: + - name: api-key-auth # ← Same policy + version: v1 + params: + in: header + name: X-API-Key +``` + +#### Transformation Policy Example + +**WebSubApi:** +```yaml +spec: + receiver: + policies: + - name: transform-payload-case # ← Same policy + version: v1 + params: + targetCase: lowercase +``` + +**WebBrokerApi:** +```yaml +spec: + policies: + on_produce: + - name: transform-payload-case # ← Same policy + version: v1 + params: + targetCase: lowercase +``` + +#### Validation Policy Example + +**WebSubApi:** +```yaml +spec: + hub: + channels: + - name: issues + policies: + - name: json-schema-validator # ← Same policy + version: v1 + params: + schema: + type: object + required: [title, body] +``` + +**WebBrokerApi:** +```yaml +spec: + channels: + "/issues": + policies: + on_produce: + - name: json-schema-validator # ← Same policy + version: v1 + params: + schema: + type: object + required: [title, body] +``` + +### Policy Definition Structure (Agnostic to API Type) + +A policy definition has **no API-type restrictions**: + +```yaml +# gateway/policies/my-policy/policy-definition.yaml +name: my-custom-policy +version: v1.0.0 +displayName: My Custom Policy +description: This policy works in both WebSubApi and WebBrokerApi + +parameters: + type: object + properties: + someParam: + type: string + description: A parameter that works everywhere + +systemParameters: + type: object + properties: {} +``` + +**No `applyTo` or `apiType` field exists!** Policies are universal. + +### When Would a Policy NOT Work? + +A policy might not be semantically appropriate (but will still technically execute) if: + +1. **Protocol-specific logic:** A policy that expects WebSocket-specific headers in a WebSub HTTP POST +2. **Broker-specific features:** A policy that uses Kafka-specific features (like partition keys) with an MQTT broker +3. **Timing mismatch:** Applying a subscription validation policy at the message delivery phase + +**But these are semantic/logical issues, not technical restrictions.** The policy engine will still execute them. + +### Benefits of This Design + +✅ **Write Once, Use Everywhere:** Develop a policy once, use it in any API type +✅ **Consistent Behavior:** Same policy behaves identically across API types +✅ **Simplified Testing:** Test policies independently of API types +✅ **Reusable Libraries:** Build policy libraries that work universally +✅ **Easier Migration:** Move policies between API types without modification +✅ **Future-Proof:** New API types automatically support existing policies + +--- + +## 12. Extensibility & Plugin Architecture + +### Pluggable Receivers and Broker Drivers + +The WebBrokerApi implementation uses a **plugin/registry pattern** that allows adding new receivers and broker drivers without modifying the core runtime. While WebSocket and Kafka are the default implementations, the architecture is designed to support any streaming protocol and message broker. + +### Receiver Interface + +**File:** `event-gateway/gateway-runtime/internal/connectors/types.go` + +Any protocol can be a receiver by implementing: + +```go +type Receiver interface { + Start(ctx context.Context) error + Stop(ctx context.Context) error +} +``` + +**Currently Implemented:** +- `websub` - HTTP WebSub (WebHooks) +- `websocket` - WebSocket receiver for legacy single-channel protocol mediation + - Simple 1:1 passthrough between WebSocket client and broker + - Single topic per API + - Uses `ProcessInbound()` policy enforcement only +- `websocket-broker-api` - WebSocket receiver for multi-channel WebBrokerApi + - Supports multiple channels per API via `X-channel` header routing + - Per-channel policy chains (onConnectionInit, onProduce, onConsume) + - Separate produce/consume topics per channel + - Designed for the WebBrokerApi specification + +**Future Possibilities:** +- `sse` - Server-Sent Events +- `grpc-stream` - gRPC bidirectional streaming +- `http-long-poll` - HTTP Long Polling +- `amqp` - AMQP protocol +- `mqtt-ws` - MQTT over WebSocket + +### BrokerDriver Interface + +**File:** `event-gateway/gateway-runtime/internal/connectors/types.go` + +Any message broker can be a broker driver by implementing: + +```go +type BrokerDriver interface { + Publish(ctx context.Context, topic string, msg *Message) error + Subscribe(groupID string, topics []string, handler MessageHandler) (Receiver, error) + TopicExists(ctx context.Context, topic string) (bool, error) + EnsureTopics(ctx context.Context, topics []string) error + DeleteTopics(ctx context.Context, topics []string) error + Close() error +} +``` + +**Currently Implemented:** +- `kafka` - Apache Kafka via franz-go + +**Future Possibilities:** +- `mqtt` - MQTT broker +- `rabbitmq` - RabbitMQ +- `pulsar` - Apache Pulsar +- `redis` - Redis Streams +- `nats` - NATS JetStream +- `aws-sqs` - AWS SQS +- `azure-servicebus` - Azure Service Bus + +### Plugin Registration + +**File:** `event-gateway/gateway-runtime/cmd/event-gateway/plugins.go` + +New types are registered at startup: + +```go +func registerConnectors(registry *connectors.Registry, cfg *config.Config) { + // Register broker drivers + registry.RegisterBrokerDriver("kafka", func(cfg map[string]interface{}) (BrokerDriver, error) { + return kafka.NewBrokerDriver(brokers) + }) + + registry.RegisterBrokerDriver("mqtt", func(cfg map[string]interface{}) (BrokerDriver, error) { + return mqtt.NewBrokerDriver(cfg) // Future implementation + }) + + // Register receivers + registry.RegisterReceiver("websocket-broker-api", func(cfg ReceiverConfig) (Receiver, error) { + return websocket.NewBrokerApiReceiver(cfg, opts) + }) + + registry.RegisterReceiver("sse", func(cfg ReceiverConfig) (Receiver, error) { + return sse.NewBrokerApiReceiver(cfg, opts) // Future implementation + }) +} +``` + +### Adding a New Receiver (e.g., SSE) + +**1. Implement the Receiver interface:** + +```go +// event-gateway/gateway-runtime/internal/connectors/receiver/sse/broker_api_connector.go +package sse + +type SSEBrokerApiReceiver struct { + channel connectors.ChannelInfo + processor connectors.MessageProcessor + brokerDriver connectors.BrokerDriver + connections map[string]*sseConnection +} + +func NewBrokerApiReceiver(cfg connectors.ReceiverConfig, opts Options) (connectors.Receiver, error) { + receiver := &SSEBrokerApiReceiver{ + channel: cfg.Channel, + processor: cfg.Processor, + brokerDriver: cfg.BrokerDriver, + connections: make(map[string]*sseConnection), + } + + // Register HTTP handler for SSE endpoint + cfg.Mux.HandleFunc(cfg.Channel.Context, receiver.handleSSE) + + return receiver, nil +} + +func (r *SSEBrokerApiReceiver) Start(ctx context.Context) error { + // Initialize SSE receiver + return nil +} + +func (r *SSEBrokerApiReceiver) Stop(ctx context.Context) error { + // Close all SSE connections + return nil +} +``` + +**2. Register in plugins.go:** + +```go +registry.RegisterReceiver("sse", func(cfg connectors.ReceiverConfig) (connectors.Receiver, error) { + return sse.NewBrokerApiReceiver(cfg, sse.BrokerApiOptions{ + Port: config.Server.HTTPPort, + ConsumerGroupPrefix: config.Kafka.ConsumerGroupPrefix, + Topics: cfg.Channel.Topics, + }) +}) +``` + +**3. Use in API spec:** + +```yaml +apiVersion: wso2.com/v1 +kind: WebBrokerApi +metadata: + name: sse-events +spec: + context: /sse/events + version: v1 + receiver: + name: sse-receiver + type: sse # ← New receiver type + properties: + retry: 3000 + broker-driver: + name: kafka-driver + type: kafka + properties: + bootstrap.servers: kafka:9092 +``` + +### Adding a New Broker Driver (e.g., MQTT) + +**1. Implement the BrokerDriver interface:** + +```go +// event-gateway/gateway-runtime/internal/connectors/brokerdriver/mqtt/endpoint.go +package mqtt + +type MQTTBrokerDriver struct { + client mqtt.Client + brokers []string +} + +func NewBrokerDriver(brokers []string) (connectors.BrokerDriver, error) { + // Initialize MQTT client + return &MQTTBrokerDriver{brokers: brokers}, nil +} + +func (d *MQTTBrokerDriver) Publish(ctx context.Context, topic string, msg *connectors.Message) error { + // Publish to MQTT broker + return d.client.Publish(topic, 0, false, msg.Value) +} + +func (d *MQTTBrokerDriver) Subscribe(groupID string, topics []string, handler MessageHandler) (Receiver, error) { + // Create MQTT subscriber + return newMQTTSubscriber(d.client, topics, handler) +} + +// Implement other interface methods... +``` + +**2. Register in plugins.go:** + +```go +registry.RegisterBrokerDriver("mqtt", func(cfg map[string]interface{}) (BrokerDriver, error) { + brokers := extractBrokers(cfg) + return mqtt.NewBrokerDriver(brokers) +}) +``` + +**3. Use in API spec:** + +```yaml +apiVersion: wso2.com/v1 +kind: WebBrokerApi +metadata: + name: mqtt-events +spec: + context: /ws/mqtt + version: v1 + receiver: + name: ws-receiver + type: websocket + broker-driver: + name: mqtt-driver + type: mqtt # ← New broker driver type + properties: + brokers: + - tcp://mqtt-broker:1883 + client_id: event-gateway +``` + +### Example: SSE ↔ MQTT + +Combining new receiver and broker driver types: + +```yaml +apiVersion: wso2.com/v1 +kind: WebBrokerApi +metadata: + name: iot-events +spec: + context: /sse/iot + version: v1 + receiver: + name: sse-receiver + type: sse # ← Server-Sent Events + broker-driver: + name: mqtt-driver + type: mqtt # ← MQTT broker + properties: + brokers: + - tcp://mqtt-broker:1883 + channels: + "/sensors": + policies: + on_produce: + - name: map-topic + version: v1 + params: + mode: produceTo + topic: iot/sensors/data +``` + +This enables: **SSE Client → Policy Engine → MQTT Broker** + +### Benefits of This Architecture + +- ✅ **Zero core changes** - Add new types without modifying runtime or controller +- ✅ **Type safety** - Compile-time checks via Go interfaces +- ✅ **Dynamic loading** - Runtime selects implementation based on spec +- ✅ **Configuration flexibility** - Each type gets custom properties +- ✅ **Testability** - Mock receivers/drivers for testing +- ✅ **Community extensibility** - Third parties can add new connectors + +--- + +## Summary + +Both **WebSubApi** and **WebBrokerApi** leverage the same foundational architecture: + +1. **Controller** manages API lifecycle via REST API and SQLite storage +2. **xDS Protocol** distributes configurations to runtime instances +3. **Runtime** instantiates protocol-specific receivers and broker drivers via **plugin registry** +4. **Hub** orchestrates policy execution and message routing +5. **Policy Engine** enforces security and transformation at defined points + +The architecture ensures: +- ✅ **Consistent patterns** across both API types +- ✅ **Dynamic configuration** via xDS for zero-downtime updates +- ✅ **Protocol flexibility** through pluggable receivers (WebSocket, SSE, etc.) +- ✅ **Broker flexibility** through pluggable drivers (Kafka, MQTT, RabbitMQ, etc.) +- ✅ **Policy enforcement** at appropriate protocol lifecycle points +- ✅ **Extensibility** without modifying core runtime code diff --git a/event-gateway/README.md b/event-gateway/README.md index 1d394f4cf..b7b5b9d62 100644 --- a/event-gateway/README.md +++ b/event-gateway/README.md @@ -218,6 +218,147 @@ docker compose logs wh-listener You should see the event body and headers printed by the listener. +### WebBrokerApi Walkthrough + +The WebBrokerApi enables bidirectional WebSocket ↔ Kafka protocol mediation. This walkthrough demonstrates creating a stock trading API where clients can produce messages to Kafka and consume messages in real-time over WebSocket. + +#### Step 1: Create a WebBroker API + +Use the following curl command to create a WebBrokerApi with a `prices` channel that maps to Kafka topics: + +```bash +curl --location 'http://localhost:9090/api/management/v0.9/webbroker-apis' \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +--header 'Authorization: Basic YWRtaW46YWRtaW4=' \ +--data '{ + "apiVersion": "gateway.api-platform.wso2.com/v1alpha1", + "kind": "WebBrokerApi", + "metadata": { + "name": "stock-trading-v1.0" + }, + "spec": { + "displayName": "Stock Trading WebBroker API", + "version": "v1.0", + "context": "/stock-trading/v1.0", + "receiver": { + "name": "websocket-receiver", + "type": "websocket" + }, + "broker": { + "name": "kafka-driver", + "type": "kafka", + "properties": { + "brokers": [ + "kafka:29092" + ] + } + }, + "allChannels": { + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + }, + "channels": { + "prices": { + "produceTo": { + "topic": "stock.prices" + }, + "consumeFrom": { + "topic": "dummy.prices" + }, + "on_connection_init": { + "policies": [] + }, + "on_produce": { + "policies": [] + }, + "on_consume": { + "policies": [] + } + } + } + } +}' +``` + +This creates a WebBrokerApi where: +- Client messages are published to the `stock.prices` Kafka topic +- Messages from the `dummy.prices` Kafka topic are delivered to the WebSocket client + +#### Step 2: Connect via WebSocket + +Install `wscat` if you haven't already: + +```bash +npm install -g wscat +``` + +Connect to the WebBroker API and select the `prices` channel using the `X-channel` header: + +```bash +wscat -c ws://localhost:8081/stock-trading/v1.0 -H "X-channel: prices" +``` + +Once connected, you'll see: +``` +Connected (press CTRL+C to quit) +> +``` + +#### Step 3: Monitor Messages Published to Kafka + +In a new terminal, start a Kafka consumer to monitor messages that clients send via WebSocket: + +```bash +docker exec -it event-gateway-kafka-1 /opt/kafka/bin/kafka-console-consumer.sh \ + --bootstrap-server localhost:9092 \ + --topic stock.prices \ + --from-beginning +``` + +Now, type a message in your WebSocket terminal (Step 2) and press Enter: + +``` +> {"symbol": "AAPL", "price": 150.25, "timestamp": "2026-05-13T10:30:00Z"} +``` + +The message should appear in the Kafka consumer terminal immediately. + +#### Step 4: Publish Messages from Kafka to WebSocket + +In another terminal, start a Kafka producer to send messages that will be delivered to WebSocket clients: + +```bash +docker exec -it event-gateway-kafka-1 /opt/kafka/bin/kafka-console-producer.sh \ + --bootstrap-server localhost:9092 \ + --topic dummy.prices +``` + +Type a message in the Kafka producer terminal and press Enter: + +``` +> {"symbol": "GOOGL", "price": 2750.50, "timestamp": "2026-05-13T10:31:00Z"} +``` + +The message should appear in your WebSocket terminal (Step 2): + +``` +< {"symbol": "GOOGL", "price": 2750.50, "timestamp": "2026-05-13T10:31:00Z"} +``` + +**Key Points:** +- WebSocket → Kafka: Messages typed in wscat are published to `stock.prices` +- Kafka → WebSocket: Messages published to `dummy.prices` are delivered to the WebSocket client +- Bidirectional: Both directions work simultaneously over the same WebSocket connection +- Per-Connection Isolation: Each WebSocket connection gets its own Kafka consumer group + ### Other Control Plane Operations | Request | Method | URL | diff --git a/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go b/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go index 28400c9b1..33cfac522 100644 --- a/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go +++ b/event-gateway/gateway-runtime/cmd/event-gateway/plugins.go @@ -60,4 +60,12 @@ func registerConnectors(registry *connectors.Registry, cfg *config.Config) { ConsumerGroupPrefix: cfg.Kafka.ConsumerGroupPrefix, }) }) + + registry.RegisterReceiver("websocket-broker-api", func(ecfg connectors.ReceiverConfig) (connectors.Receiver, error) { + return websocket.NewBrokerApiReceiver(ecfg, websocket.BrokerApiOptions{ + Port: cfg.Server.WebSocketPort, + ConsumerGroupPrefix: cfg.Kafka.ConsumerGroupPrefix, + Topics: ecfg.Channel.Topics, + }) + }) } diff --git a/event-gateway/gateway-runtime/configs/channels-webbrokerapi-example.yaml b/event-gateway/gateway-runtime/configs/channels-webbrokerapi-example.yaml new file mode 100644 index 000000000..f8599845e --- /dev/null +++ b/event-gateway/gateway-runtime/configs/channels-webbrokerapi-example.yaml @@ -0,0 +1,84 @@ +# Example WebBrokerApi Configuration for Protocol Mediation +# This demonstrates bidirectional WebSocket ↔ Kafka streaming + +channels: + # WebBrokerApi example: WebSocket to Kafka protocol mediation + - kind: WebBrokerApi + apiId: websocket-kafka-api-v1-0 + name: websocket-kafka-api + version: v1.0 + context: /websocket-kafka + receiver: + type: websocket + properties: {} + broker-driver: + type: kafka + properties: + brokers: + - localhost:9092 + # Other optional properties: + # tls: false + # sasl_mechanism: plain + # sasl_username: user + # sasl_password: pass + # Channel definitions with topic mappings + channels: + issues: + produceTo: + topic: kafka-repo-issues + consumeFrom: + topic: kafka-repo-issues + policies: + on_connection_init: + request: + - name: api-key-auth + version: v1 + params: + in: header + name: X-API-Key + response: [] + on_produce: [] + on_consume: [] + commits: + produceTo: + topic: kafka-repo-commits + consumeFrom: + topic: kafka-repo-commits + policies: + on_connection_init: {} + on_produce: [] + on_consume: [] + pull-requests: + produceTo: + topic: kafka-repo-pull-requests + consumeFrom: + topic: kafka-repo-pull-requests + policies: + on_connection_init: {} + on_produce: [] + on_consume: [] + + # WebSubApi example (for comparison) + - kind: WebSubApi + name: repo-watcher + version: v1 + context: /repos + channels: + - name: issues + - name: pull-requests + receiver: + type: websub + broker-driver: + type: kafka + config: + brokers: + - kafka:29092 + policies: + subscribe: + - name: basic-auth + version: v1 + params: + username: "admin" + password: "admin" + inbound: [] + outbound: [] diff --git a/event-gateway/gateway-runtime/internal/binding/loader.go b/event-gateway/gateway-runtime/internal/binding/loader.go index 58414e450..1fbad2dcf 100644 --- a/event-gateway/gateway-runtime/internal/binding/loader.go +++ b/event-gateway/gateway-runtime/internal/binding/loader.go @@ -37,13 +37,15 @@ type rawChannelsConfig struct { // ParseResult holds the parsed bindings from a channels YAML file. type ParseResult struct { - Bindings []Binding - WebSubApiBindings []WebSubApiBinding + Bindings []Binding + WebSubApiBindings []WebSubApiBinding + WebBrokerApiBindings []WebBrokerApiBinding } // ParseChannels reads and parses the channels YAML file. // It discriminates entries by the "kind" field: // - "WebSubApi" entries are parsed as WebSubApiBinding (multi-channel per API) +// - "WebBrokerApi" entries are parsed as WebBrokerApiBinding (protocol mediation) // - All other entries are parsed as Binding (legacy flat format) func ParseChannels(filePath string) (*ParseResult, error) { data, err := os.ReadFile(filePath) @@ -70,6 +72,12 @@ func ParseChannels(filePath string) (*ParseResult, error) { return nil, fmt.Errorf("failed to parse WebSubApi entry %d: %w", i, err) } result.WebSubApiBindings = append(result.WebSubApiBindings, wsb) + case "WebBrokerApi": + var wbb WebBrokerApiBinding + if err := node.Decode(&wbb); err != nil { + return nil, fmt.Errorf("failed to parse WebBrokerApi entry %d: %w", i, err) + } + result.WebBrokerApiBindings = append(result.WebBrokerApiBindings, wbb) default: var b Binding if err := node.Decode(&b); err != nil { diff --git a/event-gateway/gateway-runtime/internal/binding/types.go b/event-gateway/gateway-runtime/internal/binding/types.go index 47c5c4f5d..c5862c0d6 100644 --- a/event-gateway/gateway-runtime/internal/binding/types.go +++ b/event-gateway/gateway-runtime/internal/binding/types.go @@ -60,19 +60,66 @@ type ChannelDef struct { Policies PolicyBindings `yaml:"policies"` } +// WebBrokerApiBinding represents a WebBrokerApi for protocol mediation. +// It provides bidirectional streaming between web-friendly protocols (WebSocket, SSE) +// and message brokers (Kafka, MQTT) with per-connection isolation. +type WebBrokerApiBinding struct { + Kind string `yaml:"kind"` // "WebBrokerApi" + APIID string `yaml:"apiId"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Context string `yaml:"context"` + Vhost string `yaml:"vhost"` + Receiver ReceiverSpec `yaml:"receiver"` + BrokerDriver BrokerDriverSpec `yaml:"broker-driver"` + Policies ProtocolMediationPolicies `yaml:"policies"` // API-level policies + Channels map[string]WebBrokerChannelDef `yaml:"channels,omitempty"` // Channel-specific policies +} + +// WebBrokerChannelDef defines a single channel within a WebBrokerApi with its policies. +type WebBrokerChannelDef struct { + ProduceTo *TopicMapping `yaml:"produce_to,omitempty"` + ConsumeFrom *TopicMapping `yaml:"consume_from,omitempty"` + OnConnectionInit ConnectionInitPolicies `yaml:"on_connection_init"` + OnProduce []PolicyRef `yaml:"on_produce"` + OnConsume []PolicyRef `yaml:"on_consume"` +} + +// TopicMapping defines a Kafka topic mapping +type TopicMapping struct { + Topic string `yaml:"topic"` +} + +// ProtocolMediationPolicies defines policy enforcement points for protocol mediation. +type ProtocolMediationPolicies struct { + OnConnectionInit ConnectionInitPolicies `yaml:"on_connection_init"` + OnProduce []PolicyRef `yaml:"on_produce"` + OnConsume []PolicyRef `yaml:"on_consume"` +} + +// ConnectionInitPolicies defines policies for the connection handshake phase. +type ConnectionInitPolicies struct { + Request []PolicyRef `yaml:"request"` + Response []PolicyRef `yaml:"response"` +} + // ReceiverSpec defines the receiver connector type and configuration. type ReceiverSpec struct { - Type string `yaml:"type"` // "websub" or "websocket" - Path string `yaml:"path"` - Backpressure string `yaml:"backpressure"` // "drop-oldest", "block", "close" + Name string `yaml:"name,omitempty"` // Receiver instance name (for WebBrokerApi) + Type string `yaml:"type"` // "websub", "websocket", or "sse" + Path string `yaml:"path"` + Backpressure string `yaml:"backpressure"` // "drop-oldest", "block", "close" + Properties map[string]interface{} `yaml:"properties"` } // BrokerDriverSpec defines the broker-driver connector type and configuration. type BrokerDriverSpec struct { - Type string `yaml:"type"` // "kafka" - Topic string `yaml:"topic"` - Ordering string `yaml:"ordering"` // "ordered" or "unordered" - Config map[string]interface{} `yaml:"config"` // broker-driver-specific config (e.g. brokers, tls) + Name string `yaml:"name,omitempty"` // Broker driver instance name (for WebBrokerApi) + Type string `yaml:"type"` // "kafka" + Topic string `yaml:"topic"` + Ordering string `yaml:"ordering"` // "ordered" or "unordered" + Config map[string]interface{} `yaml:"config"` // broker-driver-specific config (e.g. brokers, tls) + Properties map[string]interface{} `yaml:"properties"` // Alternative config field for WebBrokerApi } // PolicyBindings holds subscribe, unsubscribe, inbound, and outbound policy configurations. diff --git a/event-gateway/gateway-runtime/internal/connectors/brokerdriver/kafka/consumer.go b/event-gateway/gateway-runtime/internal/connectors/brokerdriver/kafka/consumer.go index f1ae471b9..0881bd7c5 100644 --- a/event-gateway/gateway-runtime/internal/connectors/brokerdriver/kafka/consumer.go +++ b/event-gateway/gateway-runtime/internal/connectors/brokerdriver/kafka/consumer.go @@ -94,6 +94,12 @@ func (c *Consumer) consumeLoop(ctx context.Context) { } fetches.EachRecord(func(record *kgo.Record) { + slog.Debug("[7] Message read from Kafka", + "topic", record.Topic, + "partition", record.Partition, + "offset", record.Offset, + "size_bytes", len(record.Value)) + msg := recordToMessage(record) if err := c.handler(ctx, msg); err != nil { slog.Error("Message handler error", diff --git a/event-gateway/gateway-runtime/internal/connectors/receiver/websocket/broker_api_connector.go b/event-gateway/gateway-runtime/internal/connectors/receiver/websocket/broker_api_connector.go new file mode 100644 index 000000000..56f6e67eb --- /dev/null +++ b/event-gateway/gateway-runtime/internal/connectors/receiver/websocket/broker_api_connector.go @@ -0,0 +1,525 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package websocket + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "sync" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/binding" + "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/connectors" +) + +// BrokerApiOptions holds configuration for WebBrokerApi receiver. +type BrokerApiOptions struct { + Port int + ConsumerGroupPrefix string + Topics []string // Topics to subscribe to from broker +} + +// WebBrokerApiReceiver implements protocol mediation for WebBrokerApi. +// Each WebSocket connection gets: +// - Dedicated Kafka consumer (unique consumer group) +// - Dedicated Kafka producer +// - Inbound Go channel (client → broker) +// - Outbound Go channel (broker → client) +type WebBrokerApiReceiver struct { + channel connectors.ChannelInfo + processor connectors.MessageProcessor + brokerDriver connectors.BrokerDriver + opts BrokerApiOptions + mu sync.Mutex + connections map[string]*brokerApiConnection // connID → connection + ctx context.Context +} + +// brokerApiConnection represents a single WebSocket connection with bidirectional channels. +type brokerApiConnection struct { + connID string + ws *websocket.Conn + inbound chan *connectors.Message // client → broker + outbound chan *connectors.Message // broker → client + kafkaConsumer connectors.Receiver + cancel context.CancelFunc + closed bool + mu sync.Mutex + channelName string // Selected channel from X-channel header + produceChainKey string // Policy chain key for on_produce + consumeChainKey string // Policy chain key for on_consume + produceTopic string // Target Kafka topic for producing messages + consumeTopic string // Source Kafka topic for consuming messages +} + +// NewBrokerApiReceiver creates a WebSocket receiver for WebBrokerApi protocol mediation. +func NewBrokerApiReceiver(cfg connectors.ReceiverConfig, opts BrokerApiOptions) (connectors.Receiver, error) { + e := &WebBrokerApiReceiver{ + channel: cfg.Channel, + processor: cfg.Processor, + brokerDriver: cfg.BrokerDriver, + opts: opts, + connections: make(map[string]*brokerApiConnection), + } + + // Register upgrade handler on shared mux. + cfg.Mux.HandleFunc(cfg.Channel.Context, e.handleUpgrade) + + slog.Info("WebBrokerApi receiver registered HTTP handler", + "channel", cfg.Channel.Name, + "path", cfg.Channel.Context, + "mode", cfg.Channel.Mode, + "port", opts.Port) + + return e, nil +} + +// Start initializes the receiver. +func (e *WebBrokerApiReceiver) Start(ctx context.Context) error { + e.ctx = ctx + + // Ensure all topics exist in Kafka. + if len(e.opts.Topics) > 0 { + slog.Info("Ensuring Kafka topics exist", + "channel", e.channel.Name, + "topics", e.opts.Topics) + if err := e.brokerDriver.EnsureTopics(ctx, e.opts.Topics); err != nil { + return fmt.Errorf("failed to ensure kafka topics: %w", err) + } + slog.Info("Kafka topics verified", + "channel", e.channel.Name, + "topics", e.opts.Topics) + } else { + slog.Warn("No Kafka topics configured for WebBrokerApi", + "channel", e.channel.Name) + } + + slog.Info("WebBrokerApi WebSocket receiver started", + "channel", e.channel.Name, + "context", e.channel.Context, + "topics", e.opts.Topics, + "listening_on", fmt.Sprintf("ws://0.0.0.0:%d%s", e.opts.Port, e.channel.Context)) + return nil +} + +// Stop closes all connections. +func (e *WebBrokerApiReceiver) Stop(ctx context.Context) error { + e.mu.Lock() + snapshot := make(map[string]*brokerApiConnection, len(e.connections)) + for k, v := range e.connections { + snapshot[k] = v + } + e.mu.Unlock() + + for _, conn := range snapshot { + e.closeConnection(conn) + } + + return nil +} + +// handleUpgrade handles WebSocket upgrade requests. +func (e *WebBrokerApiReceiver) handleUpgrade(w http.ResponseWriter, r *http.Request) { + // Extract channel name from X-channel header. + xChannelHeader := r.Header.Get("X-channel") + channelName := xChannelHeader + if channelName == "" { + slog.Error("Missing X-channel header in WebSocket connection", "api", e.channel.Name, "remote", r.RemoteAddr) + http.Error(w, "Missing X-channel header", http.StatusBadRequest) + return + } + + // Validate channel exists in metadata. + channelNamesIface, ok := e.channel.Metadata["channelNames"] + if !ok { + slog.Error("Missing channelNames in metadata", "api", e.channel.Name, "remote", r.RemoteAddr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + channelNames, ok := channelNamesIface.([]string) + if !ok { + slog.Error("Invalid channelNames metadata type", "api", e.channel.Name, "remote", r.RemoteAddr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + channelExists := false + for _, ch := range channelNames { + if ch == channelName { + channelExists = true + break + } + } + + if !channelExists { + slog.Error("Unknown channel in X-channel header", "api", e.channel.Name, "channel", channelName, "remote", r.RemoteAddr) + http.Error(w, fmt.Sprintf("Unknown channel: %s", channelName), http.StatusNotFound) + return + } + + slog.Debug("[1] WebSocket connection attempted", + "api", e.channel.Name, + "channel", channelName, + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + "upgrade_header", r.Header.Get("Upgrade"), + "connection_header", r.Header.Get("Connection")) + + // Apply API-level on_connection_init.request policies. + slog.Debug("[2] Applying API-level onConnectionInit.request policies", + "api", e.channel.Name, + "channel", channelName, + "remote_addr", r.RemoteAddr) + + msg := &connectors.Message{ + Headers: r.Header, + Metadata: map[string]interface{}{ + "method": r.Method, + "path": r.URL.Path, + }, + } + + processed, shortCircuited, err := e.processor.ProcessConnectionInitRequest(r.Context(), e.channel.Name, msg) + if err != nil { + slog.Error("[2] onConnectionInit.request policy failed", "channel", e.channel.Name, "error", err) + http.Error(w, "connection init failed", http.StatusForbidden) + return + } + if shortCircuited { + slog.Warn("[2] Connection rejected by onConnectionInit.request policy", "channel", e.channel.Name) + // Policy rejected the connection. + statusCode := http.StatusForbidden + if sc, ok := processed.Metadata["status_code"].(int); ok { + statusCode = sc + } + for k, vals := range processed.Headers { + for _, v := range vals { + w.Header().Add(k, v) + } + } + w.WriteHeader(statusCode) + w.Write(processed.Value) + return + } + + // Update request headers from policy result. + for k, vals := range processed.Headers { + r.Header.Del(k) + for _, v := range vals { + r.Header.Add(k, v) + } + } + + // Upgrade to WebSocket. + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("WebSocket upgrade failed", "error", err) + return + } + + // Apply API-level on_connection_init.response policies. + slog.Debug("[3] Applying API-level onConnectionInit.response policies", "api", e.channel.Name, "channel", channelName) + + respMsg := &connectors.Message{ + Headers: map[string][]string{}, + } + if _, err := e.processor.ProcessConnectionInitResponse(r.Context(), e.channel.Name, respMsg); err != nil { + slog.Error("[3] onConnectionInit.response policy failed", "channel", e.channel.Name, "error", err) + ws.Close() + return + } + + // Extract channel-specific policy chain keys from metadata. + var produceChainKey, consumeChainKey string + var produceTopic, consumeTopic string + if channelChainsIface, ok := e.channel.Metadata["channelChains"]; ok { + // channelChains is stored as map[string]map[string]string + if channelChainsMap, ok := channelChainsIface.(map[string]map[string]string); ok { + if chainData, ok := channelChainsMap[channelName]; ok { + produceChainKey = chainData["ProduceKey"] + consumeChainKey = chainData["ConsumeKey"] + } + } + } + + // Extract channel topic mappings (produceTo, consumeFrom) + if channelTopicsIface, ok := e.channel.Metadata["channelTopics"]; ok { + if channelTopicsMap, ok := channelTopicsIface.(map[string]map[string]string); ok { + if topicMapping, ok := channelTopicsMap[channelName]; ok { + produceTopic = topicMapping["produceTo"] + consumeTopic = topicMapping["consumeFrom"] + } + } + } + + // Determine topics for this channel from metadata. + // Use the consumeTopic from channel config, fallback to topicToChannel mapping. + channelTopics := []string{} + if consumeTopic != "" { + channelTopics = append(channelTopics, consumeTopic) + } else { + // Fallback: extract topics from topicToChannel mapping (legacy) + topicToChannelIface, _ := e.channel.Metadata["topicToChannel"] + topicToChannel, _ := topicToChannelIface.(map[string]string) + for topic, ch := range topicToChannel { + if ch == channelName { + channelTopics = append(channelTopics, topic) + } + } + } + if len(channelTopics) == 0 { + slog.Warn("No topics found for channel", "api", e.channel.Name, "channel", channelName) + } + + // Create per-connection resources. + connID := uuid.New().String() + ctx, cancel := context.WithCancel(e.ctx) + + conn := &brokerApiConnection{ + connID: connID, + ws: ws, + inbound: make(chan *connectors.Message, 256), + outbound: make(chan *connectors.Message, 256), + cancel: cancel, + channelName: channelName, + produceChainKey: produceChainKey, + consumeChainKey: consumeChainKey, + produceTopic: produceTopic, + consumeTopic: consumeTopic, + } + + // Create unique consumer group for this connection. + groupID := fmt.Sprintf("%s-ws-%s", e.opts.ConsumerGroupPrefix, connID) + consumer, err := e.brokerDriver.Subscribe(groupID, channelTopics, func(ctx context.Context, msg *connectors.Message) error { + // Kafka message received → outbound channel. + select { + case conn.outbound <- msg: + case <-ctx.Done(): + return ctx.Err() + default: + slog.Warn("Outbound channel full, dropping message", "connID", connID) + } + return nil + }) + if err != nil { + slog.Error("Failed to create per-connection consumer", "connID", connID, "error", err) + ws.Close() + cancel() + return + } + conn.kafkaConsumer = consumer + + // Start the consumer. + if err := consumer.Start(ctx); err != nil { + slog.Error("Failed to start per-connection consumer", "connID", connID, "error", err) + ws.Close() + cancel() + return + } + + // Register connection. + e.mu.Lock() + e.connections[connID] = conn + e.mu.Unlock() + + slog.Debug("[4] WebSocket handshake completed", "connID", connID, "api", e.channel.Name, "channel", channelName, "remote", ws.RemoteAddr(), "consumer_group", groupID, "topics", channelTopics) + + // Start goroutines for bidirectional communication. + go e.inboundLoop(ctx, conn) + go e.outboundLoop(ctx, conn) + go e.readLoop(ctx, conn) +} + +// readLoop reads WebSocket messages and sends them to the inbound channel. +func (e *WebBrokerApiReceiver) readLoop(ctx context.Context, conn *brokerApiConnection) { + defer e.closeConnection(conn) + + for { + select { + case <-ctx.Done(): + return + default: + } + + msgType, data, err := conn.ws.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + slog.Error("WebSocket read error", "connID", conn.connID, "error", err) + } + return + } + + if msgType != websocket.BinaryMessage && msgType != websocket.TextMessage { + continue + } + + slog.Debug("[5] Message received from WebSocket client", + "connID", conn.connID, + "api", e.channel.Name, + "channel", conn.channelName, + "size_bytes", len(data)) + + // Extract headers from WebSocket message (if any). + // For now, we'll just pass the raw data. + msg := &connectors.Message{ + Value: data, + Headers: make(map[string][]string), + } + + select { + case conn.inbound <- msg: + case <-ctx.Done(): + return + default: + slog.Warn("Inbound channel full, dropping message", "connID", conn.connID) + } + } +} + +// inboundLoop processes messages from client → broker. +func (e *WebBrokerApiReceiver) inboundLoop(ctx context.Context, conn *brokerApiConnection) { + for { + select { + case <-ctx.Done(): + return + case msg := <-conn.inbound: + // Apply channel-specific on_produce policies. + slog.Debug("[5] Applying channel onProduce policies", + "connID", conn.connID, + "api", e.channel.Name, + "channel", conn.channelName, + "chain_key", conn.produceChainKey, + "message_size", len(msg.Value)) + + // Set default topic to normalized channel name (can be overridden by policies) + if msg.Topic == "" { + msg.Topic = binding.NormalizeTopicSegment(conn.channelName) + } + + // Use channel-specific policy chain key via ProcessByChainKey + processed, shortCircuited, err := e.processor.ProcessByChainKey(ctx, e.channel.Name, conn.produceChainKey, msg) + if err != nil { + slog.Error("[5] onProduce policy failed", "connID", conn.connID, "api", e.channel.Name, "channel", conn.channelName, "error", err) + continue + } + if shortCircuited { + slog.Info("[5] Message dropped by onProduce policy", "connID", conn.connID, "api", e.channel.Name, "channel", conn.channelName) + continue + } + + // Determine target topic from channel config + targetTopic := conn.produceTopic + if targetTopic == "" { + // Final fallback: normalized channel name + targetTopic = binding.NormalizeTopicSegment(conn.channelName) + slog.Warn("No target topic set in config or by policies, using normalized channel name as default", + "connID", conn.connID, + "channel", conn.channelName, + "topic", targetTopic) + } + + // Publish to Kafka. + slog.Debug("[6] Publishing message to Kafka", + "connID", conn.connID, + "api", e.channel.Name, + "channel", conn.channelName, + "topic", targetTopic, + "message_size", len(processed.Value)) + + if err := e.brokerDriver.Publish(ctx, targetTopic, processed); err != nil { + slog.Error("[6] Failed to publish to Kafka", "connID", conn.connID, "topic", targetTopic, "error", err) + } else { + slog.Debug("[6] Message successfully published to Kafka", "connID", conn.connID, "topic", targetTopic) + } + } + } +} + +// outboundLoop processes messages from broker → client. +func (e *WebBrokerApiReceiver) outboundLoop(ctx context.Context, conn *brokerApiConnection) { + for { + select { + case <-ctx.Done(): + return + case msg := <-conn.outbound: + slog.Debug("[7] Applying channel onConsume policies", + "connID", conn.connID, + "api", e.channel.Name, + "channel", conn.channelName, + "chain_key", conn.consumeChainKey, + "message_size", len(msg.Value)) + + // Use channel-specific policy chain key via ProcessByChainKey + processed, shortCircuited, err := e.processor.ProcessByChainKey(ctx, e.channel.Name, conn.consumeChainKey, msg) + if err != nil { + slog.Error("[7] onConsume policy failed", "connID", conn.connID, "api", e.channel.Name, "channel", conn.channelName, "error", err) + continue + } + if shortCircuited { + slog.Info("[7] Message dropped by onConsume policy", "connID", conn.connID, "api", e.channel.Name, "channel", conn.channelName) + continue + } + + slog.Debug("[8] Sending message to WebSocket client", + "connID", conn.connID, + "channel", e.channel.Name, + "message_size", len(processed.Value)) + + // Send to WebSocket client. + if err := conn.ws.WriteMessage(websocket.BinaryMessage, processed.Value); err != nil { + slog.Error("[8] Failed to write to WebSocket", "connID", conn.connID, "error", err) + return + } + } + } +} + +// closeConnection closes a connection and cleans up resources. +func (e *WebBrokerApiReceiver) closeConnection(conn *brokerApiConnection) { + conn.mu.Lock() + if conn.closed { + conn.mu.Unlock() + return + } + conn.closed = true + conn.mu.Unlock() + + conn.cancel() + + if conn.kafkaConsumer != nil { + if err := conn.kafkaConsumer.Stop(context.Background()); err != nil { + slog.Error("Failed to stop per-connection consumer", "connID", conn.connID, "error", err) + } + } + + close(conn.inbound) + close(conn.outbound) + conn.ws.Close() + + e.mu.Lock() + delete(e.connections, conn.connID) + e.mu.Unlock() + + slog.Info("WebSocket connection closed", "connID", conn.connID, "channel", e.channel.Name) +} diff --git a/event-gateway/gateway-runtime/internal/connectors/types.go b/event-gateway/gateway-runtime/internal/connectors/types.go index 655b9f0d5..9bc1c705b 100644 --- a/event-gateway/gateway-runtime/internal/connectors/types.go +++ b/event-gateway/gateway-runtime/internal/connectors/types.go @@ -48,6 +48,15 @@ type MessageProcessor interface { ProcessUnsubscribe(ctx context.Context, bindingName string, msg *Message) (*Message, bool, error) ProcessInbound(ctx context.Context, bindingName string, msg *Message) (*Message, bool, error) ProcessOutbound(ctx context.Context, bindingName string, msg *Message) (*Message, bool, error) + + // Protocol mediation policy enforcement points (WebBrokerApi) + ProcessConnectionInitRequest(ctx context.Context, bindingName string, msg *Message) (*Message, bool, error) + ProcessConnectionInitResponse(ctx context.Context, bindingName string, msg *Message) (*Message, error) + ProcessProduce(ctx context.Context, bindingName string, msg *Message) (*Message, bool, error) + ProcessConsume(ctx context.Context, bindingName string, msg *Message) (*Message, bool, error) + + // Execute policies using a specific chain key (for channel-specific policies) + ProcessByChainKey(ctx context.Context, bindingName string, chainKey string, msg *Message) (*Message, bool, error) } // BrokerDriver manages connections to a backend event system (e.g. Kafka, NATS). @@ -74,8 +83,10 @@ type ChannelInfo struct { PublicTopic string BrokerDriverTopic string Ordering string - Channels map[string]string // channel-name → Kafka topic (WebSubApi only) - InternalSubTopic string // internal subscription sync topic (WebSubApi only) + Channels map[string]string // channel-name → Kafka topic (WebSubApi only) + InternalSubTopic string // internal subscription sync topic (WebSubApi only) + Topics []string // topics to subscribe to (WebBrokerApi only) + Metadata map[string]interface{} // additional metadata (e.g., channelChains, topicToChannel) } // RouteMux is an HTTP request multiplexer that supports dynamic route registration. diff --git a/event-gateway/gateway-runtime/internal/hub/hub.go b/event-gateway/gateway-runtime/internal/hub/hub.go index 8bed4d5c6..1fc302e6b 100644 --- a/event-gateway/gateway-runtime/internal/hub/hub.go +++ b/event-gateway/gateway-runtime/internal/hub/hub.go @@ -529,3 +529,132 @@ func summarizeImmediateResponseBody(body []byte) string { } return text } + +// ProcessConnectionInitRequest applies on_connection_init.request policies during connection handshake. +// Used by protocol mediation (WebBrokerApi) for WebSocket upgrade or SSE connection initialization. +// Returns the (possibly mutated) message and whether it was short-circuited. +func (h *Hub) ProcessConnectionInitRequest(ctx context.Context, bindingName string, msg *connectors.Message) (*connectors.Message, bool, error) { + binding := h.GetBinding(bindingName) + if binding == nil { + return nil, false, fmt.Errorf("binding not found: %s", bindingName) + } + + // Apply connection_init request policies if present. + if binding.SubscribeChainKey != "" { // Reuse SubscribeChainKey for on_connection_init.request + chain := h.engine.GetChain(binding.SubscribeChainKey) + if chain != nil { + reqHeaderCtx := MessageToRequestHeaderContext(msg, binding) + result, err := h.engine.ExecuteRequestHeaderPolicies(ctx, binding.SubscribeChainKey, reqHeaderCtx.SharedContext, reqHeaderCtx) + if err != nil { + return nil, false, fmt.Errorf("connection_init request policy execution failed: %w", err) + } + if result.ShortCircuited { + logShortCircuit("Connection init request short-circuited", bindingName, binding.SubscribeChainKey, result.ImmediateResponse) + return immediateResponseToMessage(result.ImmediateResponse), true, nil + } + if err := ApplyRequestHeaderResult(result, msg); err != nil { + return nil, false, fmt.Errorf("failed to apply connection_init request result: %w", err) + } + } + } + + return msg, false, nil +} + +// ProcessConnectionInitResponse applies on_connection_init.response policies during connection handshake. +// Used by protocol mediation (WebBrokerApi) for response customization during handshake. +// Returns the (possibly mutated) message. +func (h *Hub) ProcessConnectionInitResponse(ctx context.Context, bindingName string, msg *connectors.Message) (*connectors.Message, error) { + binding := h.GetBinding(bindingName) + if binding == nil { + return nil, fmt.Errorf("binding not found: %s", bindingName) + } + + // Apply connection_init response policies if present. + // Currently using OutboundChainKey for on_connection_init.response + // In the future, we could add a dedicated field to ChannelBinding if needed. + if binding.OutboundChainKey != "" { + chain := h.engine.GetChain(binding.OutboundChainKey) + if chain != nil { + reqHeaderCtx := MessageToRequestHeaderContext(msg, binding) + result, err := h.engine.ExecuteRequestHeaderPolicies(ctx, binding.OutboundChainKey, reqHeaderCtx.SharedContext, reqHeaderCtx) + if err != nil { + return nil, fmt.Errorf("connection_init response policy execution failed: %w", err) + } + if err := ApplyRequestHeaderResult(result, msg); err != nil { + return nil, fmt.Errorf("failed to apply connection_init response result: %w", err) + } + } + } + + return msg, nil +} + +// ProcessProduce applies on_produce policies when client sends messages to broker. +// Used by protocol mediation (WebBrokerApi) for the produce path (client → broker). +// Returns the (possibly mutated) message and whether it was short-circuited. +func (h *Hub) ProcessProduce(ctx context.Context, bindingName string, msg *connectors.Message) (*connectors.Message, bool, error) { + // Reuse ProcessInbound for on_produce policies + return h.ProcessInbound(ctx, bindingName, msg) +} + +// ProcessConsume applies on_consume policies when broker messages are delivered to client. +// Used by protocol mediation (WebBrokerApi) for the consume path (broker → client). +// Returns the (possibly mutated) message and whether it was short-circuited. +func (h *Hub) ProcessConsume(ctx context.Context, bindingName string, msg *connectors.Message) (*connectors.Message, bool, error) { + // Reuse ProcessOutbound for on_consume policies + return h.ProcessOutbound(ctx, bindingName, msg) +} + +// ProcessByChainKey executes policies using a specific chain key directly. +// This is used for WebBrokerApi channel-specific policies where we know the exact chain key to use. +// Returns the (possibly mutated) message and whether it was short-circuited. +func (h *Hub) ProcessByChainKey(ctx context.Context, bindingName string, chainKey string, msg *connectors.Message) (*connectors.Message, bool, error) { + if chainKey == "" { + // No chain to execute + return msg, false, nil + } + + // Get binding for metadata (API context, version, etc.) + binding := h.GetBinding(bindingName) + if binding == nil { + return nil, false, fmt.Errorf("binding not found: %s", bindingName) + } + + chain := h.engine.GetChain(chainKey) + if chain == nil { + // Chain key not found - this might be OK if the chain has no policies + return msg, false, nil + } + + // Execute header policies + reqHeaderCtx := MessageToRequestHeaderContext(msg, binding) + result, err := h.engine.ExecuteRequestHeaderPolicies(ctx, chainKey, reqHeaderCtx.SharedContext, reqHeaderCtx) + if err != nil { + return nil, false, fmt.Errorf("policy header execution failed: %w", err) + } + if result.ShortCircuited { + logShortCircuit("Message short-circuited by policy", bindingName, chainKey, result.ImmediateResponse) + return immediateResponseToMessage(result.ImmediateResponse), true, nil + } + if err := ApplyRequestHeaderResult(result, msg); err != nil { + return nil, false, fmt.Errorf("failed to apply header result: %w", err) + } + + // Execute body policies if chain requires them + if chain.RequiresRequestBody { + reqCtx := MessageToRequestContext(msg, binding) + bodyResult, err := h.engine.ExecuteRequestBodyPolicies(ctx, chainKey, reqCtx.SharedContext, reqCtx) + if err != nil { + return nil, false, fmt.Errorf("policy body execution failed: %w", err) + } + if bodyResult.ShortCircuited { + return immediateResponseToMessage(bodyResult.ImmediateResponse), true, nil + } + if err := ApplyRequestBodyResult(bodyResult, msg); err != nil { + return nil, false, fmt.Errorf("failed to apply body result: %w", err) + } + } + + return msg, false, nil +} diff --git a/event-gateway/gateway-runtime/internal/hub/policy_adapter.go b/event-gateway/gateway-runtime/internal/hub/policy_adapter.go index 746cf628f..cf3dee8e7 100644 --- a/event-gateway/gateway-runtime/internal/hub/policy_adapter.go +++ b/event-gateway/gateway-runtime/internal/hub/policy_adapter.go @@ -216,6 +216,9 @@ func ApplyRequestBodyResult(result *engine.RequestBodyResult, msg *connectors.Me if result.Body != nil { msg.Value = result.Body } + if result.Topic != "" { + msg.Topic = result.Topic + } return nil } diff --git a/event-gateway/gateway-runtime/internal/runtime/runtime.go b/event-gateway/gateway-runtime/internal/runtime/runtime.go index d7ac5d330..46a5a9362 100644 --- a/event-gateway/gateway-runtime/internal/runtime/runtime.go +++ b/event-gateway/gateway-runtime/internal/runtime/runtime.go @@ -58,16 +58,15 @@ type Runtime struct { servers []*managedServer // shared servers for port sharing // Dynamic binding management (xDS mode) - mu sync.RWMutex - activeReceivers map[string]connectors.Receiver - activeBrokerDrivers map[string]connectors.BrokerDriver - bindingPaths map[string][]string // name → registered mux paths - bindingTopics map[string][]string // name → Kafka topics (data + internal sub) - websubMux *DynamicMux - websubServer *managedServer - webSubServersCreated bool // true if LoadChannels created WebSub servers - runCtx context.Context - running bool // true after Run() starts servers + mu sync.RWMutex + activeReceivers map[string]connectors.Receiver + activeBrokerDrivers map[string]connectors.BrokerDriver + bindingPaths map[string][]string // name → registered mux paths + bindingTopics map[string][]string // name → Kafka topics (data + internal sub) + websubMux *DynamicMux + wsMux *http.ServeMux // WebSocket mux for dynamic WebBrokerApi bindings + runCtx context.Context + running bool // true after Run() starts servers } type managedServer struct { @@ -106,6 +105,7 @@ func New(cfg *config.Config, rawConfig map[string]interface{}, registry *connect bindingPaths: make(map[string][]string), bindingTopics: make(map[string][]string), websubMux: NewDynamicMux(), + wsMux: http.NewServeMux(), }, nil } @@ -130,6 +130,10 @@ func (r *Runtime) LoadChannels(channelsPath string) error { // Create shared HTTP muxes for port sharing. wsMux := http.NewServeMux() websubMux := http.NewServeMux() + + // Store wsMux for dynamic bindings + r.wsMux = wsMux + hasWS := false hasWebSub := false @@ -287,6 +291,117 @@ func (r *Runtime) LoadChannels(channelsPath string) error { ) } + // Process WebBrokerApi bindings (protocol mediation). + for _, wbb := range parseResult.WebBrokerApiBindings { + vhost := defaultVhost(wbb.Vhost) + + // Build API-level policy chains. + apiConnInitReqKey, _, _, _, err := r.buildWebBrokerApiPolicyChains(wbb, vhost, "") + if err != nil { + return fmt.Errorf("failed to build API-level chains for WebBrokerApi %q: %w", wbb.Name, err) + } + + // Build per-channel policy chains and collect topics. + channelChains := make(map[string]ChannelPolicyChains) + allTopics := []string{} // All topics (produce + consume) for ensuring they exist + topicToChannel := make(map[string]string) // Only consume topics for subscription mapping + + for channelName, channelDef := range wbb.Channels { + connInitReqKey, connInitRespKey, produceKey, consumeKey, err := r.buildWebBrokerApiPolicyChains(wbb, vhost, channelName) + if err != nil { + return fmt.Errorf("failed to build chains for channel %q in WebBrokerApi %q: %w", channelName, wbb.Name, err) + } + + channelChains[channelName] = ChannelPolicyChains{ + ConnInitReqKey: connInitReqKey, + ConnInitRespKey: connInitRespKey, + ProduceKey: produceKey, + ConsumeKey: consumeKey, + } + + // Extract ALL topics (produce + consume) to ensure they exist in Kafka + allChannelTopics := extractAllTopicsFromChannelPolicies(channelName, channelDef) + allTopics = append(allTopics, allChannelTopics...) + + // Extract ONLY consume topics for subscription mapping + consumeTopics := extractTopicsFromChannelPolicies(channelName, channelDef) + for _, topic := range consumeTopics { + topicToChannel[topic] = channelName + } + } + + // Register binding in hub. + r.hub.RegisterBinding(hub.ChannelBinding{ + APIID: wbb.APIID, + Name: wbb.Name, + Mode: "protocol-mediation", + Context: wbb.Context, + Version: wbb.Version, + Vhost: vhost, + SubscribeChainKey: apiConnInitReqKey, + InboundChainKey: "", // Determined per-channel + OutboundChainKey: "", // Determined per-channel + }) + + // Create broker-driver. + brokerDriverType := wbb.BrokerDriver.Type + if brokerDriverType == "" { + brokerDriverType = "kafka" + } + brokerDriverConfig := wbb.BrokerDriver.Config + if brokerDriverConfig == nil { + brokerDriverConfig = wbb.BrokerDriver.Properties + } + brokerDriver, err := r.registry.CreateBrokerDriver(brokerDriverType, brokerDriverConfig) + if err != nil { + return fmt.Errorf("failed to create broker-driver for WebBrokerApi %q: %w", wbb.Name, err) + } + r.brokerDrivers = append(r.brokerDrivers, brokerDriver) + + hasWS = true + + ch := connectors.ChannelInfo{ + Name: wbb.Name, + Mode: "protocol-mediation", + Context: wbb.Context, + Version: wbb.Version, + Vhost: vhost, + Topics: allTopics, + Metadata: map[string]interface{}{ + "channelChains": channelChainsToMap(channelChains), + "topicToChannel": topicToChannel, + "channelNames": getChannelNames(wbb.Channels), + }, + } + + // Create WebBrokerApi receiver. + receiverType := wbb.Receiver.Type + if receiverType == "" { + receiverType = "websocket" + } + + ep, err := r.registry.CreateReceiver(receiverType+"-broker-api", connectors.ReceiverConfig{ + Channel: ch, + Processor: r.hub, + BrokerDriver: brokerDriver, + RuntimeID: r.cfg.RuntimeID, + Mux: wsMux, + }) + if err != nil { + return fmt.Errorf("failed to create receiver for WebBrokerApi %q: %w", wbb.Name, err) + } + r.receivers = append(r.receivers, ep) + + slog.Info("Registered WebBrokerApi binding", + "name", wbb.Name, + "context", wbb.Context, + "version", wbb.Version, + "receiver", receiverType, + "topics", allTopics, + "channels", len(wbb.Channels), + ) + } + // Create shared HTTP servers. if hasWS { wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, wsMux, false) @@ -310,7 +425,6 @@ func (r *Runtime) LoadChannels(channelsPath string) error { } r.servers = append(r.servers, websubHTTPSServer) } - r.webSubServersCreated = true // Mark that LoadChannels created WebSub servers } return nil @@ -328,33 +442,48 @@ func (r *Runtime) Run(ctx context.Context) error { }() } - // If in xDS mode, ensure the websub server is started for dynamic bindings. + // If in xDS mode, ensure servers are started for dynamic bindings. r.mu.Lock() - if !r.webSubServersCreated && r.websubServer == nil && r.cfg.ControlPlane.Enabled && r.cfg.Server.WebSubEnabled { - // Create and start HTTP server - websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, r.websubMux, false) + if r.cfg.ControlPlane.Enabled { + // Create WebSocket server for dynamic WebBrokerApi bindings + slog.Info("Creating WebSocket server for dynamic WebBrokerApi bindings", "port", r.cfg.Server.WebSocketPort) + wsServer, err := r.newManagedServer("WebSocket", r.cfg.Server.WebSocketPort, r.wsMux, false) if err != nil { r.mu.Unlock() - return fmt.Errorf("failed to create WebSub HTTP server: %w", err) + return fmt.Errorf("failed to create WebSocket server: %w", err) } - r.servers = append(r.servers, websubHTTPServer) + r.servers = append(r.servers, wsServer) go func() { - r.runServer(websubHTTPServer) + r.runServer(wsServer) }() - // Create and start HTTPS server if TLS is enabled - if r.cfg.Server.WebSubTLSEnabled { - websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, r.websubMux, true) + + // Create WebSub servers for dynamic WebSubApi bindings + if r.cfg.Server.WebSubEnabled { + slog.Info("Creating WebSub HTTP server for dynamic WebSubApi bindings", "port", r.cfg.Server.WebSubHTTPPort) + websubHTTPServer, err := r.newManagedServer("WebSub-HTTP", r.cfg.Server.WebSubHTTPPort, r.websubMux, false) if err != nil { r.mu.Unlock() - return fmt.Errorf("failed to create WebSub HTTPS server: %w", err) + return fmt.Errorf("failed to create WebSub HTTP server: %w", err) } - r.websubServer = websubHTTPSServer + r.servers = append(r.servers, websubHTTPServer) go func() { - r.runServer(websubHTTPSServer) + r.runServer(websubHTTPServer) }() + + // Create HTTPS server if TLS is enabled + if r.cfg.Server.WebSubTLSEnabled { + slog.Info("Creating WebSub HTTPS server for dynamic WebSubApi bindings", "port", r.cfg.Server.WebSubHTTPSPort) + websubHTTPSServer, err := r.newManagedServer("WebSub-HTTPS", r.cfg.Server.WebSubHTTPSPort, r.websubMux, true) + if err != nil { + r.mu.Unlock() + return fmt.Errorf("failed to create WebSub HTTPS server: %w", err) + } + r.servers = append(r.servers, websubHTTPSServer) + go func() { + r.runServer(websubHTTPSServer) + }() + } } - // Note: r.websubServer is only set when TLS is enabled; HTTP-only mode leaves it nil - // to avoid double-shutdown since the HTTP server is already in r.servers } r.runCtx = ctx r.running = true @@ -426,16 +555,6 @@ func (r *Runtime) Run(ctx context.Context) error { } } - // Shutdown xDS-mode websub server if created. - r.mu.RLock() - wsSrv := r.websubServer - r.mu.RUnlock() - if wsSrv != nil { - if err := wsSrv.server.Shutdown(shutdownCtx); err != nil { - slog.Error("Failed to shutdown WebSub server", "addr", wsSrv.server.Addr, "error", err) - } - } - if err := r.admin.Stop(shutdownCtx); err != nil { slog.Error("Failed to stop admin server", "error", err) } @@ -630,6 +749,154 @@ func (r *Runtime) buildWebSubApiPolicyChains(wsb binding.WebSubApiBinding, vhost return subscribeKey, unsubscribeKey, inboundKey, outboundKey, channelChainKeys, nil } +// ChannelPolicyChains holds policy chain keys for a single channel. +type ChannelPolicyChains struct { + ConnInitReqKey string + ConnInitRespKey string + ProduceKey string + ConsumeKey string +} + +func (r *Runtime) buildWebBrokerApiPolicyChains(wbb binding.WebBrokerApiBinding, vhost string, channelName string) (connInitReqKey, connInitRespKey, produceKey, consumeKey string, err error) { + basePath := wbb.Context + if wbb.Version != "" { + basePath = path.Join(wbb.Context, wbb.Version) + } + + suffix := "" + if channelName != "" { + suffix = "_" + channelName + } + + // Connection init request chain: on_connection_init.request policies. + connInitReqKey = binding.GenerateRouteKey("CONNECT_INIT"+suffix, basePath, vhost) + // Connection init response chain: on_connection_init.response policies (optional). + connInitRespKey = binding.GenerateRouteKey("CONNECT_INIT_RESP"+suffix, basePath, vhost) + // Produce chain: on_produce policies (client → broker). + produceKey = binding.GenerateRouteKey("PRODUCE"+suffix, basePath, vhost) + // Consume chain: on_consume policies (broker → client). + consumeKey = binding.GenerateRouteKey("CONSUME"+suffix, basePath, vhost) + + var onConnInitReq, onConnInitResp, onProduce, onConsume []binding.PolicyRef + + if channelName == "" { + // Build API-level policies + onConnInitReq = wbb.Policies.OnConnectionInit.Request + onConnInitResp = wbb.Policies.OnConnectionInit.Response + onProduce = wbb.Policies.OnProduce + onConsume = wbb.Policies.OnConsume + } else { + // Build channel-specific policies + if channelDef, ok := wbb.Channels[channelName]; ok { + onConnInitReq = channelDef.OnConnectionInit.Request + onConnInitResp = channelDef.OnConnectionInit.Response + onProduce = channelDef.OnProduce + onConsume = channelDef.OnConsume + } + } + + if err = r.buildChain(connInitReqKey, onConnInitReq); err != nil { + return "", "", "", "", err + } + if err = r.buildChain(connInitRespKey, onConnInitResp); err != nil { + return "", "", "", "", err + } + if err = r.buildChain(produceKey, onProduce); err != nil { + return "", "", "", "", err + } + if err = r.buildChain(consumeKey, onConsume); err != nil { + return "", "", "", "", err + } + + return connInitReqKey, connInitRespKey, produceKey, consumeKey, nil +} + +// extractTopicsFromChannelPolicies extracts Kafka topics to subscribe to from a channel's consumeFrom config. +// If consumeFrom is not specified, defaults to the normalized channel name. +// These topics are used for Kafka consumer subscription. +func extractTopicsFromChannelPolicies(channelName string, channelDef binding.WebBrokerChannelDef) []string { + topics := make(map[string]bool) // Use map to deduplicate + + // Check consumeFrom field + if channelDef.ConsumeFrom != nil && channelDef.ConsumeFrom.Topic != "" { + topics[channelDef.ConsumeFrom.Topic] = true + } + + // If no consumeFrom topics found, default to normalized channel name + if len(topics) == 0 { + topics[binding.NormalizeTopicSegment(channelName)] = true + } + + // Convert map to slice + result := make([]string, 0, len(topics)) + for topic := range topics { + result = append(result, topic) + } + return result +} + +// extractAllTopicsFromChannelPolicies extracts ALL Kafka topics (both produce and consume) from a channel's config. +// If produceTo/consumeFrom are not specified, defaults to the normalized channel name. +// Used to ensure all necessary topics exist in Kafka before the API starts. +func extractAllTopicsFromChannelPolicies(channelName string, channelDef binding.WebBrokerChannelDef) []string { + topics := make(map[string]bool) // Use map to deduplicate + hasProduceTopics := false + hasConsumeTopics := false + + // Check produceTo field + if channelDef.ProduceTo != nil && channelDef.ProduceTo.Topic != "" { + topics[channelDef.ProduceTo.Topic] = true + hasProduceTopics = true + } + + // Check consumeFrom field + if channelDef.ConsumeFrom != nil && channelDef.ConsumeFrom.Topic != "" { + topics[channelDef.ConsumeFrom.Topic] = true + hasConsumeTopics = true + } + + // If no consume topics found, use normalized channel name as default + if !hasConsumeTopics { + topics[binding.NormalizeTopicSegment(channelName)] = true + } + + // If no produce topics were found, also add normalized channel name for producing + if !hasProduceTopics { + topics[binding.NormalizeTopicSegment(channelName)] = true + } + + // Convert map to slice + result := make([]string, 0, len(topics)) + for topic := range topics { + result = append(result, topic) + } + return result +} + +// getChannelNames extracts channel names from the channels map. +func getChannelNames(channels map[string]binding.WebBrokerChannelDef) []string { + names := make([]string, 0, len(channels)) + for name := range channels { + names = append(names, name) + } + return names +} + +// channelChainsToMap converts ChannelPolicyChains map to a map structure +// that can be easily accessed from other packages without type dependencies. +func channelChainsToMap(chains map[string]ChannelPolicyChains) map[string]map[string]string { + result := make(map[string]map[string]string, len(chains)) + for channelName, chainKeys := range chains { + result[channelName] = map[string]string{ + "ConnInitReqKey": chainKeys.ConnInitReqKey, + "ConnInitRespKey": chainKeys.ConnInitRespKey, + "ProduceKey": chainKeys.ProduceKey, + "ConsumeKey": chainKeys.ConsumeKey, + } + } + return result +} + func (r *Runtime) buildChain(routeKey string, policies []binding.PolicyRef) error { if routeKey == "" { return nil @@ -853,6 +1120,195 @@ func (r *Runtime) RemoveWebSubApiBinding(name string) error { return nil } +// AddWebBrokerApiBinding dynamically adds a WebBrokerApi binding at runtime. +func (r *Runtime) AddWebBrokerApiBinding(wbb binding.WebBrokerApiBinding) error { + r.mu.Lock() + + vhost := defaultVhost(wbb.Vhost) + + // Build API-level policy chains. + apiConnInitReqKey, _, _, _, err := r.buildWebBrokerApiPolicyChains(wbb, vhost, "") + if err != nil { + r.mu.Unlock() + return fmt.Errorf("failed to build API-level chains for WebBrokerApi %q: %w", wbb.Name, err) + } + + // Build per-channel policy chains and collect topics. + channelChains := make(map[string]ChannelPolicyChains) + allTopics := []string{} // All topics (produce + consume) for ensuring they exist + topicToChannel := make(map[string]string) // Only consume topics for subscription mapping + channelTopics := make(map[string]map[string]string) // Channel-level topic mappings (produceTo, consumeFrom) + + for channelName, channelDef := range wbb.Channels { + connInitReqKey, connInitRespKey, produceKey, consumeKey, err := r.buildWebBrokerApiPolicyChains(wbb, vhost, channelName) + if err != nil { + r.mu.Unlock() + return fmt.Errorf("failed to build chains for channel %q in WebBrokerApi %q: %w", channelName, wbb.Name, err) + } + + channelChains[channelName] = ChannelPolicyChains{ + ConnInitReqKey: connInitReqKey, + ConnInitRespKey: connInitRespKey, + ProduceKey: produceKey, + ConsumeKey: consumeKey, + } + + // Store channel topic mappings (use defaults if not specified) + topicMapping := make(map[string]string) + if channelDef.ProduceTo != nil && channelDef.ProduceTo.Topic != "" { + topicMapping["produceTo"] = channelDef.ProduceTo.Topic + } else { + // Default: use normalized channel name for producing + topicMapping["produceTo"] = binding.NormalizeTopicSegment(channelName) + } + if channelDef.ConsumeFrom != nil && channelDef.ConsumeFrom.Topic != "" { + topicMapping["consumeFrom"] = channelDef.ConsumeFrom.Topic + } else { + // Default: use normalized channel name for consuming + topicMapping["consumeFrom"] = binding.NormalizeTopicSegment(channelName) + } + channelTopics[channelName] = topicMapping + + // Extract ALL topics (produce + consume) to ensure they exist in Kafka + allChannelTopics := extractAllTopicsFromChannelPolicies(channelName, channelDef) + allTopics = append(allTopics, allChannelTopics...) + + // Extract ONLY consume topics for subscription mapping + consumeTopics := extractTopicsFromChannelPolicies(channelName, channelDef) + for _, topic := range consumeTopics { + topicToChannel[topic] = channelName + } + + slog.Info("Built policy chains for WebBrokerApi channel", + "api", wbb.Name, + "channel", channelName, + "topics", allChannelTopics) + } + + r.hub.RegisterBinding(hub.ChannelBinding{ + APIID: wbb.APIID, + Name: wbb.Name, + Mode: "protocol-mediation", + Context: wbb.Context, + Version: wbb.Version, + Vhost: vhost, + SubscribeChainKey: apiConnInitReqKey, + InboundChainKey: "", // Determined per-channel + OutboundChainKey: "", // Determined per-channel + }) + + // Create broker-driver. + brokerDriverType := "kafka" + if wbb.BrokerDriver.Type != "" { + brokerDriverType = wbb.BrokerDriver.Type + } + brokerDriver, err := r.registry.CreateBrokerDriver(brokerDriverType, wbb.BrokerDriver.Config) + if err != nil { + r.mu.Unlock() + return fmt.Errorf("failed to create broker-driver for WebBrokerApi %q: %w", wbb.Name, err) + } + r.activeBrokerDrivers[wbb.Name] = brokerDriver + + ch := connectors.ChannelInfo{ + Name: wbb.Name, + Mode: "protocol-mediation", + Context: wbb.Context, + Version: wbb.Version, + Vhost: vhost, + Topics: allTopics, + Metadata: map[string]interface{}{ + "channelChains": channelChainsToMap(channelChains), + "topicToChannel": topicToChannel, + "channelNames": getChannelNames(wbb.Channels), + "channelTopics": channelTopics, + }, + } + + // Determine receiver type (websocket, sse, etc.) + receiverType := "websocket-broker-api" + if wbb.Receiver.Type != "" { + receiverType = wbb.Receiver.Type + "-broker-api" + } + + receiver, err := r.registry.CreateReceiver(receiverType, connectors.ReceiverConfig{ + Channel: ch, + Processor: r.hub, + BrokerDriver: brokerDriver, + RuntimeID: r.cfg.RuntimeID, + Mux: r.wsMux, + }) + if err != nil { + r.mu.Unlock() + return fmt.Errorf("failed to create receiver for WebBrokerApi %q: %w", wbb.Name, err) + } + r.activeReceivers[wbb.Name] = receiver + + startNow := r.running + startCtx := r.runCtx + r.mu.Unlock() + + // If runtime is already running, start the receiver immediately. + if startNow { + if startCtx == nil { + startCtx = context.Background() + } + if err := r.startReceiverWithRetry(startCtx, wbb.Name, receiver); err != nil { + return fmt.Errorf("failed to start receiver for WebBrokerApi %q: %w", wbb.Name, err) + } + } + + slog.Info("Dynamically added WebBrokerApi binding", + "name", wbb.Name, + "context", wbb.Context, + "version", wbb.Version, + "receiver_type", receiverType, + "channels", len(wbb.Channels), + "topics", allTopics) + + return nil +} + +// RemoveWebBrokerApiBinding dynamically removes a WebBrokerApi binding at runtime. +func (r *Runtime) RemoveWebBrokerApiBinding(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Stop and remove receiver. + if receiver, ok := r.activeReceivers[name]; ok { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := receiver.Stop(ctx); err != nil { + slog.Error("Failed to stop receiver during removal", "name", name, "error", err) + } + delete(r.activeReceivers, name) + } + + // Close and remove broker driver. + if bd, ok := r.activeBrokerDrivers[name]; ok { + if err := bd.Close(); err != nil { + slog.Error("Failed to close broker-driver during removal", "name", name, "error", err) + } + delete(r.activeBrokerDrivers, name) + } + + // Remove binding chains. + r.unregisterBindingChains(r.hub.GetBinding(name)) + + // Remove hub binding. + r.hub.RemoveBinding(name) + + // Deregister HTTP routes from the mux. + if paths, ok := r.bindingPaths[name]; ok { + for _, p := range paths { + r.websubMux.Remove(p) + } + delete(r.bindingPaths, name) + } + + slog.Info("Dynamically removed WebBrokerApi binding", "name", name) + return nil +} + // Hub returns the hub instance (for testing/inspection). func (r *Runtime) Hub() *hub.Hub { return r.hub diff --git a/event-gateway/gateway-runtime/internal/xdsclient/handler.go b/event-gateway/gateway-runtime/internal/xdsclient/handler.go index c80fc96fd..317807f31 100644 --- a/event-gateway/gateway-runtime/internal/xdsclient/handler.go +++ b/event-gateway/gateway-runtime/internal/xdsclient/handler.go @@ -34,10 +34,12 @@ import ( "github.com/wso2/api-platform/event-gateway/gateway-runtime/internal/binding" ) -// BindingManager can add/remove WebSubApi bindings dynamically. +// BindingManager can add/remove WebSubApi and WebBrokerApi bindings dynamically. type BindingManager interface { AddWebSubApiBinding(wsb binding.WebSubApiBinding) error RemoveWebSubApiBinding(name string) error + AddWebBrokerApiBinding(wbb binding.WebBrokerApiBinding) error + RemoveWebBrokerApiBinding(name string) error } // KafkaConfig holds local Kafka broker settings used as defaults. @@ -61,10 +63,35 @@ type EventChannelResource struct { Context string `json:"context"` Version string `json:"version"` Deleted bool `json:"deleted,omitempty"` - Channels []ChannelEntry `json:"channels"` - Receiver ReceiverEntry `json:"receiver"` - BrokerDriver BrokerDriverEntry `json:"brokerDriver"` - Policies PoliciesEntry `json:"policies"` + Channels interface{} `json:"channels"` // []ChannelEntry for WebSubApi, map[string]WebBrokerChannelEntry for WebBrokerApi + Receiver ReceiverEntry `json:"receiver"` // For WebBrokerApi + BrokerDriver BrokerDriverEntry `json:"broker-driver"` // For WebBrokerApi + Policies interface{} `json:"policies"` // PoliciesEntry for WebSubApi, ProtocolMediationPolicies for WebBrokerApi +} + +// WebBrokerChannelEntry represents a WebBrokerApi channel with policies +type WebBrokerChannelEntry struct { + ProduceTo *TopicMapping `json:"produce_to,omitempty"` + ConsumeFrom *TopicMapping `json:"consume_from,omitempty"` + Policies ProtocolMediationPolicies `json:"policies"` +} + +// TopicMapping defines a Kafka topic mapping +type TopicMapping struct { + Topic string `json:"topic"` +} + +// ProtocolMediationPolicies defines policies for WebBrokerApi +type ProtocolMediationPolicies struct { + OnConnectionInit ConnectionInitPolicies `json:"on_connection_init"` + OnProduce []PolicyEntry `json:"on_produce"` + OnConsume []PolicyEntry `json:"on_consume"` +} + +// ConnectionInitPolicies defines policies for connection initialization +type ConnectionInitPolicies struct { + Request []PolicyEntry `json:"request"` + Response []PolicyEntry `json:"response"` } // ChannelEntry represents one channel in the EventChannelConfig. @@ -75,13 +102,15 @@ type ChannelEntry struct { // ReceiverEntry specifies the receiver type. type ReceiverEntry struct { + Name string `json:"name"` Type string `json:"type"` } // BrokerDriverEntry specifies the broker driver configuration. type BrokerDriverEntry struct { - Type string `json:"type"` - Config map[string]interface{} `json:"config"` + Name string `json:"name"` + Type string `json:"type"` + Properties map[string]interface{} `json:"properties"` } // PoliciesEntry holds the 4-phase policy references. @@ -160,7 +189,6 @@ func (h *Handler) HandleResources(ctx context.Context, resources []*discoveryv3. slog.Warn("EventChannelConfig resource missing UUID, skipping") continue } - // Deletion markers are pushed by the controller to work around // a go-control-plane LinearCache limitation for custom type URLs. // Treat them as absent so the diff logic removes the binding. @@ -175,9 +203,9 @@ func (h *Handler) HandleResources(ctx context.Context, resources []*discoveryv3. // Compute diff: removals for uuid, old := range h.current { if _, exists := incoming[uuid]; !exists { - slog.Info("Removing binding via xDS", "name", old.Name, "uuid", uuid) - if err := h.manager.RemoveWebSubApiBinding(old.Name); err != nil { - slog.Error("Failed to remove binding", "name", old.Name, "error", err) + slog.Info("Removing binding via xDS", "name", old.Name, "uuid", uuid, "kind", old.Kind) + if err := h.removeBinding(old); err != nil { + slog.Error("Failed to remove binding", "name", old.Name, "kind", old.Kind, "error", err) } } } @@ -189,16 +217,15 @@ func (h *Handler) HandleResources(ctx context.Context, resources []*discoveryv3. continue } // Update: remove then re-add - slog.Info("Updating binding via xDS", "name", ecr.Name, "uuid", uuid) - if err := h.manager.RemoveWebSubApiBinding(old.Name); err != nil { - slog.Error("Failed to remove binding for update", "name", old.Name, "error", err) + slog.Info("Updating binding via xDS", "name", ecr.Name, "uuid", uuid, "kind", ecr.Kind) + if err := h.removeBinding(old); err != nil { + slog.Error("Failed to remove binding for update", "name", old.Name, "kind", old.Kind, "error", err) } } else { - slog.Info("Adding binding via xDS", "name", ecr.Name, "uuid", uuid) + slog.Info("Adding binding via xDS", "name", ecr.Name, "uuid", uuid, "kind", ecr.Kind) } - wsb := h.toWebSubApiBinding(ecr) - if err := h.manager.AddWebSubApiBinding(wsb); err != nil { + if err := h.addBinding(ecr); err != nil { return fmt.Errorf("failed to add binding %q: %w", ecr.Name, err) } } @@ -208,8 +235,39 @@ func (h *Handler) HandleResources(ctx context.Context, resources []*discoveryv3. } func (h *Handler) toWebSubApiBinding(ecr EventChannelResource) binding.WebSubApiBinding { - channels := make([]binding.ChannelDef, len(ecr.Channels)) - for i, ch := range ecr.Channels { + // For WebSubApi, Channels is []ChannelEntry + var channelEntries []ChannelEntry + if ecr.Channels != nil { + // Try to decode as []ChannelEntry (for WebSubApi) + if channelsSlice, ok := ecr.Channels.([]interface{}); ok { + for _, chIface := range channelsSlice { + if chMap, ok := chIface.(map[string]interface{}); ok { + var ch ChannelEntry + if name, ok := chMap["name"].(string); ok { + ch.Name = name + } + if policiesIface, ok := chMap["policies"].(map[string]interface{}); ok { + if subIface, ok := policiesIface["subscribe"].([]interface{}); ok { + ch.Policies.Subscribe = mapGenericPolicyEntryList(subIface) + } + if unsubIface, ok := policiesIface["unsubscribe"].([]interface{}); ok { + ch.Policies.Unsubscribe = mapGenericPolicyEntryList(unsubIface) + } + if inIface, ok := policiesIface["inbound"].([]interface{}); ok { + ch.Policies.Inbound = mapGenericPolicyEntryList(inIface) + } + if outIface, ok := policiesIface["outbound"].([]interface{}); ok { + ch.Policies.Outbound = mapGenericPolicyEntryList(outIface) + } + } + channelEntries = append(channelEntries, ch) + } + } + } + } + + channels := make([]binding.ChannelDef, len(channelEntries)) + for i, ch := range channelEntries { channels[i] = binding.ChannelDef{ Name: ch.Name, Policies: binding.PolicyBindings{ @@ -221,10 +279,29 @@ func (h *Handler) toWebSubApiBinding(ecr EventChannelResource) binding.WebSubApi } } - subscribe := mapPolicyEntries(ecr.Policies.Subscribe) - unsubscribe := mapPolicyEntries(ecr.Policies.Unsubscribe) - inbound := mapPolicyEntries(ecr.Policies.Inbound) - outbound := mapPolicyEntries(ecr.Policies.Outbound) + // For WebSubApi, Policies is PoliciesEntry + var policies PoliciesEntry + if ecr.Policies != nil { + if policiesMap, ok := ecr.Policies.(map[string]interface{}); ok { + if subIface, ok := policiesMap["subscribe"].([]interface{}); ok { + policies.Subscribe = mapGenericPolicyEntryList(subIface) + } + if unsubIface, ok := policiesMap["unsubscribe"].([]interface{}); ok { + policies.Unsubscribe = mapGenericPolicyEntryList(unsubIface) + } + if inIface, ok := policiesMap["inbound"].([]interface{}); ok { + policies.Inbound = mapGenericPolicyEntryList(inIface) + } + if outIface, ok := policiesMap["outbound"].([]interface{}); ok { + policies.Outbound = mapGenericPolicyEntryList(outIface) + } + } + } + + subscribe := mapPolicyEntries(policies.Subscribe) + unsubscribe := mapPolicyEntries(policies.Unsubscribe) + inbound := mapPolicyEntries(policies.Inbound) + outbound := mapPolicyEntries(policies.Outbound) return binding.WebSubApiBinding{ Kind: "WebSubApi", @@ -246,6 +323,27 @@ func (h *Handler) toWebSubApiBinding(ecr EventChannelResource) binding.WebSubApi } } +// mapGenericPolicyEntryList converts []interface{} to []PolicyEntry +func mapGenericPolicyEntryList(policies []interface{}) []PolicyEntry { + result := make([]PolicyEntry, 0, len(policies)) + for _, pIface := range policies { + if pMap, ok := pIface.(map[string]interface{}); ok { + policyEntry := PolicyEntry{} + if name, ok := pMap["name"].(string); ok { + policyEntry.Name = name + } + if version, ok := pMap["version"].(string); ok { + policyEntry.Version = version + } + if params, ok := pMap["params"].(map[string]interface{}); ok { + policyEntry.Params = params + } + result = append(result, policyEntry) + } + } + return result +} + // mapPolicyEntries converts a slice of PolicyEntry to binding.PolicyRef. func mapPolicyEntries(entries []PolicyEntry) []binding.PolicyRef { if len(entries) == 0 { @@ -266,7 +364,7 @@ func (h *Handler) resolveBrokerDriver(bd BrokerDriverEntry) binding.BrokerDriver driverType = "kafka" } - cfg := bd.Config + cfg := bd.Properties if len(cfg) == 0 { // Use the event gateway's own Kafka brokers. cfg = map[string]interface{}{ @@ -283,7 +381,138 @@ func (h *Handler) resolveBrokerDriver(bd BrokerDriverEntry) binding.BrokerDriver } return binding.BrokerDriverSpec{ + Name: bd.Name, Type: driverType, Config: cfg, } } + +// addBinding routes to the appropriate manager method based on Kind. +func (h *Handler) addBinding(ecr EventChannelResource) error { + switch ecr.Kind { + case "WebSubApi": + wsb := h.toWebSubApiBinding(ecr) + return h.manager.AddWebSubApiBinding(wsb) + case "WebBrokerApi": + wbb := h.toWebBrokerApiBinding(ecr) + return h.manager.AddWebBrokerApiBinding(wbb) + default: + return fmt.Errorf("unsupported kind: %s", ecr.Kind) + } +} + +// removeBinding routes to the appropriate manager method based on Kind. +func (h *Handler) removeBinding(ecr EventChannelResource) error { + switch ecr.Kind { + case "WebSubApi": + return h.manager.RemoveWebSubApiBinding(ecr.Name) + case "WebBrokerApi": + return h.manager.RemoveWebBrokerApiBinding(ecr.Name) + default: + return fmt.Errorf("unsupported kind: %s", ecr.Kind) + } +} + +// toWebBrokerApiBinding converts EventChannelResource to WebBrokerApiBinding. +func (h *Handler) toWebBrokerApiBinding(ecr EventChannelResource) binding.WebBrokerApiBinding { + // Parse API-level policies from Policies field (interface{}) + var apiPolicies binding.ProtocolMediationPolicies + if ecr.Policies != nil { + if policiesMap, ok := ecr.Policies.(map[string]interface{}); ok { + // Parse on_connection_init (now a flat array) + if connInitIface, ok := policiesMap["on_connection_init"].([]interface{}); ok { + // Put all policies in Request for backward compatibility with execution logic + apiPolicies.OnConnectionInit.Request = mapGenericPolicyList(connInitIface) + } + // Parse on_produce + if produceIface, ok := policiesMap["on_produce"].([]interface{}); ok { + apiPolicies.OnProduce = mapGenericPolicyList(produceIface) + } + // Parse on_consume + if consumeIface, ok := policiesMap["on_consume"].([]interface{}); ok { + apiPolicies.OnConsume = mapGenericPolicyList(consumeIface) + } + } + } + + // Parse channels map from Channels field (interface{}) + channels := make(map[string]binding.WebBrokerChannelDef) + if ecr.Channels != nil { + if channelsMap, ok := ecr.Channels.(map[string]interface{}); ok { + for channelName, channelIface := range channelsMap { + if channelData, ok := channelIface.(map[string]interface{}); ok { + var channelDef binding.WebBrokerChannelDef + + // Parse produce_to + if produceToIface, ok := channelData["produce_to"].(map[string]interface{}); ok { + if topic, ok := produceToIface["topic"].(string); ok && topic != "" { + channelDef.ProduceTo = &binding.TopicMapping{Topic: topic} + } + } + + // Parse consume_from + if consumeFromIface, ok := channelData["consume_from"].(map[string]interface{}); ok { + if topic, ok := consumeFromIface["topic"].(string); ok && topic != "" { + channelDef.ConsumeFrom = &binding.TopicMapping{Topic: topic} + } + } + + // Parse policies from nested "policies" field + if policiesIface, ok := channelData["policies"].(map[string]interface{}); ok { + // Parse channel on_connection_init (now a flat array) + if connInitIface, ok := policiesIface["on_connection_init"].([]interface{}); ok { + // Put all policies in Request for backward compatibility with execution logic + channelDef.OnConnectionInit.Request = mapGenericPolicyList(connInitIface) + } + // Parse channel on_produce + if produceIface, ok := policiesIface["on_produce"].([]interface{}); ok { + channelDef.OnProduce = mapGenericPolicyList(produceIface) + } + // Parse channel on_consume + if consumeIface, ok := policiesIface["on_consume"].([]interface{}); ok { + channelDef.OnConsume = mapGenericPolicyList(consumeIface) + } + } + + channels[channelName] = channelDef + } + } + } + } + + return binding.WebBrokerApiBinding{ + Kind: "WebBrokerApi", + APIID: ecr.UUID, + Name: ecr.Name, + Version: ecr.Version, + Context: ecr.Context, + Receiver: binding.ReceiverSpec{ + Name: ecr.Receiver.Name, + Type: ecr.Receiver.Type, + }, + BrokerDriver: h.resolveBrokerDriver(ecr.BrokerDriver), + Policies: apiPolicies, + Channels: channels, + } +} + +// mapGenericPolicyList converts []interface{} to []binding.PolicyRef +func mapGenericPolicyList(policies []interface{}) []binding.PolicyRef { + result := make([]binding.PolicyRef, 0, len(policies)) + for _, pIface := range policies { + if pMap, ok := pIface.(map[string]interface{}); ok { + policyRef := binding.PolicyRef{} + if name, ok := pMap["name"].(string); ok { + policyRef.Name = name + } + if version, ok := pMap["version"].(string); ok { + policyRef.Version = version + } + if params, ok := pMap["params"].(map[string]interface{}); ok { + policyRef.Params = params + } + result = append(result, policyRef) + } + } + return result +} diff --git a/event-gateway/gateway-runtime/internal/xdsclient/handler_test.go b/event-gateway/gateway-runtime/internal/xdsclient/handler_test.go index c497b174c..a9bcaf4bf 100644 --- a/event-gateway/gateway-runtime/internal/xdsclient/handler_test.go +++ b/event-gateway/gateway-runtime/internal/xdsclient/handler_test.go @@ -167,6 +167,16 @@ func (m *recordingBindingManager) RemoveWebSubApiBinding(name string) error { return nil } +func (m *recordingBindingManager) AddWebBrokerApiBinding(wbb binding.WebBrokerApiBinding) error { + m.added = append(m.added, wbb.Name) + return nil +} + +func (m *recordingBindingManager) RemoveWebBrokerApiBinding(name string) error { + m.removed = append(m.removed, name) + return nil +} + func (m *recordingBindingManager) addedNames() []string { out := append([]string(nil), m.added...) return out diff --git a/gateway/gateway-controller/api/management-openapi.yaml b/gateway/gateway-controller/api/management-openapi.yaml index 3dc645643..94a2a4ef6 100644 --- a/gateway/gateway-controller/api/management-openapi.yaml +++ b/gateway/gateway-controller/api/management-openapi.yaml @@ -1354,6 +1354,190 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /webbroker-apis: + post: + summary: Create a new WebBrokerAPI + description: Add a new WebBrokerAPI to the Gateway. WebBrokerAPI provides bidirectional streaming between WebSocket clients and Kafka brokers with per-connection isolation. + operationId: createWebBrokerApi + x-basicauth-roles: [admin, developer] + tags: + - WebBroker API Management + requestBody: + required: true + content: + application/yaml: + schema: + $ref: "#/components/schemas/WebBrokerApiRequest" + application/json: + schema: + $ref: "#/components/schemas/WebBrokerApiRequest" + responses: + "201": + description: WebBrokerAPI created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/WebBrokerApi" + "400": + description: Invalid configuration (validation failed) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Conflict - WebBroker API with same name and version already exists + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + get: + summary: List all WebBrokerAPIs + description: List WebBrokerAPIs registered in the Gateway, optionally filtered by name, version, or status. + operationId: listWebBrokerApis + x-basicauth-roles: [admin, developer] + tags: + - WebBroker API Management + parameters: + - name: displayName + in: query + required: false + description: Filter by WebBroker API display name + schema: + type: string + example: Stock Trading WebBroker API + - name: version + in: query + required: false + description: Filter by WebBroker API version + schema: + type: string + example: v1.0 + - name: status + in: query + required: false + description: Filter by deployment status + schema: + type: string + enum: [ deployed, undeployed ] + example: deployed + responses: + "200": + description: List of WebBrokerAPIs + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + count: + type: integer + example: 3 + apis: + type: array + items: + $ref: "#/components/schemas/WebBrokerApi" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /webbroker-apis/{id}: + get: + summary: Get WebBrokerAPI by id + description: Get a WebBrokerAPI by its ID. + operationId: getWebBrokerApiById + x-basicauth-roles: [admin, developer] + tags: + - WebBroker API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier for the WebBroker API. + schema: + type: string + example: stock-trading-webbroker-api + responses: + "200": + description: WebBrokerAPI details + content: + application/json: + schema: + $ref: "#/components/schemas/WebBrokerApi" + application/yaml: + schema: + $ref: "#/components/schemas/WebBrokerApi" + "404": + description: WebBrokerAPI not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + delete: + summary: Delete a WebBrokerAPI + description: Delete a WebBrokerAPI from the Gateway. + operationId: deleteWebBrokerApiById + x-basicauth-roles: [admin, developer] + tags: + - WebBroker API Management + parameters: + - name: id + in: path + required: true + description: | + Unique public identifier of the WebBroker API to delete. + schema: + type: string + example: stock-trading-webbroker-api + responses: + "200": + description: WebBrokerAPI deleted successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + message: + type: string + example: WebBrokerAPI deleted successfully + id: + type: string + example: stock-trading-webbroker-api + "404": + description: WebBrokerAPI not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /certificates: get: summary: List all custom certificates @@ -3907,6 +4091,293 @@ components: # description: Protocol-specific channel bindings (arbitrary key/value structure). # additionalProperties: true + WebBrokerApiRequest: + type: object + required: + - apiVersion + - metadata + - kind + - spec + properties: + apiVersion: + type: string + description: API specification version + example: gateway.api-platform.wso2.com/v1alpha1 + enum: + - gateway.api-platform.wso2.com/v1alpha1 + kind: + type: string + description: API type + example: WebBrokerApi + enum: + - WebBrokerApi + metadata: + $ref: "#/components/schemas/Metadata" + spec: + $ref: '#/components/schemas/WebBrokerApiData' + example: + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: WebBrokerApi + metadata: + name: stock-trading-v1.0 + spec: + displayName: Stock Trading WebBroker API + version: v1.0 + context: /stock-trading/$version + receiver: + name: websocket-receiver + type: websocket + broker: + name: kafka-driver + type: kafka + properties: + brokers: + - kafka-broker-1:9092 + - kafka-broker-2:9092 + allChannels: + on_connection_init: + policies: [] + on_produce: + policies: [] + on_consume: + policies: [] + channels: + prices: + produceTo: + topic: stock.prices + consumeFrom: + topic: stock.prices + on_connection_init: + policies: [] + on_produce: + policies: [] + on_consume: + policies: [] + + WebBrokerApi: + allOf: + - $ref: '#/components/schemas/WebBrokerApiRequest' + - type: object + properties: + status: + readOnly: true + description: Server-managed lifecycle fields. Populated on responses. + allOf: + - $ref: '#/components/schemas/ResourceStatus' + example: + apiVersion: gateway.api-platform.wso2.com/v1alpha1 + kind: WebBrokerApi + metadata: + name: stock-trading-v1.0 + spec: + displayName: Stock Trading WebBroker API + version: v1.0 + context: /stock-trading/$version + receiver: + name: websocket-receiver + type: websocket + broker: + name: kafka-driver + type: kafka + properties: + brokers: + - kafka-broker-1:9092 + - kafka-broker-2:9092 + allChannels: + on_connection_init: + policies: [] + on_produce: + policies: [] + on_consume: + policies: [] + channels: + prices: + produceTo: + topic: stock.prices + consumeFrom: + topic: stock.prices + on_connection_init: + policies: [] + on_produce: + policies: [] + on_consume: + policies: [] + status: + id: stock-trading-v1.0 + state: deployed + createdAt: 2026-04-24T07:21:13Z + updatedAt: 2026-04-24T07:21:13Z + deployedAt: 2026-04-24T07:21:13Z + + WebBrokerApiData: + type: object + required: + - displayName + - version + - context + - receiver + - broker + - channels + properties: + displayName: + type: string + description: Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed) + minLength: 1 + maxLength: 100 + pattern: '^[a-zA-Z0-9\-_\. ]+$' + example: Stock Trading WebBroker API + version: + type: string + description: Semantic version of the API + pattern: '^v\d+\.\d+$' + example: v1.0 + context: + type: string + description: Base path for all API routes (must start with /, no trailing slash) + pattern: '^\/[a-zA-Z0-9_\-\/]*[^\/]$' + minLength: 1 + maxLength: 200 + example: /stock-trading + receiver: + $ref: '#/components/schemas/WebBrokerApiReceiver' + broker: + $ref: '#/components/schemas/WebBrokerApiBroker' + allChannels: + $ref: '#/components/schemas/WebBrokerApiAllChannelPolicies' + channels: + type: object + description: Map of WebSocket channels for bidirectional streaming with Kafka (key is channel name) + minProperties: 1 + additionalProperties: + $ref: '#/components/schemas/WebBrokerApiChannel' + vhosts: + type: object + required: + - main + description: Custom virtual hosts/domains for the API + properties: + main: + type: string + description: Custom virtual host/domain for production traffic + pattern: '^[a-zA-Z0-9\.\-]+$' + example: api.example.com + sandbox: + type: string + description: Custom virtual host/domain for sandbox traffic + pattern: '^[a-zA-Z0-9\.\-]+$' + example: sandbox-api.example.com + deploymentState: + type: string + description: Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration and policies are preserved for potential redeployment. + enum: [deployed, undeployed] + default: deployed + example: deployed + + WebBrokerApiReceiver: + type: object + description: WebSocket receiver configuration + required: + - name + - type + properties: + name: + type: string + description: Receiver name + example: websocket-receiver + type: + type: string + description: Receiver type + example: websocket + properties: + type: object + description: Additional receiver properties + additionalProperties: true + + WebBrokerApiBroker: + type: object + description: Message broker driver configuration + required: + - name + - type + - properties + properties: + name: + type: string + description: Broker driver name + example: kafka-driver + type: + type: string + description: Broker driver type + example: kafka + properties: + type: object + description: Broker driver properties (e.g., bootstrap servers) + additionalProperties: true + example: + brokers: + - kafka-broker-1:9092 + - kafka-broker-2:9092 + + WebBrokerApiAllChannelPolicies: + type: object + description: Protocol mediation policies applied to all channels + properties: + on_connection_init: + $ref: '#/components/schemas/WebBrokerApiPolicyGroup' + on_produce: + $ref: '#/components/schemas/WebBrokerApiPolicyGroup' + on_consume: + $ref: '#/components/schemas/WebBrokerApiPolicyGroup' + + WebBrokerApiPolicyGroup: + type: object + description: Group of policies + properties: + policies: + type: array + description: List of policies to apply + items: + $ref: '#/components/schemas/Policy' + + WebBrokerApiChannel: + type: object + description: WebSocket channel configuration with Kafka topic mapping + properties: + produceTo: + $ref: '#/components/schemas/WebBrokerApiProduceConfig' + consumeFrom: + $ref: '#/components/schemas/WebBrokerApiConsumeConfig' + on_connection_init: + $ref: '#/components/schemas/WebBrokerApiPolicyGroup' + on_produce: + $ref: '#/components/schemas/WebBrokerApiPolicyGroup' + on_consume: + $ref: '#/components/schemas/WebBrokerApiPolicyGroup' + + WebBrokerApiProduceConfig: + type: object + description: Configuration for producing messages from WebSocket to Kafka + required: + - topic + properties: + topic: + type: string + description: Kafka topic to produce messages to + example: stock.prices + + WebBrokerApiConsumeConfig: + type: object + description: Configuration for consuming messages from Kafka to WebSocket + required: + - topic + properties: + topic: + type: string + description: Kafka topic to consume messages from + example: stock.prices + + + APIKeyCreationRequest: type: object properties: diff --git a/gateway/gateway-controller/cmd/controller/main.go b/gateway/gateway-controller/cmd/controller/main.go index 193821b45..67265f890 100644 --- a/gateway/gateway-controller/cmd/controller/main.go +++ b/gateway/gateway-controller/cmd/controller/main.go @@ -760,6 +760,11 @@ func generateAuthConfig(config *config.Config) commonmodels.AuthConfig { "PUT /websub-apis/:id": {"admin", "developer"}, "DELETE /websub-apis/:id": {"admin", "developer"}, + "POST /webbroker-apis": {"admin", "developer"}, + "GET /webbroker-apis": {"admin", "developer"}, + "GET /webbroker-apis/:id": {"admin", "developer"}, + "DELETE /webbroker-apis/:id": {"admin", "developer"}, + "GET /certificates": {"admin", "developer"}, "POST /certificates": {"admin", "developer"}, "DELETE /certificates/:id": {"admin"}, diff --git a/gateway/gateway-controller/pkg/api/handlers/webbroker_api_handler.go b/gateway/gateway-controller/pkg/api/handlers/webbroker_api_handler.go new file mode 100644 index 000000000..1a25a3ae4 --- /dev/null +++ b/gateway/gateway-controller/pkg/api/handlers/webbroker_api_handler.go @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package handlers + +import ( + "io" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/wso2/api-platform/common/eventhub" + api "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/management" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/api/middleware" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/models" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/storage" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" +) + +// CreateWebBrokerApi handles POST /webbroker-apis +func (s *APIServer) CreateWebBrokerApi(c *gin.Context) { + log := middleware.GetLogger(c, s.logger) + + body, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Error("Failed to read request body", slog.Any("error", err)) + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: "Failed to read request body", + }) + return + } + + correlationID := middleware.GetCorrelationID(c) + + result, err := s.deploymentService.DeployAPIConfiguration(utils.APIDeploymentParams{ + Data: body, + ContentType: c.GetHeader("Content-Type"), + Kind: "WebBrokerApi", + APIID: "", + Origin: models.OriginGatewayAPI, + CorrelationID: correlationID, + Logger: log, + }) + if err != nil { + log.Error("Failed to deploy WebBrokerApi configuration", slog.Any("error", err)) + if storage.IsConflictError(err) { + c.JSON(http.StatusConflict, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + if mapRenderError(c, "create", err) { + return + } + c.JSON(http.StatusBadRequest, api.ErrorResponse{ + Status: "error", + Message: err.Error(), + }) + return + } + + cfg := result.StoredConfig + + c.JSON(http.StatusCreated, buildResourceResponseFromStored(cfg.SourceConfiguration, cfg)) + + if result.IsStale { + return + } + + if s.controlPlaneClient != nil && s.controlPlaneClient.IsConnected() && s.systemConfig.Controller.ControlPlane.DeploymentPushEnabled { + go s.waitForDeploymentAndPush(cfg.UUID, correlationID, log) + } +} + +// ListWebBrokerApis handles GET /webbroker-apis +func (s *APIServer) ListWebBrokerApis(c *gin.Context, params api.ListWebBrokerApisParams) { + configs, err := s.db.GetAllConfigsByKind(string(models.KindWebBrokerApi)) + if err != nil { + s.logger.Error("Failed to list WebBrokerApis", slog.Any("error", err)) + c.JSON(http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to list WebBrokerApi configurations", + }) + return + } + + // TODO: Implement query parameter filtering (displayName, version, status) + // For now, returning all WebBrokerApis without filtering + items := make([]any, 0, len(configs)) + for _, cfg := range configs { + items = append(items, buildResourceResponseFromStored(cfg.SourceConfiguration, cfg)) + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "count": len(items), + "webBrokerApis": items, + }) +} + +// GetWebBrokerApiById handles GET /webbroker-apis/{id} +func (s *APIServer) GetWebBrokerApiById(c *gin.Context, id string) { + log := middleware.GetLogger(c, s.logger) + handle := id + + cfg, err := s.db.GetConfigByKindAndHandle(models.KindWebBrokerApi, handle) + if err != nil { + if storage.IsDatabaseUnavailableError(err) { + log.Error("Database unavailable", slog.Any("error", err)) + c.JSON(http.StatusServiceUnavailable, api.ErrorResponse{ + Status: "error", + Message: "Database is temporarily unavailable", + }) + return + } + log.Warn("WebBrokerApi not found", slog.String("handle", handle)) + c.JSON(http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: "WebBrokerApi not found", + }) + return + } + + c.JSON(http.StatusOK, buildResourceResponseFromStored(cfg.SourceConfiguration, cfg)) +} + +// DeleteWebBrokerApiById handles DELETE /webbroker-apis/{id} +func (s *APIServer) DeleteWebBrokerApiById(c *gin.Context, id string) { + log := middleware.GetLogger(c, s.logger) + handle := id + + cfg, err := s.db.GetConfigByKindAndHandle(models.KindWebBrokerApi, handle) + if err != nil { + if storage.IsDatabaseUnavailableError(err) { + log.Error("Database unavailable", slog.Any("error", err)) + c.JSON(http.StatusServiceUnavailable, api.ErrorResponse{ + Status: "error", + Message: "Database is temporarily unavailable", + }) + return + } + log.Warn("WebBrokerApi not found", slog.String("handle", handle)) + c.JSON(http.StatusNotFound, api.ErrorResponse{ + Status: "error", + Message: "WebBrokerApi not found", + }) + return + } + + correlationID := middleware.GetCorrelationID(c) + + if err := s.db.DeleteConfig(cfg.UUID); err != nil { + log.Error("Failed to delete WebBrokerApi from database", slog.Any("error", err)) + c.JSON(http.StatusInternalServerError, api.ErrorResponse{ + Status: "error", + Message: "Failed to delete configuration", + }) + return + } + + // Publish delete event for xDS propagation + s.publishWebSubEvent(eventhub.EventTypeAPI, "DELETE", cfg.UUID, correlationID, log) + + log.Info("WebBrokerApi deleted successfully", + slog.String("id", id), + slog.String("correlation_id", correlationID)) + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "WebBrokerApi deleted successfully", + }) +} diff --git a/gateway/gateway-controller/pkg/api/management/generated.go b/gateway/gateway-controller/pkg/api/management/generated.go index a7c728937..3d71483c0 100644 --- a/gateway/gateway-controller/pkg/api/management/generated.go +++ b/gateway/gateway-controller/pkg/api/management/generated.go @@ -372,6 +372,32 @@ const ( UpstreamAuthAuthTypeApiKey UpstreamAuthAuthType = "api-key" ) +// Defines values for WebBrokerApiApiVersion. +const ( + WebBrokerApiApiVersionGatewayApiPlatformWso2Comv1alpha1 WebBrokerApiApiVersion = "gateway.api-platform.wso2.com/v1alpha1" +) + +// Defines values for WebBrokerApiKind. +const ( + WebBrokerApiKindWebBrokerApi WebBrokerApiKind = "WebBrokerApi" +) + +// Defines values for WebBrokerApiDataDeploymentState. +const ( + WebBrokerApiDataDeploymentStateDeployed WebBrokerApiDataDeploymentState = "deployed" + WebBrokerApiDataDeploymentStateUndeployed WebBrokerApiDataDeploymentState = "undeployed" +) + +// Defines values for WebBrokerApiRequestApiVersion. +const ( + WebBrokerApiRequestApiVersionGatewayApiPlatformWso2Comv1alpha1 WebBrokerApiRequestApiVersion = "gateway.api-platform.wso2.com/v1alpha1" +) + +// Defines values for WebBrokerApiRequestKind. +const ( + WebBrokerApiRequestKindWebBrokerApi WebBrokerApiRequestKind = "WebBrokerApi" +) + // Defines values for WebSubAPIApiVersion. const ( WebSubAPIApiVersionGatewayApiPlatformWso2Comv1alpha1 WebSubAPIApiVersion = "gateway.api-platform.wso2.com/v1alpha1" @@ -429,6 +455,12 @@ const ( REVOKED ListSubscriptionsParamsStatus = "REVOKED" ) +// Defines values for ListWebBrokerApisParamsStatus. +const ( + ListWebBrokerApisParamsStatusDeployed ListWebBrokerApisParamsStatus = "deployed" + ListWebBrokerApisParamsStatusUndeployed ListWebBrokerApisParamsStatus = "undeployed" +) + // Defines values for ListWebSubAPIsParamsStatus. const ( Deployed ListWebSubAPIsParamsStatus = "deployed" @@ -1545,6 +1577,154 @@ type ValidationError struct { Message *string `json:"message,omitempty" yaml:"message,omitempty"` } +// WebBrokerApi defines model for WebBrokerApi. +type WebBrokerApi struct { + // ApiVersion API specification version + ApiVersion WebBrokerApiApiVersion `json:"apiVersion" yaml:"apiVersion"` + + // Kind API type + Kind WebBrokerApiKind `json:"kind" yaml:"kind"` + Metadata Metadata `json:"metadata" yaml:"metadata"` + Spec WebBrokerApiData `json:"spec" yaml:"spec"` + + // Status Server-managed lifecycle fields. Populated on responses. + Status *ResourceStatus `json:"status,omitempty" yaml:"status,omitempty"` +} + +// WebBrokerApiApiVersion API specification version +type WebBrokerApiApiVersion string + +// WebBrokerApiKind API type +type WebBrokerApiKind string + +// WebBrokerApiAllChannelPolicies Protocol mediation policies applied to all channels +type WebBrokerApiAllChannelPolicies struct { + // OnConnectionInit Group of policies + OnConnectionInit *WebBrokerApiPolicyGroup `json:"on_connection_init,omitempty" yaml:"on_connection_init,omitempty"` + + // OnConsume Group of policies + OnConsume *WebBrokerApiPolicyGroup `json:"on_consume,omitempty" yaml:"on_consume,omitempty"` + + // OnProduce Group of policies + OnProduce *WebBrokerApiPolicyGroup `json:"on_produce,omitempty" yaml:"on_produce,omitempty"` +} + +// WebBrokerApiBroker Message broker driver configuration +type WebBrokerApiBroker struct { + // Name Broker driver name + Name string `json:"name" yaml:"name"` + + // Properties Broker driver properties (e.g., bootstrap servers) + Properties map[string]interface{} `json:"properties" yaml:"properties"` + + // Type Broker driver type + Type string `json:"type" yaml:"type"` +} + +// WebBrokerApiChannel WebSocket channel configuration with Kafka topic mapping +type WebBrokerApiChannel struct { + // ConsumeFrom Configuration for consuming messages from Kafka to WebSocket + ConsumeFrom *WebBrokerApiConsumeConfig `json:"consumeFrom,omitempty" yaml:"consumeFrom,omitempty"` + + // OnConnectionInit Group of policies + OnConnectionInit *WebBrokerApiPolicyGroup `json:"on_connection_init,omitempty" yaml:"on_connection_init,omitempty"` + + // OnConsume Group of policies + OnConsume *WebBrokerApiPolicyGroup `json:"on_consume,omitempty" yaml:"on_consume,omitempty"` + + // OnProduce Group of policies + OnProduce *WebBrokerApiPolicyGroup `json:"on_produce,omitempty" yaml:"on_produce,omitempty"` + + // ProduceTo Configuration for producing messages from WebSocket to Kafka + ProduceTo *WebBrokerApiProduceConfig `json:"produceTo,omitempty" yaml:"produceTo,omitempty"` +} + +// WebBrokerApiConsumeConfig Configuration for consuming messages from Kafka to WebSocket +type WebBrokerApiConsumeConfig struct { + // Topic Kafka topic to consume messages from + Topic string `json:"topic" yaml:"topic"` +} + +// WebBrokerApiData defines model for WebBrokerApiData. +type WebBrokerApiData struct { + // AllChannels Protocol mediation policies applied to all channels + AllChannels *WebBrokerApiAllChannelPolicies `json:"allChannels,omitempty" yaml:"allChannels,omitempty"` + + // Broker Message broker driver configuration + Broker WebBrokerApiBroker `json:"broker" yaml:"broker"` + + // Channels Map of WebSocket channels for bidirectional streaming with Kafka (key is channel name) + Channels map[string]WebBrokerApiChannel `json:"channels" yaml:"channels"` + + // Context Base path for all API routes (must start with /, no trailing slash) + Context string `json:"context" yaml:"context"` + + // DeploymentState Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration and policies are preserved for potential redeployment. + DeploymentState *WebBrokerApiDataDeploymentState `json:"deploymentState,omitempty" yaml:"deploymentState,omitempty"` + + // DisplayName Human-readable API name (must be URL-friendly - only letters, numbers, spaces, hyphens, underscores, and dots allowed) + DisplayName string `json:"displayName" yaml:"displayName"` + + // Receiver WebSocket receiver configuration + Receiver WebBrokerApiReceiver `json:"receiver" yaml:"receiver"` + + // Version Semantic version of the API + Version string `json:"version" yaml:"version"` + + // Vhosts Custom virtual hosts/domains for the API + Vhosts *struct { + // Main Custom virtual host/domain for production traffic + Main string `json:"main" yaml:"main"` + + // Sandbox Custom virtual host/domain for sandbox traffic + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + } `json:"vhosts,omitempty" yaml:"vhosts,omitempty"` +} + +// WebBrokerApiDataDeploymentState Desired deployment state - 'deployed' (default) or 'undeployed'. When set to 'undeployed', the API is removed from router traffic but configuration and policies are preserved for potential redeployment. +type WebBrokerApiDataDeploymentState string + +// WebBrokerApiPolicyGroup Group of policies +type WebBrokerApiPolicyGroup struct { + // Policies List of policies to apply + Policies *[]Policy `json:"policies,omitempty" yaml:"policies,omitempty"` +} + +// WebBrokerApiProduceConfig Configuration for producing messages from WebSocket to Kafka +type WebBrokerApiProduceConfig struct { + // Topic Kafka topic to produce messages to + Topic string `json:"topic" yaml:"topic"` +} + +// WebBrokerApiReceiver WebSocket receiver configuration +type WebBrokerApiReceiver struct { + // Name Receiver name + Name string `json:"name" yaml:"name"` + + // Properties Additional receiver properties + Properties *map[string]interface{} `json:"properties,omitempty" yaml:"properties,omitempty"` + + // Type Receiver type + Type string `json:"type" yaml:"type"` +} + +// WebBrokerApiRequest defines model for WebBrokerApiRequest. +type WebBrokerApiRequest struct { + // ApiVersion API specification version + ApiVersion WebBrokerApiRequestApiVersion `json:"apiVersion" yaml:"apiVersion"` + + // Kind API type + Kind WebBrokerApiRequestKind `json:"kind" yaml:"kind"` + Metadata Metadata `json:"metadata" yaml:"metadata"` + Spec WebBrokerApiData `json:"spec" yaml:"spec"` +} + +// WebBrokerApiRequestApiVersion API specification version +type WebBrokerApiRequestApiVersion string + +// WebBrokerApiRequestKind API type +type WebBrokerApiRequestKind string + // WebSubAPI defines model for WebSubAPI. type WebSubAPI struct { // ApiVersion API specification version @@ -1746,6 +1926,21 @@ type ListSubscriptionsParams struct { // ListSubscriptionsParamsStatus defines parameters for ListSubscriptions. type ListSubscriptionsParamsStatus string +// ListWebBrokerApisParams defines parameters for ListWebBrokerApis. +type ListWebBrokerApisParams struct { + // DisplayName Filter by WebBroker API display name + DisplayName *string `form:"displayName,omitempty" json:"displayName,omitempty" yaml:"displayName,omitempty"` + + // Version Filter by WebBroker API version + Version *string `form:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` + + // Status Filter by deployment status + Status *ListWebBrokerApisParamsStatus `form:"status,omitempty" json:"status,omitempty" yaml:"status,omitempty"` +} + +// ListWebBrokerApisParamsStatus defines parameters for ListWebBrokerApis. +type ListWebBrokerApisParamsStatus string + // ListWebSubAPIsParams defines parameters for ListWebSubAPIs. type ListWebSubAPIsParams struct { // DisplayName Filter by WebSub API display name @@ -1842,6 +2037,9 @@ type CreateSubscriptionJSONRequestBody = SubscriptionCreateRequest // UpdateSubscriptionJSONRequestBody defines body for UpdateSubscription for application/json ContentType. type UpdateSubscriptionJSONRequestBody = SubscriptionUpdateRequest +// CreateWebBrokerApiJSONRequestBody defines body for CreateWebBrokerApi for application/json ContentType. +type CreateWebBrokerApiJSONRequestBody = WebBrokerApiRequest + // CreateWebSubAPIJSONRequestBody defines body for CreateWebSubAPI for application/json ContentType. type CreateWebSubAPIJSONRequestBody = WebSubAPIRequest @@ -2436,6 +2634,18 @@ type ServerInterface interface { // Update a subscription // (PUT /subscriptions/{subscriptionId}) UpdateSubscription(c *gin.Context, subscriptionId string) + // List all WebBrokerAPIs + // (GET /webbroker-apis) + ListWebBrokerApis(c *gin.Context, params ListWebBrokerApisParams) + // Create a new WebBrokerAPI + // (POST /webbroker-apis) + CreateWebBrokerApi(c *gin.Context) + // Delete a WebBrokerAPI + // (DELETE /webbroker-apis/{id}) + DeleteWebBrokerApiById(c *gin.Context, id string) + // Get WebBrokerAPI by id + // (GET /webbroker-apis/{id}) + GetWebBrokerApiById(c *gin.Context, id string) // List all WebSubAPIs // (GET /websub-apis) ListWebSubAPIs(c *gin.Context, params ListWebSubAPIsParams) @@ -4089,6 +4299,117 @@ func (siw *ServerInterfaceWrapper) UpdateSubscription(c *gin.Context) { siw.Handler.UpdateSubscription(c, subscriptionId) } +// ListWebBrokerApis operation middleware +func (siw *ServerInterfaceWrapper) ListWebBrokerApis(c *gin.Context) { + + var err error + + c.Set(BasicAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params ListWebBrokerApisParams + + // ------------- Optional query parameter "displayName" ------------- + + err = runtime.BindQueryParameter("form", true, false, "displayName", c.Request.URL.Query(), ¶ms.DisplayName) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter displayName: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "version" ------------- + + err = runtime.BindQueryParameter("form", true, false, "version", c.Request.URL.Query(), ¶ms.Version) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter version: %w", err), http.StatusBadRequest) + return + } + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameter("form", true, false, "status", c.Request.URL.Query(), ¶ms.Status) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter status: %w", err), http.StatusBadRequest) + return + } + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.ListWebBrokerApis(c, params) +} + +// CreateWebBrokerApi operation middleware +func (siw *ServerInterfaceWrapper) CreateWebBrokerApi(c *gin.Context) { + + c.Set(BasicAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.CreateWebBrokerApi(c) +} + +// DeleteWebBrokerApiById operation middleware +func (siw *ServerInterfaceWrapper) DeleteWebBrokerApiById(c *gin.Context) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", c.Param("id"), &id, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter id: %w", err), http.StatusBadRequest) + return + } + + c.Set(BasicAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteWebBrokerApiById(c, id) +} + +// GetWebBrokerApiById operation middleware +func (siw *ServerInterfaceWrapper) GetWebBrokerApiById(c *gin.Context) { + + var err error + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", c.Param("id"), &id, runtime.BindStyledParameterOptions{Explode: false, Required: true}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter id: %w", err), http.StatusBadRequest) + return + } + + c.Set(BasicAuthScopes, []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.GetWebBrokerApiById(c, id) +} + // ListWebSubAPIs operation middleware func (siw *ServerInterfaceWrapper) ListWebSubAPIs(c *gin.Context) { @@ -4477,6 +4798,10 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.DELETE(options.BaseURL+"/subscriptions/:subscriptionId", wrapper.DeleteSubscription) router.GET(options.BaseURL+"/subscriptions/:subscriptionId", wrapper.GetSubscription) router.PUT(options.BaseURL+"/subscriptions/:subscriptionId", wrapper.UpdateSubscription) + router.GET(options.BaseURL+"/webbroker-apis", wrapper.ListWebBrokerApis) + router.POST(options.BaseURL+"/webbroker-apis", wrapper.CreateWebBrokerApi) + router.DELETE(options.BaseURL+"/webbroker-apis/:id", wrapper.DeleteWebBrokerApiById) + router.GET(options.BaseURL+"/webbroker-apis/:id", wrapper.GetWebBrokerApiById) router.GET(options.BaseURL+"/websub-apis", wrapper.ListWebSubAPIs) router.POST(options.BaseURL+"/websub-apis", wrapper.CreateWebSubAPI) router.DELETE(options.BaseURL+"/websub-apis/:id", wrapper.DeleteWebSubAPI) @@ -4492,241 +4817,257 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/gateway/gateway-controller/pkg/eventlistener/api_processor.go b/gateway/gateway-controller/pkg/eventlistener/api_processor.go index a175f3363..2905caad6 100644 --- a/gateway/gateway-controller/pkg/eventlistener/api_processor.go +++ b/gateway/gateway-controller/pkg/eventlistener/api_processor.go @@ -107,8 +107,10 @@ func (l *EventListener) handleAPICreateOrUpdate(event eventhub.Event) { } } - // Update xDS snapshot - l.updateSnapshotAsync(entityID, event.EventID, "Failed to update xDS snapshot after replica sync") + // Update xDS snapshot for REST APIs only (WebSubApi and WebBrokerApi use Policy xDS) + if storedConfig.Kind != models.KindWebSubApi && storedConfig.Kind != models.KindWebBrokerApi { + l.updateSnapshotAsync(entityID, event.EventID, "Failed to update xDS snapshot after replica sync") + } // Update policies l.updatePoliciesForAPI(storedConfig, event.EventID) @@ -184,16 +186,19 @@ func (l *EventListener) handleAPIDelete(event eventhub.Event) { } } - // Update xDS snapshot - l.updateSnapshotAsync(entityID, event.EventID, "Failed to update xDS snapshot after API deletion") + // Update xDS snapshot for REST APIs only (WebSubApi and WebBrokerApi use Policy xDS) + if existingConfig == nil || (existingConfig.Kind != models.KindWebSubApi && existingConfig.Kind != models.KindWebBrokerApi) { + l.updateSnapshotAsync(entityID, event.EventID, "Failed to update xDS snapshot after API deletion") + } // Remove runtime config for the deleted API if l.policyManager != nil && existingConfig != nil { - if existingConfig.Kind == models.KindWebSubApi { - // WebSubApi: refresh event channel cache (config already removed from ConfigStore) + if existingConfig.Kind == models.KindWebSubApi || existingConfig.Kind == models.KindWebBrokerApi { + // WebSubApi/WebBrokerApi: refresh event channel cache (config already removed from ConfigStore) if err := l.policyManager.UpdateEventChannelSnapshot(); err != nil { - l.logger.Warn("Failed to update event channel snapshot after WebSubApi deletion", + l.logger.Warn("Failed to update event channel snapshot after event API deletion", slog.String("api_id", entityID), + slog.String("kind", string(existingConfig.Kind)), slog.Any("error", err)) } } else if err := l.policyManager.DeleteAPIConfig(existingConfig.Kind, existingConfig.Handle); err != nil { @@ -214,8 +219,8 @@ func (l *EventListener) updatePoliciesForAPI(cfg *models.StoredConfig, correlati return } - if cfg.Kind == models.KindWebSubApi { - // WebSubApi doesn't need RuntimeDeployConfig transformation. + if cfg.Kind == models.KindWebSubApi || cfg.Kind == models.KindWebBrokerApi { + // WebSubApi and WebBrokerApi don't need RuntimeDeployConfig transformation. // Just refresh the event channel config cache. if err := l.policyManager.UpdateEventChannelSnapshot(); err != nil { l.logger.Error("Failed to update event channel snapshot", diff --git a/gateway/gateway-controller/pkg/models/stored_config.go b/gateway/gateway-controller/pkg/models/stored_config.go index 526174070..1ffd4d912 100644 --- a/gateway/gateway-controller/pkg/models/stored_config.go +++ b/gateway/gateway-controller/pkg/models/stored_config.go @@ -32,11 +32,12 @@ import ( type ArtifactKind = string const ( - KindRestApi ArtifactKind = "RestApi" - KindWebSubApi ArtifactKind = "WebSubApi" - KindMcp ArtifactKind = "Mcp" - KindLlmProxy ArtifactKind = "LlmProxy" - KindLlmProvider ArtifactKind = "LlmProvider" + KindRestApi ArtifactKind = "RestApi" + KindWebSubApi ArtifactKind = "WebSubApi" + KindWebBrokerApi ArtifactKind = "WebBrokerApi" + KindMcp ArtifactKind = "Mcp" + KindLlmProxy ArtifactKind = "LlmProxy" + KindLlmProvider ArtifactKind = "LlmProvider" ) // DesiredState represents the intended deployment state of an API configuration. diff --git a/gateway/gateway-controller/pkg/policyxds/event_channel_translator.go b/gateway/gateway-controller/pkg/policyxds/event_channel_translator.go index 6e2fbf41f..9e66d9c91 100644 --- a/gateway/gateway-controller/pkg/policyxds/event_channel_translator.go +++ b/gateway/gateway-controller/pkg/policyxds/event_channel_translator.go @@ -47,7 +47,7 @@ func (t *Translator) TranslateWebSubApisToEventChannelConfigs(configs []*models. continue } - resource, err := t.buildEventChannelResource(cfg.UUID, &webSubCfg) + resource, err := t.buildEventChannelResourceForWebSub(cfg.UUID, &webSubCfg) if err != nil { t.logger.Error("Failed to build EventChannelConfig resource", slog.String("uuid", cfg.UUID), @@ -65,7 +65,45 @@ func (t *Translator) TranslateWebSubApisToEventChannelConfigs(configs []*models. return resources } -func (t *Translator) buildEventChannelResource(uuid string, webSubCfg *api.WebSubAPI) (types.Resource, error) { +// TranslateWebBrokerApisToEventChannelConfigs translates WebBrokerApi StoredConfigs into +// EventChannelConfig xDS resources for the event gateway runtime. +func (t *Translator) TranslateWebBrokerApisToEventChannelConfigs(configs []*models.StoredConfig) map[string]types.Resource { + resources := make(map[string]types.Resource) + + for _, cfg := range configs { + if cfg.Kind != models.KindWebBrokerApi { + continue + } + if cfg.DesiredState != models.StateDeployed { + continue + } + + webBrokerCfg, ok := cfg.Configuration.(api.WebBrokerApi) + if !ok { + t.logger.Warn("Failed to type-assert WebBrokerApi configuration", + slog.String("uuid", cfg.UUID)) + continue + } + + resource, err := t.buildEventChannelResourceForWebBroker(cfg.UUID, &webBrokerCfg) + if err != nil { + t.logger.Error("Failed to build EventChannelConfig resource for WebBrokerApi", + slog.String("uuid", cfg.UUID), + slog.Any("error", err)) + continue + } + + resources[cfg.UUID] = resource + } + + t.logger.Info("Translated WebBrokerApis to EventChannelConfig resources", + slog.Int("input_configs", len(configs)), + slog.Int("output_resources", len(resources))) + + return resources +} + +func (t *Translator) buildEventChannelResourceForWebSub(uuid string, webSubCfg *api.WebSubAPI) (types.Resource, error) { spec := webSubCfg.Spec // Build channels list from channels, including per-channel policies. @@ -147,6 +185,113 @@ func (t *Translator) buildEventChannelResource(uuid string, webSubCfg *api.WebSu return toAnyResource(data, EventChannelConfigTypeURL) } +func (t *Translator) buildEventChannelResourceForWebBroker(uuid string, webBrokerCfg *api.WebBrokerApi) (types.Resource, error) { + spec := webBrokerCfg.Spec + + // Build receiver configuration + receiver := map[string]interface{}{ + "name": spec.Receiver.Name, + "type": spec.Receiver.Type, + } + if spec.Receiver.Properties != nil { + receiver["properties"] = *spec.Receiver.Properties + } + + // Build broker-driver configuration + brokerDriver := map[string]interface{}{ + "name": spec.Broker.Name, + "type": spec.Broker.Type, + "properties": spec.Broker.Properties, + } + slog.Info("DEBUG: Building EventChannelConfig for WebBrokerApi", + "name", webBrokerCfg.Metadata.Name, + "receiverName", spec.Receiver.Name, + "brokerDriverName", spec.Broker.Name, + "brokerDriverType", spec.Broker.Type) + + // Build API-level policies from AllChannels + var apiOnConnectionInit []interface{} + var apiOnProduce []interface{} + var apiOnConsume []interface{} + + if spec.AllChannels != nil { + if spec.AllChannels.OnConnectionInit != nil && spec.AllChannels.OnConnectionInit.Policies != nil { + apiOnConnectionInit = buildPolicyList(spec.AllChannels.OnConnectionInit.Policies) + } + if spec.AllChannels.OnProduce != nil && spec.AllChannels.OnProduce.Policies != nil { + apiOnProduce = buildPolicyList(spec.AllChannels.OnProduce.Policies) + } + if spec.AllChannels.OnConsume != nil && spec.AllChannels.OnConsume.Policies != nil { + apiOnConsume = buildPolicyList(spec.AllChannels.OnConsume.Policies) + } + } + + // Build channels map with channel-specific policies and topic mappings + channels := make(map[string]interface{}) + if spec.Channels != nil { + for channelName, channelConfig := range spec.Channels { + var channelOnConnectionInit []interface{} + var channelOnProduce []interface{} + var channelOnConsume []interface{} + + // Extract policies from channel-level policy groups + if channelConfig.OnConnectionInit != nil && channelConfig.OnConnectionInit.Policies != nil { + channelOnConnectionInit = buildPolicyList(channelConfig.OnConnectionInit.Policies) + } + if channelConfig.OnProduce != nil && channelConfig.OnProduce.Policies != nil { + channelOnProduce = buildPolicyList(channelConfig.OnProduce.Policies) + } + if channelConfig.OnConsume != nil && channelConfig.OnConsume.Policies != nil { + channelOnConsume = buildPolicyList(channelConfig.OnConsume.Policies) + } + + // Build channel entry with policies nested inside "policies" field + channelEntry := map[string]interface{}{} + + // Add produceTo topic mapping if specified + if channelConfig.ProduceTo != nil { + channelEntry["produce_to"] = map[string]interface{}{ + "topic": channelConfig.ProduceTo.Topic, + } + } + + // Add consumeFrom topic mapping if specified + if channelConfig.ConsumeFrom != nil { + channelEntry["consume_from"] = map[string]interface{}{ + "topic": channelConfig.ConsumeFrom.Topic, + } + } + + // Nest all policies inside a "policies" field (flattened structure) + channelEntry["policies"] = map[string]interface{}{ + "on_connection_init": channelOnConnectionInit, + "on_produce": channelOnProduce, + "on_consume": channelOnConsume, + } + + channels[channelName] = channelEntry + } + } + + data := map[string]interface{}{ + "uuid": uuid, + "name": string(webBrokerCfg.Metadata.Name), + "kind": "WebBrokerApi", + "context": spec.Context, + "version": spec.Version, + "receiver": receiver, + "broker-driver": brokerDriver, + "policies": map[string]interface{}{ + "on_connection_init": apiOnConnectionInit, + "on_produce": apiOnProduce, + "on_consume": apiOnConsume, + }, + "channels": channels, + } + + return toAnyResource(data, EventChannelConfigTypeURL) +} + func buildPolicyList(policies *[]api.Policy) []interface{} { if policies == nil || len(*policies) == 0 { return []interface{}{} diff --git a/gateway/gateway-controller/pkg/policyxds/snapshot.go b/gateway/gateway-controller/pkg/policyxds/snapshot.go index d4a39c8cd..b4d71a236 100644 --- a/gateway/gateway-controller/pkg/policyxds/snapshot.go +++ b/gateway/gateway-controller/pkg/policyxds/snapshot.go @@ -151,6 +151,14 @@ func (sm *SnapshotManager) UpdateSnapshot(ctx context.Context) error { if sm.configStore != nil { eventChannelResources := sm.translator.TranslateWebSubApisToEventChannelConfigs(sm.configStore.GetAllByKind("WebSubApi")) + // Also translate WebBrokerApi configs + webBrokerResources := sm.translator.TranslateWebBrokerApisToEventChannelConfigs(sm.configStore.GetAllByKind("WebBrokerApi")) + + // Merge both resource maps + for uuid, resource := range webBrokerResources { + eventChannelResources[uuid] = resource + } + // The go-control-plane LinearCache does not notify SotW wildcard watches // when resources are only deleted (non-full-state custom type URL). // Work around this by pushing a deletion marker via UpdateResource before diff --git a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql index e2bb79b68..bd0b91967 100644 --- a/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql +++ b/gateway/gateway-controller/pkg/storage/gateway-controller-db.sql @@ -44,6 +44,14 @@ CREATE TABLE IF NOT EXISTS websub_apis ( FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS webbroker_apis ( + uuid TEXT NOT NULL, + gateway_id TEXT NOT NULL, + configuration TEXT NOT NULL, + PRIMARY KEY (gateway_id, uuid), + FOREIGN KEY(gateway_id, uuid) REFERENCES artifacts(gateway_id, uuid) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS llm_providers ( uuid TEXT NOT NULL, gateway_id TEXT NOT NULL, diff --git a/gateway/gateway-controller/pkg/storage/sql_store.go b/gateway/gateway-controller/pkg/storage/sql_store.go index fbc0649cb..03597cf94 100644 --- a/gateway/gateway-controller/pkg/storage/sql_store.go +++ b/gateway/gateway-controller/pkg/storage/sql_store.go @@ -139,6 +139,8 @@ func kindToResourceTable(kind string) (string, error) { return "rest_apis", nil case "WebSubApi": return "websub_apis", nil + case "WebBrokerApi": + return "webbroker_apis", nil case "LlmProvider": return "llm_providers", nil case "LlmProxy": @@ -171,6 +173,13 @@ func unmarshalSourceConfig(cfg *models.StoredConfig, jsonData string) error { } cfg.SourceConfiguration = config cfg.Configuration = config + case "WebBrokerApi": + var config api.WebBrokerApi + if err := json.Unmarshal([]byte(jsonData), &config); err != nil { + return fmt.Errorf("failed to unmarshal configuration: %w", err) + } + cfg.SourceConfiguration = config + cfg.Configuration = config case "LlmProvider": var config api.LLMProviderConfiguration if err := json.Unmarshal([]byte(jsonData), &config); err != nil { diff --git a/gateway/gateway-controller/pkg/utils/api_deployment.go b/gateway/gateway-controller/pkg/utils/api_deployment.go index 1dc777d6f..cfc4c8af0 100644 --- a/gateway/gateway-controller/pkg/utils/api_deployment.go +++ b/gateway/gateway-controller/pkg/utils/api_deployment.go @@ -202,6 +202,15 @@ func (s *APIDeploymentService) DeployAPIConfiguration(params APIDeploymentParams kind = string(webSubConfig.Kind) parsedConfig = webSubConfig annotationArtifactID = annotationValue(webSubConfig.Metadata.Annotations, commonconstants.AnnotationArtifactID) + case "WebBrokerApi": + var webBrokerConfig api.WebBrokerApi + if err := s.parser.Parse(params.Data, params.ContentType, &webBrokerConfig); err != nil { + return nil, fmt.Errorf("failed to parse configuration: %w", err) + } + handle = webBrokerConfig.Metadata.Name + kind = string(webBrokerConfig.Kind) + parsedConfig = webBrokerConfig + annotationArtifactID = annotationValue(webBrokerConfig.Metadata.Annotations, commonconstants.AnnotationArtifactID) case "RestApi": var restConfig api.RestAPI if err := s.parser.Parse(params.Data, params.ContentType, &restConfig); err != nil { @@ -212,7 +221,7 @@ func (s *APIDeploymentService) DeployAPIConfiguration(params APIDeploymentParams parsedConfig = restConfig annotationArtifactID = annotationValue(restConfig.Metadata.Annotations, commonconstants.AnnotationArtifactID) default: - return nil, fmt.Errorf("unsupported resource kind %q: must be \"RestApi\" or \"WebSubApi\"", resolvedKind) + return nil, fmt.Errorf("unsupported resource kind %q: must be \"RestApi\", \"WebSubApi\", or \"WebBrokerApi\"", resolvedKind) } // Resolve API ID: explicit param > artifact-id annotation > auto-generate @@ -299,6 +308,15 @@ func (s *APIDeploymentService) DeployAPIConfiguration(params APIDeploymentParams s.logValidationErrors(params.Logger, apiID, apiName, validationErrors) return nil, &ValidationErrorListError{Errors: validationErrors} } + case api.WebBrokerApi: + apiName = c.Spec.DisplayName + apiVersion = c.Spec.Version + // TODO: Add validation for WebBrokerApi once validator supports it + // validationErrors := s.validator.Validate(&c) + // if len(validationErrors) > 0 { + // s.logValidationErrors(params.Logger, apiID, apiName, validationErrors) + // return nil, &ValidationErrorListError{Errors: validationErrors} + // } case api.RestAPI: apiName = c.Spec.DisplayName apiVersion = c.Spec.Version @@ -698,6 +716,33 @@ func resolveVhostSentinels(cfg *any, routerCfg *config.RouterConfig) error { } } *cfg = c + case api.WebBrokerApi: + if c.Spec.Vhosts == nil { + main := routerCfg.VHosts.Main.Default + c.Spec.Vhosts = &struct { + Main string `json:"main" yaml:"main"` + Sandbox *string `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + }{ + Main: main, + } + if sandboxDefault := routerCfg.VHosts.Sandbox.Default; sandboxDefault != "" { + c.Spec.Vhosts.Sandbox = &sandboxDefault + } + *cfg = c + return nil + } + if c.Spec.Vhosts.Main == constants.VHostGatewayDefault { + c.Spec.Vhosts.Main = routerCfg.VHosts.Main.Default + } + if c.Spec.Vhosts.Sandbox != nil && *c.Spec.Vhosts.Sandbox == constants.VHostGatewayDefault { + resolved := routerCfg.VHosts.Sandbox.Default + if resolved != "" { + c.Spec.Vhosts.Sandbox = &resolved + } else { + c.Spec.Vhosts.Sandbox = nil + } + } + *cfg = c } return nil } diff --git a/gateway/gateway-runtime/policy-engine/internal/executor/chain.go b/gateway/gateway-runtime/policy-engine/internal/executor/chain.go index 4649ce8c7..29f3c8a3d 100644 --- a/gateway/gateway-runtime/policy-engine/internal/executor/chain.go +++ b/gateway/gateway-runtime/policy-engine/internal/executor/chain.go @@ -22,11 +22,12 @@ import ( "context" "encoding/json" "fmt" - "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/utils" "log/slog" "strings" "time" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/utils" + "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/constants" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/metrics" "github.com/wso2/api-platform/gateway/gateway-runtime/policy-engine/internal/registry" @@ -213,8 +214,9 @@ type RequestPolicyResult struct { // RequestExecutionResult represents the result of executing all request policies in a chain type RequestExecutionResult struct { Results []RequestPolicyResult - ShortCircuited bool // true if chain stopped early due to ImmediateResponse - FinalAction policy.RequestAction // Final action to apply + ShortCircuited bool // true if chain stopped early due to ImmediateResponse + FinalAction policy.RequestAction // Final action to apply + Metadata map[string]interface{} // Metadata from SharedContext (for inter-policy communication) TotalExecutionTime time.Duration } @@ -361,6 +363,10 @@ func (c *ChainExecutor) ExecuteRequestPolicies(ctx context.Context, policyList [ } result.TotalExecutionTime = time.Since(startTime) + // Capture metadata from SharedContext for inter-policy communication + if reqCtx != nil && reqCtx.SharedContext != nil { + result.Metadata = reqCtx.SharedContext.Metadata + } return result, nil } diff --git a/gateway/gateway-runtime/policy-engine/pkg/engine/types.go b/gateway/gateway-runtime/policy-engine/pkg/engine/types.go index dd56efc17..471efd411 100644 --- a/gateway/gateway-runtime/policy-engine/pkg/engine/types.go +++ b/gateway/gateway-runtime/policy-engine/pkg/engine/types.go @@ -41,6 +41,7 @@ type RequestBodyResult struct { HeadersToSet map[string]string HeadersToRemove []string Body []byte + Topic string // Broker topic for publish (set by policies like map-topic) ShortCircuited bool ImmediateResponse *ImmediateResponseResult TotalDuration time.Duration @@ -134,6 +135,13 @@ func mapRequestBodyResult(r *executor.RequestExecutionResult) *RequestBodyResult TotalDuration: r.TotalExecutionTime, } + // Extract topic from metadata if set by policies (e.g., map-topic policy) + if r.Metadata != nil { + if topic, ok := r.Metadata["topic"].(string); ok && topic != "" { + res.Topic = topic + } + } + if r.FinalAction != nil { switch a := r.FinalAction.(type) { case policy.UpstreamRequestModifications: