diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..a1b829b8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+Copyright 2020 Fortune Ngwenya
+
+Licensed 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.
\ No newline at end of file
diff --git a/README.md b/README.md
index fed4b653..c6900bf6 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -12,9 +12,15 @@
-
-
-
+
+
+
+
+
+
+
+
+
@@ -41,18 +47,21 @@
| Protocol | Examples |
|----------|----------|
-| **Kafka** | Apache Kafka, Confluent, Redpanda, Azure Event Hubs, ... |
-| **AMQP 1.0** | RabbitMQ, ActiveMQ, Azure Service Bus, Qpid, ... |
-| **AMQP 0-9-1** | RabbitMQ, LavinMQ, ... |
-| **MQTT 3.1.1** | Mosquitto, EMQX, HiveMQ, ... |
-| **MQTT 5.0** | Mosquitto, EMQX, HiveMQ, Azure Event Grid, ... |
-| **HTTP** | Webhooks, REST APIs, Azure Event Grid, ... |
-| **STOMP** | ActiveMQ, ... |
-| **WebSocket** | WebSocket Applications/Servers, ... |
+| **Kafka** | Apache Kafka, Confluent Platform, Redpanda, Azure Event Hubs, AWS MSK, Aiven for Apache Kafka, Instaclustr, CloudKarafka |
+| **AMQP 1.0** | Apache ActiveMQ, Apache Artemis, Azure Service Bus, Azure Event Hubs, Apache Qpid, RabbitMQ, SwiftMQ, Solace PubSub+, IBM MQ |
+| **AMQP 0-9-1** | RabbitMQ, LavinMQ, Apache Qpid, CloudAMQP, Amazon MQ |
+| **MQTT 3.1.1** | Eclipse Mosquitto, EMQX, HiveMQ, VerneMQ, NanoMQ, RabbitMQ, AWS IoT Core, Azure IoT Hub |
+| **MQTT 5.0** | Eclipse Mosquitto, EMQX, HiveMQ, VerneMQ, NanoMQ, RabbitMQ, Azure Event Grid, AWS IoT Core |
+| **Redis** | Redis, Valkey, Dragonfly, KeyDB, AWS ElastiCache, Azure Cache for Redis, Upstash Redis, Google Cloud Memorystore (Pub/Sub & Streams) |
+| **NATS** | NATS Server, NATS JetStream, Synadia Cloud, NGS (NATS Global Service) |
+| **Pulsar** | Apache Pulsar, StreamNative Cloud, DataStax Astra Streaming, StreamNative Private Cloud, Clever Cloud Pulsar |
+| **HTTP** | Webhooks, REST APIs, Azure Event Grid, AWS EventBridge, Google Cloud Pub/Sub Push, Twilio, Slack, Discord, Custom HTTP Endpoints |
+| **STOMP** | Apache ActiveMQ, Apache Artemis, RabbitMQ, EMQX, HornetQ |
+| **WebSocket** | Custom WebSocket Servers, Socket.IO, SignalR, Ably, Pusher, WebSocket-based Chat Applications |
## Quick Start (5 minutes)
-### Step 1: Download or create docker-compose.yml
+### Step 1: Download or create docker-compose.yml
```yaml
services:
@@ -84,6 +93,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: amqp-0.9.1
kete.routes.quick-start.destination.host: rabbitmq
@@ -131,6 +141,17 @@ docker compose up -d
[Create new issue β](https://github.com/FortuneN/kete/issues/new)
+## Please consider supporting the project
+
+| Platform | Type | Link |
+|----------|------|------|
+| **GitHub (Stars)** | Free | [Give the project a star](https://github.com/FortuneN/kete) |
+| **GitHub (Sponsors)** | One-time / Recurring | [Sponsor on GitHub](https://github.com/sponsors/FortuneN) |
+| **PayPal** | One-time / Recurring | [Donate using PayPal](https://paypal.me/FortuneNgwenya) |
+| **Buy Me a Coffee** | One-time / Recurring | [Donate using Buy Me a Coffee](https://www.buymeacoffee.com/FortuneN) |
+| **Ko-fi** | One-time / Recurring | [Donate using Ko-fi](https://ko-fi.com/FortuneN) |
+| **Liberapay** | Recurring | [Donate using Liberapay](https://liberapay.com/FortuneN) |
+
## Credits
| Library | Description |
@@ -139,6 +160,7 @@ docker compose up -d
| [Lombok](https://projectlombok.org/) | Boilerplate reduction for Java |
| [Apache Commons](https://commons.apache.org/) | Configuration2, Lang3, Text, IO, Pool2 utilities |
| [Apache Kafka Client](https://kafka.apache.org/) | Kafka producer library |
+| [Apache Pulsar Client](https://pulsar.apache.org/) | Pulsar producer library |
| [RabbitMQ Client](https://www.rabbitmq.com/java-client.html) | AMQP 0-9-1 client |
| [Eclipse Paho](https://www.eclipse.org/paho/) | MQTT 3.1.1 and MQTT 5.0 clients |
| [Apache Qpid JMS](https://qpid.apache.org/components/jms/) | AMQP 1.0 JMS client |
@@ -146,6 +168,8 @@ docker compose up -d
| [Pooled JMS](https://github.com/messaginghub/pooled-jms) | JMS connection pooling |
| [Java-WebSocket](https://github.com/TooTallNate/Java-WebSocket) | WebSocket client library |
| [OkHttp](https://square.github.io/okhttp/) | HTTP client with TLS support |
+| [Lettuce](https://lettuce.io/) | Redis client for Pub/Sub and Streams |
+| [NATS Java Client](https://github.com/nats-io/nats.java) | NATS and JetStream messaging |
| [Nimbus OAuth SDK](https://connect2id.com/products/nimbus-oauth-openid-connect-sdk) | OAuth 2.0 client credentials |
| [Resilience4j](https://resilience4j.readme.io/) | Retry patterns with exponential backoff |
| [Jackson](https://github.com/FasterXML/jackson) | JSON, XML, YAML, CSV, CBOR, TOML, Smile, Properties |
@@ -158,14 +182,3 @@ docker compose up -d
| [Mockito](https://site.mockito.org/) | Mocking framework for tests |
| [AssertJ](https://assertj.github.io/doc/) | Fluent assertions for tests |
| [Testcontainers](https://testcontainers.com/) | Docker-based integration testing |
-
-## Please consider supporting the project
-
-| Platform | Type | Link |
-|----------|------|------|
-| **GitHub (Stars)** | Free | [Give the project a star](https://github.com/FortuneN/kete) |
-| **GitHub (Sponsors)** | One-time / Recurring | [Sponsor on GitHub](https://github.com/sponsors/FortuneN) |
-| **PayPal** | One-time / Recurring | [Donate using PayPal](https://paypal.me/FortuneNgwenya) |
-| **Buy Me a Coffee** | One-time / Recurring | [Donate using Buy Me a Coffee](https://www.buymeacoffee.com/FortuneN) |
-| **Ko-fi** | One-time / Recurring | [Donate using Ko-fi](https://ko-fi.com/FortuneN) |
-| **Liberapay** | Recurring | [Donate using Liberapay](https://liberapay.com/FortuneN) |
diff --git a/docs/developer-guide/future-enhancements.md b/docs/developer-guide/future-enhancements.md
index 34526d4b..51295dc1 100644
--- a/docs/developer-guide/future-enhancements.md
+++ b/docs/developer-guide/future-enhancements.md
@@ -198,73 +198,6 @@ kete.routes.tibco.destination.password=admin
These destinations use open standard protocols, providing wide broker compatibility with a single client libraryβsimilar to how KETE's `amqp-1` destination works with any AMQP 1.0 broker.
-### STOMP (`stomp`)
-
-Stream events to any STOMP-compatible message broker.
-
-**Priority:** π₯ High (unlocks ActiveMQ Classic and many enterprise systems)
-
-**Protocol:** STOMP 1.2 (Simple Text Oriented Messaging Protocol)
-
-**Compatible Systems:**
-
-| System | STOMP Support | Already Reachable Via KETE? |
-|--------|:-------------:|:---------------------------:|
-| **ActiveMQ Classic** | β
Port 61613 | β None (main gap!) |
-| **ActiveMQ Artemis** | β
Port 61613 | AMQP 1.0 |
-| **RabbitMQ** | β
Plugin | AMQP 0-9-1, AMQP 1.0 |
-| **Amazon MQ (ActiveMQ)** | β
Port 61614 | β None |
-| **Apache Apollo** | β
Native | β None |
-| **HornetQ** | β
Native | β None (legacy) |
-| **Spring WebSocket STOMP** | β
Native | β None |
-| **Solace PubSub+** | β
Native | AMQP 1.0 |
-| **TIBCO EMS** | β
Native | β None |
-| **OpenMQ** | β
Native | β None |
-| **SwiftMQ** | β
Native | β None |
-| **LavinMQ** | β
Native | AMQP 0-9-1 |
-| **WildFly (embedded)** | β
Native | β None |
-| **Payara (embedded)** | β
Native | β None |
-| **Kaazing Gateway** | β
WebSocket | β None |
-| **CoilMQ** | β
Native | β None |
-
-**Potential Configuration:**
-
-```properties
-kete.routes.stomp.destination.kind=stomp
-kete.routes.stomp.destination.host=activemq.example.com
-kete.routes.stomp.destination.port=61613
-kete.routes.stomp.destination.destination=/queue/keycloak-events
-kete.routes.stomp.destination.username=admin
-kete.routes.stomp.destination.password=admin
-```
-
-**Implementation Notes:**
-
-STOMP is a simple text protocol (like HTTP):
-
-```
-SEND
-destination:/queue/keycloak-events
-content-type:application/json
-
-{"type":"LOGIN","userId":"123"}
-^@
-```
-
-**Dependencies Required:**
-- Lightweight option: `io.github.stomp-js:stompjava` or custom TCP client
-- Full option: `org.springframework:spring-messaging` + WebSocket support
-
-**Why This Matters:**
-
-ActiveMQ Classic is still widely deployed in enterprises. It only speaks:
-- OpenWire (proprietary, no good standalone Java client)
-- STOMP β
-
-It does NOT natively support AMQP 1.0, AMQP 0-9-1, Kafka, or MQTT.
-
----
-
### SignalR (`signalr`)
Stream events to Microsoft SignalR hubs for real-time web applications.
@@ -431,7 +364,22 @@ kete.routes.sproc.destination.jdbc.procedure=CALL usp_ProcessKeycloakEvent(?, ?,
### Redis Pub/Sub (`redis-pubsub`)
-Publish events to Redis channels.
+Publish events to Redis channels for real-time messaging.
+
+**Priority:** π₯ High (wide adoption, simple implementation)
+
+**Compatible Systems:**
+
+| System | Notes |
+|--------|-------|
+| **Redis** | Self-hosted, open-source |
+| **Redis Cloud** | Managed Redis by Redis Inc. |
+| **Amazon ElastiCache** | AWS managed Redis |
+| **Azure Cache for Redis** | Azure managed Redis |
+| **Google Memorystore** | GCP managed Redis |
+| **Upstash** | Serverless Redis |
+| **KeyDB** | Redis-compatible, multi-threaded |
+| **Dragonfly** | Redis-compatible, high-performance |
**Potential Configuration:**
@@ -452,6 +400,10 @@ kete.routes.redis.destination.password=secret
Append events to Redis Streams for persistent, ordered messaging.
+**Priority:** π₯ High (persistent messaging with consumer groups)
+
+**Compatible Systems:** Same as Redis Pub/Sub above.
+
**Potential Configuration:**
```properties
@@ -466,7 +418,18 @@ kete.routes.redis.destination.max-len=100000 # Trim to max entries
### NATS (`nats`)
-Publish events to NATS subjects.
+Publish events to NATS subjects for lightweight messaging.
+
+**Priority:** π₯ Medium (simple, fast, growing adoption)
+
+**Compatible Systems:**
+
+| System | Notes |
+|--------|-------|
+| **NATS Server** | Open-source, lightweight |
+| **NATS JetStream** | Persistent streaming layer |
+| **Synadia Cloud** | Managed NATS |
+| **Synadia NGS** | Global NATS service |
**Potential Configuration:**
@@ -485,7 +448,9 @@ kete.routes.nats.destination.password=secret
### NATS JetStream (`nats-jetstream`)
-Publish events to NATS JetStream for persistent messaging.
+Publish events to NATS JetStream for persistent messaging with replay and consumer groups.
+
+**Priority:** π₯ Medium (persistent layer for NATS)
**Potential Configuration:**
@@ -496,12 +461,25 @@ kete.routes.nats.destination.stream=KEYCLOAK
kete.routes.nats.destination.subject=keycloak.events
```
+**Dependencies Required:**
+- `io.nats:jnats`
+
---
### Apache Pulsar Native (`pulsar-native`)
Publish events to Apache Pulsar topics using the native Pulsar client (alternative to JMS wrapper).
+**Priority:** π₯ Low (JMS wrapper preferred, see `pulsar-jms` above)
+
+**Compatible Systems:**
+
+| System | Notes |
+|--------|-------|
+| **Apache Pulsar** | Self-hosted |
+| **StreamNative Cloud** | Managed Pulsar |
+| **DataStax Astra Streaming** | Managed Pulsar |
+
**Potential Configuration:**
```properties
@@ -564,6 +542,136 @@ kete.routes.proto.serializer.schema-file=/path/to/event.proto
---
+## Logging & Observability Destinations
+
+### Syslog (`syslog`)
+
+Stream events to Syslog-compatible log aggregators using RFC 5424.
+
+**Priority:** π₯ High (wide enterprise adoption, low implementation effort)
+
+**Protocol:** Syslog RFC 5424 over UDP/TCP/TLS
+
+**Compatible Systems:**
+
+| System | Notes |
+|--------|-------|
+| **rsyslog** | Linux default, high-performance |
+| **syslog-ng** | Enterprise syslog with advanced routing |
+| **Graylog** | Log management with syslog input |
+| **Splunk** | Via syslog input or HEC |
+| **Elastic/Logstash** | Via syslog input plugin |
+| **Papertrail** | Managed cloud logging |
+| **Datadog** | Log ingestion via syslog |
+| **Sumo Logic** | Cloud SIEM with syslog |
+| **Loggly** | Cloud log management |
+| **Fluentd/Fluent Bit** | Via syslog input |
+
+**Potential Configuration:**
+
+```properties
+kete.routes.syslog.destination.kind=syslog
+kete.routes.syslog.destination.host=syslog.example.com
+kete.routes.syslog.destination.port=514
+kete.routes.syslog.destination.protocol=udp # udp, tcp, or tls
+kete.routes.syslog.destination.facility=local0
+kete.routes.syslog.destination.severity=info
+kete.routes.syslog.destination.app-name=keycloak
+```
+
+**Dependencies Required:**
+- `com.cloudbees:syslog-java-client` or custom RFC 5424 implementation
+
+**Why This Matters:**
+- Nearly universal enterprise adoption
+- Simple protocol (text-based, like STOMP)
+- Integrates with existing log infrastructure
+- Low implementation effort
+
+---
+
+## Real-Time Streaming Destinations
+
+### Server-Sent Events (`sse`)
+
+Stream events to clients via HTTP Server-Sent Events.
+
+**Priority:** π₯ Medium (browser-friendly, simple implementation)
+
+**Protocol:** SSE (HTTP streaming, text/event-stream)
+
+**Compatible Systems:**
+
+| System | Notes |
+|--------|-------|
+| **Browsers** | Native EventSource API |
+| **curl** | `curl -N` for streaming |
+| **Real-time dashboards** | React, Vue, Angular apps |
+| **API Gateways** | Kong, AWS API Gateway |
+| **Mobile apps** | iOS/Android SSE clients |
+
+**Potential Configuration:**
+
+```properties
+kete.routes.sse.destination.kind=sse
+kete.routes.sse.destination.url=http://dashboard.example.com/events
+kete.routes.sse.destination.event-type=keycloak-event
+kete.routes.sse.destination.retry=3000
+```
+
+**Implementation Notes:**
+- KETE acts as SSE publisher to an endpoint
+- Can be used with an SSE relay/fanout server
+- Simpler than WebSocket (unidirectional)
+
+**Use Cases:**
+- Live admin dashboards
+- Real-time audit displays
+- Browser-based monitoring
+
+---
+
+### gRPC Streaming (`grpc`)
+
+Stream events to gRPC servers using bidirectional or server streaming.
+
+**Priority:** π₯ Low (high effort, niche use case)
+
+**Protocol:** gRPC over HTTP/2
+
+**Compatible Systems:**
+
+| System | Notes |
+|--------|-------|
+| **Custom gRPC servers** | Any language with gRPC support |
+| **Envoy Proxy** | gRPC routing and load balancing |
+| **Google Cloud Run** | Serverless gRPC |
+| **Kubernetes services** | Native gRPC support |
+| **Istio** | Service mesh with gRPC |
+
+**Potential Configuration:**
+
+```properties
+kete.routes.grpc.destination.kind=grpc
+kete.routes.grpc.destination.target=grpc-server.example.com:9090
+kete.routes.grpc.destination.service=keycloak.EventService
+kete.routes.grpc.destination.method=StreamEvents
+kete.routes.grpc.destination.tls.enabled=true
+```
+
+**Dependencies Required:**
+- `io.grpc:grpc-netty-shaded`
+- `io.grpc:grpc-protobuf`
+- `io.grpc:grpc-stub`
+- Protobuf schema for events
+
+**Implementation Notes:**
+- Requires defining a `.proto` schema for Keycloak events
+- Higher complexity than HTTP/WebSocket
+- Best for microservices architectures already using gRPC
+
+---
+
## Notes
These enhancements are ideas for future development based on common use cases. Implementation priority depends on:
diff --git a/docs/developer-guide/integration-tests.md b/docs/developer-guide/integration-tests.md
index ebbbdf36..e4be1b23 100644
--- a/docs/developer-guide/integration-tests.md
+++ b/docs/developer-guide/integration-tests.md
@@ -7,6 +7,8 @@
- [Debugging Integration Tests](#debugging-integration-tests)
- [Test Categories](#test-categories)
- [Writing New Integration Tests](#writing-new-integration-tests)
+- [Container File Mounting Best Practices](#container-file-mounting-best-practices)
+- [Troubleshooting](#troubleshooting)
@@ -158,6 +160,22 @@ class MyIntegrationTests {
}
```
+### Mounting Configuration Files
+
+**CRITICAL**: When tests require custom configuration files for containers (broker configs, certificates, etc.), **ALWAYS use in-memory `Transferable.of()` with 0777 permissions**. NEVER use `withFileSystemBind()` or `withCopyToContainer()` without permissions.
+
+```java
+// Step 1: Read file content into memory
+var brokerXmlBytes = Files.readAllBytes(Path.of(brokerXmlPath));
+
+// Step 2: Copy to container memory with full permissions
+@Container
+static GenericContainer> broker = new GenericContainer<>(imageName)
+ .withCopyToContainer(Transferable.of(brokerXmlBytes, 0777), "/etc/broker.xml");
+```
+
+See [Container File Mounting Best Practices](#container-file-mounting-best-practices) for complete details.
+
### Key Configuration Environment Variables
| Variable | Description | Example |
@@ -188,6 +206,161 @@ static RabbitMQContainer rabbitmq = new RabbitMQContainer(DockerImageName.parse(
+## Container File Mounting Best Practices
+
+### Why In-Memory Transfer with Full Permissions?
+
+**ALWAYS use `withCopyToContainer(Transferable.of(bytes, 0777))` for mounting files into containers. NEVER use `withFileSystemBind()`.**
+
+| Aspect | In-Memory Transfer (`Transferable.of()`) | File System Bind (`withFileSystemBind`) |
+|--------|------------------------------------------|----------------------------------------|
+| **GitHub Actions** | β
Works reliably in CI/CD | β Fails due to filesystem limitations |
+| **Permissions** | Full control with 0777 parameter | Unpredictable based on host OS |
+| **Cross-platform** | Identical behavior on all OS | Different behavior Windows/Linux/macOS |
+| **Performance** | Fast in-memory copy | Filesystem mount overhead |
+| **Best Practice** | **REQUIRED in this codebase** | **FORBIDDEN** |
+
+### Standard In-Memory Transfer Pattern
+
+Use this pattern consistently across all TestBase classes:
+
+```java
+// Step 1: Read file content into byte array
+var configBytes = Files.readAllBytes(Path.of(sourceConfigPath));
+var keystoreBytes = Files.readAllBytes(Path.of(sourceKeystorePath));
+var truststoreBytes = Files.readAllBytes(Path.of(sourceTruststorePath));
+
+// Step 2: Copy to container memory with 0777 permissions
+container = new GenericContainer<>(imageName)
+ .withCopyToContainer(Transferable.of(configBytes, 0777), "/container/path/config.xml")
+ .withCopyToContainer(Transferable.of(keystoreBytes, 0777), "/container/path/keystore.jks")
+ .withCopyToContainer(Transferable.of(truststoreBytes, 0777), "/container/path/truststore.jks");
+```
+
+### Complete Example: AMQP1 with TLS
+
+From `io.github.fortunen.kete.integrationtests.amqp1destination.TestBase`:
+
+```java
+private void startActiveMqArtemisWithTls(TlsMaterial tls, boolean requireClientAuth) throws Exception {
+
+ // Create broker configuration XML as string
+ var brokerXml = createArtemisBrokerXml(
+ tls.getKeyStorePassword(),
+ tls.getTrustStorePassword(),
+ requireClientAuth
+ );
+
+ // Read certificate files into memory
+ var keystoreBytes = Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath()));
+ var truststoreBytes = Files.readAllBytes(Path.of(tls.getTrustStoreFilePath()));
+
+ // Copy all files to container memory with 0777 permissions
+ container = new GenericContainer<>(DockerImageName.parse("apache/activemq-artemis:2.40.0-alpine"))
+ .withEnv("ARTEMIS_USER", DEFAULT_USERNAME)
+ .withEnv("ARTEMIS_PASSWORD", DEFAULT_PASSWORD)
+ .withEnv("ANONYMOUS_LOGIN", "true")
+ .withCopyToContainer(
+ Transferable.of(brokerXml.getBytes(StandardCharsets.UTF_8), 0777),
+ "/var/lib/artemis-instance/etc-override/broker.xml")
+ .withCopyToContainer(
+ Transferable.of(keystoreBytes, 0777),
+ "/var/lib/artemis-instance/etc-override/keystore.jks")
+ .withCopyToContainer(
+ Transferable.of(truststoreBytes, 0777),
+ "/var/lib/artemis-instance/etc-override/truststore.jks")
+ .withExposedPorts(AMQP_PORT, AMQPS_PORT, 8161)
+ .waitingFor(Wait.forLogMessage(".*AMQ221007.*", 1))
+ .withStartupTimeout(Duration.ofMinutes(10));
+
+ container.start();
+}
+```
+
+### Inline Content Pattern
+
+For configuration generated as strings (XML, YAML, TOML, properties):
+
+```java
+// Create config content as string
+var mosquittoConf = """
+ listener 1883
+ allow_anonymous true
+ """;
+
+mosquitto = new GenericContainer<>(DockerImageName.parse("eclipse-mosquitto:2.0"))
+ .withNetwork(createNetwork())
+ .withNetworkAliases("mosquitto")
+ .withExposedPorts(MQTT_PORT)
+ .withCommand("mosquitto", "-c", "/mosquitto-no-auth.conf")
+ .withCopyToContainer(
+ Transferable.of(mosquittoConf.getBytes(StandardCharsets.UTF_8), 0777),
+ "/mosquitto-no-auth.conf");
+```
+
+### Key Implementation Details
+
+1. **Always specify 0777 permissions**: `Transferable.of(content, 0777)` ensures maximum compatibility
+2. **Read files into memory**: Use `Files.readAllBytes(Path.of(path))` for binary files
+3. **Convert strings to bytes**: Use `.getBytes(StandardCharsets.UTF_8)` for text content
+4. **Import Transferable**: `import org.testcontainers.utility.MountableFile.Transferable;`
+5. **No cleanup needed**: In-memory content is garbage collected automatically
+
+### Pattern Variations by Use Case
+
+#### Multiple Configuration Files (STOMP, AMQP1, WebSocket)
+```java
+var activeMqXml = createActiveMqConfig();
+var keyStoreBytes = Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath()));
+var trustStoreBytes = Files.readAllBytes(Path.of(tls.getTrustStoreFilePath()));
+
+container
+ .withCopyToContainer(Transferable.of(activeMqXml.getBytes(UTF_8), 0777), "/conf/activemq.xml")
+ .withCopyToContainer(Transferable.of(keyStoreBytes, 0777), "/conf/keystore.jks")
+ .withCopyToContainer(Transferable.of(trustStoreBytes, 0777), "/conf/truststore.jks");
+```
+
+#### Single Config File (MQTT E2E)
+```java
+var mosquittoConf = "listener 1883\nallow_anonymous true\n";
+
+container.withCopyToContainer(
+ Transferable.of(mosquittoConf.getBytes(UTF_8), 0777),
+ "/mosquitto-no-auth.conf");
+```
+
+#### TLS Certificates (Pulsar, NATS, Redis)
+```java
+var certBytes = Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath()));
+var keyBytes = Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath()));
+var caBytes = Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath()));
+
+container
+ .withCopyToContainer(Transferable.of(certBytes, 0777), "/certs/server.crt")
+ .withCopyToContainer(Transferable.of(keyBytes, 0777), "/certs/server.key")
+ .withCopyToContainer(Transferable.of(caBytes, 0777), "/certs/ca.crt");
+```
+
+### Common Pitfalls to Avoid
+
+| Don't | β
Do |
+|--------|-------|
+| `withFileSystemBind(hostPath, "/path", BindMode.READ_ONLY)` | `withCopyToContainer(Transferable.of(bytes, 0777), "/path")` |
+| `Transferable.of(content)` without permissions | `Transferable.of(content, 0777)` |
+| `withCopyToContainer(Transferable.of(bytes), "/path")` | `withCopyToContainer(Transferable.of(bytes, 0777), "/path")` |
+| Create temp files on disk | Read directly into memory with `Files.readAllBytes()` |
+| Use `BindMode.READ_ONLY` | Always use 0777 permissions for maximum compatibility |
+
+### Why This Matters
+
+- **GitHub Actions Compatibility**: Eliminates filesystem mounting issues in CI/CD
+- **Cross-platform**: Identical behavior on Windows, Linux, macOS
+- **Permissions**: 0777 ensures containers can read/write/execute without issues
+- **Simplicity**: No temp file cleanup needed, automatic garbage collection
+- **Reliability**: Consistent pattern across all test classes reduces bugs
+
+
+
## Troubleshooting
### Container Fails to Start
@@ -196,7 +369,7 @@ static RabbitMQContainer rabbitmq = new RabbitMQContainer(DockerImageName.parse(
- Check for port conflicts
- Increase container startup timeout:
```java
- keycloak.withStartupTimeout(Duration.ofMinutes(5));
+ keycloak.withStartupTimeout(Duration.ofMinutes(10));
```
### Provider Not Loading
@@ -214,8 +387,16 @@ static RabbitMQContainer rabbitmq = new RabbitMQContainer(DockerImageName.parse(
- Check destination container is reachable (network aliases)
- Enable debug logging in the provider
+### File Mounting Issues
+
+- **File not accessible in container**: Ensure using `Transferable.of(bytes, 0777)` with full permissions
+- **Permission denied**: Always use 0777 permissions parameter
+- **GitHub Actions failures**: Never use `withFileSystemBind()`, always use `Transferable.of()`
+- **Missing permissions parameter**: Verify all `Transferable.of()` calls include `0777` as second parameter
+
## Related Documentation
- [Testing Reference](testing.md)
+- [Test Patterns and Conventions](test-patterns-and-conventions.md)
diff --git a/docs/developer-guide/quickstart-testing.md b/docs/developer-guide/quickstart-testing.md
new file mode 100644
index 00000000..ec04da6f
--- /dev/null
+++ b/docs/developer-guide/quickstart-testing.md
@@ -0,0 +1,279 @@
+# Quickstart Testing Guide
+
+This document provides guidance for testing KETE quickstarts to verify event flow.
+
+## Testing Approach
+
+Each quickstart can be tested manually using the following general pattern:
+
+### 1. Start the Quickstart
+
+```bash
+cd quick-starts/
+docker compose up -d
+```
+
+### 2. Wait for Services
+
+Wait for all containers to be healthy:
+
+```bash
+docker compose ps
+```
+
+Most quickstarts use Keycloak on port 8080. Some use 8180 or other ports.
+
+### 3. Trigger an Event
+
+Login to Keycloak using the admin CLI:
+
+```bash
+curl -X POST 'http://localhost:8080/realms/master/protocol/openid-connect/token' \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ -d 'client_id=admin-cli' \
+ -d 'username=admin' \
+ -d 'password=admin' \
+ -d 'grant_type=password'
+```
+
+Replace `8080` with the appropriate port for the quickstart.
+
+### 4. Verify Event Delivery
+
+#### For Message Brokers (Kafka, MQTT, AMQP, etc.)
+
+Each quickstart README contains broker-specific commands to consume messages. Examples:
+
+**Kafka:**
+```bash
+docker exec kafka-console-consumer \
+ --bootstrap-server localhost:9092 \
+ --topic keycloak-events \
+ --from-beginning
+```
+
+**MQTT:**
+```bash
+docker exec mosquitto_sub \
+ -h localhost \
+ -t keycloak-events \
+ -v
+```
+
+**AMQP (RabbitMQ):**
+```bash
+docker exec rabbitmqadmin get queue=keycloak-events
+```
+
+#### For HTTP/WebSocket
+
+Check the target service logs for incoming requests:
+
+```bash
+docker compose logs
+```
+
+#### For Redis
+
+**Redis Pub/Sub:**
+```bash
+docker exec redis-cli SUBSCRIBE keycloak-events
+```
+
+**Redis Streams:**
+```bash
+docker exec redis-cli XREAD STREAMS keycloak-events 0
+```
+
+### 5. Check KETE Logs
+
+Always verify KETE is initialized correctly:
+
+```bash
+docker compose logs keycloak | grep kete
+```
+
+Look for:
+- `kete (x.x.x) initializing`
+- `kete Route 'quick-start' initialized`
+- `kete initialized`
+
+If events aren't flowing, check for errors in Keycloak logs:
+
+```bash
+docker compose logs keycloak --tail=100
+```
+
+### 6. Cleanup
+
+```bash
+docker compose down -v
+```
+
+## Known Issues
+
+### Pulsar
+
+β **Pulsar quickstart is not yet available**
+
+The Pulsar destination exists in the code but is not included in the `quick-start-keycloak` Docker image yet. It will be added in a future release.
+
+### Quickstart Port Conflicts
+
+If you're running multiple quickstarts simultaneously, you may encounter port conflicts. Make sure to stop one quickstart before starting another, or modify the port mappings in `docker-compose.yml`.
+
+## Quickstart Inventory
+
+Total quickstarts: 42
+
+### AMQP 0.9.1 (2)
+- amqp-0.9.1-lavinmq
+- amqp-0.9.1-rabbitmq
+
+### AMQP 1.0 (5)
+- amqp-1-activemq
+- amqp-1-azure-event-hubs
+- amqp-1-azure-service-bus
+- amqp-1-qpid
+- amqp-1-rabbitmq
+
+### HTTP (2)
+- http-azure-event-grid
+- http-webhook
+
+### Kafka (5)
+- kafka-apache
+- kafka-azure-event-hubs
+- kafka-azure-event-hubs-emulator
+- kafka-confluent
+- kafka-redpanda
+
+### MQTT 3 (5)
+- mqtt-3-emqx
+- mqtt-3-hivemq
+- mqtt-3-mosquitto
+- mqtt-3-rabbitmq
+- mqtt-3-vernemq
+
+### MQTT 5 (6)
+- mqtt-5-azure-event-grid
+- mqtt-5-emqx
+- mqtt-5-hivemq
+- mqtt-5-mosquitto
+- mqtt-5-rabbitmq
+- mqtt-5-vernemq
+
+### NATS (2)
+- nats-jetstream-nats-server
+- nats-nats-server
+
+### Redis (10)
+- redis-pubsub-azure-cache-for-redis
+- redis-pubsub-dragonfly
+- redis-pubsub-keydb
+- redis-pubsub-redis
+- redis-pubsub-upstash
+- redis-streams-azure-cache-for-redis
+- redis-streams-dragonfly
+- redis-streams-keydb
+- redis-streams-redis
+- redis-streams-upstash
+
+### STOMP (3)
+- stomp-activemq
+- stomp-artemis
+- stomp-rabbitmq
+
+### WebSocket (1)
+- websocket-echo
+
+### Utility (2)
+- quick-start-curl
+- quick-start-keycloak
+
+## Automated Testing
+
+The `test-all-quickstarts.ps1` script provides basic automated testing functionality. However, manual verification of event delivery is recommended for thorough testing.
+
+### Usage
+
+Test a single quickstart:
+
+```powershell
+.\test-all-quickstarts.ps1 -QuickStart mqtt-3-mosquitto
+```
+
+Test all quickstarts:
+
+```powershell
+.\test-all-quickstarts.ps1
+```
+
+**Note:** The automated script has limitations:
+- Does not verify actual message delivery to destinations
+- Only checks that Keycloak starts and can be accessed
+- May have false negatives if services take longer to initialize
+
+## Manual Test Checklist
+
+For each quickstart:
+
+- [ ] Services start without errors
+- [ ] Services become healthy (where health checks exist)
+- [ ] Keycloak web UI accessible
+- [ ] KETE initializes correctly (check logs)
+- [ ] Route configuration loads successfully
+- [ ] Destination connection succeeds
+- [ ] Login event triggers successfully
+- [ ] Event appears in destination
+- [ ] Event structure is valid JSON (or configured format)
+- [ ] Event contains expected fields (id, type, realm, etc.)
+- [ ] Services shut down cleanly
+
+## Troubleshooting
+
+### Common Issues
+
+| Issue | Solution |
+|-------|----------|
+| Port already in use | Stop conflicting container or change port in docker-compose.yml |
+| Keycloak fails to start | Check logs: `docker compose logs keycloak` |
+| KETE not loading | Verify `ghcr.io/fortunen/kete/quick-start-keycloak` image version |
+| Events not appearing | Check destination connection settings and credentials |
+| Health check failing | Wait longer or check service-specific requirements |
+
+### Debug Commands
+
+```bash
+# Check all container status
+docker compose ps
+
+# View logs for specific service
+docker compose logs --tail=100 --follow
+
+# Check network connectivity
+docker compose exec keycloak ping
+
+# Verify destination is listening
+docker compose exec netstat -ln | grep
+
+# Restart specific service
+docker compose restart
+```
+
+## Contributing
+
+When adding a new quickstart:
+
+1. Create folder in `quick-starts/` following naming convention
+2. Add `docker-compose.yml` with all required services
+3. Add `README.md` with specific testing instructions
+4. Test manually following this guide
+5. Update this document with new quickstart entry
+6. Update `docs/user-guide/destinations/.md` with quickstart link
+
+## See Also
+
+- [Architecture Documentation](architecture.md)
+- [Integration Tests](integration-tests.md)
+- [User Guide](../user-guide/overview.md)
diff --git a/docs/user-guide/destinations/amqp-0.9.1.md b/docs/user-guide/destinations/amqp-0.9.1.md
index 410cfddf..5d0da87b 100644
--- a/docs/user-guide/destinations/amqp-0.9.1.md
+++ b/docs/user-guide/destinations/amqp-0.9.1.md
@@ -63,6 +63,20 @@ This destination uses AMQP 0.9.1 (RabbitMQ's native protocol). For AMQP 1 broker
+## Features
+
+- Full AMQP 0.9.1 protocol support
+- Exchange and routing key configuration
+- TLS/SSL support with mutual TLS (mTLS)
+- Persistent and non-persistent delivery modes
+- Message priority and TTL configuration
+- Automatic reconnection and topology recovery
+- Custom headers on messages
+- Virtual host support
+- Dynamic exchange and routing key (templating)
+
+
+
## Configuration Properties
### Required Properties
@@ -103,19 +117,30 @@ kete.routes.main-rabbitmq.destination.routing-key=events
| `routing-key` | Routing key (supports templating) | `""` | `${eventTypeLowerCase}` |
| `priority` | Message priority (0-9) | `4` | `7` |
| `delivery-mode` | Message durability: `persistent` or `non-persistent` | `persistent` | `persistent` |
-| `time-to-live` | Message TTL in milliseconds (0 = never expires) | `0` | `60000` |
-| `connection-timeout` | Connection timeout (ms) | `10000` | `30000` |
-| `handshake-timeout` | Handshake timeout (ms) | `10000` | `5000` |
-| `channel-rpc-timeout` | Channel RPC timeout (ms) | `10000` | `15000` |
-| `requested-heartbeat` | Heartbeat interval (seconds) | `30` | `60` |
+| `time-to-live-seconds` | Message TTL in seconds (0 = never expires) | `0` | `60` |
+| `connection-timeout-seconds` | Connection timeout in seconds | `10` | `30` |
+| `handshake-timeout-seconds` | Handshake timeout in seconds | `10` | `5` |
+| `channel-rpc-timeout-seconds` | Channel RPC timeout in seconds | `10` | `15` |
+| `requested-heartbeat-seconds` | Heartbeat interval in seconds | `30` | `60` |
| `automatic-recovery-enabled` | Enable automatic connection recovery | `true` | `false` |
-| `network-recovery-interval` | Network recovery interval (ms) | `5000` | `10000` |
+| `network-recovery-interval-seconds` | Network recovery interval in seconds | `5` | `10` |
| `topology-recovery-enabled` | Enable topology recovery | Same as `automatic-recovery-enabled` | `true` |
-| `message-headers-enabled` | Include event metadata as headers | `true` | `false` |
-| `min-pool-size` | Minimum connections in pool | `5` | `10` |
-| `max-pool-size` | Maximum connections in pool | `20` | `50` |
+| `pool.min-idle` | `1` | Minimum idle connections in pool | `5` |
+| `pool.max-idle` | `10` | Maximum idle connections in pool | `20` |
+| `pool.max-total` | `20` | Maximum total connections in pool | `50` |
| `tls.*` | TLS/SSL configuration | - | See [TLS & mTLS](overview.md#tls-mtls) |
+### Custom Headers
+
+Custom headers can be added to AMQP messages:
+
+```bash
+kete.routes.rabbitmq.destination.headers.X-Source=keycloak
+kete.routes.rabbitmq.destination.headers.X-Environment=production
+```
+
+Headers are included in the AMQP message properties.
+
### Dynamic Exchange/Routing Key (Templating)
The `exchange` and `routing-key` properties support template variables:
diff --git a/docs/user-guide/destinations/amqp-1.md b/docs/user-guide/destinations/amqp-1.md
index 0f4276cd..2ba6dbbd 100644
--- a/docs/user-guide/destinations/amqp-1.md
+++ b/docs/user-guide/destinations/amqp-1.md
@@ -14,12 +14,14 @@ Stream Keycloak events to AMQP 1 brokers.
| System | Notes |
|--------|-------|
| **Apache ActiveMQ Artemis** | Primary target, full JMS 2.0 support |
-| **Azure Service Bus** | Auto-enables TLS when hostname contains `servicebus` |
+| **RabbitMQ 4.0+** | Native AMQP 1.0 support (no plugin required) |
+| **Azure Service Bus** | Requires TLS (`tls.enabled=true`, port 5671) |
| **Azure Event Hubs** | Via AMQP 1.0 |
| **Apache Qpid** | Full AMQP 1.0 support |
| **Amazon MQ for ActiveMQ** | Classic and Artemis flavors |
+| **Solace PubSub+** | Native AMQP 1.0 support |
-This destination uses AMQP 1.0 (OASIS standard). For RabbitMQ or LavinMQ, see the [AMQP 0-9-1 destination](amqp-0.9.1.md) (`kind=amqp-0.9.1`).
+This destination uses AMQP 1.0 (OASIS standard). For RabbitMQ 3.x or LavinMQ, see the [AMQP 0-9-1 destination](amqp-0.9.1.md) (`kind=amqp-0.9.1`).
@@ -41,8 +43,8 @@ This destination uses AMQP 1.0 (OASIS standard). For RabbitMQ or LavinMQ, see th
=== "Azure Service Bus"
```bash
- # TLS auto-enabled when hostname contains 'servicebus'
kete.routes.asb.destination.kind=amqp-1
+ kete.routes.asb.destination.tls.enabled=true
kete.routes.asb.destination.host=your-namespace.servicebus.windows.net
kete.routes.asb.destination.port=5671
kete.routes.asb.destination.username=your-policy-name
@@ -110,15 +112,26 @@ Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLower
| `password` | `""` | AMQP password | `secret123` |
| `delivery-mode` | `persistent` | Message durability: `persistent` or `non-persistent` | `persistent` |
| `priority` | `4` | Message priority (0-9) | `7` |
-| `time-to-live` | `0` | Message TTL in milliseconds (0 = never expires) | `60000` |
-| `idle-timeout` | `60000` | Connection idle timeout in milliseconds for keep-alive (0 = disabled) | `30000` |
-| `message-headers-enabled` | `true` | Include event metadata as JMS properties | `false` |
-| `min-pool-size` | `5` | Minimum connections in pool | `10` |
-| `max-pool-size` | `20` | Maximum connections in pool | `50` |
+| `time-to-live-seconds` | `0` | Message TTL in seconds (0 = never expires) | `60` |
+| `idle-timeout-seconds` | `60` | Connection idle timeout in seconds for keep-alive (0 = disabled) | `30` |
+| `pool.min-idle` | `1` | Minimum idle connections in pool | `5` |
+| `pool.max-idle` | `10` | Maximum idle connections in pool | `20` |
+| `pool.max-total` | `20` | Maximum total connections in pool | `50` |
| `tls.*` | - | TLS/SSL configuration | See [TLS & mTLS](overview.md#tls-mtls) |
**Note**: Retry configuration (`retry.enabled`, `retry.max-attempts`, `retry.wait-duration`) is configured at the route level. See [Routes - Retry](../routes.md#retry) for details.
+### Custom Headers
+
+Custom headers can be added to AMQP 1.0 messages:
+
+```bash
+kete.routes.amqp.destination.headers.X-Source=keycloak
+kete.routes.amqp.destination.headers.X-Environment=production
+```
+
+Headers are included in the JMS message properties.
+
## Delivery Modes
@@ -237,7 +250,7 @@ kete.routes.priority-events.destination.port=5672
kete.routes.priority-events.destination.destination-name=keycloak.high-priority
kete.routes.priority-events.destination.delivery-mode=persistent
kete.routes.priority-events.destination.priority=9
-kete.routes.priority-events.destination.time-to-live=300000
+kete.routes.priority-events.destination.time-to-live-seconds=300
```
### Example 5: Non-Persistent High-Throughput
@@ -281,7 +294,7 @@ kete.routes.amqp.destination.delivery-mode=non-persistent
```bash
# High priority, expires in 5 minutes
kete.routes.amqp.destination.priority=9
-kete.routes.amqp.destination.time-to-live=300000
+kete.routes.amqp.destination.time-to-live-seconds=300
```
diff --git a/docs/user-guide/destinations/http.md b/docs/user-guide/destinations/http.md
index f94e6b70..57a7f2db 100644
--- a/docs/user-guide/destinations/http.md
+++ b/docs/user-guide/destinations/http.md
@@ -126,9 +126,9 @@ Instead of `url`, you can configure each component separately:
| `destination.path-and-query` | `/` | URL path and query string | `/api/v1/events?source=keycloak` |
| `destination.method` | `POST` | HTTP method (POST or PUT) | `PUT` |
| `destination.timeout-seconds` | `10` | Request timeout in seconds | `60` |
-| `destination.message-headers-enabled` | `true` | Include event metadata as HTTP headers | `false` |
-| `destination.min-pool-size` | `5` | Minimum connections in pool | `10` |
-| `destination.max-pool-size` | `20` | Maximum connections in pool | `50` |
+| `destination.pool.min-idle` | `1` | Minimum idle connections in pool | `5` |
+| `destination.pool.max-idle` | `10` | Maximum idle connections in pool | `20` |
+| `destination.pool.max-total` | `20` | Maximum total connections in pool | `50` |
### Custom Headers
diff --git a/docs/user-guide/destinations/kafka.md b/docs/user-guide/destinations/kafka.md
index 47ac902c..4a4175e9 100644
--- a/docs/user-guide/destinations/kafka.md
+++ b/docs/user-guide/destinations/kafka.md
@@ -15,11 +15,15 @@ Stream Keycloak events to Kafka-compatible systems.
| System | Notes |
|--------|-------|
| **Apache Kafka** | Primary target, all features supported |
-| **Redpanda** | Kafka-compatible |
+| **Redpanda** | Kafka-compatible, zero-JVM |
| **Confluent Cloud** | Managed Kafka (supports SASL/TLS) |
| **Azure Event Hubs** | Kafka protocol endpoint (requires SASL_SSL) |
| **Amazon MSK** | Managed Kafka (supports IAM/SASL) |
-| **Aiven for Kafka** | Managed Kafka |
+| **Amazon MSK Serverless** | Serverless managed Kafka |
+| **Aiven for Kafka** | Multi-cloud managed Kafka |
+| **Strimzi** | Kubernetes Kafka operator |
+| **WarpStream** | Confluent's zero-disk Kafka |
+| **Instaclustr** | Multi-cloud managed Kafka |
@@ -131,9 +135,9 @@ Any property under `kete.routes..destination.*` is passed directly to the
| `max.in.flight.requests.per.connection` | `5` |
| `key.serializer` | StringSerializer |
| `value.serializer` | ByteArraySerializer |
-| `message-headers-enabled` | `true` |
-| `min-pool-size` | `5` |
-| `max-pool-size` | `20` |
+| `pool.min-idle` | `1` |
+| `pool.max-idle` | `10` |
+| `pool.max-total` | `20` |
#### Common Properties
@@ -149,8 +153,20 @@ Any property under `kete.routes..destination.*` is passed directly to the
| `retries` | Retry attempts | `2147483647` | `3` |
| `max.in.flight.requests.per.connection` | Max unacked requests | `5` | `1` |
| `enable.idempotence` | Idempotent producer | `true` | `true`, `false` |
-| `min-pool-size` | Minimum connections in pool | `5` | `10` |
-| `max-pool-size` | Maximum connections in pool | `20` | `50` |
+| `pool.min-idle` | Minimum idle connections in pool | `1` | `5` |
+| `pool.max-idle` | Maximum idle connections in pool | `10` | `20` |
+| `pool.max-total` | Maximum total connections in pool | `20` | `50` |
+
+### Custom Headers
+
+Custom headers can be added to Kafka messages:
+
+```bash
+kete.routes.kafka.destination.headers.X-Source=keycloak
+kete.routes.kafka.destination.headers.X-Environment=production
+```
+
+All custom headers are included in the Kafka message headers.
### Topic Templating
diff --git a/docs/user-guide/destinations/mqtt-3.md b/docs/user-guide/destinations/mqtt-3.md
index f4cbb17b..d6c870bc 100644
--- a/docs/user-guide/destinations/mqtt-3.md
+++ b/docs/user-guide/destinations/mqtt-3.md
@@ -15,8 +15,15 @@ Stream Keycloak events to MQTT 3 brokers.
|--------|-------|
| **Eclipse Mosquitto** | Most popular open-source broker |
| **HiveMQ** | Enterprise MQTT, clustering |
+| **HiveMQ Cloud** | Managed HiveMQ service |
| **EMQX** | High-performance, clustering |
+| **EMQX Cloud** | Managed EMQX service |
+| **NanoMQ** | Ultra-lightweight, IoT edge |
| **VerneMQ** | Distributed, Erlang-based |
+| **RabbitMQ** | Via `rabbitmq_mqtt` plugin |
+| **ActiveMQ Artemis** | Multi-protocol broker |
+| **Azure Event Grid** | MQTT Broker feature |
+| **Solace PubSub+** | Native MQTT support |
| **AWS IoT Core** | Managed, auto-scaling |
| **Azure IoT Hub** | Managed, device management |
@@ -123,13 +130,16 @@ Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLower
| `retained` | `false` | Retain message on broker | `true` |
| `client-id-prefix` | `kete-` | Client ID prefix (UUID appended) | `keycloak-` |
| `clean-session` | `true` | Start with clean session | `false` |
-| `connection-timeout` | `10` | Connection timeout in seconds | `60` |
-| `keep-alive-interval` | `60` | Keep-alive ping interval in seconds | `120` |
+| `connection-timeout-seconds` | `10` | Connection timeout in seconds | `60` |
+| `keep-alive-interval-seconds` | `60` | Keep-alive ping interval in seconds | `120` |
| `username` | `""` | MQTT username | `admin` |
| `password` | `""` | MQTT password | `secret123` |
-| `message-headers-enabled` | `false` | **Not supported** β must remain `false` | - |
-| `min-pool-size` | `5` | Minimum connections in pool | `10` |
-| `max-pool-size` | `20` | Maximum connections in pool | `50` |
+| `pool.min-idle` | `1` | Minimum idle connections in pool | `5` |
+| `pool.max-idle` | `10` | Maximum idle connections in pool | `20` |
+| `pool.max-total` | `20` | Maximum total connections in pool | `50` |
+
+!!! note "No Message Headers"
+ MQTT 3.1.1 does not support message headers (this is a protocol limitation). For header support, use [MQTT 5](mqtt-5.md).
### TLS Properties
diff --git a/docs/user-guide/destinations/mqtt-5.md b/docs/user-guide/destinations/mqtt-5.md
index d97b3eb2..277d53df 100644
--- a/docs/user-guide/destinations/mqtt-5.md
+++ b/docs/user-guide/destinations/mqtt-5.md
@@ -14,9 +14,16 @@ Stream Keycloak events to MQTT 5 brokers.
| System | Notes |
|--------|-------|
| **HiveMQ** | Full MQTT 5 support, enterprise features |
+| **HiveMQ Cloud** | Managed HiveMQ service |
| **EMQX** | High-performance, full MQTT 5 |
+| **EMQX Cloud** | Managed EMQX service |
+| **NanoMQ** | Ultra-lightweight, full MQTT 5 |
| **Eclipse Mosquitto 2.0+** | Open-source, MQTT 5 since v2.0 |
| **VerneMQ** | Distributed, full MQTT 5 |
+| **RabbitMQ** | Via `rabbitmq_mqtt` plugin (3.13+) |
+| **ActiveMQ Artemis** | Multi-protocol broker (v2.28+) |
+| **Azure Event Grid** | MQTT Broker feature, full MQTT 5 |
+| **Solace PubSub+** | Native MQTT 5 support |
Not all brokers support MQTT 5. Azure IoT Hub and older Mosquitto versions only support MQTT 3. For broader compatibility, see [mqtt-3](mqtt-3.md).
@@ -135,13 +142,24 @@ Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLower
| `retained` | `false` | Retain message on broker | `true` |
| `client-id-prefix` | `kete-` | Client ID prefix (UUID appended) | `keycloak-` |
| `clean-session` | `true` | Clean start (MQTT 5 term) | `false` |
-| `connection-timeout` | `10` | Connection timeout in seconds | `60` |
-| `keep-alive-interval` | `60` | Keep-alive ping interval in seconds | `120` |
+| `connection-timeout-seconds` | `10` | Connection timeout in seconds | `60` |
+| `keep-alive-interval-seconds` | `60` | Keep-alive ping interval in seconds | `120` |
| `username` | `""` | MQTT username | `admin` |
| `password` | `""` | MQTT password | `secret123` |
-| `message-headers-enabled` | `true` | Include event metadata as user properties | `false` |
-| `min-pool-size` | `5` | Minimum connections in pool | `10` |
-| `max-pool-size` | `20` | Maximum connections in pool | `50` |
+| `pool.min-idle` | `1` | Minimum idle connections in pool | `5` |
+| `pool.max-idle` | `10` | Maximum idle connections in pool | `20` |
+| `pool.max-total` | `20` | Maximum total connections in pool | `50` |
+
+### Custom Headers (User Properties)
+
+MQTT 5 supports custom headers via User Properties:
+
+```bash
+kete.routes.mqtt.destination.headers.X-Source=keycloak
+kete.routes.mqtt.destination.headers.X-Environment=production
+```
+
+These are included as MQTT 5 User Properties in the message.
### TLS Properties
diff --git a/docs/user-guide/destinations/nats-jetstream.md b/docs/user-guide/destinations/nats-jetstream.md
new file mode 100644
index 00000000..cb38538a
--- /dev/null
+++ b/docs/user-guide/destinations/nats-jetstream.md
@@ -0,0 +1,272 @@
+# NATS JetStream Destination
+
+Stream Keycloak events to NATS JetStream for persistent, at-least-once delivery.
+
+| Property | Value |
+|----------|-------|
+| **`destination.kind`** | `nats-jetstream` |
+| **Protocol** | NATS JetStream |
+
+
+
+## Compatible Systems
+
+| System | Notes |
+|--------|-------|
+| **NATS Server** | Open-source messaging system with JetStream enabled |
+| **Synadia Cloud** | Managed NATS service with JetStream |
+| **NATS Kubernetes** | Self-hosted NATS on Kubernetes with JetStream |
+
+!!! info "JetStream vs Core NATS"
+ JetStream provides persistence, at-least-once delivery, and publish acknowledgments. For fire-and-forget pub/sub, use [NATS Core](nats.md) instead.
+
+
+
+## Example Configurations
+
+=== "Basic JetStream"
+
+ ```bash
+ kete.routes.jetstream.destination.kind=nats-jetstream
+ kete.routes.jetstream.destination.servers=nats://localhost:4222
+ kete.routes.jetstream.destination.subject=keycloak.events
+ kete.routes.jetstream.destination.stream=KEYCLOAK_EVENTS
+ kete.routes.jetstream.destination.authentication-method=none
+ ```
+
+=== "Username/Password"
+
+ ```bash
+ kete.routes.jetstream.destination.kind=nats-jetstream
+ kete.routes.jetstream.destination.servers=nats://localhost:4222
+ kete.routes.jetstream.destination.subject=keycloak.events
+ kete.routes.jetstream.destination.stream=KEYCLOAK_EVENTS
+ kete.routes.jetstream.destination.authentication-method=username-and-password
+ kete.routes.jetstream.destination.username=keycloak
+ kete.routes.jetstream.destination.password=secret
+ ```
+
+=== "Credentials File"
+
+ ```bash
+ kete.routes.jetstream.destination.kind=nats-jetstream
+ kete.routes.jetstream.destination.servers=nats://localhost:4222
+ kete.routes.jetstream.destination.subject=keycloak.events
+ kete.routes.jetstream.destination.stream=KEYCLOAK_EVENTS
+ kete.routes.jetstream.destination.authentication-method=credentials-file-path
+ kete.routes.jetstream.destination.credentials-file-path=/secrets/nats.creds
+ ```
+
+=== "Custom Timeout"
+
+ ```bash
+ kete.routes.jetstream.destination.kind=nats-jetstream
+ kete.routes.jetstream.destination.servers=nats://localhost:4222
+ kete.routes.jetstream.destination.subject=keycloak.events
+ kete.routes.jetstream.destination.stream=KEYCLOAK_EVENTS
+ kete.routes.jetstream.destination.authentication-method=none
+ kete.routes.jetstream.destination.publish-timeout-seconds=30
+ ```
+
+
+
+## Features
+
+- Persistent message storage
+- At-least-once delivery with acknowledgments
+- Publish confirmation with timeout
+- Stream verification on startup
+- Subject-based routing with wildcards
+- TLS/SSL support with mutual TLS (mTLS)
+- Multiple authentication methods
+- Automatic reconnection and failover
+- Message headers support
+- Dynamic subject names (templating)
+
+
+
+## Configuration Properties
+
+### Required Properties
+
+| Property | Description | Example |
+|----------|-------------|---------|
+| `servers` | Comma-separated NATS server URLs | `nats://localhost:4222` |
+| `subject` | NATS subject to publish to (supports templating) | `keycloak.events` |
+| `stream` | JetStream stream name | `KEYCLOAK_EVENTS` |
+| `authentication-method` | Authentication method (see [NATS Auth](nats.md#authentication-methods)) | `none` |
+
+!!! warning "Stream Must Exist"
+ The specified stream must exist in NATS JetStream before KETE starts. KETE verifies stream existence on initialization and fails if the stream is not found.
+
+### Dynamic Subjects (Templating)
+
+The `subject` property supports template variables:
+
+```bash
+# Dynamic subject per realm
+kete.routes.jetstream.destination.subject=keycloak.${realmLowerCase}.events
+
+# Dynamic subject per event type
+kete.routes.jetstream.destination.subject=keycloak.events.${eventTypeLowerCase}
+```
+
+Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLowerCase}`, `${eventTypeUpperCase}`, `${kindLowerCase}`, `${kindUpperCase}`, `${resourceTypeLowerCase}`, `${resourceTypeUpperCase}`, `${operationTypeLowerCase}`, `${operationTypeUpperCase}`, `${resultLowerCase}`, `${resultUpperCase}`
+
+### Custom Headers
+
+Custom headers can be added to NATS JetStream messages:
+
+```bash
+kete.routes.jetstream.destination.headers.X-Source=keycloak
+kete.routes.jetstream.destination.headers.X-Environment=production
+```
+
+Headers are included in the NATS message headers.
+
+### Optional Properties
+
+| Property | Default | Description | Example |
+|----------|---------|-------------|---------|
+| `connection-timeout-seconds` | `10` | Connection timeout in seconds | `30` |
+| `ping-interval-seconds` | `60` | Ping interval for health checks | `120` |
+| `connection-name` | `kete` | Client connection name | `keycloak-events` |
+| `publish-timeout-seconds` | `10` | Timeout for publish acknowledgment | `30` |
+
+
+
+## Authentication Methods
+
+JetStream uses the same authentication methods as [NATS Core](nats.md#authentication-methods):
+
+| Method | Description | Required Properties |
+|--------|-------------|---------------------|
+| `none` | No authentication | - |
+| `username-and-password` | Username/password authentication | `username`, `password` |
+| `token` | Token-based authentication | `token` |
+| `nkey` | NKey seed authentication | `nkey-seed` |
+| `credentials-file-path` | Credentials file from filesystem | `credentials-file-path` |
+| `credentials-file-text` | Credentials file content inline | `credentials-file-text` |
+| `credentials-file-base64` | Base64-encoded credentials file | `credentials-file-base64` |
+
+
+
+## TLS Properties
+
+See [TLS & mTLS](overview.md#tls-mtls) for full details on TLS options.
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `tls.enabled` | `false` | Enable TLS |
+| `tls.key-store.*` | - | Client certificate for mTLS |
+| `tls.trust-store.*` | - | CA certificates |
+
+
+
+## Stream Configuration
+
+Before using this destination, create a JetStream stream that matches your subject pattern:
+
+```bash
+nats stream add KEYCLOAK_EVENTS \
+ --subjects "keycloak.>" \
+ --storage file \
+ --replicas 3 \
+ --retention limits \
+ --max-age 7d \
+ --max-bytes 10GB \
+ --discard old
+```
+
+!!! tip "Subject Pattern"
+ Use wildcards in the stream subject filter (e.g., `keycloak.>`) if you use dynamic subjects.
+
+
+
+## Configuration Examples
+
+### Basic JetStream
+
+```bash
+kete.routes.jetstream.destination.kind=nats-jetstream
+kete.routes.jetstream.realm-matchers.realm=list:master
+kete.routes.jetstream.destination.servers=nats://localhost:4222
+kete.routes.jetstream.destination.subject=keycloak.events
+kete.routes.jetstream.destination.stream=KEYCLOAK_EVENTS
+kete.routes.jetstream.destination.authentication-method=none
+```
+
+### JetStream with TLS
+
+```bash
+kete.routes.secure-jetstream.destination.kind=nats-jetstream
+kete.routes.secure-jetstream.destination.servers=tls://nats.example.com:4222
+kete.routes.secure-jetstream.destination.subject=keycloak.events
+kete.routes.secure-jetstream.destination.stream=KEYCLOAK_EVENTS
+kete.routes.secure-jetstream.destination.authentication-method=none
+kete.routes.secure-jetstream.destination.tls.enabled=true
+```
+
+### JetStream with mTLS
+
+```bash
+kete.routes.mtls-jetstream.destination.kind=nats-jetstream
+kete.routes.mtls-jetstream.destination.servers=tls://nats.example.com:4222
+kete.routes.mtls-jetstream.destination.subject=keycloak.events
+kete.routes.mtls-jetstream.destination.stream=KEYCLOAK_EVENTS
+kete.routes.mtls-jetstream.destination.authentication-method=none
+kete.routes.mtls-jetstream.destination.tls.enabled=true
+kete.routes.mtls-jetstream.destination.tls.key-store.loader.kind=pkcs12-file-path
+kete.routes.mtls-jetstream.destination.tls.key-store.loader.path=/certs/client.p12
+kete.routes.mtls-jetstream.destination.tls.key-store.password=keystorepass
+kete.routes.mtls-jetstream.destination.tls.trust-store.loader.kind=jks-file-path
+kete.routes.mtls-jetstream.destination.tls.trust-store.loader.path=/certs/truststore.jks
+kete.routes.mtls-jetstream.destination.tls.trust-store.password=truststorepass
+```
+
+### Synadia Cloud JetStream
+
+```bash
+kete.routes.synadia.destination.kind=nats-jetstream
+kete.routes.synadia.destination.servers=tls://connect.ngs.global:4222
+kete.routes.synadia.destination.subject=keycloak.events
+kete.routes.synadia.destination.stream=KEYCLOAK_EVENTS
+kete.routes.synadia.destination.authentication-method=credentials-file-path
+kete.routes.synadia.destination.credentials-file-path=/secrets/synadia.creds
+kete.routes.synadia.destination.tls.enabled=true
+```
+
+### JetStream with Extended Timeout
+
+```bash
+kete.routes.jetstream.destination.kind=nats-jetstream
+kete.routes.jetstream.destination.servers=nats://localhost:4222
+kete.routes.jetstream.destination.subject=keycloak.events
+kete.routes.jetstream.destination.stream=KEYCLOAK_EVENTS
+kete.routes.jetstream.destination.authentication-method=none
+kete.routes.jetstream.destination.publish-timeout-seconds=30
+kete.routes.jetstream.destination.connection-timeout-seconds=20
+```
+
+### Dynamic Subject per Realm
+
+```bash
+kete.routes.dynamic.destination.kind=nats-jetstream
+kete.routes.dynamic.destination.servers=nats://localhost:4222
+kete.routes.dynamic.destination.subject=keycloak.${realmLowerCase}.events
+kete.routes.dynamic.destination.stream=KEYCLOAK_EVENTS
+kete.routes.dynamic.destination.authentication-method=none
+```
+
+
+
+## Comparison: NATS Core vs JetStream
+
+| Feature | NATS Core | JetStream |
+|---------|-----------|-----------|
+| Delivery | At-most-once | At-least-once |
+| Persistence | No | Yes |
+| Acknowledgment | No | Yes |
+| Replay | No | Yes |
+| Use Case | Fire-and-forget | Reliable delivery |
+| Latency | Lower | Slightly higher |
diff --git a/docs/user-guide/destinations/nats.md b/docs/user-guide/destinations/nats.md
new file mode 100644
index 00000000..2de922a3
--- /dev/null
+++ b/docs/user-guide/destinations/nats.md
@@ -0,0 +1,293 @@
+# NATS Destination
+
+Stream Keycloak events to NATS messaging system.
+
+| Property | Value |
+|----------|-------|
+| **`destination.kind`** | `nats` |
+| **Protocol** | NATS Protocol |
+
+
+
+## Compatible Systems
+
+| System | Notes |
+|--------|-------|
+| **NATS Server** | Open-source messaging system |
+| **Synadia Cloud** | Managed NATS service |
+| **NATS Kubernetes** | Self-hosted NATS on Kubernetes |
+
+!!! note "Core NATS Semantics"
+ Core NATS is a fire-and-forget pub/sub system. Messages are delivered to connected subscribers only. For persistent messaging with acknowledgments, use [NATS JetStream](nats-jetstream.md) instead.
+
+
+
+## Example Configurations
+
+=== "Basic NATS"
+
+ ```bash
+ kete.routes.nats.destination.kind=nats
+ kete.routes.nats.destination.servers=nats://localhost:4222
+ kete.routes.nats.destination.subject=keycloak.events
+ kete.routes.nats.destination.authentication-method=none
+ ```
+
+=== "Username/Password"
+
+ ```bash
+ kete.routes.nats.destination.kind=nats
+ kete.routes.nats.destination.servers=nats://localhost:4222
+ kete.routes.nats.destination.subject=keycloak.events
+ kete.routes.nats.destination.authentication-method=username-and-password
+ kete.routes.nats.destination.username=keycloak
+ kete.routes.nats.destination.password=secret
+ ```
+
+=== "Token Auth"
+
+ ```bash
+ kete.routes.nats.destination.kind=nats
+ kete.routes.nats.destination.servers=nats://localhost:4222
+ kete.routes.nats.destination.subject=keycloak.events
+ kete.routes.nats.destination.authentication-method=token
+ kete.routes.nats.destination.token=myAuthToken
+ ```
+
+=== "NKey Auth"
+
+ ```bash
+ kete.routes.nats.destination.kind=nats
+ kete.routes.nats.destination.servers=nats://localhost:4222
+ kete.routes.nats.destination.subject=keycloak.events
+ kete.routes.nats.destination.authentication-method=nkey
+ kete.routes.nats.destination.nkey-seed=SUAM...
+ ```
+
+=== "Credentials File"
+
+ ```bash
+ kete.routes.nats.destination.kind=nats
+ kete.routes.nats.destination.servers=nats://localhost:4222
+ kete.routes.nats.destination.subject=keycloak.events
+ kete.routes.nats.destination.authentication-method=credentials-file-path
+ kete.routes.nats.destination.credentials-file-path=/secrets/nats.creds
+ ```
+
+=== "Multiple Servers"
+
+ ```bash
+ kete.routes.nats.destination.kind=nats
+ kete.routes.nats.destination.servers=nats://server1:4222,nats://server2:4222,nats://server3:4222
+ kete.routes.nats.destination.subject=keycloak.events
+ kete.routes.nats.destination.authentication-method=none
+ ```
+
+
+
+## Features
+
+- Lightweight, high-performance messaging
+- At-most-once delivery semantics
+- Subject-based routing with wildcards
+- TLS/SSL support with mutual TLS (mTLS)
+- Multiple authentication methods
+- Automatic reconnection and failover
+- Message headers support (NATS 2.2+)
+- Dynamic subject names (templating)
+
+
+
+## Configuration Properties
+
+### Required Properties
+
+| Property | Description | Example |
+|----------|-------------|---------|
+| `servers` | Comma-separated NATS server URLs | `nats://localhost:4222` |
+| `subject` | NATS subject to publish to (supports templating) | `keycloak.events` |
+| `authentication-method` | Authentication method (see below) | `none` |
+
+### Dynamic Subjects (Templating)
+
+The `subject` property supports template variables:
+
+```bash
+# Dynamic subject per realm
+kete.routes.nats.destination.subject=keycloak.${realmLowerCase}.events
+
+# Dynamic subject per event type
+kete.routes.nats.destination.subject=keycloak.events.${eventTypeLowerCase}
+```
+
+Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLowerCase}`, `${eventTypeUpperCase}`, `${kindLowerCase}`, `${kindUpperCase}`, `${resourceTypeLowerCase}`, `${resourceTypeUpperCase}`, `${operationTypeLowerCase}`, `${operationTypeUpperCase}`, `${resultLowerCase}`, `${resultUpperCase}`
+
+### Custom Headers
+
+Custom headers can be added to NATS messages:
+
+```bash
+kete.routes.nats.destination.headers.X-Source=keycloak
+kete.routes.nats.destination.headers.X-Environment=production
+```
+
+Headers are included in the NATS message headers.
+
+### Optional Properties
+
+| Property | Default | Description | Example |
+|----------|---------|-------------|---------|
+| `connection-timeout-seconds` | `10` | Connection timeout in seconds | `30` |
+| `ping-interval-seconds` | `60` | Ping interval for health checks | `120` |
+| `connection-name` | `kete` | Client connection name | `keycloak-events` |
+
+
+
+## Authentication Methods
+
+NATS supports multiple authentication methods via the `authentication-method` property:
+
+| Method | Description | Required Properties |
+|--------|-------------|---------------------|
+| `none` | No authentication | - |
+| `username-and-password` | Username/password authentication | `username`, `password` |
+| `token` | Token-based authentication | `token` |
+| `nkey` | NKey seed authentication | `nkey-seed` |
+| `credentials-file-path` | Credentials file from filesystem | `credentials-file-path` |
+| `credentials-file-text` | Credentials file content inline | `credentials-file-text` |
+| `credentials-file-base64` | Base64-encoded credentials file | `credentials-file-base64` |
+
+### Username/Password Authentication
+
+```bash
+kete.routes.nats.destination.authentication-method=username-and-password
+kete.routes.nats.destination.username=keycloak
+kete.routes.nats.destination.password=secret
+```
+
+### Token Authentication
+
+```bash
+kete.routes.nats.destination.authentication-method=token
+kete.routes.nats.destination.token=myAuthToken
+```
+
+### NKey Authentication
+
+```bash
+kete.routes.nats.destination.authentication-method=nkey
+kete.routes.nats.destination.nkey-seed=SUAM...
+```
+
+!!! note "NKey Seed Format"
+ NKey seeds start with `S` followed by the key type (e.g., `SU` for user keys).
+
+### Credentials File Authentication
+
+=== "File Path"
+
+ ```bash
+ kete.routes.nats.destination.authentication-method=credentials-file-path
+ kete.routes.nats.destination.credentials-file-path=/secrets/nats.creds
+ ```
+
+=== "Inline Text"
+
+ ```bash
+ kete.routes.nats.destination.authentication-method=credentials-file-text
+ kete.routes.nats.destination.credentials-file-text=-----BEGIN NATS USER JWT-----\n...\n-----END NATS USER JWT-----\n-----BEGIN USER NKEY SEED-----\n...\n-----END USER NKEY SEED-----
+ ```
+
+=== "Base64 Encoded"
+
+ ```bash
+ kete.routes.nats.destination.authentication-method=credentials-file-base64
+ kete.routes.nats.destination.credentials-file-base64=LS0tLS1CRUdJTi...
+ ```
+
+!!! tip "JWT Expiry Warning"
+ KETE automatically checks JWT expiry in credentials files and logs a warning if the JWT expires within 30 days.
+
+
+
+## TLS Properties
+
+See [TLS & mTLS](overview.md#tls-mtls) for full details on TLS options.
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `tls.enabled` | `false` | Enable TLS |
+| `tls.key-store.*` | - | Client certificate for mTLS |
+| `tls.trust-store.*` | - | CA certificates |
+
+
+
+## Configuration Examples
+
+### Basic NATS
+
+```bash
+kete.routes.nats.destination.kind=nats
+kete.routes.nats.realm-matchers.realm=list:master
+kete.routes.nats.destination.servers=nats://localhost:4222
+kete.routes.nats.destination.subject=keycloak.events
+kete.routes.nats.destination.authentication-method=none
+```
+
+### NATS with TLS
+
+```bash
+kete.routes.secure-nats.destination.kind=nats
+kete.routes.secure-nats.destination.servers=tls://nats.example.com:4222
+kete.routes.secure-nats.destination.subject=keycloak.events
+kete.routes.secure-nats.destination.authentication-method=none
+kete.routes.secure-nats.destination.tls.enabled=true
+```
+
+### NATS with mTLS
+
+```bash
+kete.routes.mtls-nats.destination.kind=nats
+kete.routes.mtls-nats.destination.servers=tls://nats.example.com:4222
+kete.routes.mtls-nats.destination.subject=keycloak.events
+kete.routes.mtls-nats.destination.authentication-method=none
+kete.routes.mtls-nats.destination.tls.enabled=true
+kete.routes.mtls-nats.destination.tls.key-store.loader.kind=pkcs12-file-path
+kete.routes.mtls-nats.destination.tls.key-store.loader.path=/certs/client.p12
+kete.routes.mtls-nats.destination.tls.key-store.password=keystorepass
+kete.routes.mtls-nats.destination.tls.trust-store.loader.kind=jks-file-path
+kete.routes.mtls-nats.destination.tls.trust-store.loader.path=/certs/truststore.jks
+kete.routes.mtls-nats.destination.tls.trust-store.password=truststorepass
+```
+
+### Synadia Cloud
+
+```bash
+kete.routes.synadia.destination.kind=nats
+kete.routes.synadia.destination.servers=tls://connect.ngs.global:4222
+kete.routes.synadia.destination.subject=keycloak.events
+kete.routes.synadia.destination.authentication-method=credentials-file-path
+kete.routes.synadia.destination.credentials-file-path=/secrets/synadia.creds
+kete.routes.synadia.destination.tls.enabled=true
+```
+
+### NATS Cluster with Failover
+
+```bash
+kete.routes.cluster.destination.kind=nats
+kete.routes.cluster.destination.servers=nats://node1:4222,nats://node2:4222,nats://node3:4222
+kete.routes.cluster.destination.subject=keycloak.events
+kete.routes.cluster.destination.authentication-method=username-and-password
+kete.routes.cluster.destination.username=keycloak
+kete.routes.cluster.destination.password=secret
+kete.routes.cluster.destination.connection-name=keycloak-events
+```
+
+### Dynamic Subject per Realm
+
+```bash
+kete.routes.dynamic.destination.kind=nats
+kete.routes.dynamic.destination.servers=nats://localhost:4222
+kete.routes.dynamic.destination.subject=keycloak.${realmLowerCase}.events
+kete.routes.dynamic.destination.authentication-method=none
+```
diff --git a/docs/user-guide/destinations/overview.md b/docs/user-guide/destinations/overview.md
index 594ac373..cc1b39fc 100644
--- a/docs/user-guide/destinations/overview.md
+++ b/docs/user-guide/destinations/overview.md
@@ -6,6 +6,9 @@ Where events are delivered.
Every route needs exactly one destination. Destinations connect to message brokers, APIs, and other systems.
+!!! tip "Which broker supports which destination?"
+ See the [Destination Support Matrix](support-matrix.md) for a comprehensive cross-reference of all supported brokers and destinations.
+
## Common Features
All destinations support:
@@ -21,35 +24,80 @@ KETE maintains a pool of destination instances for each route. Destination pooli
### Pool Configuration
+KETE uses [Apache Commons Pool 2](https://commons.apache.org/proper/commons-pool/) for destination pooling. All properties are under the `destination.pool.*` prefix.
+
+#### Core Properties
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `destination.pool.min-idle` | `1` | Minimum number of idle connections maintained in the pool |
+| `destination.pool.max-idle` | `10` | Maximum number of idle connections allowed in the pool |
+| `destination.pool.max-total` | `20` | Maximum total connections (active + idle) allowed in the pool |
+| `destination.pool.max-wait-seconds` | `30` | Maximum seconds to wait when borrowing from an exhausted pool |
+| `destination.pool.block-when-exhausted` | `true` | Whether to block when the pool is exhausted (if false, throws exception) |
+
+#### Advanced Properties
+
| Property | Default | Description |
|----------|---------|-------------|
-| `destination.min-pool-size` | `5` | Minimum number of idle connections in the pool |
-| `destination.max-pool-size` | `20` | Maximum number of connections in the pool |
+| `destination.pool.lifo` | `true` | Last In First Out - uses recently returned connections first (stack behavior) |
+| `destination.pool.fairness` | `false` | Whether to ensure fairness when threads wait for connections (adds overhead) |
+| `destination.pool.test-on-create` | `false` | Validate connections when created |
+| `destination.pool.test-on-borrow` | `false` | Validate connections before use (recommended for production) |
+| `destination.pool.test-on-return` | `false` | Validate connections when returned to pool |
+| `destination.pool.test-while-idle` | `false` | Validate idle connections proactively (recommended with eviction enabled) |
+| `destination.pool.time-between-eviction-runs-seconds` | `-1` | Seconds between eviction runs (-1 disables, recommended: 60 for 1 minute) |
+| `destination.pool.min-evictable-idle-time-seconds` | `1800` | Minimum idle time in seconds before connection eligible for eviction (30 minutes) |
+| `destination.pool.soft-min-evictable-idle-time-seconds` | `-1` | Soft minimum idle time in seconds (only evicts if min-idle exceeded, -1 disables) |
+| `destination.pool.num-tests-per-eviction-run` | `3` | Number of connections to test during each eviction run |
### Example
```bash
-# Configure pool sizes for high-volume route
+# Basic pool configuration for high-volume route
kete.routes.high-volume.destination.kind=kafka
kete.routes.high-volume.destination.bootstrap.servers=kafka:9092
kete.routes.high-volume.destination.topic=keycloak-events
-kete.routes.high-volume.destination.min-pool-size=10
-kete.routes.high-volume.destination.max-pool-size=50
+kete.routes.high-volume.destination.pool.min-idle=10
+kete.routes.high-volume.destination.pool.max-idle=25
+kete.routes.high-volume.destination.pool.max-total=50
+
+# Production-ready configuration with validation and eviction
+kete.routes.production.destination.kind=amqp-0.9.1
+kete.routes.production.destination.host=rabbitmq.prod
+kete.routes.production.destination.exchange=events
+kete.routes.production.destination.pool.min-idle=5
+kete.routes.production.destination.pool.max-idle=15
+kete.routes.production.destination.pool.max-total=30
+kete.routes.production.destination.pool.test-on-borrow=true
+kete.routes.production.destination.pool.test-while-idle=true
+kete.routes.production.destination.pool.time-between-eviction-runs-seconds=60
```
### Tuning Guidelines
| Scenario | Recommendation |
|----------|----------------|
-| **Low volume** (< 10 events/sec) | Default values (`5` / `20`) are sufficient |
-| **Medium volume** (10-100 events/sec) | Consider `min-pool-size=10`, `max-pool-size=30` |
-| **High volume** (> 100 events/sec) | Consider `min-pool-size=20`, `max-pool-size=100` |
-| **Fixed pool size** | Set both to the same value (e.g., `min-pool-size=15`, `max-pool-size=15`) |
+| **Low volume** (< 10 events/sec) | Default values are sufficient (`min-idle=5`, `max-idle=10`, `max-total=20`) |
+| **Medium volume** (10-100 events/sec) | `min-idle=10`, `max-idle=20`, `max-total=30` |
+| **High volume** (> 100 events/sec) | `min-idle=20`, `max-idle=50`, `max-total=100` |
+| **Fixed pool size** | Set `min-idle=max-idle=max-total` (e.g., all to `15`) |
+| **Production environments** | Enable `test-on-borrow=true` and `test-while-idle=true` with `time-between-eviction-runs-seconds=60` |
+| **Unstable networks** | Enable validation (`test-on-borrow=true`) to detect broken connections before use |
+
+!!! tip "Performance vs. Reliability"
+ **Default configuration favors performance** with validation disabled. For production:
+
+ - Enable `test-on-borrow=true` (~1-5ms latency per borrow, but prevents broken connections)
+ - Enable `test-while-idle=true` + set `time-between-eviction-runs-seconds=60` (proactive health checks)
+ - Reduce `max-idle` to match `max-total` more closely (avoid holding excess idle connections)
!!! note "Validation Rules"
- - `min-pool-size` must be greater than 0
- - `max-pool-size` must be greater than 0
- - `max-pool-size` must be greater than or equal to `min-pool-size`
+ - `pool.min-idle` must be greater than 0
+ - `pool.max-idle` must be greater than 0
+ - `pool.max-total` must be greater than 0
+ - `pool.max-total` must be greater than or equal to `pool.min-idle`
+ - `pool.max-wait-seconds` must be greater than 0
## Template Variables
@@ -106,7 +154,7 @@ kete.routes.events.destination.url=https://api.example.com/${realmLowerCase}/eve
## Message Headers
-Most destinations send event metadata as headers alongside the message body. This is controlled by the `message-headers-enabled` property (default: `true`).
+Most destinations send event metadata as headers alongside the message body.
### Standard Headers
@@ -129,20 +177,12 @@ Header names are **all lowercase with no dashes or underscores** for maximum com
| [AMQP 1](amqp-1.md) | β
| `contenttype` JMS property | All three as JMS String properties |
| [MQTT 5](mqtt-5.md) | β
| Native MQTT `contentType` | `eventtype` and `eventkind` as User Properties |
| [MQTT 3](mqtt-3.md) | β | Not supported | Protocol limitation |
+| [Redis Streams](redis-streams.md) | β
| `contenttype` field | All three as stream entry fields |
+| [Redis Pub/Sub](redis-pubsub.md) | β | Not supported | Protocol limitation |
| [HTTP](http.md) | β
| `contenttype` header | All three as HTTP headers |
| [WebSocket](websocket.md) | β | Not supported | Headers sent via handshake only |
| [STOMP](stomp.md) | β
| Native `content-type` header | `eventtype` and `eventkind` as STOMP headers |
-### Disabling Headers
-
-To disable headers for a destination:
-
-```bash
-kete.routes.myroute.destination.message-headers-enabled=false
-```
-
-When disabled, only the message body is sent - no metadata headers are included.
-
## TLS & mTLS
All destinations support TLS encryption for secure communication. There are two main scenarios:
@@ -202,12 +242,17 @@ For detailed information about each loader and their properties, see **[Certific
|--------------------|----------|-------------------|
| **[kafka](kafka.md)** | Kafka Protocol | Kafka, Redpanda, Confluent, Azure Event Hubs, Amazon MSK |
| **[amqp-0.9.1](amqp-0.9.1.md)** | AMQP 0-9-1 | RabbitMQ, LavinMQ |
-| **[amqp-1](amqp-1.md)** | AMQP 1 | ActiveMQ Artemis, Azure Service Bus, Azure Event Hubs, Qpid |
-| **[mqtt-3](mqtt-3.md)** | MQTT 3 | Mosquitto, HiveMQ, AWS IoT, Azure IoT Hub |
-| **[mqtt-5](mqtt-5.md)** | MQTT 5 | HiveMQ, EMQX, Mosquitto 2.0+ |
-| **[http](http.md)** | HTTP/HTTPS | Webhooks, REST APIs, any HTTP endpoint |
+| **[amqp-1](amqp-1.md)** | AMQP 1 | ActiveMQ Artemis, Azure Service Bus, Azure Event Hubs, Qpid, RabbitMQ 4.0+ |
+| **[mqtt-3](mqtt-3.md)** | MQTT 3 | Mosquitto, HiveMQ, EMQX, VerneMQ, NanoMQ, RabbitMQ, AWS IoT, Azure IoT Hub |
+| **[mqtt-5](mqtt-5.md)** | MQTT 5 | HiveMQ, EMQX, VerneMQ, NanoMQ, Mosquitto 2.0+, RabbitMQ, Azure Event Grid |
+| **[redis-pubsub](redis-pubsub.md)** | Redis RESP | Redis, Valkey, Dragonfly, KeyDB, ElastiCache, Azure Cache for Redis, Upstash |
+| **[redis-streams](redis-streams.md)** | Redis RESP | Redis 5.0+, Valkey, Dragonfly, KeyDB, ElastiCache, Azure Cache for Redis, Upstash |
+| **[nats](nats.md)** | NATS Protocol | NATS Server, Synadia Cloud |
+| **[nats-jetstream](nats-jetstream.md)** | NATS JetStream | NATS Server, Synadia Cloud |
+| **[pulsar](pulsar.md)** | Pulsar Protocol | Apache Pulsar, StreamNative Cloud, DataStax Astra Streaming |
+| **[http](http.md)** | HTTP/HTTPS | Webhooks, REST APIs, any HTTP endpoint, Azure Event Grid |
| **[websocket](websocket.md)** | WebSocket | Real-time servers, custom backends, dashboards |
-| **[stomp](stomp.md)** | STOMP 1.2 | ActiveMQ Classic, Amazon MQ, RabbitMQ, Artemis |
+| **[stomp](stomp.md)** | STOMP 1.2 | ActiveMQ Classic, ActiveMQ Artemis, Amazon MQ, RabbitMQ, EMQX |
## Cloud Services Compatibility
@@ -217,12 +262,21 @@ KETE works with major cloud messaging services through protocol compatibility:
|---------------|-----------------|---------------|
| **Azure Event Hubs** | `kafka` or `amqp-1` | [Kafka](kafka.md) / [AMQP 1](amqp-1.md) |
| **Azure Service Bus** | `amqp-1` | [AMQP 1](amqp-1.md) |
+| **Azure Event Grid** | `http` or `mqtt-5` | [HTTP](http.md) / [MQTT 5](mqtt-5.md) |
+| **Azure Cache for Redis** | `redis-pubsub` or `redis-streams` | [Redis Pub/Sub](redis-pubsub.md) / [Redis Streams](redis-streams.md) |
+| **Amazon ElastiCache** | `redis-pubsub` or `redis-streams` | [Redis Pub/Sub](redis-pubsub.md) / [Redis Streams](redis-streams.md) |
| **Amazon MSK** | `kafka` | [Kafka](kafka.md) |
-| **Amazon MQ (Artemis)** | `amqp-1` | [AMQP 1](amqp-1.md) |
-| **Amazon MQ (ActiveMQ)** | `stomp` | [STOMP](stomp.md) |
+| **Amazon MQ (Artemis)** | `amqp-1` or `stomp` | [AMQP 1](amqp-1.md) / [STOMP](stomp.md) |
+| **Amazon MQ (ActiveMQ)** | `amqp-1` or `stomp` | [AMQP 1](amqp-1.md) / [STOMP](stomp.md) |
+| **Amazon MQ (RabbitMQ)** | `amqp-0.9.1` or `amqp-1` | [AMQP 0.9.1](amqp-0.9.1.md) / [AMQP 1](amqp-1.md) |
| **Confluent Cloud** | `kafka` | [Kafka](kafka.md) |
-| **AWS IoT Core** | `mqtt-3` / `mqtt-5` | [MQTT 3](mqtt-3.md) / [MQTT 5](mqtt-5.md) |
-| **Azure IoT Hub** | `mqtt-3` / `mqtt-5` | [MQTT 3](mqtt-3.md) / [MQTT 5](mqtt-5.md) |
+| **AWS IoT Core** | `mqtt-3` | [MQTT 3](mqtt-3.md) |
+| **Azure IoT Hub** | `mqtt-3` | [MQTT 3](mqtt-3.md) |
+| **Google Cloud Memorystore** | `redis-pubsub` or `redis-streams` | [Redis Pub/Sub](redis-pubsub.md) / [Redis Streams](redis-streams.md) |
+| **Upstash** | `redis-pubsub` or `redis-streams` | [Redis Pub/Sub](redis-pubsub.md) / [Redis Streams](redis-streams.md) |
+| **Aiven for Kafka** | `kafka` | [Kafka](kafka.md) |
+| **StreamNative Cloud** | `pulsar` | [Pulsar](pulsar.md) |
+| **DataStax Astra Streaming** | `pulsar` | [Pulsar](pulsar.md) |
!!! tip "No SDK Required"
Azure Event Hubs, Azure Service Bus, Amazon MSK, and Amazon MQ all work through standard protocolsβno cloud-specific SDKs needed.
diff --git a/docs/user-guide/destinations/pulsar.md b/docs/user-guide/destinations/pulsar.md
new file mode 100644
index 00000000..32377d79
--- /dev/null
+++ b/docs/user-guide/destinations/pulsar.md
@@ -0,0 +1,298 @@
+# Pulsar Destination
+
+Stream Keycloak events to Apache Pulsar.
+
+| Property | Value |
+|----------|-------|
+| **`destination.kind`** | `pulsar` |
+| **Protocol** | Apache Pulsar Protocol |
+
+
+
+## Compatible Systems
+
+| System | Notes |
+|--------|-------|
+| **Apache Pulsar** | Primary target, all features supported |
+| **DataStax Luna Streaming** | Pulsar-compatible managed service |
+| **StreamNative Cloud** | Pulsar-compatible managed service |
+
+
+
+## Example Configurations
+
+=== "Apache Pulsar"
+
+ ```bash
+ kete.routes.pulsar.destination.kind=pulsar
+ kete.routes.pulsar.destination.service-url=pulsar://localhost:6650
+ kete.routes.pulsar.destination.topic=persistent://public/default/keycloak-events
+ # Defaults: compression=LZ4, batching enabled
+ ```
+
+=== "Pulsar with TLS"
+
+ ```bash
+ kete.routes.pulsar-tls.destination.kind=pulsar
+ kete.routes.pulsar-tls.destination.service-url=pulsar+ssl://pulsar:6651
+ kete.routes.pulsar-tls.destination.topic=persistent://public/default/keycloak-events
+ kete.routes.pulsar-tls.destination.tls.enabled=true
+ kete.routes.pulsar-tls.destination.tls.trust-store.loader.kind=jks-file-path
+ kete.routes.pulsar-tls.destination.tls.trust-store.loader.path=/path/to/truststore.jks
+ kete.routes.pulsar-tls.destination.tls.trust-store.password=changeit
+ ```
+
+=== "Pulsar with Token Authentication"
+
+ ```bash
+ kete.routes.pulsar-auth.destination.kind=pulsar
+ kete.routes.pulsar-auth.destination.service-url=pulsar://pulsar:6650
+ kete.routes.pulsar-auth.destination.topic=persistent://public/default/keycloak-events
+ kete.routes.pulsar-auth.destination.token=eyJhbGciOiJIUzI1NiJ9...
+ ```
+
+=== "Pulsar with Basic Authentication"
+
+ ```bash
+ kete.routes.pulsar-basic.destination.kind=pulsar
+ kete.routes.pulsar-basic.destination.service-url=pulsar://pulsar:6650
+ kete.routes.pulsar-basic.destination.topic=persistent://public/default/keycloak-events
+ kete.routes.pulsar-basic.destination.username=admin
+ kete.routes.pulsar-basic.destination.password=admin
+ ```
+
+
+
+## Features
+
+- β
Full Pulsar producer configuration support
+- β
Topic templating with variables
+- β
Multiple compression types (LZ4, ZSTD, ZLIB, Snappy)
+- β
Message batching
+- β
Token and Basic authentication
+- β
TLS/mTLS support
+- β
Event metadata in message properties
+
+
+
+## Configuration Properties
+
+### Required Properties
+
+```bash
+kete.routes..destination.kind=pulsar
+kete.routes..destination.service-url=
+kete.routes..destination.topic=
+```
+
+### Basic Example
+
+```bash
+# Configure Pulsar destination
+kete.routes.main-pulsar.destination.kind=pulsar
+kete.routes.main-pulsar.realm-matchers.realm=list:master
+kete.routes.main-pulsar.event-matchers.filter=glob:*
+
+# Pulsar-specific configuration
+kete.routes.main-pulsar.destination.service-url=pulsar://localhost:6650
+kete.routes.main-pulsar.destination.topic=persistent://public/default/keycloak-events
+```
+
+### All Configuration Properties
+
+#### Core Properties
+
+| Property | Description | Default | Example |
+|----------|-------------|---------|---------|
+| `service-url` | Pulsar service URL (required) | - | `pulsar://pulsar:6650` |
+| `topic` | Topic name (required, supports templating) | - | `persistent://public/default/events` |
+| `compression-type` | Compression algorithm | `LZ4` | `LZ4`, `ZSTD`, `ZLIB`, `SNAPPY`, `NONE` |
+| `send-timeout-seconds` | Send timeout | `30` | `60` |
+| `operation-timeout-seconds` | Operation timeout | `30` | `60` |
+| `connection-timeout-seconds` | Connection timeout | `10` | `30` |
+| `keep-alive-interval-seconds` | Keep-alive interval | `30` | `60` |
+| `pool.min-idle` | Minimum idle connections in pool | `1` | `5` |
+| `pool.max-idle` | Maximum idle connections in pool | `10` | `20` |
+| `pool.max-total` | Maximum total connections in pool | `20` | `50` |
+
+#### Batching Properties
+
+| Property | Description | Default | Example |
+|----------|-------------|---------|---------|
+| `batching-max-messages` | Maximum messages per batch | `1000` | `2000` |
+| `batching-max-publish-delay-seconds` | Maximum batch delay | `1` | `5` |
+| `max-pending-messages` | Maximum pending messages | `1000` | `5000` |
+| `block-if-queue-full` | Block if queue is full | `true` | `false` |
+
+#### Authentication Properties
+
+| Property | Description | Default | Example |
+|----------|-------------|---------|---------|
+| `token` | JWT token for authentication | - | `eyJhbGciOiJIUzI1NiJ9...` |
+| `username` | Username for basic auth | - | `admin` |
+| `password` | Password for basic auth | - | `secret` |
+
+#### Optional Properties
+
+| Property | Description | Default | Example |
+|----------|-------------|---------|---------|
+| `producer-name` | Producer name | - | `keycloak-producer` |
+| `listener-name` | Listener name (multi-region) | - | `us-west` |
+
+### Custom Headers
+
+Custom headers can be added to Pulsar messages as properties:
+
+```bash
+kete.routes.pulsar.destination.headers.X-Source=keycloak
+kete.routes.pulsar.destination.headers.X-Environment=production
+```
+
+All custom headers are included in the Pulsar message properties.
+
+### Topic Templating
+
+The topic name supports variable substitution:
+
+```bash
+# Dynamic topic per realm
+kete.routes.pulsar.destination.topic=persistent://public/default/keycloak-events-${realmLowerCase}
+
+# Dynamic topic per event type
+kete.routes.pulsar.destination.topic=persistent://public/default/keycloak-${eventTypeLowerCase}
+```
+
+Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLowerCase}`, `${eventTypeUpperCase}`, `${kindLowerCase}`, `${kindUpperCase}`, `${resourceTypeLowerCase}`, `${resourceTypeUpperCase}`, `${operationTypeLowerCase}`, `${operationTypeUpperCase}`, `${resultLowerCase}`, `${resultUpperCase}`
+
+### TLS Configuration
+
+For TLS/mTLS connections:
+
+```bash
+kete.routes.pulsar-tls.destination.kind=pulsar
+kete.routes.pulsar-tls.destination.service-url=pulsar+ssl://pulsar:6651
+kete.routes.pulsar-tls.destination.topic=persistent://public/default/keycloak-events
+
+# TLS configuration
+kete.routes.pulsar-tls.destination.tls.enabled=true
+kete.routes.pulsar-tls.destination.tls.trust-store.loader.kind=jks-file-path
+kete.routes.pulsar-tls.destination.tls.trust-store.loader.path=/path/to/truststore.jks
+kete.routes.pulsar-tls.destination.tls.trust-store.password=changeit
+
+# Optional: mTLS (client certificate)
+kete.routes.pulsar-tls.destination.tls.key-store.loader.kind=jks-file-path
+kete.routes.pulsar-tls.destination.tls.key-store.loader.path=/path/to/keystore.jks
+kete.routes.pulsar-tls.destination.tls.key-store.password=changeit
+kete.routes.pulsar-tls.destination.tls.key-store.key-password=changeit
+```
+
+### Configuration Examples
+
+#### Production Configuration (High Reliability)
+
+```bash
+kete.routes.prod.destination.service-url=pulsar://pulsar:6650
+kete.routes.prod.destination.topic=persistent://public/default/keycloak-events
+kete.routes.prod.destination.compression-type=ZSTD
+kete.routes.prod.destination.send-timeout-seconds=60
+kete.routes.prod.destination.max-pending-messages=5000
+kete.routes.prod.destination.block-if-queue-full=true
+```
+
+#### High Throughput Configuration
+
+```bash
+kete.routes.throughput.destination.service-url=pulsar://pulsar:6650
+kete.routes.throughput.destination.topic=persistent://public/default/keycloak-events
+kete.routes.throughput.destination.compression-type=LZ4
+kete.routes.throughput.destination.batching-max-messages=2000
+kete.routes.throughput.destination.batching-max-publish-delay-seconds=5
+kete.routes.throughput.destination.max-pending-messages=10000
+```
+
+#### Low Latency Configuration
+
+```bash
+kete.routes.lowlatency.destination.service-url=pulsar://pulsar:6650
+kete.routes.lowlatency.destination.topic=persistent://public/default/keycloak-events
+kete.routes.lowlatency.destination.compression-type=NONE
+kete.routes.lowlatency.destination.batching-max-messages=1
+kete.routes.lowlatency.destination.batching-max-publish-delay-seconds=0
+```
+
+
+
+## Configuration Examples
+
+### Example 1: Multiple Tenants
+
+```bash
+# Tenant 1 - Dedicated topic
+kete.routes.tenant1.destination.kind=pulsar
+kete.routes.tenant1.realm-matchers.realm=list:tenant1
+kete.routes.tenant1.event-matchers.filter=glob:*
+kete.routes.tenant1.destination.service-url=pulsar://pulsar:6650
+kete.routes.tenant1.destination.topic=persistent://tenant1/default/keycloak-events
+
+# Tenant 2 - Dedicated topic
+kete.routes.tenant2.destination.kind=pulsar
+kete.routes.tenant2.realm-matchers.realm=list:tenant2
+kete.routes.tenant2.event-matchers.filter=glob:*
+kete.routes.tenant2.destination.service-url=pulsar://pulsar:6650
+kete.routes.tenant2.destination.topic=persistent://tenant2/default/keycloak-events
+```
+
+### Example 2: Multi-Region Setup
+
+```bash
+# Primary region
+kete.routes.primary.destination.kind=pulsar
+kete.routes.primary.realm-matchers.realm=list:master
+kete.routes.primary.event-matchers.filter=glob:*
+kete.routes.primary.destination.service-url=pulsar://pulsar-us-west:6650
+kete.routes.primary.destination.topic=persistent://public/default/events
+kete.routes.primary.destination.listener-name=us-west
+
+# Backup region
+kete.routes.backup.destination.kind=pulsar
+kete.routes.backup.realm-matchers.realm=list:master
+kete.routes.backup.event-matchers.filter=glob:*
+kete.routes.backup.destination.service-url=pulsar://pulsar-us-east:6650
+kete.routes.backup.destination.topic=persistent://public/default/events
+kete.routes.backup.destination.listener-name=us-east
+```
+
+### Example 3: Per-Event-Type Topics
+
+```bash
+# Login events
+kete.routes.logins.destination.kind=pulsar
+kete.routes.logins.realm-matchers.realm=list:master
+kete.routes.logins.event-matchers.login=glob:LOGIN*
+kete.routes.logins.destination.service-url=pulsar://pulsar:6650
+kete.routes.logins.destination.topic=persistent://public/default/keycloak-logins
+
+# Admin events
+kete.routes.admin.destination.kind=pulsar
+kete.routes.admin.realm-matchers.realm=list:master
+kete.routes.admin.event-matchers.user-ops=glob:USER_*
+kete.routes.admin.destination.service-url=pulsar://pulsar:6650
+kete.routes.admin.destination.topic=persistent://public/default/keycloak-admin
+```
+
+
+
+## Quick Starts
+
+| Quick Start | Description |
+|-------------|-------------|
+| [pulsar-apache](https://github.com/FortuneN/kete/tree/develop/quick-starts/pulsar-apache) | Apache Pulsar standalone |
+
+
+
+## See Also
+
+- [Serializers](../serializers/overview.md) - Choose JSON, YAML, CBOR, Properties, etc.
+- [Matchers](../matchers/overview.md) - Filter events by realm, type, resource, operation
+- [Event Types](../event-types.md) - List of all event types
+- [Certificate Loaders](../certificate-loaders/overview.md) - For TLS configuration
diff --git a/docs/user-guide/destinations/redis-pubsub.md b/docs/user-guide/destinations/redis-pubsub.md
new file mode 100644
index 00000000..9b32987d
--- /dev/null
+++ b/docs/user-guide/destinations/redis-pubsub.md
@@ -0,0 +1,188 @@
+# Redis Pub/Sub Destination
+
+Stream Keycloak events to Redis using Pub/Sub messaging.
+
+| Property | Value |
+|----------|-------|
+| **`destination.kind`** | `redis-pubsub` |
+| **Protocol** | Redis RESP2/RESP3 |
+
+
+
+## Compatible Systems
+
+| System | Notes |
+|--------|-------|
+| **Redis** | Open-source, in-memory data store |
+| **Valkey** | Linux Foundation Redis fork, 100% compatible |
+| **Redis Stack** | Redis with additional modules |
+| **Amazon ElastiCache** | AWS-managed Redis/Valkey |
+| **Azure Cache for Redis** | Azure-managed Redis |
+| **Google Cloud Memorystore** | GCP-managed Redis/Valkey |
+| **Upstash** | Serverless Redis |
+| **Dragonfly** | Redis-compatible, multi-threaded |
+| **KeyDB** | Redis-compatible, multi-threaded |
+| **Microsoft Garnet** | High-performance Redis-compatible cache |
+
+!!! note "Pub/Sub Limitations"
+ Redis Pub/Sub is fire-and-forget. Messages are not persisted and will be lost if no subscribers are connected. For persistent messaging, use [Redis Streams](redis-streams.md) instead.
+
+
+
+## Example Configurations
+
+=== "Redis"
+
+ ```bash
+ kete.routes.redis.destination.kind=redis-pubsub
+ kete.routes.redis.destination.host=redis.example.com
+ kete.routes.redis.destination.port=6379
+ kete.routes.redis.destination.channel=keycloak-events
+ ```
+
+=== "Redis with Auth"
+
+ ```bash
+ kete.routes.redis.destination.kind=redis-pubsub
+ kete.routes.redis.destination.host=redis.example.com
+ kete.routes.redis.destination.port=6379
+ kete.routes.redis.destination.channel=keycloak-events
+ kete.routes.redis.destination.username=default
+ kete.routes.redis.destination.password=secret
+ ```
+
+=== "Amazon ElastiCache"
+
+ ```bash
+ kete.routes.elasticache.destination.kind=redis-pubsub
+ kete.routes.elasticache.destination.host=my-cluster.abc123.cache.amazonaws.com
+ kete.routes.elasticache.destination.port=6379
+ kete.routes.elasticache.destination.channel=keycloak-events
+ ```
+
+=== "Azure Cache for Redis"
+
+ ```bash
+ kete.routes.azure.destination.kind=redis-pubsub
+ kete.routes.azure.destination.host=myredis.redis.cache.windows.net
+ kete.routes.azure.destination.port=6380
+ kete.routes.azure.destination.channel=keycloak-events
+ kete.routes.azure.destination.tls.enabled=true
+ kete.routes.azure.destination.password=your-access-key
+ ```
+
+
+
+## Features
+
+- Simple publish/subscribe messaging pattern
+- Low latency message delivery
+- TLS/SSL support with mutual TLS (mTLS)
+- Username/password authentication (Redis 6+)
+- Configurable connection and command timeouts
+- Automatic reconnection
+- Dynamic channel names (templating)
+
+!!! warning "No Message Headers"
+ Redis Pub/Sub does not support message headers (this is a protocol limitation). For header support, use [Redis Streams](redis-streams.md).
+
+
+
+## Configuration Properties
+
+### Required Properties
+
+| Property | Description | Example |
+|----------|-------------|---------|
+| `host` | Redis server hostname | `redis.example.com` |
+| `channel` | Redis channel to publish to (supports templating) | `keycloak-events` |
+
+### Dynamic Channels (Templating)
+
+The `channel` property supports template variables:
+
+```bash
+# Dynamic channel per realm
+kete.routes.redis.destination.channel=keycloak-${realmLowerCase}-events
+
+# Dynamic channel per event type
+kete.routes.redis.destination.channel=keycloak-${eventTypeLowerCase}
+```
+
+Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLowerCase}`, `${eventTypeUpperCase}`, `${kindLowerCase}`, `${kindUpperCase}`, `${resourceTypeLowerCase}`, `${resourceTypeUpperCase}`, `${operationTypeLowerCase}`, `${operationTypeUpperCase}`, `${resultLowerCase}`, `${resultUpperCase}`
+
+### Optional Properties
+
+| Property | Default | Description | Example |
+|----------|---------|-------------|---------|
+| `port` | `6379` (TCP) / `6380` (TLS) | Redis server port | `6380` |
+| `database` | `0` | Redis database number | `1` |
+| `username` | `""` | Redis username (Redis 6+) | `default` |
+| `password` | `""` | Redis password | `secret123` |
+| `client-name` | `kete` | Client name for connection | `keycloak-events` |
+| `connection-timeout-seconds` | `10` | Connection timeout in seconds | `30` |
+| `command-timeout-seconds` | `60` | Command timeout in seconds | `120` |
+
+### TLS Properties
+
+See [TLS & mTLS](overview.md#tls-mtls) for full details on TLS options.
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `tls.enabled` | `false` | Enable TLS (auto-enabled for port 6380) |
+| `tls.key-store.*` | - | Client certificate for mTLS |
+| `tls.trust-store.*` | - | CA certificates |
+
+
+
+## Configuration Examples
+
+### Basic Redis Pub/Sub
+
+```bash
+kete.routes.redis.destination.kind=redis-pubsub
+kete.routes.redis.realm-matchers.realm=list:master
+kete.routes.redis.destination.host=redis.example.com
+kete.routes.redis.destination.port=6379
+kete.routes.redis.destination.channel=keycloak-events
+```
+
+### Redis with TLS
+
+```bash
+kete.routes.secure-redis.destination.kind=redis-pubsub
+kete.routes.secure-redis.destination.host=redis.example.com
+kete.routes.secure-redis.destination.port=6380
+kete.routes.secure-redis.destination.channel=keycloak-events
+kete.routes.secure-redis.destination.tls.enabled=true
+kete.routes.secure-redis.destination.password=secret
+```
+
+### Redis with mTLS
+
+```bash
+kete.routes.mtls-redis.destination.kind=redis-pubsub
+kete.routes.mtls-redis.destination.host=secure-redis.example.com
+kete.routes.mtls-redis.destination.port=6380
+kete.routes.mtls-redis.destination.channel=keycloak-events
+kete.routes.mtls-redis.destination.tls.enabled=true
+kete.routes.mtls-redis.destination.tls.key-store.loader.kind=pkcs12-file-path
+kete.routes.mtls-redis.destination.tls.key-store.loader.path=/certs/client.p12
+kete.routes.mtls-redis.destination.tls.key-store.password=keystorepass
+kete.routes.mtls-redis.destination.tls.trust-store.loader.kind=jks-file-path
+kete.routes.mtls-redis.destination.tls.trust-store.loader.path=/certs/truststore.jks
+kete.routes.mtls-redis.destination.tls.trust-store.password=truststorepass
+```
+
+### Redis with Authentication and Database
+
+```bash
+kete.routes.auth-redis.destination.kind=redis-pubsub
+kete.routes.auth-redis.destination.host=redis.example.com
+kete.routes.auth-redis.destination.port=6379
+kete.routes.auth-redis.destination.channel=keycloak-events
+kete.routes.auth-redis.destination.database=2
+kete.routes.auth-redis.destination.username=keycloak
+kete.routes.auth-redis.destination.password=secret
+kete.routes.auth-redis.destination.client-name=keycloak-events
+```
diff --git a/docs/user-guide/destinations/redis-streams.md b/docs/user-guide/destinations/redis-streams.md
new file mode 100644
index 00000000..cbee28d5
--- /dev/null
+++ b/docs/user-guide/destinations/redis-streams.md
@@ -0,0 +1,241 @@
+# Redis Streams Destination
+
+Stream Keycloak events to Redis Streams for persistent, ordered message storage.
+
+| Property | Value |
+|----------|-------|
+| **`destination.kind`** | `redis-streams` |
+| **Protocol** | Redis RESP2/RESP3 |
+
+
+
+## Compatible Systems
+
+| System | Notes |
+|--------|-------|
+| **Redis 5.0+** | Native Streams support |
+| **Valkey** | Linux Foundation Redis fork, 100% compatible |
+| **Redis Stack** | Redis with additional modules |
+| **Amazon ElastiCache** | AWS-managed Redis/Valkey (6.2+) |
+| **Azure Cache for Redis** | Azure-managed Redis |
+| **Google Cloud Memorystore** | GCP-managed Redis/Valkey |
+| **Upstash** | Serverless Redis with Streams |
+| **Dragonfly** | Redis-compatible, multi-threaded |
+| **KeyDB** | Redis-compatible, multi-threaded |
+| **Microsoft Garnet** | High-performance Redis-compatible cache |
+
+
+
+## Example Configurations
+
+=== "Redis"
+
+ ```bash
+ kete.routes.redis.destination.kind=redis-streams
+ kete.routes.redis.destination.host=redis.example.com
+ kete.routes.redis.destination.port=6379
+ kete.routes.redis.destination.stream=keycloak-events
+ ```
+
+=== "Redis with Trimming"
+
+ ```bash
+ kete.routes.redis.destination.kind=redis-streams
+ kete.routes.redis.destination.host=redis.example.com
+ kete.routes.redis.destination.port=6379
+ kete.routes.redis.destination.stream=keycloak-events
+ kete.routes.redis.destination.max-len=10000
+ kete.routes.redis.destination.approximate-trimming=true
+ ```
+
+=== "Amazon ElastiCache"
+
+ ```bash
+ kete.routes.elasticache.destination.kind=redis-streams
+ kete.routes.elasticache.destination.host=my-cluster.abc123.cache.amazonaws.com
+ kete.routes.elasticache.destination.port=6379
+ kete.routes.elasticache.destination.stream=keycloak-events
+ kete.routes.elasticache.destination.max-len=50000
+ ```
+
+=== "Azure Cache for Redis"
+
+ ```bash
+ kete.routes.azure.destination.kind=redis-streams
+ kete.routes.azure.destination.host=myredis.redis.cache.windows.net
+ kete.routes.azure.destination.port=6380
+ kete.routes.azure.destination.stream=keycloak-events
+ kete.routes.azure.destination.tls.enabled=true
+ kete.routes.azure.destination.password=your-access-key
+ ```
+
+
+
+## Features
+
+- Persistent message storage with automatic trimming
+- Event metadata as stream fields (headers)
+- TLS/SSL support with mutual TLS (mTLS)
+- Username/password authentication (Redis 6+)
+- Configurable connection and command timeouts
+- Automatic reconnection
+- Dynamic stream names (templating)
+- Consumer group support (via Redis native features)
+
+!!! tip "When to use Redis Streams vs Pub/Sub"
+ Use **Redis Streams** when you need message persistence, consumer groups, or message headers.
+ Use **Redis Pub/Sub** for simple, low-latency fire-and-forget messaging.
+
+
+
+## Redis Streams Capabilities
+
+Redis Streams provides features not available in Pub/Sub:
+
+| Feature | Description |
+|---------|-------------|
+| **Persistence** | Messages are stored until explicitly deleted |
+| **Ordering** | Messages are strictly ordered by ID |
+| **Consumer Groups** | Load balancing across multiple consumers |
+| **Message Acknowledgment** | At-least-once delivery semantics |
+| **Message Headers** | Event metadata stored as stream fields |
+| **Stream Trimming** | Automatic size/age management |
+| **Replay** | Read messages from any point in the stream |
+
+
+
+## Configuration Properties
+
+### Required Properties
+
+| Property | Description | Example |
+|----------|-------------|---------|
+| `host` | Redis server hostname | `redis.example.com` |
+| `stream` | Redis stream name (supports templating) | `keycloak-events` |
+
+### Dynamic Streams (Templating)
+
+The `stream` property supports template variables:
+
+```bash
+# Dynamic stream per realm
+kete.routes.redis.destination.stream=keycloak-${realmLowerCase}-events
+
+# Dynamic stream per event type
+kete.routes.redis.destination.stream=keycloak-${eventTypeLowerCase}
+```
+
+Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLowerCase}`, `${eventTypeUpperCase}`, `${kindLowerCase}`, `${kindUpperCase}`, `${resourceTypeLowerCase}`, `${resourceTypeUpperCase}`, `${operationTypeLowerCase}`, `${operationTypeUpperCase}`, `${resultLowerCase}`, `${resultUpperCase}`
+
+### Custom Headers
+
+Custom headers can be added to Redis stream entries as additional fields:
+
+```bash
+kete.routes.redis.destination.headers.X-Source=keycloak
+kete.routes.redis.destination.headers.X-Environment=production
+```
+
+Headers are included as fields in the Redis stream entry.
+
+### Optional Properties
+
+| Property | Default | Description | Example |
+|----------|---------|-------------|---------|
+| `port` | `6379` (TCP) / `6380` (TLS) | Redis server port | `6380` |
+| `database` | `0` | Redis database number | `1` |
+| `username` | `""` | Redis username (Redis 6+) | `default` |
+| `password` | `""` | Redis password | `secret123` |
+| `client-name` | `kete` | Client name for connection | `keycloak-events` |
+| `connection-timeout-seconds` | `10` | Connection timeout in seconds | `30` |
+| `command-timeout-seconds` | `60` | Command timeout in seconds | `120` |
+| `max-len` | `0` | Max stream length (0 = no limit) | `10000` |
+| `approximate-trimming` | `true` | Use `~` for efficient trimming | `false` |
+
+### Stream Trimming
+
+Control stream size with `max-len` and `approximate-trimming`:
+
+| Setting | Behavior |
+|---------|----------|
+| `max-len=0` | No trimming, stream grows indefinitely |
+| `max-len=10000` + `approximate-trimming=true` | Trim to ~10000 entries (efficient) |
+| `max-len=10000` + `approximate-trimming=false` | Trim to exactly 10000 entries (slower) |
+
+!!! tip "Approximate Trimming"
+ Redis uses `MAXLEN ~` for approximate trimming, which is more efficient as it trims entries in whole nodes rather than one at a time. Recommended for most use cases.
+
+### TLS Properties
+
+See [TLS & mTLS](overview.md#tls-mtls) for full details on TLS options.
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `tls.enabled` | `false` | Enable TLS (auto-enabled for port 6380) |
+| `tls.key-store.*` | - | Client certificate for mTLS |
+| `tls.trust-store.*` | - | CA certificates |
+
+
+
+## Message Format
+
+Messages are stored as stream entries with the following fields:
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| `eventkind` | Event kind (`EVENT` or `ADMIN-EVENT`) | `EVENT` |
+| `eventtype` | Event type | `LOGIN` |
+| `contenttype` | Content type of body | `application/json` |
+| `body` | Serialized event payload | `{"id":"...","type":"LOGIN",...}` |
+
+
+
+## Configuration Examples
+
+### Basic Redis Streams
+
+```bash
+kete.routes.redis.destination.kind=redis-streams
+kete.routes.redis.realm-matchers.realm=list:master
+kete.routes.redis.destination.host=redis.example.com
+kete.routes.redis.destination.port=6379
+kete.routes.redis.destination.stream=keycloak-events
+```
+
+### Redis Streams with TLS
+
+```bash
+kete.routes.secure-redis.destination.kind=redis-streams
+kete.routes.secure-redis.destination.host=redis.example.com
+kete.routes.secure-redis.destination.port=6380
+kete.routes.secure-redis.destination.stream=keycloak-events
+kete.routes.secure-redis.destination.tls.enabled=true
+kete.routes.secure-redis.destination.password=secret
+```
+
+### Redis Streams with mTLS
+
+```bash
+kete.routes.mtls-redis.destination.kind=redis-streams
+kete.routes.mtls-redis.destination.host=secure-redis.example.com
+kete.routes.mtls-redis.destination.port=6380
+kete.routes.mtls-redis.destination.stream=keycloak-events
+kete.routes.mtls-redis.destination.tls.enabled=true
+kete.routes.mtls-redis.destination.tls.key-store.loader.kind=pkcs12-file-path
+kete.routes.mtls-redis.destination.tls.key-store.loader.path=/certs/client.p12
+kete.routes.mtls-redis.destination.tls.key-store.password=keystorepass
+kete.routes.mtls-redis.destination.tls.trust-store.loader.kind=jks-file-path
+kete.routes.mtls-redis.destination.tls.trust-store.loader.path=/certs/truststore.jks
+kete.routes.mtls-redis.destination.tls.trust-store.password=truststorepass
+```
+
+### Redis Streams with Trimming
+
+```bash
+kete.routes.trimmed-redis.destination.kind=redis-streams
+kete.routes.trimmed-redis.destination.host=redis.example.com
+kete.routes.trimmed-redis.destination.port=6379
+kete.routes.trimmed-redis.destination.stream=keycloak-events
+kete.routes.trimmed-redis.destination.max-len=50000
+kete.routes.trimmed-redis.destination.approximate-trimming=true
+```
diff --git a/docs/user-guide/destinations/stomp.md b/docs/user-guide/destinations/stomp.md
index 22622226..c5305147 100644
--- a/docs/user-guide/destinations/stomp.md
+++ b/docs/user-guide/destinations/stomp.md
@@ -16,15 +16,15 @@ STOMP (Simple Text Oriented Messaging Protocol) is supported by many enterprise
| System | STOMP Port | Notes |
|--------|:----------:|-------|
| **ActiveMQ Classic** | 61613 | Native support, widely deployed |
-| **ActiveMQ Artemis** | 61613 | Native support |
-| **RabbitMQ** | 61613 | Via STOMP plugin |
+| **ActiveMQ Artemis** | 61613/61616 | Native support (61616 is multi-protocol) |
+| **RabbitMQ** | 61613 | Via `rabbitmq_stomp` plugin |
+| **EMQX** | 61613 | Via STOMP gateway |
| **Amazon MQ** | 61614 | Managed ActiveMQ |
| **Apache Apollo** | 61613 | Native support |
| **HornetQ** | 61613 | Legacy, native support |
| **Solace PubSub+** | 61613 | Native support |
| **TIBCO EMS** | 61613 | Native support |
| **OpenMQ** | 61613 | Native support |
-| **LavinMQ** | 61613 | Native support |
!!! tip "When to Use STOMP"
STOMP is particularly useful for **ActiveMQ Classic**, which doesn't support AMQP 1.0 natively.
@@ -45,6 +45,21 @@ STOMP (Simple Text Oriented Messaging Protocol) is supported by many enterprise
kete.routes.activemq.destination.password=admin
```
+=== "ActiveMQ Artemis"
+
+ ```bash
+ kete.routes.artemis.destination.kind=stomp
+ kete.routes.artemis.destination.host=artemis.example.com
+ kete.routes.artemis.destination.port=61613
+ kete.routes.artemis.destination.destination=/queue/keycloak-events
+ kete.routes.artemis.destination.username=admin
+ kete.routes.artemis.destination.password=admin
+ ```
+
+ !!! warning "Artemis Broker Configuration Required"
+ ActiveMQ Artemis requires the STOMP acceptor to be configured with `anycastPrefix` and `multicastPrefix`
+ for `/queue/*` destinations to create ANYCAST queues instead of MULTICAST addresses. See [Artemis Configuration](#artemis-configuration) below.
+
=== "Amazon MQ"
```bash
@@ -115,6 +130,17 @@ kete.routes.stomp.destination.destination=/topic/keycloak/${eventTypeLowerCase}
Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLowerCase}`, `${eventTypeUpperCase}`, `${kindLowerCase}`, `${kindUpperCase}`, `${resourceTypeLowerCase}`, `${resourceTypeUpperCase}`, `${operationTypeLowerCase}`, `${operationTypeUpperCase}`, `${resultLowerCase}`, `${resultUpperCase}`
+### Custom Headers
+
+Custom headers can be added to STOMP messages:
+
+```bash
+kete.routes.stomp.destination.headers.X-Source=keycloak
+kete.routes.stomp.destination.headers.X-Environment=production
+```
+
+Headers are included in the STOMP message headers.
+
### Optional Properties
| Property | Default | Description | Example |
@@ -123,13 +149,13 @@ Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLower
| `destination.username` | `""` | STOMP login username | `admin` |
| `destination.password` | `""` | STOMP login passcode | `secret` |
| `destination.virtual-host` | Same as `host` | Virtual host for STOMP CONNECT | `/` |
-| `destination.receipt-enabled` | `false` | Wait for broker receipt acknowledgment | `true` |
-| `destination.heart-beat-outgoing` | `30000` | Outgoing heart-beat interval (ms), 0=disabled | `10000` |
-| `destination.heart-beat-incoming` | `30000` | Incoming heart-beat interval (ms), 0=disabled | `10000` |
-| `destination.read-timeout-millis` | `30000` | Socket read timeout in milliseconds | `60000` |
-| `destination.message-headers-enabled` | `true` | Include event metadata as STOMP headers | `false` |
-| `destination.min-pool-size` | `5` | Minimum connections in pool | `10` |
-| `destination.max-pool-size` | `20` | Maximum connections in pool | `50` |
+| `destination.receipt-enabled` | `true` | Wait for broker receipt acknowledgment | `false` |
+| `destination.heart-beat-outgoing-seconds` | `30` | Outgoing heart-beat interval in seconds, 0=disabled | `10` |
+| `destination.heart-beat-incoming-seconds` | `30` | Incoming heart-beat interval in seconds, 0=disabled | `10` |
+| `destination.read-timeout-seconds` | `30` | Socket read timeout in seconds | `60` |
+| `destination.pool.min-idle` | `1` | Minimum idle connections in pool | `5` |
+| `destination.pool.max-idle` | `10` | Maximum idle connections in pool | `20` |
+| `destination.pool.max-total` | `20` | Maximum total connections in pool | `50` |
### TLS Properties
@@ -155,9 +181,43 @@ STOMP destinations follow broker-specific conventions:
+## Artemis Configuration
+
+ActiveMQ Artemis requires special STOMP acceptor configuration for `/queue/*` destinations to work correctly.
+
+!!! danger "Critical: Without this configuration, messages fail"
+ By default, Artemis creates MULTICAST addresses for all STOMP destinations. This causes messages sent to
+ `/queue/keycloak-events` to be undeliverable because no queue is bound to the MULTICAST address.
+
+Add the `anycastPrefix` and `multicastPrefix` parameters to your Artemis STOMP acceptor in `broker.xml`:
+
+```xml
+
+
+
+ tcp://0.0.0.0:61613?protocols=STOMP;anycastPrefix=/queue/;multicastPrefix=/topic/
+
+
+```
+
+This configuration tells Artemis:
+
+- Destinations starting with `/queue/` β Create **ANYCAST** addresses (point-to-point queues)
+- Destinations starting with `/topic/` β Create **MULTICAST** addresses (pub/sub topics)
+
+For a complete minimal `broker.xml` for Docker deployments, see the
+[stomp-artemis quickstart](https://github.com/FortuneN/kete/tree/develop/quick-starts/stomp-artemis).
+
+**References:**
+
+- [Stack Overflow: STOMP sending to queue but arriving as topic on Artemis](https://stackoverflow.com/questions/68895552/stomp-sending-to-queue-but-arriving-as-topic-on-artemis)
+- [Artemis STOMP Documentation](https://activemq.apache.org/components/artemis/documentation/latest/stomp.html)
+
+
+
## Message Headers
-When `message-headers-enabled=true` (default), the following STOMP headers are included:
+The following STOMP headers are always included with each message:
| Header | Description |
|--------|-------------|
@@ -212,6 +272,6 @@ kete.routes.secure-stomp.destination.tls.trust-store.path=/certs/ca.pem
kete.routes.heartbeat.destination.kind=stomp
kete.routes.heartbeat.destination.host=activemq.example.com
kete.routes.heartbeat.destination.destination=/queue/keycloak-events
-kete.routes.heartbeat.destination.heart-beat-outgoing=10000
-kete.routes.heartbeat.destination.heart-beat-incoming=10000
+kete.routes.heartbeat.destination.heart-beat-outgoing-seconds=10
+kete.routes.heartbeat.destination.heart-beat-incoming-seconds=10
```
diff --git a/docs/user-guide/destinations/support-matrix.md b/docs/user-guide/destinations/support-matrix.md
new file mode 100644
index 00000000..4ade2fad
--- /dev/null
+++ b/docs/user-guide/destinations/support-matrix.md
@@ -0,0 +1,374 @@
+# Destination Support Matrix
+
+This page provides a comprehensive cross-reference of message brokers and the destinations KETE supports for each.
+
+## Quick Reference Matrix
+
+| Broker | AMQP 0.9.1 | AMQP 1.0 | MQTT 3 | MQTT 5 | STOMP | Kafka | Redis | NATS | Pulsar | HTTP | WebSocket |
+|--------|:----------:|:--------:|:------:|:------:|:-----:|:-----:|:-----:|:----:|:------:|:----:|:---------:|
+| **RabbitMQ** | β
| β
4.0+ | β
Plugin | β
Plugin | β
Plugin | β | β | β | β | β | β
Plugin |
+| **LavinMQ** | β
| β | β | β | β | β | β | β | β | β | β
|
+| **Redis** | β | β | β | β | β | β | β
| β | β | β | β |
+| **Valkey** | β | β | β | β | β | β | β
| β | β | β | β |
+| **Dragonfly** | β | β | β | β | β | β | β
| β | β | β | β |
+| **KeyDB** | β | β | β | β | β | β | β
| β | β | β | β |
+| **Microsoft Garnet** | β | β | β | β | β | β | β
| β | β | β | β |
+| **Amazon ElastiCache** | β | β | β | β | β | β | β
| β | β | β | β |
+| **Azure Cache for Redis** | β | β | β | β | β | β | β
| β | β | β | β |
+| **Google Cloud Memorystore** | β | β | β | β | β | β | β
| β | β | β | β |
+| **Upstash** | β | β | β | β | β | β | β
| β | β | β | β |
+| **ActiveMQ Artemis** | β | β
| β
| β
| β
| β | β | β | β | β | β |
+| **ActiveMQ Classic** | β | β
| β
| β | β
| β | β | β | β | β | β |
+| **Eclipse Mosquitto** | β | β | β
| β
2.0+ | β | β | β | β | β | β | β
|
+| **HiveMQ** | β | β | β
| β
| β | β | β | β | β | β | β
|
+| **HiveMQ Cloud** | β | β | β
| β
| β | β | β | β | β | β | β
|
+| **EMQX** | β | β | β
| β
| β
Gateway | β | β | β | β | β | β
|
+| **EMQX Cloud** | β | β | β
| β
| β
Gateway | β | β | β | β | β | β
|
+| **NanoMQ** | β | β | β
| β
| β | β | β | β | β | β | β
|
+| **VerneMQ** | β | β | β
| β
| β | β | β | β | β | β | β
|
+| **Apache Qpid** | β | β
| β | β | β | β | β | β | β | β | β |
+| **IBM MQ** | β | β
| β
| β | β | β | β | β | β | β | β |
+| **NATS Server** | β | β | β | β | β | β | β | β
| β | β | β |
+| **Synadia Cloud** | β | β | β | β | β | β | β | β
| β | β | β |
+| **Apache Pulsar** | β | β | β | β | β | β | β | β | β
| β | β |
+| **StreamNative Cloud** | β | β | β | β | β | β | β | β | β
| β | β |
+| **DataStax Astra Streaming** | β | β | β | β | β | β | β | β | β
| β | β |
+| **Azure Service Bus** | β | β
| β | β | β | β | β | β | β | β
| β |
+| **Azure Event Hubs** | β | β
| β | β | β | β
| β | β | β | β
| β |
+| **Azure Event Grid** | β | β | β
| β
| β | β | β | β | β | β
| β
|
+| **Apache Kafka** | β | β | β | β | β | β
| β | β | β | β | β |
+| **Amazon MSK** | β | β | β | β | β | β
| β | β | β | β | β |
+| **Redpanda** | β | β | β | β | β | β
| β | β | β | β | β |
+| **Confluent Cloud** | β | β | β | β | β | β
| β | β | β | β | β |
+| **Aiven for Kafka** | β | β | β | β | β | β
| β | β | β | β | β |
+| **Strimzi** | β | β | β | β | β | β
| β | β | β | β | β |
+| **Solace PubSub+** | β | β | β
| β
| β
| β | β | β | β | β
| β
|
+| **AWS IoT Core** | β | β | β
| β | β | β | β | β | β | β
| β
|
+| **Azure IoT Hub** | β | β | β
| β | β | β | β | β | β | β
| β |
+
+
+
+## Multi-Protocol Brokers
+
+These brokers support **multiple protocols**, giving you flexibility in how you connect:
+
+### RabbitMQ
+
+RabbitMQ is highly versatile, supporting many protocols via plugins:
+
+| Protocol | KETE Destination | Port | Notes |
+|----------|------------------|:----:|-------|
+| AMQP 0.9.1 | `amqp-0.9.1` | 5672 | Native protocol |
+| AMQP 1.0 | `amqp-1` | 5672 | Native in RabbitMQ 4.0+ |
+| MQTT 3.1.1 | `mqtt-3` | 1883 | Via `rabbitmq_mqtt` plugin |
+| MQTT 5 | `mqtt-5` | 1883 | Via `rabbitmq_mqtt` plugin (3.13+) |
+| STOMP | `stomp` | 61613 | Via `rabbitmq_stomp` plugin |
+
+**Quickstarts available:** [amqp-0.9.1-rabbitmq](../../quick-starts/amqp-0.9.1-rabbitmq/), [amqp-1-rabbitmq](../../quick-starts/amqp-1-rabbitmq/), [mqtt-3-rabbitmq](../../quick-starts/mqtt-3-rabbitmq/), [mqtt-5-rabbitmq](../../quick-starts/mqtt-5-rabbitmq/), [stomp-rabbitmq](../../quick-starts/stomp-rabbitmq/)
+
+
+### ActiveMQ Artemis
+
+Apache ActiveMQ Artemis is a high-performance multi-protocol broker:
+
+| Protocol | KETE Destination | Port | Notes |
+|----------|------------------|:----:|-------|
+| AMQP 1.0 | `amqp-1` | 5672 | Native support |
+| MQTT 3.1.1 | `mqtt-3` | 1883 | Native support |
+| MQTT 5 | `mqtt-5` | 1883 | Native support |
+| STOMP | `stomp` | 61613/61616 | Requires `anycastPrefix` config (see [STOMP docs](stomp.md#artemis-configuration)) |
+
+**Quickstarts available:** [amqp-1-activemq](../../quick-starts/amqp-1-activemq/), [stomp-activemq](../../quick-starts/stomp-activemq/), [stomp-artemis](../../quick-starts/stomp-artemis/)
+
+
+### EMQX
+
+EMQX is a high-performance MQTT broker with multi-protocol gateway support:
+
+| Protocol | KETE Destination | Port | Notes |
+|----------|------------------|:----:|-------|
+| MQTT 3.1.1 | `mqtt-3` | 1883 | Native support |
+| MQTT 5 | `mqtt-5` | 1883 | Full MQTT 5 compliance |
+| STOMP | `stomp` | 61613 | Via STOMP gateway |
+
+**Quickstarts available:** [mqtt-3-emqx](../../quick-starts/mqtt-3-emqx/), [mqtt-5-emqx](../../quick-starts/mqtt-5-emqx/)
+
+
+### Solace PubSub+
+
+Solace supports multiple open protocols:
+
+| Protocol | KETE Destination | Port | Notes |
+|----------|------------------|:----:|-------|
+| MQTT 3.1.1 | `mqtt-3` | 1883 | Native support |
+| MQTT 5 | `mqtt-5` | 1883 | Native support |
+| STOMP | `stomp` | 61613 | Native support |
+
+
+### Apache Pulsar
+
+Apache Pulsar is a cloud-native, distributed messaging and streaming platform:
+
+| Protocol | KETE Destination | Port | Notes |
+|----------|------------------|:----:|-------|
+| Pulsar | `pulsar` | 6650 | Native protocol, TLS support on 6651 |
+
+**Quickstarts available:** [pulsar-apache](../../quick-starts/pulsar-apache/)
+
+
+
+
+
+Use `destination.kind=amqp-0.9.1`:
+
+- **RabbitMQ** - Primary target
+- **LavinMQ** - Lightweight alternative
+- **CloudAMQP** - Managed RabbitMQ
+- **Amazon MQ for RabbitMQ** - AWS managed
+
+**Quickstarts available:** [amqp-0.9.1-rabbitmq](../../quick-starts/amqp-0.9.1-rabbitmq/), [amqp-0.9.1-lavinmq](../../quick-starts/amqp-0.9.1-lavinmq/)
+
+See: [AMQP 0.9.1 Destination](amqp-0.9.1.md)
+
+
+### AMQP 1.0 Brokers
+
+Use `destination.kind=amqp-1`:
+
+- **Apache ActiveMQ Artemis** - Primary target
+- **RabbitMQ 4.0+** - Native AMQP 1.0 support
+- **Azure Service Bus** - TLS required
+- **Azure Event Hubs** - Event streaming
+- **Apache Qpid** - Reference implementation
+- **Amazon MQ** - Managed ActiveMQ
+- **Solace PubSub+** - Multi-protocol
+
+**Quickstarts available:** [amqp-1-activemq](../../quick-starts/amqp-1-activemq/), [amqp-1-azure-event-hubs](../../quick-starts/amqp-1-azure-event-hubs/), [amqp-1-azure-service-bus](../../quick-starts/amqp-1-azure-service-bus/), [amqp-1-azure-service-bus-emulator](../../quick-starts/amqp-1-azure-service-bus-emulator/), [amqp-1-qpid](../../quick-starts/amqp-1-qpid/), [amqp-1-rabbitmq](../../quick-starts/amqp-1-rabbitmq/)
+
+See: [AMQP 1 Destination](amqp-1.md)
+
+
+### MQTT 3.1.1 Brokers
+
+Use `destination.kind=mqtt-3`:
+
+- **Eclipse Mosquitto** - Most popular open-source
+- **HiveMQ** - Enterprise, clustering
+- **EMQX** - High-performance
+- **VerneMQ** - Distributed
+- **NanoMQ** - Lightweight
+- **RabbitMQ** - Via plugin
+- **Azure Event Grid** - MQTT Broker feature
+- **Solace PubSub+** - Native
+- **AWS IoT Core** - Managed
+- **Azure IoT Hub** - Managed
+
+**Quickstarts available:** [mqtt-3-mosquitto](../../quick-starts/mqtt-3-mosquitto/), [mqtt-3-emqx](../../quick-starts/mqtt-3-emqx/), [mqtt-3-hivemq](../../quick-starts/mqtt-3-hivemq/), [mqtt-3-vernemq](../../quick-starts/mqtt-3-vernemq/), [mqtt-3-nanomq](../../quick-starts/mqtt-3-nanomq/), [mqtt-3-rabbitmq](../../quick-starts/mqtt-3-rabbitmq/)
+
+See: [MQTT 3 Destination](mqtt-3.md)
+
+
+### MQTT 5 Brokers
+
+Use `destination.kind=mqtt-5`:
+
+- **HiveMQ** - Full MQTT 5
+- **EMQX** - Full MQTT 5
+- **Eclipse Mosquitto 2.0+** - MQTT 5 since v2.0
+- **VerneMQ** - Full MQTT 5
+- **NanoMQ** - Lightweight
+- **RabbitMQ** - Via plugin (3.13+)
+- **Azure Event Grid** - MQTT Broker
+- **Solace PubSub+** - Native
+
+**Quickstarts available:** [mqtt-5-mosquitto](../../quick-starts/mqtt-5-mosquitto/), [mqtt-5-emqx](../../quick-starts/mqtt-5-emqx/), [mqtt-5-hivemq](../../quick-starts/mqtt-5-hivemq/), [mqtt-5-vernemq](../../quick-starts/mqtt-5-vernemq/), [mqtt-5-nanomq](../../quick-starts/mqtt-5-nanomq/), [mqtt-5-rabbitmq](../../quick-starts/mqtt-5-rabbitmq/), [mqtt-5-azure-event-grid](../../quick-starts/mqtt-5-azure-event-grid/)
+
+See: [MQTT 5 Destination](mqtt-5.md)
+
+
+### STOMP Brokers
+
+Use `destination.kind=stomp`:
+
+- **ActiveMQ Classic** - Native, widely deployed
+- **ActiveMQ Artemis** - Native
+- **RabbitMQ** - Via plugin
+- **EMQX** - Via gateway
+- **Solace PubSub+** - Native
+- **Amazon MQ** - Managed ActiveMQ
+- **TIBCO EMS** - Native
+
+**Quickstarts available:** [stomp-activemq](../../quick-starts/stomp-activemq/), [stomp-artemis](../../quick-starts/stomp-artemis/), [stomp-rabbitmq](../../quick-starts/stomp-rabbitmq/), [stomp-emqx](../../quick-starts/stomp-emqx/)
+
+See: [STOMP Destination](stomp.md)
+
+
+### Kafka-Compatible Systems
+
+Use `destination.kind=kafka`:
+
+- **Apache Kafka** - Reference implementation
+- **Redpanda** - Kafka-compatible
+- **Confluent Cloud** - Managed Kafka
+- **Azure Event Hubs** - Kafka protocol support
+- **Amazon MSK** - Managed Kafka
+- **Aiven for Kafka** - Managed
+
+**Quickstarts available:** [kafka-apache](../../quick-starts/kafka-apache/), [kafka-redpanda](../../quick-starts/kafka-redpanda/), [kafka-confluent](../../quick-starts/kafka-confluent/), [kafka-azure-event-hubs](../../quick-starts/kafka-azure-event-hubs/), [kafka-azure-event-hubs-emulator](../../quick-starts/kafka-azure-event-hubs-emulator/)
+
+See: [Kafka Destination](kafka.md)
+
+
+### Redis-Compatible Systems
+
+Use `destination.kind=redis-pubsub` or `destination.kind=redis-streams`:
+
+- **Redis** - Open-source, in-memory data store
+- **Redis Stack** - Redis with additional modules
+- **Valkey** - Redis fork, fully compatible
+- **Dragonfly** - Redis-compatible, multi-threaded
+- **KeyDB** - Redis-compatible, multi-threaded
+- **Amazon ElastiCache** - AWS-managed Redis
+- **Azure Cache for Redis** - Azure-managed Redis
+- **Google Cloud Memorystore** - GCP-managed Redis
+- **Upstash** - Serverless Redis
+
+**Quickstarts available (Pub/Sub):** [redis-pubsub-redis](../../quick-starts/redis-pubsub-redis/), [redis-pubsub-valkey](../../quick-starts/redis-pubsub-valkey/), [redis-pubsub-dragonfly](../../quick-starts/redis-pubsub-dragonfly/), [redis-pubsub-keydb](../../quick-starts/redis-pubsub-keydb/), [redis-pubsub-azure-cache-for-redis](../../quick-starts/redis-pubsub-azure-cache-for-redis/), [redis-pubsub-upstash](../../quick-starts/redis-pubsub-upstash/)
+
+**Quickstarts available (Streams):** [redis-streams-redis](../../quick-starts/redis-streams-redis/), [redis-streams-valkey](../../quick-starts/redis-streams-valkey/), [redis-streams-dragonfly](../../quick-starts/redis-streams-dragonfly/), [redis-streams-keydb](../../quick-starts/redis-streams-keydb/), [redis-streams-azure-cache-for-redis](../../quick-starts/redis-streams-azure-cache-for-redis/), [redis-streams-upstash](../../quick-starts/redis-streams-upstash/)
+
+See: [Redis Pub/Sub Destination](redis-pubsub.md), [Redis Streams Destination](redis-streams.md)
+
+
+### NATS-Compatible Systems
+
+Use `destination.kind=nats` or `destination.kind=nats-jetstream`:
+
+- **NATS Server** - Open-source messaging system
+- **Synadia Cloud** - Managed NATS service
+- **NATS Kubernetes** - Self-hosted NATS on Kubernetes
+
+**Quickstarts available:** [nats-nats-server](../../quick-starts/nats-nats-server/), [nats-jetstream-nats-server](../../quick-starts/nats-jetstream-nats-server/)
+
+See: [NATS Destination](nats.md), [NATS JetStream Destination](nats-jetstream.md)
+
+
+
+## Choosing the Right Protocol
+
+### Decision Guide
+
+```
+Is your broker RabbitMQ or LavinMQ?
+βββ Yes β Use amqp-0.9.1 (native protocol, best performance)
+βββ No β Continue...
+
+Is your broker Kafka, Redpanda, or Event Hubs (Kafka mode)?
+βββ Yes β Use kafka
+βββ No β Continue...
+
+Is your broker Redis, ElastiCache, Azure Cache for Redis, Dragonfly, KeyDB, or Upstash?
+βββ Yes β Do you need message persistence?
+β βββ Yes β Use redis-streams (persistent, consumer groups, headers)
+β βββ No β Use redis-pubsub (fire-and-forget, lower latency)
+βββ No β Continue...
+
+Is your broker NATS Server or Synadia Cloud?
+βββ Yes β Do you need message persistence?
+β βββ Yes β Use nats-jetstream (persistent, acknowledgments)
+β βββ No β Use nats (fire-and-forget, lowest latency)
+βββ No β Continue...
+
+Is your broker ActiveMQ Artemis, Azure Service Bus, or Qpid?
+βββ Yes β Use amqp-1
+βββ No β Continue...
+
+Is your broker an MQTT broker (Mosquitto, HiveMQ, EMQX, VerneMQ)?
+βββ Yes β Does it support MQTT 5?
+β βββ Yes β Use mqtt-5 (for user properties/headers)
+β βββ No β Use mqtt-3
+βββ No β Continue...
+
+Is your broker ActiveMQ Classic or needs STOMP?
+βββ Yes β Use stomp
+βββ No β Continue...
+
+Is it a REST API, webhook, or HTTP endpoint?
+βββ Yes β Use http
+βββ No β Use websocket for generic WebSocket servers
+```
+
+### Performance Considerations
+
+| Protocol | Throughput | Latency | Best For |
+|----------|:----------:|:-------:|----------|
+| `kafka` | βββββ | βββ | High-volume event streaming |
+| `pulsar` | βββββ | βββ | Multi-tenancy, geo-replication |
+| `nats-jetstream` | βββββ | ββββ | Persistent messaging with acknowledgments |
+| `nats` | βββββ | βββββ | Fire-and-forget, ultra-low latency |
+| `redis-streams` | βββββ | βββββ | Persistent messaging, consumer groups |
+| `redis-pubsub` | ββββ | βββββ | Fire-and-forget, lowest latency |
+| `amqp-0.9.1` | ββββ | ββββ | RabbitMQ workloads |
+| `amqp-1` | ββββ | ββββ | Enterprise messaging |
+| `mqtt-5` | ββββ | βββββ | IoT, lightweight clients |
+| `mqtt-3` | ββββ | βββββ | IoT, legacy support |
+| `stomp` | βββ | βββ | Text-based, debugging |
+| `http` | ββ | ββ | Webhooks, integrations |
+| `websocket` | βββ | ββββ | Real-time dashboards |
+
+
+
+## Available Quickstarts
+
+All quickstarts are in the `quick-starts/` directory:
+
+| Protocol | Broker | Quickstart Folder |
+|----------|--------|-------------------|
+| AMQP 0.9.1 | RabbitMQ | `amqp-0.9.1-rabbitmq/` |
+| AMQP 0.9.1 | LavinMQ | `amqp-0.9.1-lavinmq/` |
+| AMQP 1.0 | ActiveMQ | `amqp-1-activemq/` |
+| AMQP 1.0 | Azure Event Hubs | `amqp-1-azure-event-hubs/` |
+| AMQP 1.0 | Azure Service Bus | `amqp-1-azure-service-bus/` |
+| AMQP 1.0 | Azure Service Bus Emulator | `amqp-1-azure-service-bus-emulator/` |
+| AMQP 1.0 | Apache Qpid | `amqp-1-qpid/` |
+| AMQP 1.0 | RabbitMQ | `amqp-1-rabbitmq/` |
+| MQTT 3 | Mosquitto | `mqtt-3-mosquitto/` |
+| MQTT 3 | EMQX | `mqtt-3-emqx/` |
+| MQTT 3 | RabbitMQ | `mqtt-3-rabbitmq/` |
+| MQTT 3 | HiveMQ | `mqtt-3-hivemq/` |
+| MQTT 3 | VerneMQ | `mqtt-3-vernemq/` |
+| MQTT 3 | NanoMQ | `mqtt-3-nanomq/` |
+| MQTT 5 | Mosquitto | `mqtt-5-mosquitto/` |
+| MQTT 5 | EMQX | `mqtt-5-emqx/` |
+| MQTT 5 | HiveMQ | `mqtt-5-hivemq/` |
+| MQTT 5 | Azure Event Grid | `mqtt-5-azure-event-grid/` |
+| MQTT 5 | RabbitMQ | `mqtt-5-rabbitmq/` |
+| MQTT 5 | VerneMQ | `mqtt-5-vernemq/` |
+| MQTT 5 | NanoMQ | `mqtt-5-nanomq/` |
+| STOMP | ActiveMQ | `stomp-activemq/` |
+| STOMP | Artemis | `stomp-artemis/` |
+| STOMP | RabbitMQ | `stomp-rabbitmq/` |
+| STOMP | EMQX | `stomp-emqx/` |
+| Kafka | Apache Kafka | `kafka-apache/` |
+| Kafka | Azure Event Hubs | `kafka-azure-event-hubs/` |
+| Kafka | Azure Event Hubs Emulator | `kafka-azure-event-hubs-emulator/` |
+| Kafka | Redpanda | `kafka-redpanda/` |
+| Kafka | Confluent | `kafka-confluent/` |
+| Redis Pub/Sub | Redis | `redis-pubsub-redis/` |
+| Redis Pub/Sub | Valkey | `redis-pubsub-valkey/` |
+| Redis Pub/Sub | Dragonfly | `redis-pubsub-dragonfly/` |
+| Redis Pub/Sub | KeyDB | `redis-pubsub-keydb/` |
+| Redis Pub/Sub | Azure Cache for Redis | `redis-pubsub-azure-cache-for-redis/` |
+| Redis Pub/Sub | Upstash | `redis-pubsub-upstash/` |
+| Redis Streams | Redis | `redis-streams-redis/` |
+| Redis Streams | Valkey | `redis-streams-valkey/` |
+| Redis Streams | Dragonfly | `redis-streams-dragonfly/` |
+| Redis Streams | KeyDB | `redis-streams-keydb/` |
+| Redis Streams | Azure Cache for Redis | `redis-streams-azure-cache-for-redis/` |
+| Redis Streams | Upstash | `redis-streams-upstash/` |
+| NATS | NATS Server | `nats-nats-server/` |
+| NATS JetStream | NATS Server | `nats-jetstream-nats-server/` |
+| Pulsar | Apache Pulsar | `pulsar-apache/` |
+| HTTP | Azure Event Grid | `http-azure-event-grid/` |
+| HTTP | Webhook | `http-webhook/` |
+| WebSocket | Echo Server | `websocket-echo/` |
diff --git a/docs/user-guide/destinations/websocket.md b/docs/user-guide/destinations/websocket.md
index 19f760ce..2b08d7ab 100644
--- a/docs/user-guide/destinations/websocket.md
+++ b/docs/user-guide/destinations/websocket.md
@@ -13,6 +13,13 @@ Stream Keycloak events to WebSocket servers.
| System | Notes |
|--------|-------|
+| **Eclipse Mosquitto** | MQTT over WebSocket (port 9001) |
+| **HiveMQ** | MQTT over WebSocket |
+| **EMQX** | MQTT over WebSocket (port 8083/8084) |
+| **VerneMQ** | MQTT over WebSocket |
+| **RabbitMQ** | Via `rabbitmq_web_stomp` or `rabbitmq_web_mqtt` plugins |
+| **Azure Web PubSub** | Managed WebSocket service |
+| **Azure Event Grid** | WebSocket support for MQTT |
| **Any WebSocket server** | Standard RFC 6455 compatible |
| **Custom backends** | Node.js, Python, Go, Java WebSocket servers |
| **Real-time dashboards** | Live event monitoring applications |
@@ -20,6 +27,9 @@ Stream Keycloak events to WebSocket servers.
| **API Gateways** | Kong, AWS API Gateway WebSocket APIs |
| **Serverless** | AWS API Gateway WebSocket, Azure Web PubSub |
+!!! note "WebSocket vs Native Protocol"
+ Many message brokers support WebSocket as a **transport layer** for their native protocol (e.g., MQTT over WebSocket, STOMP over WebSocket). KETE's WebSocket destination sends raw messages directly to WebSocket endpoints, which is ideal for custom backends and dashboards. For broker integrations, consider using the native protocol destination (e.g., `mqtt-3`, `stomp`) with the broker's WebSocket port.
+
## Example Configurations
@@ -47,6 +57,30 @@ Stream Keycloak events to WebSocket servers.
kete.routes.ws-auth.destination.headers.X-API-Key=my-api-key
```
+=== "With OAuth 2.0 (External)"
+
+ ```bash
+ kete.routes.oauth-ws.realm-matchers.realm=list:master
+ kete.routes.oauth-ws.destination.kind=websocket
+ kete.routes.oauth-ws.destination.url=wss://api.example.com/events
+ kete.routes.oauth-ws.destination.oauth.enabled=true
+ kete.routes.oauth-ws.destination.oauth.token-url=https://auth.example.com/oauth/token
+ kete.routes.oauth-ws.destination.oauth.client-id=keycloak-client
+ kete.routes.oauth-ws.destination.oauth.client-secret=secret
+ kete.routes.oauth-ws.destination.oauth.scope=events:write
+ ```
+
+=== "With OAuth 2.0 (Internal)"
+
+ ```bash
+ # Uses Keycloak itself as OAuth server - auto-creates client!
+ kete.routes.internal-oauth-ws.realm-matchers.realm=list:master
+ kete.routes.internal-oauth-ws.destination.kind=websocket
+ kete.routes.internal-oauth-ws.destination.url=wss://api.example.com/events
+ kete.routes.internal-oauth-ws.destination.oauth.enabled=true
+ kete.routes.internal-oauth-ws.destination.oauth.mode=internal
+ ```
+
=== "Binary Mode"
```bash
@@ -61,6 +95,7 @@ Stream Keycloak events to WebSocket servers.
- β
Text and binary message modes
- β
TLS/SSL support with mutual TLS (mTLS)
+- β
OAuth 2.0 Client Credentials with token caching
- β
Custom headers for authentication
- β
Automatic reconnection on connection loss
- β
Configurable connection timeout
@@ -75,9 +110,32 @@ Stream Keycloak events to WebSocket servers.
| Property | Description | Example |
|----------|-------------|---------|
| `destination.kind` | Must be `websocket` | `websocket` |
-| `destination.url` | Full WebSocket URL | `ws://server:8080/path` |
+| `destination.url` | Full WebSocket URL (supports templating) | `ws://server:8080/events/${realmLowerCase}` |
+
+### Dynamic URLs (Templating)
+
+The `url` property supports template variables:
-Alternatively, use individual properties:
+```bash
+# Dynamic URL per realm
+kete.routes.ws.destination.url=ws://websocket-server.example.com:8080/events/${realmLowerCase}
+
+# Dynamic URL with event type
+kete.routes.ws.destination.url=ws://websocket-server.example.com/${kindLowerCase}/${eventTypeLowerCase}
+```
+
+Available variables: `${realmLowerCase}`, `${realmUpperCase}`, `${eventTypeLowerCase}`, `${eventTypeUpperCase}`, `${kindLowerCase}`, `${kindUpperCase}`, `${resourceTypeLowerCase}`, `${resourceTypeUpperCase}`, `${operationTypeLowerCase}`, `${operationTypeUpperCase}`, `${resultLowerCase}`, `${resultUpperCase}`
+
+When using `destination.url`:
+
+- The **scheme** must be `ws://` or `wss://`
+- **TLS is auto-enabled** when the scheme is `wss://`
+- The **host**, **port**, and **path** are extracted from the URL
+- The **path and query string** can include template variables for dynamic routing
+
+If both `url` and individual properties are specified, `url` takes precedence.
+
+### Alternative: Individual Properties
| Property | Description | Example |
|----------|-------------|---------|
@@ -90,10 +148,11 @@ Alternatively, use individual properties:
| `destination.port` | `80` (WS) / `443` (WSS) | WebSocket server port | `8080` |
| `destination.path` | `/` | URL path | `/events` |
| `destination.binary-mode` | `false` | Send as binary frames (not text) | `true` |
-| `destination.connection-timeout` | `10` | Connection timeout in seconds | `30` |
-| `destination.connection-lost-timeout` | `60` | Heartbeat timeout in seconds (0 = disabled). Uses WebSocket ping/pong to detect dead connections. | `30` |
-| `destination.min-pool-size` | `5` | Minimum connections in pool | `10` |
-| `destination.max-pool-size` | `20` | Maximum connections in pool | `50` |
+| `destination.connection-timeout-seconds` | `10` | Connection timeout in seconds | `30` |
+| `destination.connection-lost-timeout-seconds` | `60` | Heartbeat timeout in seconds (0 = disabled). Uses WebSocket ping/pong to detect dead connections. | `30` |
+| `destination.pool.min-idle` | `1` | Minimum idle connections in pool | `5` |
+| `destination.pool.max-idle` | `10` | Maximum idle connections in pool | `20` |
+| `destination.pool.max-total` | `20` | Maximum total connections in pool | `50` |
### Custom Headers
@@ -104,6 +163,53 @@ kete.routes.ws.destination.headers.Authorization=Bearer my-token
kete.routes.ws.destination.headers.X-Custom-Header=value
```
+### OAuth 2.0 Properties
+
+The WebSocket destination supports two OAuth modes:
+
+#### External Mode (Default)
+
+Use an external OAuth 2.0 authorization server:
+
+| Property | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `destination.oauth.enabled` | No | `false` | Enable OAuth 2.0 Client Credentials flow |
+| `destination.oauth.mode` | No | `external` | OAuth mode: `external` or `internal` |
+| `destination.oauth.token-url` | Yes* | - | OAuth token endpoint URL |
+| `destination.oauth.client-id` | Yes* | - | OAuth client ID |
+| `destination.oauth.client-secret` | Yes* | - | OAuth client secret |
+| `destination.oauth.scope` | No | `""` | Requested OAuth scopes (space-separated) |
+
+*Required when `oauth.enabled=true` and `oauth.mode=external`.
+
+#### Internal Mode
+
+Use the current Keycloak instance as the OAuth server. This mode **automatically registers a service account client** in Keycloak during initialization - the simplest setup possible:
+
+| Property | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `destination.oauth.enabled` | Yes | `false` | Enable OAuth 2.0 |
+| `destination.oauth.mode` | Yes | - | Must be `internal` |
+| `destination.oauth.realm` | No | Route realm | Override realm for token URL |
+| `destination.oauth.client-id` | No | `kete-oauth-client` | Override auto-generated client ID |
+| `destination.oauth.client-secret` | No | Auto-generated | Override auto-generated secret |
+| `destination.oauth.scope` | No | `""` | Requested OAuth scopes |
+
+**Internal Mode Example:**
+
+```bash
+# Simplest OAuth setup - just 2 properties!
+kete.routes.ws.destination.oauth.enabled=true
+kete.routes.ws.destination.oauth.mode=internal
+```
+
+This automatically:
+
+1. Creates a confidential client `kete-oauth-client` in the route's realm
+2. Enables service account (client credentials grant)
+3. Generates a secure client secret
+4. Configures the token URL for the realm
+
### TLS Properties
See [TLS & mTLS](overview.md#tls-mtls) for full details on TLS options.
@@ -116,18 +222,6 @@ See [TLS & mTLS](overview.md#tls-mtls) for full details on TLS options.
-## URL Configuration
-
-When using `destination.url`:
-
-- The **scheme** must be `ws://` or `wss://`
-- **TLS is auto-enabled** when the scheme is `wss://`
-- The **host**, **port**, and **path** are extracted from the URL
-
-If both `url` and individual properties are specified, `url` takes precedence.
-
-
-
## Message Format
Messages are sent as either:
@@ -159,6 +253,18 @@ kete.routes.secure-ws.destination.headers.Authorization=Bearer eyJhbGc...
kete.routes.secure-ws.destination.tls.trust-store.path=/certs/ca.pem
```
+### With OAuth 2.0
+
+```bash
+kete.routes.oauth-ws.destination.kind=websocket
+kete.routes.oauth-ws.realm-matchers.realm=list:master
+kete.routes.oauth-ws.destination.url=wss://api.example.com/events
+kete.routes.oauth-ws.destination.oauth.enabled=true
+kete.routes.oauth-ws.destination.oauth.token-url=https://auth.example.com/oauth/token
+kete.routes.oauth-ws.destination.oauth.client-id=keycloak-client
+kete.routes.oauth-ws.destination.oauth.client-secret=secret
+```
+
### Using Individual Properties
```bash
diff --git a/docs/user-guide/retry.md b/docs/user-guide/retry.md
index 98f9ae85..fb29b074 100644
--- a/docs/user-guide/retry.md
+++ b/docs/user-guide/retry.md
@@ -29,7 +29,7 @@ The `wait-duration` property accepts multiple formats:
| Format | Example | Meaning |
|--------|---------|---------|
-| Milliseconds (digits only) | `1500` | 1500 ms |
+| Seconds (digits only) | `15` | 15 seconds |
| With suffix | `100ms` | 100 milliseconds |
| With suffix | `30s` | 30 seconds |
| With suffix | `15m` | 15 minutes |
diff --git a/mkdocs.yml b/mkdocs.yml
index ca2b194b..638ca70a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -113,6 +113,7 @@ nav:
- Smile: user-guide/serializers/smile.md
- Destinations:
- Overview: user-guide/destinations/overview.md
+ - Support Matrix: user-guide/destinations/support-matrix.md
- Kafka: user-guide/destinations/kafka.md
- AMQP 0.9.1: user-guide/destinations/amqp-0.9.1.md
- AMQP 1: user-guide/destinations/amqp-1.md
@@ -121,6 +122,11 @@ nav:
- HTTP: user-guide/destinations/http.md
- WebSocket: user-guide/destinations/websocket.md
- STOMP: user-guide/destinations/stomp.md
+ - NATS: user-guide/destinations/nats.md
+ - NATS JetStream: user-guide/destinations/nats-jetstream.md
+ - Pulsar: user-guide/destinations/pulsar.md
+ - Redis Pub/Sub: user-guide/destinations/redis-pubsub.md
+ - Redis Streams: user-guide/destinations/redis-streams.md
- Certificate Loaders:
- Overview: user-guide/certificate-loaders/overview.md
- PEM File Path: user-guide/certificate-loaders/pem-file-path.md
@@ -153,4 +159,4 @@ nav:
- Transaction Support: developer-guide/transaction-support.md
- Contributing: developer-guide/development.md
- Test Patterns: developer-guide/test-patterns-and-conventions.md
- - Future Enhancements: developer-guide/future-enhancements.md
+ - Quickstart Testing: developer-guide/quickstart-testing.md
diff --git a/pom.xml b/pom.xml
index 6ac984eb..0f8bb377 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,13 +1,36 @@
- ${revision}
+ KETE
kete
+ ${revision}
4.0.0
io.github.fortunen
+ https://github.com/FortuneN/kete
+ Keycloak Events To Everywhere - Stream matched events to various destinations in various formats
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+
+
+
+
+
+ Fortune Ngwenya
+ https://github.com/FortuneN
+
+
+
+
+ https://github.com/FortuneN/kete
+ scm:git:git://github.com/FortuneN/kete.git
+ scm:git:ssh://github.com/FortuneN/kete.git
+
- 0.0.0-SNAPSHOT
+ 0.0.0
UTF-8
@@ -94,6 +117,11 @@
kafka-clients
4.1.1
+
+ org.apache.pulsar
+ pulsar-client
+ 3.3.3
+
com.rabbitmq
amqp-client
@@ -194,6 +222,16 @@
bcpkix-jdk18on
1.80
+
+ io.lettuce
+ lettuce-core
+ 6.5.3.RELEASE
+
+
+ io.nats
+ jnats
+ 2.20.5
+
@@ -233,7 +271,6 @@
5.21.0
test
-
org.slf4j
slf4j-jdk14
@@ -627,11 +664,6 @@
org.bouncycastle
io.github.fortunen.kete.shaded.bouncycastle
-
-
- org.apache.activemq
- io.github.fortunen.kete.shaded.activemq
-
@@ -690,6 +722,16 @@
net.jcip
io.github.fortunen.kete.shaded.jcip
+
+
+ io.lettuce
+ io.github.fortunen.kete.shaded.lettuce
+
+
+
+ io.nats
+ io.github.fortunen.kete.shaded.nats
+
jakarta.jms
diff --git a/quick-starts/$images/activemq-artemis/Dockerfile b/quick-starts/$images/activemq-artemis/Dockerfile
new file mode 100644
index 00000000..8cab17bf
--- /dev/null
+++ b/quick-starts/$images/activemq-artemis/Dockerfile
@@ -0,0 +1,10 @@
+FROM apache/activemq-artemis:2.37.0
+
+# Copy custom broker.xml with STOMP anycast/multicast prefix configuration
+# The STOMP acceptor needs anycastPrefix=/queue/;multicastPrefix=/topic/ for proper routing
+# Using etc-override so it's copied after instance creation (per official Artemis Docker docs)
+USER root
+RUN mkdir -p /var/lib/artemis-instance/etc-override
+COPY broker.xml /var/lib/artemis-instance/etc-override/broker.xml
+RUN chown -R 1000:0 /var/lib/artemis-instance
+USER 1000
diff --git a/quick-starts/$images/activemq-artemis/broker.xml b/quick-starts/$images/activemq-artemis/broker.xml
new file mode 100644
index 00000000..bb6bca46
--- /dev/null
+++ b/quick-starts/$images/activemq-artemis/broker.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ 0.0.0.0
+
+
+ tcp://0.0.0.0:5672?protocols=AMQP
+ tcp://0.0.0.0:61616?protocols=CORE,AMQP,HORNETQ,MQTT,OPENWIRE
+ tcp://0.0.0.0:61613?protocols=STOMP;anycastPrefix=/queue/;multicastPrefix=/topic/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
diff --git a/quick-starts/$images/activemq-classic/Dockerfile b/quick-starts/$images/activemq-classic/Dockerfile
new file mode 100644
index 00000000..a56fd7ed
--- /dev/null
+++ b/quick-starts/$images/activemq-classic/Dockerfile
@@ -0,0 +1 @@
+FROM apache/activemq-classic:6.1.6
diff --git a/quick-starts/$images/alpine/Dockerfile b/quick-starts/$images/alpine/Dockerfile
new file mode 100644
index 00000000..f3eda1d6
--- /dev/null
+++ b/quick-starts/$images/alpine/Dockerfile
@@ -0,0 +1 @@
+FROM alpine:3.21
diff --git a/quick-starts/$images/amqp1-subscriber/Dockerfile b/quick-starts/$images/amqp1-subscriber/Dockerfile
new file mode 100644
index 00000000..96425eac
--- /dev/null
+++ b/quick-starts/$images/amqp1-subscriber/Dockerfile
@@ -0,0 +1,13 @@
+FROM python:3.12-slim
+
+# Install build dependencies for python-qpid-proton
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends gcc libc-dev libqpid-proton11-dev pkg-config && \
+ pip install --no-cache-dir python-qpid-proton && \
+ apt-get purge -y gcc libc-dev pkg-config && \
+ apt-get autoremove -y && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY subscribe.py /subscribe.py
+
+CMD ["python", "/subscribe.py"]
diff --git a/quick-starts/$images/amqp1-subscriber/subscribe.py b/quick-starts/$images/amqp1-subscriber/subscribe.py
new file mode 100644
index 00000000..55534e2b
--- /dev/null
+++ b/quick-starts/$images/amqp1-subscriber/subscribe.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""AMQP 1.0 subscriber for event reception verification."""
+import os
+import sys
+import time
+from proton.handlers import MessagingHandler
+from proton.reactor import Container
+
+class Subscriber(MessagingHandler):
+ def __init__(self, url, address):
+ super(Subscriber, self).__init__()
+ self.url = url
+ self.address = address
+
+ def on_start(self, event):
+ print(f"Connecting to {self.url}, receiving from {self.address}", flush=True)
+ conn = event.container.connect(self.url)
+ event.container.create_receiver(conn, self.address)
+
+ def on_message(self, event):
+ print("EVENT-RECEIVED", flush=True)
+ event.delivery.update(event.delivery.ACCEPTED)
+ event.delivery.settle()
+
+ def on_transport_error(self, event):
+ print(f"Transport error: {event.transport.condition}", file=sys.stderr, flush=True)
+
+ def on_connection_error(self, event):
+ print(f"Connection error: {event.connection.remote_condition}", file=sys.stderr, flush=True)
+
+def main():
+ host = os.getenv('AMQP_HOST', 'localhost')
+ port = os.getenv('AMQP_PORT', '5672')
+ username = os.getenv('AMQP_USERNAME', '')
+ password = os.getenv('AMQP_PASSWORD', '')
+ address = os.getenv('AMQP_ADDRESS', 'keycloak-events')
+
+ # Build connection URL
+ if username and password:
+ url = f"amqp://{username}:{password}@{host}:{port}"
+ else:
+ url = f"amqp://{host}:{port}"
+
+ print(f"Starting AMQP 1.0 subscriber for {address}", flush=True)
+
+ while True:
+ try:
+ Container(Subscriber(url, address)).run()
+ except KeyboardInterrupt:
+ break
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr, flush=True)
+ time.sleep(5)
+
+if __name__ == '__main__':
+ main()
diff --git a/quick-starts/$images/artemis/Dockerfile b/quick-starts/$images/artemis/Dockerfile
new file mode 100644
index 00000000..6431068e
--- /dev/null
+++ b/quick-starts/$images/artemis/Dockerfile
@@ -0,0 +1,11 @@
+FROM apache/activemq-artemis:2.39.0-alpine
+
+# Copy custom Jolokia config to allow CORS from any origin
+# This enables the check script to access the Jolokia API via curl
+# Using etc-override so it's copied after instance creation (per official Artemis Docker docs)
+# Need to ensure proper permissions for the artemis user (uid 1000)
+USER root
+RUN mkdir -p /var/lib/artemis-instance/etc-override
+COPY jolokia-access.xml /var/lib/artemis-instance/etc-override/jolokia-access.xml
+RUN chown -R 1000:0 /var/lib/artemis-instance
+USER 1000
diff --git a/quick-starts/$images/artemis/jolokia-access.xml b/quick-starts/$images/artemis/jolokia-access.xml
new file mode 100644
index 00000000..8ee1246d
--- /dev/null
+++ b/quick-starts/$images/artemis/jolokia-access.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+ *
+
+
+
+
+
+
diff --git a/quick-starts/$images/azurite/Dockerfile b/quick-starts/$images/azurite/Dockerfile
new file mode 100644
index 00000000..de02f72e
--- /dev/null
+++ b/quick-starts/$images/azurite/Dockerfile
@@ -0,0 +1 @@
+FROM mcr.microsoft.com/azure-storage/azurite:latest
diff --git a/quick-starts/quick-start-curl/Dockerfile b/quick-starts/$images/curl/Dockerfile
similarity index 100%
rename from quick-starts/quick-start-curl/Dockerfile
rename to quick-starts/$images/curl/Dockerfile
diff --git a/quick-starts/$images/dragonfly/Dockerfile b/quick-starts/$images/dragonfly/Dockerfile
new file mode 100644
index 00000000..d5cdecb6
--- /dev/null
+++ b/quick-starts/$images/dragonfly/Dockerfile
@@ -0,0 +1 @@
+FROM docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5
diff --git a/quick-starts/mqtt-3-emqx/emqx/Dockerfile b/quick-starts/$images/emqx/Dockerfile
similarity index 100%
rename from quick-starts/mqtt-3-emqx/emqx/Dockerfile
rename to quick-starts/$images/emqx/Dockerfile
diff --git a/quick-starts/$images/eventhubs-emulator/Dockerfile b/quick-starts/$images/eventhubs-emulator/Dockerfile
new file mode 100644
index 00000000..3799521c
--- /dev/null
+++ b/quick-starts/$images/eventhubs-emulator/Dockerfile
@@ -0,0 +1,3 @@
+FROM mcr.microsoft.com/azure-messaging/eventhubs-emulator:latest
+
+# Config must be mounted at /Eventhubs_Emulator/ConfigFiles/Config.json
diff --git a/quick-starts/mqtt-5-hivemq/hivemq/Dockerfile b/quick-starts/$images/hivemq/Dockerfile
similarity index 100%
rename from quick-starts/mqtt-5-hivemq/hivemq/Dockerfile
rename to quick-starts/$images/hivemq/Dockerfile
diff --git a/quick-starts/http-webhook/http-echo/Dockerfile b/quick-starts/$images/http-echo/Dockerfile
similarity index 100%
rename from quick-starts/http-webhook/http-echo/Dockerfile
rename to quick-starts/$images/http-echo/Dockerfile
diff --git a/quick-starts/$images/kafka-subscriber/Dockerfile b/quick-starts/$images/kafka-subscriber/Dockerfile
new file mode 100644
index 00000000..5e7a5bbd
--- /dev/null
+++ b/quick-starts/$images/kafka-subscriber/Dockerfile
@@ -0,0 +1,7 @@
+FROM python:3.12-alpine
+
+RUN pip install --no-cache-dir kafka-python-ng
+
+COPY subscribe.py /subscribe.py
+
+CMD ["python", "/subscribe.py"]
diff --git a/quick-starts/$images/kafka-subscriber/subscribe.py b/quick-starts/$images/kafka-subscriber/subscribe.py
new file mode 100644
index 00000000..00c08b18
--- /dev/null
+++ b/quick-starts/$images/kafka-subscriber/subscribe.py
@@ -0,0 +1,49 @@
+#!/usr/bin/env python3
+"""Kafka subscriber for event reception verification."""
+import os
+import sys
+import time
+from kafka import KafkaConsumer
+
+def main():
+ bootstrap_servers = os.getenv('KAFKA_BOOTSTRAP_SERVERS', 'localhost:9092')
+ topic = os.getenv('KAFKA_TOPIC', 'keycloak-events')
+ group_id = os.getenv('KAFKA_GROUP_ID', 'event-checker')
+ security_protocol = os.getenv('KAFKA_SECURITY_PROTOCOL', 'PLAINTEXT')
+ sasl_mechanism = os.getenv('KAFKA_SASL_MECHANISM', '')
+ sasl_username = os.getenv('KAFKA_SASL_USERNAME', '')
+ sasl_password = os.getenv('KAFKA_SASL_PASSWORD', '')
+
+ print(f"Connecting to {bootstrap_servers}, subscribing to {topic}", flush=True)
+
+ while True:
+ try:
+ # Build consumer config
+ config = {
+ 'bootstrap_servers': bootstrap_servers.split(','),
+ 'group_id': group_id,
+ 'auto_offset_reset': 'earliest',
+ 'enable_auto_commit': True,
+ 'security_protocol': security_protocol,
+ }
+
+ # Add SASL config if needed
+ if sasl_mechanism:
+ config['sasl_mechanism'] = sasl_mechanism
+ config['sasl_plain_username'] = sasl_username
+ config['sasl_plain_password'] = sasl_password
+
+ consumer = KafkaConsumer(topic, **config)
+ print(f"Subscribed to {topic}", flush=True)
+
+ for message in consumer:
+ print("EVENT-RECEIVED", flush=True)
+
+ except KeyboardInterrupt:
+ break
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr, flush=True)
+ time.sleep(5)
+
+if __name__ == '__main__':
+ main()
diff --git a/quick-starts/kafka-apache/kafka-ui/Dockerfile b/quick-starts/$images/kafka-ui/Dockerfile
similarity index 100%
rename from quick-starts/kafka-apache/kafka-ui/Dockerfile
rename to quick-starts/$images/kafka-ui/Dockerfile
diff --git a/quick-starts/kafka-apache/kafka/Dockerfile b/quick-starts/$images/kafka/Dockerfile
similarity index 100%
rename from quick-starts/kafka-apache/kafka/Dockerfile
rename to quick-starts/$images/kafka/Dockerfile
diff --git a/quick-starts/$images/keycloak/Dockerfile b/quick-starts/$images/keycloak/Dockerfile
new file mode 100644
index 00000000..55d9738d
--- /dev/null
+++ b/quick-starts/$images/keycloak/Dockerfile
@@ -0,0 +1,43 @@
+# -----------------------------------------------------------------------------
+# Stage 1: Build KETE JAR from source
+# -----------------------------------------------------------------------------
+
+FROM maven:3.9.9-eclipse-temurin-21-noble AS maven-build
+
+WORKDIR /src
+COPY pom.xml .
+RUN mvn clean
+RUN mvn -q -DskipTests dependency:go-offline
+COPY . .
+RUN mvn -B -DskipTests package
+
+# -----------------------------------------------------------------------------
+# Stage 2: Build Keycloak with KETE provider
+# -----------------------------------------------------------------------------
+
+FROM quay.io/keycloak/keycloak:26.0.7 AS keycloak-build
+
+# Set build-time options for Keycloak optimization
+ENV KC_HEALTH_ENABLED=true
+ENV KC_METRICS_ENABLED=true
+
+COPY --from=maven-build --chown=keycloak:keycloak /src/target/kete.jar /opt/keycloak/providers/kete.jar
+RUN /opt/keycloak/bin/kc.sh build --health-enabled=true --metrics-enabled=true
+
+# -----------------------------------------------------------------------------
+# Stage 3: Final image
+# -----------------------------------------------------------------------------
+
+FROM quay.io/keycloak/keycloak:26.0.7 AS final
+
+COPY --from=keycloak-build /opt/keycloak/ /opt/keycloak/
+
+ENV KC_HTTP_ENABLED=true
+ENV KC_HEALTH_ENABLED=true
+ENV KC_METRICS_ENABLED=true
+ENV kete.metrics.enabled=true
+ENV KC_BOOTSTRAP_ADMIN_USERNAME=admin
+ENV KC_BOOTSTRAP_ADMIN_PASSWORD=admin
+
+ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
+CMD ["start-dev"]
diff --git a/quick-starts/$images/keydb/Dockerfile b/quick-starts/$images/keydb/Dockerfile
new file mode 100644
index 00000000..4fffa849
--- /dev/null
+++ b/quick-starts/$images/keydb/Dockerfile
@@ -0,0 +1 @@
+FROM eqalpha/keydb:x86_64_v6.3.4
diff --git a/quick-starts/amqp-0.9.1-lavinmq/lavinmq/Dockerfile b/quick-starts/$images/lavinmq/Dockerfile
similarity index 100%
rename from quick-starts/amqp-0.9.1-lavinmq/lavinmq/Dockerfile
rename to quick-starts/$images/lavinmq/Dockerfile
diff --git a/quick-starts/mqtt-3-mosquitto/mosquitto/Dockerfile b/quick-starts/$images/mosquitto/Dockerfile
similarity index 100%
rename from quick-starts/mqtt-3-mosquitto/mosquitto/Dockerfile
rename to quick-starts/$images/mosquitto/Dockerfile
diff --git a/quick-starts/$images/mqtt-subscriber/Dockerfile b/quick-starts/$images/mqtt-subscriber/Dockerfile
new file mode 100644
index 00000000..814c7847
--- /dev/null
+++ b/quick-starts/$images/mqtt-subscriber/Dockerfile
@@ -0,0 +1,8 @@
+# MQTT Subscriber - Listens for messages and prints EVENT-RECEIVED marker
+FROM eclipse-mosquitto:2
+
+# Copy the subscriber script
+COPY subscribe.sh /subscribe.sh
+RUN chmod +x /subscribe.sh
+
+ENTRYPOINT ["/subscribe.sh"]
diff --git a/quick-starts/$images/mqtt-subscriber/subscribe.sh b/quick-starts/$images/mqtt-subscriber/subscribe.sh
new file mode 100644
index 00000000..bb5692c6
--- /dev/null
+++ b/quick-starts/$images/mqtt-subscriber/subscribe.sh
@@ -0,0 +1,27 @@
+#!/bin/sh
+# MQTT Subscriber Script
+# Subscribes to a topic and prints EVENT-RECEIVED when a message arrives
+
+BROKER_HOST="${MQTT_BROKER_HOST:-mqtt}"
+BROKER_PORT="${MQTT_BROKER_PORT:-1883}"
+TOPIC="${MQTT_TOPIC:-keycloak-events}"
+CLIENT_ID="${MQTT_CLIENT_ID:-kete-test-subscriber}"
+
+echo "=================================================="
+echo "MQTT Subscriber Starting"
+echo " Broker: $BROKER_HOST:$BROKER_PORT"
+echo " Topic: $TOPIC"
+echo " Client ID: $CLIENT_ID"
+echo "=================================================="
+
+# Wait for broker to be available
+sleep 2
+
+# Subscribe and print marker on each message
+mosquitto_sub -h "$BROKER_HOST" -p "$BROKER_PORT" -t "$TOPIC" -i "$CLIENT_ID" -v | while read -r line; do
+ echo "=================================================="
+ echo "MESSAGE RECEIVED:"
+ echo "$line"
+ echo "=================================================="
+ echo "EVENT-RECEIVED"
+done
diff --git a/quick-starts/$images/nanomq-mqtt5/Dockerfile b/quick-starts/$images/nanomq-mqtt5/Dockerfile
new file mode 100644
index 00000000..deaff26b
--- /dev/null
+++ b/quick-starts/$images/nanomq-mqtt5/Dockerfile
@@ -0,0 +1,4 @@
+FROM emqx/nanomq:0.22-full
+
+# Config must be mounted at /etc/nanomq.conf
+CMD ["--conf", "/etc/nanomq.conf"]
diff --git a/quick-starts/$images/nanomq/Dockerfile b/quick-starts/$images/nanomq/Dockerfile
new file mode 100644
index 00000000..30c837e5
--- /dev/null
+++ b/quick-starts/$images/nanomq/Dockerfile
@@ -0,0 +1 @@
+FROM emqx/nanomq:0.22-full
diff --git a/quick-starts/$images/nats-box/Dockerfile b/quick-starts/$images/nats-box/Dockerfile
new file mode 100644
index 00000000..ba622f63
--- /dev/null
+++ b/quick-starts/$images/nats-box/Dockerfile
@@ -0,0 +1 @@
+FROM natsio/nats-box:0.14.5
diff --git a/quick-starts/$images/nats-subscriber/Dockerfile b/quick-starts/$images/nats-subscriber/Dockerfile
new file mode 100644
index 00000000..c8ae3f79
--- /dev/null
+++ b/quick-starts/$images/nats-subscriber/Dockerfile
@@ -0,0 +1,7 @@
+# NATS Subscriber - Listens for messages and prints EVENT-RECEIVED marker
+FROM natsio/nats-box:latest
+
+# Copy the subscriber script
+COPY subscribe.sh /subscribe.sh
+
+ENTRYPOINT ["/bin/sh", "/subscribe.sh"]
diff --git a/quick-starts/$images/nats-subscriber/subscribe.sh b/quick-starts/$images/nats-subscriber/subscribe.sh
new file mode 100644
index 00000000..9ad0c52d
--- /dev/null
+++ b/quick-starts/$images/nats-subscriber/subscribe.sh
@@ -0,0 +1,28 @@
+#!/bin/sh
+# NATS Subscriber Script
+# Subscribes to a subject and prints EVENT-RECEIVED when a message arrives
+
+NATS_URL="${NATS_URL:-nats://nats:4222}"
+SUBJECT="${NATS_SUBJECT:-keycloak-events}"
+
+echo "=================================================="
+echo "NATS Subscriber Starting"
+echo " URL: $NATS_URL"
+echo " Subject: $SUBJECT"
+echo "=================================================="
+
+# Wait for NATS to be available
+sleep 2
+
+# Subscribe and output messages with marker
+# Using nats sub with --raw to get just the message
+nats sub "$SUBJECT" --server "$NATS_URL" 2>&1 | while read -r line; do
+ echo "$line"
+ # Check if this looks like an event message (contains JSON-like content)
+ case "$line" in
+ *"{"*"}"*)
+ echo "=================================================="
+ echo "EVENT-RECEIVED"
+ ;;
+ esac
+done
diff --git a/quick-starts/$images/nats/Dockerfile b/quick-starts/$images/nats/Dockerfile
new file mode 100644
index 00000000..97824c62
--- /dev/null
+++ b/quick-starts/$images/nats/Dockerfile
@@ -0,0 +1 @@
+FROM nats:2.10-alpine
diff --git a/quick-starts/$images/pulsar/Dockerfile b/quick-starts/$images/pulsar/Dockerfile
new file mode 100644
index 00000000..0be611a8
--- /dev/null
+++ b/quick-starts/$images/pulsar/Dockerfile
@@ -0,0 +1 @@
+FROM apachepulsar/pulsar:3.3.2
diff --git a/quick-starts/amqp-1-qpid/qpid/Dockerfile b/quick-starts/$images/qpid/Dockerfile
similarity index 86%
rename from quick-starts/amqp-1-qpid/qpid/Dockerfile
rename to quick-starts/$images/qpid/Dockerfile
index f7e87404..48ef15ef 100644
--- a/quick-starts/amqp-1-qpid/qpid/Dockerfile
+++ b/quick-starts/$images/qpid/Dockerfile
@@ -19,11 +19,8 @@ ENV PATH="${QPID_HOME}/bin:${PATH}"
# Create work directory
RUN mkdir -p ${QPID_WORK}
-# Copy initial configuration
-COPY initial-config.json ${QPID_HOME}/etc/initial-config.json
-
# Expose AMQP and HTTP management ports
EXPOSE 5672 8080
-# Start Qpid Broker-J with initial config
+# Start Qpid Broker-J (config must be mounted at /opt/qpid-broker-j/etc/initial-config.json)
CMD ["qpid-server", "-icp", "/opt/qpid-broker-j/etc/initial-config.json", "-prop", "qpid.amqp_port=5672", "-prop", "qpid.http_port=8080"]
diff --git a/quick-starts/amqp-0.9.1-rabbitmq/rabbitmq/Dockerfile b/quick-starts/$images/rabbitmq/Dockerfile
similarity index 100%
rename from quick-starts/amqp-0.9.1-rabbitmq/rabbitmq/Dockerfile
rename to quick-starts/$images/rabbitmq/Dockerfile
diff --git a/quick-starts/$images/redis-subscriber/Dockerfile b/quick-starts/$images/redis-subscriber/Dockerfile
new file mode 100644
index 00000000..d727082a
--- /dev/null
+++ b/quick-starts/$images/redis-subscriber/Dockerfile
@@ -0,0 +1,8 @@
+# Redis Pub/Sub Subscriber - Listens for messages and prints EVENT-RECEIVED marker
+FROM redis:7-alpine
+
+# Copy the subscriber script
+COPY subscribe.sh /subscribe.sh
+RUN chmod +x /subscribe.sh
+
+ENTRYPOINT ["/subscribe.sh"]
diff --git a/quick-starts/$images/redis-subscriber/subscribe.sh b/quick-starts/$images/redis-subscriber/subscribe.sh
new file mode 100644
index 00000000..68a5ef51
--- /dev/null
+++ b/quick-starts/$images/redis-subscriber/subscribe.sh
@@ -0,0 +1,33 @@
+#!/bin/sh
+# Redis Pub/Sub Subscriber Script
+# Subscribes to a channel and prints EVENT-RECEIVED when a message arrives
+
+REDIS_HOST="${REDIS_HOST:-redis}"
+REDIS_PORT="${REDIS_PORT:-6379}"
+CHANNEL="${REDIS_CHANNEL:-keycloak-events}"
+
+echo "=================================================="
+echo "Redis Pub/Sub Subscriber Starting"
+echo " Redis: $REDIS_HOST:$REDIS_PORT"
+echo " Channel: $CHANNEL"
+echo "=================================================="
+
+# Wait for Redis to be available
+sleep 2
+
+# Subscribe and print marker on each message
+# Redis SUBSCRIBE outputs: subscribe, channel, count, then message, channel, data
+redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" SUBSCRIBE "$CHANNEL" | while read -r line; do
+ case "$line" in
+ *"message"*)
+ # Next lines will be channel and data
+ read -r channel
+ read -r data
+ echo "=================================================="
+ echo "MESSAGE RECEIVED on channel: $channel"
+ echo "$data"
+ echo "=================================================="
+ echo "EVENT-RECEIVED"
+ ;;
+ esac
+done
diff --git a/quick-starts/$images/redis/Dockerfile b/quick-starts/$images/redis/Dockerfile
new file mode 100644
index 00000000..1716a133
--- /dev/null
+++ b/quick-starts/$images/redis/Dockerfile
@@ -0,0 +1 @@
+FROM redis:7.4-alpine
diff --git a/quick-starts/kafka-redpanda/redpanda-console/Dockerfile b/quick-starts/$images/redpanda-console/Dockerfile
similarity index 100%
rename from quick-starts/kafka-redpanda/redpanda-console/Dockerfile
rename to quick-starts/$images/redpanda-console/Dockerfile
diff --git a/quick-starts/kafka-redpanda/redpanda/Dockerfile b/quick-starts/$images/redpanda/Dockerfile
similarity index 100%
rename from quick-starts/kafka-redpanda/redpanda/Dockerfile
rename to quick-starts/$images/redpanda/Dockerfile
diff --git a/quick-starts/$images/servicebus-emulator/Dockerfile b/quick-starts/$images/servicebus-emulator/Dockerfile
new file mode 100644
index 00000000..d4410a61
--- /dev/null
+++ b/quick-starts/$images/servicebus-emulator/Dockerfile
@@ -0,0 +1,3 @@
+FROM mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
+
+# Config must be mounted at /ServiceBus_Emulator/ConfigFiles/Config.json
diff --git a/quick-starts/$images/sql-edge/Dockerfile b/quick-starts/$images/sql-edge/Dockerfile
new file mode 100644
index 00000000..d8b766d4
--- /dev/null
+++ b/quick-starts/$images/sql-edge/Dockerfile
@@ -0,0 +1 @@
+FROM mcr.microsoft.com/azure-sql-edge:latest
diff --git a/quick-starts/$images/stomp-emqx/Dockerfile b/quick-starts/$images/stomp-emqx/Dockerfile
new file mode 100644
index 00000000..29dc039e
--- /dev/null
+++ b/quick-starts/$images/stomp-emqx/Dockerfile
@@ -0,0 +1 @@
+FROM emqx/emqx:5.8
diff --git a/quick-starts/$images/stomp-subscriber/Dockerfile b/quick-starts/$images/stomp-subscriber/Dockerfile
new file mode 100644
index 00000000..c5c59300
--- /dev/null
+++ b/quick-starts/$images/stomp-subscriber/Dockerfile
@@ -0,0 +1,7 @@
+FROM python:3.12-alpine
+
+RUN pip install --no-cache-dir stomp.py
+
+COPY subscribe.py /subscribe.py
+
+CMD ["python", "/subscribe.py"]
diff --git a/quick-starts/$images/stomp-subscriber/subscribe.py b/quick-starts/$images/stomp-subscriber/subscribe.py
new file mode 100644
index 00000000..702c55e2
--- /dev/null
+++ b/quick-starts/$images/stomp-subscriber/subscribe.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+import os
+import sys
+import time
+import stomp
+
+class Subscriber(stomp.ConnectionListener):
+ def on_message(self, frame):
+ print("EVENT-RECEIVED", flush=True)
+
+ def on_error(self, frame):
+ print(f"Error: {frame.body}", file=sys.stderr, flush=True)
+
+def main():
+ host = os.getenv('STOMP_HOST', 'localhost')
+ port = int(os.getenv('STOMP_PORT', '61613'))
+ destination = os.getenv('STOMP_DESTINATION', '/topic/keycloak-events')
+
+ print(f"Connecting to {host}:{port}, subscribing to {destination}", flush=True)
+
+ while True:
+ try:
+ conn = stomp.Connection([(host, port)])
+ conn.set_listener('', Subscriber())
+ conn.connect(wait=True)
+ conn.subscribe(destination=destination, id=1, ack='auto')
+
+ print(f"Subscribed to {destination}", flush=True)
+
+ # Keep connection alive
+ while conn.is_connected():
+ time.sleep(1)
+
+ except Exception as e:
+ print(f"Connection error: {e}", file=sys.stderr, flush=True)
+ time.sleep(5)
+
+if __name__ == '__main__':
+ main()
diff --git a/quick-starts/$images/valkey/Dockerfile b/quick-starts/$images/valkey/Dockerfile
new file mode 100644
index 00000000..8e1f7894
--- /dev/null
+++ b/quick-starts/$images/valkey/Dockerfile
@@ -0,0 +1 @@
+FROM valkey/valkey:8-alpine
diff --git a/quick-starts/$images/vernemq/Dockerfile b/quick-starts/$images/vernemq/Dockerfile
new file mode 100644
index 00000000..b75b879f
--- /dev/null
+++ b/quick-starts/$images/vernemq/Dockerfile
@@ -0,0 +1 @@
+FROM vernemq/vernemq:2.0.1
diff --git a/quick-starts/$images/websocket-echo/Dockerfile b/quick-starts/$images/websocket-echo/Dockerfile
new file mode 100644
index 00000000..4b42bede
--- /dev/null
+++ b/quick-starts/$images/websocket-echo/Dockerfile
@@ -0,0 +1 @@
+FROM jmalloc/echo-server:0.3.6
diff --git a/quick-starts/amqp-0.9.1-lavinmq/check-event-reception.ps1 b/quick-starts/amqp-0.9.1-lavinmq/check-event-reception.ps1
new file mode 100644
index 00000000..0898a9bc
--- /dev/null
+++ b/quick-starts/amqp-0.9.1-lavinmq/check-event-reception.ps1
@@ -0,0 +1,9 @@
+# Check if event was received via LavinMQ Management API
+# Returns $true if message found, $false otherwise
+
+$queue = "keycloak-events"
+$url = "http://localhost:15672/api/queues/%2F/$queue"
+$cred = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("guest:guest"))
+
+$response = Invoke-RestMethod -Uri $url -Headers @{ Authorization = "Basic $cred" } -TimeoutSec 5
+return $response.ready -gt 0 -or $response.unacked -gt 0
diff --git a/quick-starts/amqp-0.9.1-lavinmq/docker-compose.yml b/quick-starts/amqp-0.9.1-lavinmq/docker-compose.yml
index 8e6e542f..66afaad4 100644
--- a/quick-starts/amqp-0.9.1-lavinmq/docker-compose.yml
+++ b/quick-starts/amqp-0.9.1-lavinmq/docker-compose.yml
@@ -27,6 +27,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: amqp-0.9.1
kete.routes.quick-start.destination.host: lavinmq
diff --git a/quick-starts/amqp-0.9.1-rabbitmq/check-event-reception.ps1 b/quick-starts/amqp-0.9.1-rabbitmq/check-event-reception.ps1
new file mode 100644
index 00000000..ac575865
--- /dev/null
+++ b/quick-starts/amqp-0.9.1-rabbitmq/check-event-reception.ps1
@@ -0,0 +1,9 @@
+# Check if event was received via RabbitMQ Management API
+# Returns $true if message found, $false otherwise
+
+$queue = "keycloak-events"
+$url = "http://localhost:15672/api/queues/%2F/$queue"
+$cred = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("guest:guest"))
+
+$response = Invoke-RestMethod -Uri $url -Headers @{ Authorization = "Basic $cred" } -TimeoutSec 5
+return $response.messages_ready -gt 0 -or $response.messages_unacknowledged -gt 0
diff --git a/quick-starts/amqp-0.9.1-rabbitmq/docker-compose.yml b/quick-starts/amqp-0.9.1-rabbitmq/docker-compose.yml
index 0c8bb92b..fe5c53bc 100644
--- a/quick-starts/amqp-0.9.1-rabbitmq/docker-compose.yml
+++ b/quick-starts/amqp-0.9.1-rabbitmq/docker-compose.yml
@@ -27,6 +27,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: amqp-0.9.1
kete.routes.quick-start.destination.host: rabbitmq
diff --git a/quick-starts/amqp-1-activemq/activemq/Dockerfile b/quick-starts/amqp-1-activemq/activemq/Dockerfile
deleted file mode 100644
index 17f709fe..00000000
--- a/quick-starts/amqp-1-activemq/activemq/Dockerfile
+++ /dev/null
@@ -1 +0,0 @@
-FROM apache/activemq-artemis:2.37.0
diff --git a/quick-starts/amqp-1-activemq/check-event-reception.ps1 b/quick-starts/amqp-1-activemq/check-event-reception.ps1
new file mode 100644
index 00000000..c458a511
--- /dev/null
+++ b/quick-starts/amqp-1-activemq/check-event-reception.ps1
@@ -0,0 +1,16 @@
+# Check if event was received by Artemis via AMQP
+# Uses Artemis Jolokia API to check queue message count
+
+$queueName = "keycloak-events"
+$creds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("admin:admin"))
+$response = Invoke-RestMethod -Uri "http://localhost:8161/console/jolokia/read/org.apache.activemq.artemis:broker=`"0.0.0.0`",component=addresses,address=`"$queueName`",subcomponent=queues,routing-type=`"anycast`",queue=`"$queueName`"/MessageCount" -Headers @{Authorization = "Basic $creds"} -TimeoutSec 5 -ErrorAction Stop
+
+if (-not $response.value -and $response.value -ne 0) {
+ throw "Artemis API returned empty MessageCount"
+}
+
+if ($response.value -gt 0) {
+ return $true
+}
+
+return $false
diff --git a/quick-starts/amqp-1-activemq/docker-compose.yml b/quick-starts/amqp-1-activemq/docker-compose.yml
index 01f58a1b..4fb18899 100644
--- a/quick-starts/amqp-1-activemq/docker-compose.yml
+++ b/quick-starts/amqp-1-activemq/docker-compose.yml
@@ -1,7 +1,7 @@
services:
activemq:
- image: ghcr.io/fortunen/kete/quick-start-activemq
+ image: ghcr.io/fortunen/kete/quick-start-activemq-artemis
ports:
- 5672:5672
- 8161:8161
@@ -19,6 +19,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: amqp-1
kete.routes.quick-start.destination.host: activemq
diff --git a/quick-starts/amqp-1-azure-event-hubs/check-event-reception.ps1 b/quick-starts/amqp-1-azure-event-hubs/check-event-reception.ps1
new file mode 100644
index 00000000..59dfc760
--- /dev/null
+++ b/quick-starts/amqp-1-azure-event-hubs/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Azure Event Hubs
+# Cannot be tested locally without real Azure resources
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Azure Event Hubs)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/amqp-1-azure-event-hubs/docker-compose.yml b/quick-starts/amqp-1-azure-event-hubs/docker-compose.yml
index 4e8f67b5..b9838d81 100644
--- a/quick-starts/amqp-1-azure-event-hubs/docker-compose.yml
+++ b/quick-starts/amqp-1-azure-event-hubs/docker-compose.yml
@@ -8,6 +8,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
# Azure Event Hubs configuration
# Replace these values or use a .env file
diff --git a/quick-starts/amqp-1-azure-event-hubs/dont-run-this-quickstart b/quick-starts/amqp-1-azure-event-hubs/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/amqp-1-azure-service-bus-emulator/Config.json b/quick-starts/amqp-1-azure-service-bus-emulator/Config.json
new file mode 100644
index 00000000..2f1c8973
--- /dev/null
+++ b/quick-starts/amqp-1-azure-service-bus-emulator/Config.json
@@ -0,0 +1,28 @@
+{
+ "UserConfig": {
+ "Namespaces": [
+ {
+ "Name": "sbemulatorns",
+ "Queues": [
+ {
+ "Name": "keycloak-events",
+ "Properties": {
+ "DeadLetteringOnMessageExpiration": false,
+ "DefaultMessageTimeToLive": "PT1H",
+ "DuplicateDetectionHistoryTimeWindow": "PT20S",
+ "ForwardDeadLetteredMessagesTo": "",
+ "ForwardTo": "",
+ "LockDuration": "PT1M",
+ "MaxDeliveryCount": 10,
+ "RequiresDuplicateDetection": false,
+ "RequiresSession": false
+ }
+ }
+ ]
+ }
+ ],
+ "Logging": {
+ "Type": "Console"
+ }
+ }
+}
diff --git a/quick-starts/amqp-1-azure-service-bus-emulator/check-event-reception.ps1 b/quick-starts/amqp-1-azure-service-bus-emulator/check-event-reception.ps1
new file mode 100644
index 00000000..574b8a12
--- /dev/null
+++ b/quick-starts/amqp-1-azure-service-bus-emulator/check-event-reception.ps1
@@ -0,0 +1,10 @@
+# Check if event was received by AMQP 1.0 subscriber
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs amqp-1-azure-service-bus-emulator-subscriber-1 2>&1
+
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+
+return $false
diff --git a/quick-starts/amqp-1-azure-service-bus-emulator/docker-compose.yml b/quick-starts/amqp-1-azure-service-bus-emulator/docker-compose.yml
new file mode 100644
index 00000000..511b1fc2
--- /dev/null
+++ b/quick-starts/amqp-1-azure-service-bus-emulator/docker-compose.yml
@@ -0,0 +1,80 @@
+# Azure Service Bus Emulator (AMQP 1.0)
+# Local development emulator - no Azure subscription required
+
+services:
+
+ # SQL Edge - required dependency for Service Bus Emulator
+ sqledge:
+ image: ghcr.io/fortunen/kete/quick-start-sql-edge
+ hostname: sqledge
+ environment:
+ ACCEPT_EULA: Y
+ MSSQL_SA_PASSWORD: "Password123!"
+ healthcheck:
+ test: ["CMD-SHELL", "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Password123!' -Q 'SELECT 1' || exit 1"]
+ interval: 10s
+ timeout: 10s
+ retries: 12
+ start_period: 30s
+
+ # Azure Service Bus Emulator
+ servicebus-emulator:
+ image: ghcr.io/fortunen/kete/quick-start-servicebus-emulator
+ hostname: servicebus-emulator
+ depends_on:
+ sqledge:
+ condition: service_healthy
+ ports:
+ - 5672:5672
+ volumes:
+ - ./Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json:ro
+ environment:
+ SQL_SERVER: sqledge
+ MSSQL_SA_PASSWORD: "Password123!"
+ ACCEPT_EULA: Y
+
+ # Wait for the emulator AMQP port to be ready before starting Keycloak
+ wait-for-emulator:
+ image: ghcr.io/fortunen/kete/quick-start-alpine
+ command: >
+ sh -c "
+ echo 'Waiting for Service Bus emulator AMQP port to be ready...';
+ while ! nc -z servicebus-emulator 5672 2>/dev/null; do
+ sleep 2;
+ done;
+ echo 'Service Bus emulator AMQP port is ready!'
+ "
+ depends_on:
+ - servicebus-emulator
+
+ # AMQP 1.0 subscriber to verify event reception
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-amqp1-subscriber
+ environment:
+ AMQP_HOST: servicebus-emulator
+ AMQP_PORT: 5672
+ AMQP_USERNAME: RootManageSharedAccessKey
+ AMQP_PASSWORD: SAS_KEY_VALUE
+ AMQP_ADDRESS: keycloak-events
+ depends_on:
+ wait-for-emulator:
+ condition: service_completed_successfully
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ depends_on:
+ wait-for-emulator:
+ condition: service_completed_successfully
+ subscriber:
+ condition: service_started
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: amqp-1
+ kete.routes.quick-start.destination.host: servicebus-emulator
+ kete.routes.quick-start.destination.port: 5672
+ kete.routes.quick-start.destination.username: RootManageSharedAccessKey
+ kete.routes.quick-start.destination.password: SAS_KEY_VALUE
+ kete.routes.quick-start.destination.destination-name: keycloak-events
diff --git a/quick-starts/amqp-1-azure-service-bus/check-event-reception.ps1 b/quick-starts/amqp-1-azure-service-bus/check-event-reception.ps1
new file mode 100644
index 00000000..e60aa5fd
--- /dev/null
+++ b/quick-starts/amqp-1-azure-service-bus/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Azure Service Bus
+# Cannot be tested locally without real Azure resources
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Azure Service Bus)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/amqp-1-azure-service-bus/docker-compose.yml b/quick-starts/amqp-1-azure-service-bus/docker-compose.yml
index 7fcac081..ef9d8d1a 100644
--- a/quick-starts/amqp-1-azure-service-bus/docker-compose.yml
+++ b/quick-starts/amqp-1-azure-service-bus/docker-compose.yml
@@ -8,6 +8,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
# Azure Service Bus configuration
# Replace these values or use a .env file
diff --git a/quick-starts/amqp-1-azure-service-bus/dont-run-this-quickstart b/quick-starts/amqp-1-azure-service-bus/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/amqp-1-qpid/check-event-reception.ps1 b/quick-starts/amqp-1-qpid/check-event-reception.ps1
new file mode 100644
index 00000000..c5f0119d
--- /dev/null
+++ b/quick-starts/amqp-1-qpid/check-event-reception.ps1
@@ -0,0 +1,8 @@
+# Check if event was received by AMQP 1.0 subscriber
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs amqp-1-qpid-subscriber-1 2>&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/amqp-1-qpid/docker-compose.yml b/quick-starts/amqp-1-qpid/docker-compose.yml
index 40d3413b..f6ed6c8f 100644
--- a/quick-starts/amqp-1-qpid/docker-compose.yml
+++ b/quick-starts/amqp-1-qpid/docker-compose.yml
@@ -1,10 +1,37 @@
services:
+ qpid:
+ image: ghcr.io/fortunen/kete/quick-start-qpid
+ ports:
+ - 5672:5672
+ - 8180:8080
+ volumes:
+ - ./initial-config.json:/opt/qpid-broker-j/etc/initial-config.json:ro
+ healthcheck:
+ test: ["CMD-SHELL", "timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/5672' 2>/dev/null"]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+
+ # AMQP 1.0 subscriber to verify event reception
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-amqp1-subscriber
+ environment:
+ AMQP_HOST: qpid
+ AMQP_PORT: 5672
+ AMQP_USERNAME: guest
+ AMQP_PASSWORD: guest
+ AMQP_ADDRESS: keycloak-events
+ depends_on:
+ qpid:
+ condition: service_healthy
+
keycloak:
image: ghcr.io/fortunen/kete/quick-start-keycloak
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: amqp-1
kete.routes.quick-start.destination.host: qpid
@@ -15,14 +42,5 @@ services:
depends_on:
qpid:
condition: service_healthy
-
- qpid:
- image: ghcr.io/fortunen/kete/quick-start-qpid
- ports:
- - 5672:5672
- - 8180:8080
- healthcheck:
- test: ["CMD-SHELL", "timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/5672' 2>/dev/null"]
- interval: 5s
- timeout: 5s
- retries: 12
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/amqp-1-qpid/qpid/initial-config.json b/quick-starts/amqp-1-qpid/initial-config.json
similarity index 100%
rename from quick-starts/amqp-1-qpid/qpid/initial-config.json
rename to quick-starts/amqp-1-qpid/initial-config.json
diff --git a/quick-starts/amqp-1-rabbitmq/check-event-reception.ps1 b/quick-starts/amqp-1-rabbitmq/check-event-reception.ps1
new file mode 100644
index 00000000..1e2a741e
--- /dev/null
+++ b/quick-starts/amqp-1-rabbitmq/check-event-reception.ps1
@@ -0,0 +1,16 @@
+# Check if event was received by RabbitMQ via Management API
+# RabbitMQ exposes a REST API on port 15672
+
+$queueName = "keycloak-events"
+$creds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("admin:admin"))
+$response = Invoke-RestMethod -Uri "http://localhost:15672/api/queues/%2f/$queueName" -Headers @{Authorization = "Basic $creds"} -TimeoutSec 5 -ErrorAction Stop
+
+if (-not $response -or $null -eq $response.messages) {
+ throw "RabbitMQ API returned empty or incomplete response"
+}
+
+if ($response.messages -gt 0) {
+ return $true
+}
+
+return $false
diff --git a/quick-starts/amqp-1-rabbitmq/docker-compose.yml b/quick-starts/amqp-1-rabbitmq/docker-compose.yml
index add7048f..f5d8530d 100644
--- a/quick-starts/amqp-1-rabbitmq/docker-compose.yml
+++ b/quick-starts/amqp-1-rabbitmq/docker-compose.yml
@@ -31,6 +31,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: amqp-1
kete.routes.quick-start.destination.host: rabbitmq
diff --git a/quick-starts/amqp-1-rabbitmq/rabbitmq/Dockerfile b/quick-starts/amqp-1-rabbitmq/rabbitmq/Dockerfile
deleted file mode 100644
index 85bf18a4..00000000
--- a/quick-starts/amqp-1-rabbitmq/rabbitmq/Dockerfile
+++ /dev/null
@@ -1 +0,0 @@
-FROM rabbitmq:4.0.5-management-alpine
diff --git a/quick-starts/http-azure-event-grid/check-event-reception.ps1 b/quick-starts/http-azure-event-grid/check-event-reception.ps1
new file mode 100644
index 00000000..15775f9e
--- /dev/null
+++ b/quick-starts/http-azure-event-grid/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Azure Event Grid
+# Cannot be tested locally without real Azure resources
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Azure Event Grid)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/http-azure-event-grid/docker-compose.yml b/quick-starts/http-azure-event-grid/docker-compose.yml
index 4b06a595..296ce09b 100644
--- a/quick-starts/http-azure-event-grid/docker-compose.yml
+++ b/quick-starts/http-azure-event-grid/docker-compose.yml
@@ -8,6 +8,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
# Azure Event Grid configuration
# Replace these values or use a .env file
diff --git a/quick-starts/http-azure-event-grid/dont-run-this-quickstart b/quick-starts/http-azure-event-grid/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/http-webhook/check-event-reception.ps1 b/quick-starts/http-webhook/check-event-reception.ps1
new file mode 100644
index 00000000..97012741
--- /dev/null
+++ b/quick-starts/http-webhook/check-event-reception.ps1
@@ -0,0 +1,6 @@
+# Check if event was received via HTTP Echo server logs
+# Returns $true if LOGIN event found in logs, $false otherwise
+
+$logs = docker logs http-webhook-webhook-1 2>&1
+$matched = $logs -match "LOGIN"
+return $matched.Count -gt 0
diff --git a/quick-starts/http-webhook/docker-compose.yml b/quick-starts/http-webhook/docker-compose.yml
index d26a6c32..bd170e0f 100644
--- a/quick-starts/http-webhook/docker-compose.yml
+++ b/quick-starts/http-webhook/docker-compose.yml
@@ -17,9 +17,10 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: http
- kete.routes.quick-start.destination.url: http://webhook:8080/keycloak-events
+ kete.routes.quick-start.destination.url: http://webhook:8090/keycloak-events
kete.routes.quick-start.destination.method: POST
depends_on:
webhook:
diff --git a/quick-starts/kafka-apache/check-event-reception.ps1 b/quick-starts/kafka-apache/check-event-reception.ps1
new file mode 100644
index 00000000..e4cf7858
--- /dev/null
+++ b/quick-starts/kafka-apache/check-event-reception.ps1
@@ -0,0 +1,15 @@
+# Check if event was received via Kafka console consumer
+# Returns $true if message found, $false otherwise
+
+# Give Kafka a moment to fully commit the message
+Start-Sleep -Milliseconds 500
+
+# Use kafka-console-consumer to check for messages
+$result = docker exec kafka-apache-kafka-1 /opt/kafka/bin/kafka-console-consumer.sh `
+ --bootstrap-server localhost:9092 `
+ --topic keycloak-events `
+ --from-beginning `
+ --max-messages 1 `
+ --timeout-ms 5000 2>&1 | Out-String
+
+return $result -match "LOGIN"
diff --git a/quick-starts/kafka-apache/docker-compose.yml b/quick-starts/kafka-apache/docker-compose.yml
index 3cf6cf27..2ff9cc97 100644
--- a/quick-starts/kafka-apache/docker-compose.yml
+++ b/quick-starts/kafka-apache/docker-compose.yml
@@ -40,6 +40,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: kafka
kete.routes.quick-start.destination.bootstrap.servers: kafka:9092
diff --git a/quick-starts/kafka-azure-event-hubs-emulator/README.md b/quick-starts/kafka-azure-event-hubs-emulator/README.md
deleted file mode 100644
index a232dc78..00000000
--- a/quick-starts/kafka-azure-event-hubs-emulator/README.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# Azure Event Hubs Emulator (Kafka Protocol)
-
-> **β οΈ CURRENTLY NOT WORKING - EMULATOR LIMITATION**
-
-## Status: Blocked
-
-This quick-start is **currently non-functional** due to a limitation in the Azure Event Hubs Emulator.
-
-### Issue
-
-The Azure Event Hubs Emulator only supports **Kafka clients β€3.8.x**, but KETE uses **Kafka 4.1.1**.
-
-When attempting to send events, the emulator returns:
-```
-io.github.fortunen.kete.shaded.kafka.common.errors.UnsupportedForMessageFormatException:
-The message format version on the broker does not support the request.
-```
-
-### Microsoft's Response
-
-Per [GitHub Issue #50](https://github.com/Azure/azure-event-hubs-emulator-installer/issues/50):
-- **Status:** Work in progress (as of July 2025)
-- **Expected:** Future emulator release will support Kafka 3.9.0+
-
-### TODO
-
-- [ ] Revisit when Microsoft releases updated Event Hubs Emulator with Kafka 3.9+ support
-- [ ] Monitor: https://github.com/Azure/azure-event-hubs-emulator-installer/issues/50
-
-## Configuration for Production Azure Event Hubs
-
-The configuration in `docker-compose.yml` is **correct for production Azure Event Hubs** (not the emulator):
-
-```yaml
-kete.routes.quick-start.destination.kind: kafka
-kete.routes.quick-start.destination.bootstrap.servers: .servicebus.windows.net:9093
-kete.routes.quick-start.destination.topic:
-kete.routes.quick-start.destination.sasl.mechanism: PLAIN
-kete.routes.quick-start.destination.security.protocol: SASL_PLAINTEXT
-kete.routes.quick-start.destination.enable.idempotence: "false"
-kete.routes.quick-start.destination.sasl.jaas.config: io.github.fortunen.kete.shaded.kafka.common.security.plain.PlainLoginModule required username="$ConnectionString" password="Endpoint=sb://.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=;";
-```
-
-Replace:
-- `` - Your Event Hubs namespace
-- `` - Your Event Hub name
-- `` - Your connection string key
-
-## Notes
-
-- **Production Azure Event Hubs supports Kafka 1.0+** (including 4.1.1)
-- This is purely a **local emulator limitation**
-- KETE works fine with real Azure Event Hubs in production
diff --git a/quick-starts/kafka-azure-event-hubs-emulator/check-event-reception.ps1 b/quick-starts/kafka-azure-event-hubs-emulator/check-event-reception.ps1
new file mode 100644
index 00000000..b6742af7
--- /dev/null
+++ b/quick-starts/kafka-azure-event-hubs-emulator/check-event-reception.ps1
@@ -0,0 +1,8 @@
+# Check if event was received by Kafka subscriber
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs kafka-azure-event-hubs-emulator-subscriber-1 2>&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/kafka-azure-event-hubs-emulator/docker-compose.yml b/quick-starts/kafka-azure-event-hubs-emulator/docker-compose.yml
index 43176567..3c3dea9c 100644
--- a/quick-starts/kafka-azure-event-hubs-emulator/docker-compose.yml
+++ b/quick-starts/kafka-azure-event-hubs-emulator/docker-compose.yml
@@ -5,7 +5,7 @@
services:
azurite:
- image: mcr.microsoft.com/azure-storage/azurite:latest
+ image: ghcr.io/fortunen/kete/quick-start-azurite
ports:
- 10000:10000
- 10001:10001
@@ -17,24 +17,24 @@ services:
retries: 10
eventhubs-emulator:
- image: mcr.microsoft.com/azure-messaging/eventhubs-emulator:latest
+ image: ghcr.io/fortunen/kete/quick-start-eventhubs-emulator
ports:
- 5672:5672
- 9092:9092
- 5300:5300
+ volumes:
+ - ./Config.json:/Eventhubs_Emulator/ConfigFiles/Config.json:ro
environment:
ACCEPT_EULA: "Y"
BLOB_SERVER: azurite
METADATA_SERVER: azurite
- volumes:
- - ./Config.json:/Eventhubs_Emulator/ConfigFiles/Config.json
depends_on:
azurite:
condition: service_healthy
# Wait for the emulator Kafka port to be ready before starting Keycloak
wait-for-emulator:
- image: alpine:latest
+ image: ghcr.io/fortunen/kete/quick-start-alpine
command: >
sh -c "
echo 'Waiting for Event Hubs emulator Kafka port to be ready...';
@@ -46,11 +46,27 @@ services:
depends_on:
- eventhubs-emulator
+ # Kafka subscriber to verify event reception
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-kafka-subscriber
+ environment:
+ KAFKA_BOOTSTRAP_SERVERS: eventhubs-emulator:9092
+ KAFKA_TOPIC: keycloak-events
+ KAFKA_GROUP_ID: event-checker
+ KAFKA_SECURITY_PROTOCOL: SASL_PLAINTEXT
+ KAFKA_SASL_MECHANISM: PLAIN
+ KAFKA_SASL_USERNAME: $$ConnectionString
+ KAFKA_SASL_PASSWORD: Endpoint=sb://eventhubs-emulator;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;
+ depends_on:
+ wait-for-emulator:
+ condition: service_completed_successfully
+
keycloak:
image: ghcr.io/fortunen/kete/quick-start-keycloak
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: kafka
kete.routes.quick-start.destination.bootstrap.servers: eventhubs-emulator:9092
@@ -62,3 +78,5 @@ services:
depends_on:
wait-for-emulator:
condition: service_completed_successfully
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/kafka-azure-event-hubs/check-event-reception.ps1 b/quick-starts/kafka-azure-event-hubs/check-event-reception.ps1
new file mode 100644
index 00000000..b11a3b8a
--- /dev/null
+++ b/quick-starts/kafka-azure-event-hubs/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Azure Event Hubs (Kafka protocol)
+# Cannot be tested locally without real Azure resources
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Azure Event Hubs Kafka)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/kafka-azure-event-hubs/docker-compose.yml b/quick-starts/kafka-azure-event-hubs/docker-compose.yml
index 7882d7b1..94a8a1b7 100644
--- a/quick-starts/kafka-azure-event-hubs/docker-compose.yml
+++ b/quick-starts/kafka-azure-event-hubs/docker-compose.yml
@@ -8,6 +8,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
# Azure Event Hubs (Kafka) configuration
# Replace these values or use a .env file
diff --git a/quick-starts/kafka-azure-event-hubs/dont-run-this-quickstart b/quick-starts/kafka-azure-event-hubs/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/kafka-confluent/check-event-reception.ps1 b/quick-starts/kafka-confluent/check-event-reception.ps1
new file mode 100644
index 00000000..430b63d4
--- /dev/null
+++ b/quick-starts/kafka-confluent/check-event-reception.ps1
@@ -0,0 +1,20 @@
+# Check if event was received by Confluent Kafka
+# Uses kafka-console-consumer to check for messages
+
+$topic = "keycloak-events"
+
+# Use kafka container to consume messages with timeout
+$result = docker exec kafka kafka-console-consumer --bootstrap-server localhost:9092 --topic $topic --from-beginning --timeout-ms 5000 2>&1
+
+# Check if we got any JSON content (event data)
+if ($result -match '\{.*"type".*\}' -or $result -match '\{.*"id".*\}') {
+ return $true
+}
+
+# Check if there's any non-empty output (excluding timeout message)
+$lines = $result | Where-Object { $_ -and $_ -notmatch "Timeout" -and $_ -notmatch "Processed a total of" }
+if ($lines) {
+ return $true
+}
+
+return $false
diff --git a/quick-starts/kafka-confluent/docker-compose.yml b/quick-starts/kafka-confluent/docker-compose.yml
index 1d3f3219..02c59a81 100644
--- a/quick-starts/kafka-confluent/docker-compose.yml
+++ b/quick-starts/kafka-confluent/docker-compose.yml
@@ -8,6 +8,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
# Confluent Cloud configuration
# Replace these values or use a .env file
diff --git a/quick-starts/kafka-confluent/dont-run-this-quickstart b/quick-starts/kafka-confluent/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/kafka-redpanda/check-event-reception.ps1 b/quick-starts/kafka-redpanda/check-event-reception.ps1
new file mode 100644
index 00000000..3c837877
--- /dev/null
+++ b/quick-starts/kafka-redpanda/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Check if event was received via Redpanda
+# Returns $true if message found, $false otherwise
+
+$result = docker exec kafka-redpanda-redpanda-1 rpk topic consume keycloak-events --num 1 --fetch-max-wait 5s 2>&1 | Out-String
+return $result -match "LOGIN"
diff --git a/quick-starts/kafka-redpanda/docker-compose.yml b/quick-starts/kafka-redpanda/docker-compose.yml
index 66d94796..58e759a7 100644
--- a/quick-starts/kafka-redpanda/docker-compose.yml
+++ b/quick-starts/kafka-redpanda/docker-compose.yml
@@ -50,6 +50,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
diff --git a/quick-starts/mqtt-3-emqx/check-event-reception.ps1 b/quick-starts/mqtt-3-emqx/check-event-reception.ps1
new file mode 100644
index 00000000..8cbb8619
--- /dev/null
+++ b/quick-starts/mqtt-3-emqx/check-event-reception.ps1
@@ -0,0 +1,8 @@
+# Check if event was received via MQTT subscriber logs
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs mqtt-3-emqx-subscriber-1 2>&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/mqtt-3-emqx/docker-compose.yml b/quick-starts/mqtt-3-emqx/docker-compose.yml
index 00e20da7..a4298a95 100644
--- a/quick-starts/mqtt-3-emqx/docker-compose.yml
+++ b/quick-starts/mqtt-3-emqx/docker-compose.yml
@@ -7,17 +7,30 @@ services:
- 18083:18083
environment:
EMQX_ALLOW_ANONYMOUS: "true"
+ EMQX_AUTHORIZATION__NO_MATCH: allow
+ EMQX_AUTHORIZATION__SOURCES: "[]"
healthcheck:
test: ["CMD", "emqx_ctl", "status"]
interval: 5s
timeout: 5s
retries: 30
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: emqx
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ emqx:
+ condition: service_healthy
+
keycloak:
image: ghcr.io/fortunen/kete/quick-start-keycloak
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: mqtt-3
kete.routes.quick-start.destination.host: emqx
@@ -26,3 +39,5 @@ services:
depends_on:
emqx:
condition: service_healthy
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/mqtt-3-hivemq/check-event-reception.ps1 b/quick-starts/mqtt-3-hivemq/check-event-reception.ps1
new file mode 100644
index 00000000..c8b8fbc2
--- /dev/null
+++ b/quick-starts/mqtt-3-hivemq/check-event-reception.ps1
@@ -0,0 +1,8 @@
+# Check if event was received via MQTT subscriber logs
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs mqtt-3-hivemq-subscriber-1 2>&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/mqtt-3-hivemq/docker-compose.yml b/quick-starts/mqtt-3-hivemq/docker-compose.yml
new file mode 100644
index 00000000..954619ee
--- /dev/null
+++ b/quick-starts/mqtt-3-hivemq/docker-compose.yml
@@ -0,0 +1,42 @@
+services:
+
+ hivemq:
+ image: ghcr.io/fortunen/kete/quick-start-hivemq
+ ports:
+ - 1883:1883
+ - 8888:8000 # HiveMQ WebSocket (remapped)
+ healthcheck:
+ test: ["CMD-SHELL", "timeout 1 bash -c '&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/mqtt-3-mosquitto/docker-compose.yml b/quick-starts/mqtt-3-mosquitto/docker-compose.yml
index e98e1777..265ec125 100644
--- a/quick-starts/mqtt-3-mosquitto/docker-compose.yml
+++ b/quick-starts/mqtt-3-mosquitto/docker-compose.yml
@@ -12,11 +12,22 @@ services:
timeout: 5s
retries: 30
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: mosquitto
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ mosquitto:
+ condition: service_healthy
+
keycloak:
image: ghcr.io/fortunen/kete/quick-start-keycloak
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: mqtt-3
kete.routes.quick-start.destination.host: mosquitto
@@ -25,3 +36,5 @@ services:
depends_on:
mosquitto:
condition: service_healthy
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/mqtt-3-nanomq/check-event-reception.ps1 b/quick-starts/mqtt-3-nanomq/check-event-reception.ps1
new file mode 100644
index 00000000..e1e913d4
--- /dev/null
+++ b/quick-starts/mqtt-3-nanomq/check-event-reception.ps1
@@ -0,0 +1,8 @@
+# Check if event was received via MQTT subscriber logs
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs mqtt-3-nanomq-subscriber-1 2>&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/mqtt-3-nanomq/docker-compose.yml b/quick-starts/mqtt-3-nanomq/docker-compose.yml
new file mode 100644
index 00000000..f394fb05
--- /dev/null
+++ b/quick-starts/mqtt-3-nanomq/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+
+ nanomq:
+ image: ghcr.io/fortunen/kete/quick-start-nanomq
+ ports:
+ - 1883:1883
+ - 8083:8083
+ healthcheck:
+ test: ["CMD-SHELL", "pidof nanomq"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: nanomq
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ nanomq:
+ condition: service_healthy
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: mqtt-3
+ kete.routes.quick-start.destination.host: nanomq
+ kete.routes.quick-start.destination.port: 1883
+ kete.routes.quick-start.destination.topic: keycloak-events
+ depends_on:
+ nanomq:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/mqtt-3-rabbitmq/check-event-reception.ps1 b/quick-starts/mqtt-3-rabbitmq/check-event-reception.ps1
new file mode 100644
index 00000000..8d12be43
--- /dev/null
+++ b/quick-starts/mqtt-3-rabbitmq/check-event-reception.ps1
@@ -0,0 +1,8 @@
+# Check if event was received via MQTT subscriber logs
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs mqtt-3-rabbitmq-subscriber-1 2>&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/mqtt-3-rabbitmq/docker-compose.yml b/quick-starts/mqtt-3-rabbitmq/docker-compose.yml
new file mode 100644
index 00000000..840f8624
--- /dev/null
+++ b/quick-starts/mqtt-3-rabbitmq/docker-compose.yml
@@ -0,0 +1,48 @@
+services:
+
+ rabbitmq:
+ image: ghcr.io/fortunen/kete/quick-start-rabbitmq
+ ports:
+ - 5672:5672
+ - 15672:15672
+ - 1883:1883
+ environment:
+ RABBITMQ_DEFAULT_USER: guest
+ RABBITMQ_DEFAULT_PASS: guest
+ command: >
+ bash -c "rabbitmq-plugins enable --offline rabbitmq_mqtt &&
+ rabbitmq-server"
+ healthcheck:
+ test: rabbitmq-diagnostics -q ping
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: rabbitmq
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: mqtt-3
+ kete.routes.quick-start.destination.host: rabbitmq
+ kete.routes.quick-start.destination.port: 1883
+ kete.routes.quick-start.destination.topic: keycloak-events
+ kete.routes.quick-start.destination.username: guest
+ kete.routes.quick-start.destination.password: guest
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/mqtt-3-vernemq/check-event-reception.ps1 b/quick-starts/mqtt-3-vernemq/check-event-reception.ps1
new file mode 100644
index 00000000..da4d66b1
--- /dev/null
+++ b/quick-starts/mqtt-3-vernemq/check-event-reception.ps1
@@ -0,0 +1,8 @@
+# Check if event was received via MQTT subscriber logs
+# Returns $true if EVENT-RECEIVED marker found in subscriber logs
+
+$logs = docker logs mqtt-3-vernemq-subscriber-1 2>&1
+if ($logs -match "EVENT-RECEIVED") {
+ return $true
+}
+return $false
diff --git a/quick-starts/mqtt-3-vernemq/docker-compose.yml b/quick-starts/mqtt-3-vernemq/docker-compose.yml
new file mode 100644
index 00000000..794d3d6d
--- /dev/null
+++ b/quick-starts/mqtt-3-vernemq/docker-compose.yml
@@ -0,0 +1,44 @@
+services:
+
+ vernemq:
+ image: ghcr.io/fortunen/kete/quick-start-vernemq
+ ports:
+ - 1883:1883
+ - 8888:8888
+ environment:
+ DOCKER_VERNEMQ_ACCEPT_EULA: "yes"
+ DOCKER_VERNEMQ_ALLOW_ANONYMOUS: "on"
+ healthcheck:
+ test: ["CMD", "vernemq", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: vernemq
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ vernemq:
+ condition: service_healthy
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ KC_BOOTSTRAP_ADMIN_USERNAME: admin
+ KC_BOOTSTRAP_ADMIN_PASSWORD: admin
+ kete.routes.quick-start.destination.kind: mqtt-3
+ kete.routes.quick-start.destination.host: vernemq
+ kete.routes.quick-start.destination.port: 1883
+ kete.routes.quick-start.destination.topic: keycloak-events
+ depends_on:
+ vernemq:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/mqtt-5-azure-event-grid/check-event-reception.ps1 b/quick-starts/mqtt-5-azure-event-grid/check-event-reception.ps1
new file mode 100644
index 00000000..65241fa5
--- /dev/null
+++ b/quick-starts/mqtt-5-azure-event-grid/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Azure Event Grid MQTT
+# Cannot be tested locally without real Azure resources
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Azure Event Grid MQTT)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/mqtt-5-azure-event-grid/docker-compose.yml b/quick-starts/mqtt-5-azure-event-grid/docker-compose.yml
index 14f549de..037e2f9b 100644
--- a/quick-starts/mqtt-5-azure-event-grid/docker-compose.yml
+++ b/quick-starts/mqtt-5-azure-event-grid/docker-compose.yml
@@ -8,6 +8,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
# Azure Event Grid MQTT configuration
# Replace these values or use a .env file
diff --git a/quick-starts/mqtt-5-azure-event-grid/dont-run-this-quickstart b/quick-starts/mqtt-5-azure-event-grid/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/mqtt-5-emqx/check-event-reception.ps1 b/quick-starts/mqtt-5-emqx/check-event-reception.ps1
new file mode 100644
index 00000000..bcafef2c
--- /dev/null
+++ b/quick-starts/mqtt-5-emqx/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs mqtt-5-emqx-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $null -ne $output
diff --git a/quick-starts/mqtt-5-emqx/docker-compose.yml b/quick-starts/mqtt-5-emqx/docker-compose.yml
index dd19c1e9..817bbc0e 100644
--- a/quick-starts/mqtt-5-emqx/docker-compose.yml
+++ b/quick-starts/mqtt-5-emqx/docker-compose.yml
@@ -7,17 +7,30 @@ services:
- 18083:18083
environment:
EMQX_ALLOW_ANONYMOUS: "true"
+ EMQX_AUTHORIZATION__NO_MATCH: allow
+ EMQX_AUTHORIZATION__SOURCES: "[]"
healthcheck:
test: ["CMD", "emqx_ctl", "status"]
interval: 5s
timeout: 5s
retries: 30
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: emqx
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ emqx:
+ condition: service_healthy
keycloak:
image: ghcr.io/fortunen/kete/quick-start-keycloak
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: mqtt-5
kete.routes.quick-start.destination.host: emqx
@@ -26,3 +39,6 @@ services:
depends_on:
emqx:
condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/mqtt-5-emqx/emqx/Dockerfile b/quick-starts/mqtt-5-emqx/emqx/Dockerfile
deleted file mode 100644
index b6e03c00..00000000
--- a/quick-starts/mqtt-5-emqx/emqx/Dockerfile
+++ /dev/null
@@ -1 +0,0 @@
-FROM emqx/emqx:5.8.4
diff --git a/quick-starts/mqtt-5-hivemq/check-event-reception.ps1 b/quick-starts/mqtt-5-hivemq/check-event-reception.ps1
new file mode 100644
index 00000000..44acc1e2
--- /dev/null
+++ b/quick-starts/mqtt-5-hivemq/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs mqtt-5-hivemq-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/mqtt-5-hivemq/docker-compose.yml b/quick-starts/mqtt-5-hivemq/docker-compose.yml
index 9489021f..39dc4188 100644
--- a/quick-starts/mqtt-5-hivemq/docker-compose.yml
+++ b/quick-starts/mqtt-5-hivemq/docker-compose.yml
@@ -11,11 +11,22 @@ services:
timeout: 5s
retries: 30
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: hivemq
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ hivemq:
+ condition: service_healthy
keycloak:
image: ghcr.io/fortunen/kete/quick-start-keycloak
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
@@ -26,3 +37,6 @@ services:
depends_on:
hivemq:
condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/mqtt-5-mosquitto/check-event-reception.ps1 b/quick-starts/mqtt-5-mosquitto/check-event-reception.ps1
new file mode 100644
index 00000000..bb40fedc
--- /dev/null
+++ b/quick-starts/mqtt-5-mosquitto/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs mqtt-5-mosquitto-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/mqtt-5-mosquitto/docker-compose.yml b/quick-starts/mqtt-5-mosquitto/docker-compose.yml
index 51954ba3..2289d9ac 100644
--- a/quick-starts/mqtt-5-mosquitto/docker-compose.yml
+++ b/quick-starts/mqtt-5-mosquitto/docker-compose.yml
@@ -12,11 +12,22 @@ services:
timeout: 5s
retries: 30
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: mosquitto
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ mosquitto:
+ condition: service_healthy
keycloak:
image: ghcr.io/fortunen/kete/quick-start-keycloak
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: mqtt-5
kete.routes.quick-start.destination.host: mosquitto
@@ -25,3 +36,6 @@ services:
depends_on:
mosquitto:
condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/mqtt-5-mosquitto/mosquitto/Dockerfile b/quick-starts/mqtt-5-mosquitto/mosquitto/Dockerfile
deleted file mode 100644
index 7ab444aa..00000000
--- a/quick-starts/mqtt-5-mosquitto/mosquitto/Dockerfile
+++ /dev/null
@@ -1 +0,0 @@
-FROM eclipse-mosquitto:2.0.20
diff --git a/quick-starts/mqtt-5-nanomq/check-event-reception.ps1 b/quick-starts/mqtt-5-nanomq/check-event-reception.ps1
new file mode 100644
index 00000000..3b8a8ef4
--- /dev/null
+++ b/quick-starts/mqtt-5-nanomq/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs mqtt-5-nanomq-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/mqtt-5-nanomq/docker-compose.yml b/quick-starts/mqtt-5-nanomq/docker-compose.yml
new file mode 100644
index 00000000..e9134361
--- /dev/null
+++ b/quick-starts/mqtt-5-nanomq/docker-compose.yml
@@ -0,0 +1,42 @@
+services:
+
+ nanomq:
+ image: ghcr.io/fortunen/kete/quick-start-nanomq-mqtt5
+ ports:
+ - 1883:1883
+ - 8083:8083
+ volumes:
+ - ./nanomq.conf:/etc/nanomq.conf:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pidof nanomq"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: nanomq
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ nanomq:
+ condition: service_healthy
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: mqtt-5
+ kete.routes.quick-start.destination.host: nanomq
+ kete.routes.quick-start.destination.port: 1883
+ kete.routes.quick-start.destination.topic: keycloak-events
+ depends_on:
+ nanomq:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/mqtt-5-nanomq/nanomq.conf b/quick-starts/mqtt-5-nanomq/nanomq.conf
new file mode 100644
index 00000000..0f0f4f23
--- /dev/null
+++ b/quick-starts/mqtt-5-nanomq/nanomq.conf
@@ -0,0 +1,16 @@
+mqtt {
+ max_packet_size = 1MB
+ max_mqueue_len = 65535
+ retry_interval = 10s
+ keepalive_multiplier = 1.25
+ property_size = 32
+}
+
+listeners.tcp {
+ bind = "0.0.0.0:1883"
+}
+
+log {
+ to = [console]
+ level = info
+}
diff --git a/quick-starts/mqtt-5-rabbitmq/check-event-reception.ps1 b/quick-starts/mqtt-5-rabbitmq/check-event-reception.ps1
new file mode 100644
index 00000000..7aaea0b6
--- /dev/null
+++ b/quick-starts/mqtt-5-rabbitmq/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs mqtt-5-rabbitmq-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/mqtt-5-rabbitmq/docker-compose.yml b/quick-starts/mqtt-5-rabbitmq/docker-compose.yml
new file mode 100644
index 00000000..93b3c962
--- /dev/null
+++ b/quick-starts/mqtt-5-rabbitmq/docker-compose.yml
@@ -0,0 +1,49 @@
+services:
+
+ rabbitmq:
+ image: ghcr.io/fortunen/kete/quick-start-rabbitmq
+ ports:
+ - 5672:5672
+ - 15672:15672
+ - 1883:1883
+ environment:
+ RABBITMQ_DEFAULT_USER: guest
+ RABBITMQ_DEFAULT_PASS: guest
+ command: >
+ bash -c "rabbitmq-plugins enable --offline rabbitmq_mqtt &&
+ rabbitmq-server"
+ healthcheck:
+ test: rabbitmq-diagnostics -q ping
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: rabbitmq
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: mqtt-5
+ kete.routes.quick-start.destination.host: rabbitmq
+ kete.routes.quick-start.destination.port: 1883
+ kete.routes.quick-start.destination.topic: keycloak-events
+ kete.routes.quick-start.destination.username: guest
+ kete.routes.quick-start.destination.password: guest
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/mqtt-5-vernemq/check-event-reception.ps1 b/quick-starts/mqtt-5-vernemq/check-event-reception.ps1
new file mode 100644
index 00000000..044e2528
--- /dev/null
+++ b/quick-starts/mqtt-5-vernemq/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs mqtt-5-vernemq-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/mqtt-5-vernemq/docker-compose.yml b/quick-starts/mqtt-5-vernemq/docker-compose.yml
new file mode 100644
index 00000000..03a79f60
--- /dev/null
+++ b/quick-starts/mqtt-5-vernemq/docker-compose.yml
@@ -0,0 +1,45 @@
+services:
+
+ vernemq:
+ image: ghcr.io/fortunen/kete/quick-start-vernemq
+ ports:
+ - 1883:1883
+ - 8888:8888
+ environment:
+ DOCKER_VERNEMQ_ACCEPT_EULA: "yes"
+ DOCKER_VERNEMQ_ALLOW_ANONYMOUS: "on"
+ healthcheck:
+ test: ["CMD", "vernemq", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-mqtt-subscriber
+ environment:
+ MQTT_BROKER_HOST: vernemq
+ MQTT_BROKER_PORT: 1883
+ MQTT_TOPIC: keycloak-events
+ depends_on:
+ vernemq:
+ condition: service_healthy
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ KC_BOOTSTRAP_ADMIN_USERNAME: admin
+ KC_BOOTSTRAP_ADMIN_PASSWORD: admin
+ kete.routes.quick-start.destination.kind: mqtt-5
+ kete.routes.quick-start.destination.host: vernemq
+ kete.routes.quick-start.destination.port: 1883
+ kete.routes.quick-start.destination.topic: keycloak-events
+ depends_on:
+ vernemq:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/nats-jetstream-nats-server/check-event-reception.ps1 b/quick-starts/nats-jetstream-nats-server/check-event-reception.ps1
new file mode 100644
index 00000000..5b81eec4
--- /dev/null
+++ b/quick-starts/nats-jetstream-nats-server/check-event-reception.ps1
@@ -0,0 +1,18 @@
+# Check if event was received by NATS JetStream
+# JetStream persists messages - we can check stream contents
+
+$streamName = "KEYCLOAK_EVENTS"
+$containerName = "nats-jetstream-nats-server-nats-setup-1"
+
+# Use nats-setup container to check stream
+$result = docker exec $containerName nats stream info $streamName --server nats://nats:4222 2>&1 | Out-String
+
+# Look for "Messages: N" where N > 0
+if ($result -match '(?m)^\s*Messages:\s+(\d+)') {
+ $messageCount = [int]$Matches[1]
+ if ($messageCount -gt 0) {
+ return $true
+ }
+}
+
+return $false
diff --git a/quick-starts/nats-jetstream-nats-server/docker-compose.yml b/quick-starts/nats-jetstream-nats-server/docker-compose.yml
new file mode 100644
index 00000000..e96ad1f1
--- /dev/null
+++ b/quick-starts/nats-jetstream-nats-server/docker-compose.yml
@@ -0,0 +1,51 @@
+services:
+
+ nats:
+ image: ghcr.io/fortunen/kete/quick-start-nats
+ ports:
+ - 4222:4222
+ - 8222:8222
+ command: ["--jetstream", "--http_port", "8222"]
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q --spider http://localhost:8222/varz || exit 1"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ nats-setup:
+ image: ghcr.io/fortunen/kete/quick-start-nats-box
+ depends_on:
+ nats:
+ condition: service_healthy
+ command:
+ - sh
+ - -c
+ - |
+ nats -s nats://nats:4222 stream add KEYCLOAK_EVENTS --defaults \
+ --subjects "keycloak.>" \
+ --storage file \
+ --replicas 1 \
+ --retention limits \
+ --max-age 7d \
+ --max-bytes 1GB \
+ --discard old \
+ --deny-delete \
+ --deny-purge &&
+ echo "Stream KEYCLOAK_EVENTS created successfully" &&
+ sleep infinity
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: nats-jetstream
+ kete.routes.quick-start.destination.servers: nats://nats:4222
+ kete.routes.quick-start.destination.subject: keycloak.events
+ kete.routes.quick-start.destination.stream: KEYCLOAK_EVENTS
+ kete.routes.quick-start.destination.authentication-method: none
+ depends_on:
+ nats-setup:
+ condition: service_started
diff --git a/quick-starts/nats-nats-server/check-event-reception.ps1 b/quick-starts/nats-nats-server/check-event-reception.ps1
new file mode 100644
index 00000000..d4c6fb96
--- /dev/null
+++ b/quick-starts/nats-nats-server/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs nats-nats-server-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $null -ne $output
diff --git a/quick-starts/nats-nats-server/docker-compose.yml b/quick-starts/nats-nats-server/docker-compose.yml
new file mode 100644
index 00000000..88fa95eb
--- /dev/null
+++ b/quick-starts/nats-nats-server/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-nats-subscriber
+ environment:
+ NATS_URL: nats://nats:4222
+ NATS_SUBJECT: keycloak.events
+ depends_on:
+ nats:
+ condition: service_healthy
+
+ nats:
+ image: ghcr.io/fortunen/kete/quick-start-nats
+ ports:
+ - 4222:4222
+ - 8222:8222
+ command: ["--http_port", "8222"]
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q --spider http://localhost:8222/varz || exit 1"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: nats
+ kete.routes.quick-start.destination.servers: nats://nats:4222
+ kete.routes.quick-start.destination.subject: keycloak.events
+ kete.routes.quick-start.destination.authentication-method: none
+ depends_on:
+ nats:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/pulsar-apache/check-event-reception.ps1 b/quick-starts/pulsar-apache/check-event-reception.ps1
new file mode 100644
index 00000000..c7a384f0
--- /dev/null
+++ b/quick-starts/pulsar-apache/check-event-reception.ps1
@@ -0,0 +1,24 @@
+# Check if event was received by Apache Pulsar
+# Uses pulsar-admin to check topic stats
+
+$tenant = "public"
+$namespace = "default"
+$topic = "keycloak-events"
+
+# Use pulsar container to check topic stats
+$result = docker exec pulsar-apache-pulsar-1 bin/pulsar-admin topics stats "persistent://$tenant/$namespace/$topic" 2>&1
+
+# Convert array to single string for regex matching
+$output = $result -join "`n"
+
+# Check for message count in stats
+if ($output -match '"msgInCounter"\s*:\s*(\d+)' -and [int]$Matches[1] -gt 0) {
+ return $true
+}
+
+# Alternative: check storageSize
+if ($output -match '"storageSize"\s*:\s*(\d+)' -and [int]$Matches[1] -gt 0) {
+ return $true
+}
+
+return $false
diff --git a/quick-starts/pulsar-apache/docker-compose.yml b/quick-starts/pulsar-apache/docker-compose.yml
new file mode 100644
index 00000000..ca5fd7d7
--- /dev/null
+++ b/quick-starts/pulsar-apache/docker-compose.yml
@@ -0,0 +1,28 @@
+services:
+
+ pulsar:
+ image: ghcr.io/fortunen/kete/quick-start-pulsar
+ ports:
+ - 6650:6650
+ - 8081:8080
+ command: bin/pulsar standalone
+ healthcheck:
+ test: ["CMD-SHELL", "curl -sf http://localhost:8080/admin/v2/brokers/healthcheck || exit 1"]
+ interval: 5s
+ timeout: 10s
+ retries: 30
+ start_period: 30s
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: pulsar
+ kete.routes.quick-start.destination.service-url: pulsar://pulsar:6650
+ kete.routes.quick-start.destination.topic: persistent://public/default/keycloak-events
+ depends_on:
+ pulsar:
+ condition: service_healthy
diff --git a/quick-starts/quick-start-keycloak/Dockerfile b/quick-starts/quick-start-keycloak/Dockerfile
index 10502108..92440d70 100644
--- a/quick-starts/quick-start-keycloak/Dockerfile
+++ b/quick-starts/quick-start-keycloak/Dockerfile
@@ -6,6 +6,7 @@ FROM maven:3.9.9-eclipse-temurin-21-noble AS maven-build
WORKDIR /src
COPY pom.xml .
+RUN mvn clean
RUN mvn -q -DskipTests dependency:go-offline
COPY . .
RUN mvn -B -DskipTests package
diff --git a/quick-starts/redis-pubsub-azure-cache-for-redis/GUIDE.md b/quick-starts/redis-pubsub-azure-cache-for-redis/GUIDE.md
new file mode 100644
index 00000000..aab6d6d4
--- /dev/null
+++ b/quick-starts/redis-pubsub-azure-cache-for-redis/GUIDE.md
@@ -0,0 +1,136 @@
+# Azure Cache for Redis (Pub/Sub) Quick Start
+
+This quick start demonstrates forwarding Keycloak events to **Azure Cache for Redis** using Redis Pub/Sub.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
+- An Azure subscription
+- An Azure Cache for Redis instance
+
+## Azure Setup
+
+### 1. Create an Azure Cache for Redis
+
+```bash
+# Create a resource group (if needed)
+az group create --name keycloak-events-rg --location eastus
+
+# Create an Azure Cache for Redis (Basic C0 for testing)
+az redis create \
+ --name \
+ --resource-group keycloak-events-rg \
+ --location eastus \
+ --sku Basic \
+ --vm-size c0 \
+ --enable-non-ssl-port false
+```
+
+> **Note**: Production workloads should use Standard or Premium tiers.
+
+### 2. Get the Access Key
+
+```bash
+# Get the primary access key
+az redis list-keys \
+ --name \
+ --resource-group keycloak-events-rg \
+ --query primaryKey -o tsv
+```
+
+### 3. Get the Host Name
+
+```bash
+# Get the host name
+az redis show \
+ --name \
+ --resource-group keycloak-events-rg \
+ --query hostName -o tsv
+```
+
+The hostname will be: `.redis.cache.windows.net`
+
+## Configuration
+
+### Option 1: Edit docker-compose.yml directly
+
+Edit [docker-compose.yml](docker-compose.yml) and replace the placeholders:
+
+| Placeholder | Description | Example |
+|-------------|-------------|---------|
+| `` | Azure Cache for Redis name | `my-keycloak-cache` |
+| `` | Primary or secondary access key | `abc123...` |
+
+### Option 2: Use environment variables
+
+Create a `.env` file:
+
+```env
+AZURE_REDIS_HOST=my-keycloak-cache.redis.cache.windows.net
+AZURE_REDIS_KEY=your-primary-access-key
+```
+
+## Running
+
+```bash
+docker-compose up -d
+```
+
+## Testing
+
+1. Open Keycloak at http://localhost:8080
+2. Log in with `admin` / `admin`
+3. Perform actions (create users, login, etc.)
+4. Check Azure Cache for Redis for received messages:
+
+### Using Azure CLI
+
+```bash
+# Connect to your Redis instance
+az redis console --name --resource-group keycloak-events-rg
+
+# Subscribe to the channel
+SUBSCRIBE keycloak-events
+```
+
+### Using redis-cli with TLS
+
+```bash
+# Install redis-cli if needed
+# Connect with TLS
+redis-cli -h .redis.cache.windows.net \
+ -p 6380 \
+ --tls \
+ -a
+
+# Subscribe to the channel
+SUBSCRIBE keycloak-events
+```
+
+## Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| Connection refused | Ensure TLS is enabled (port 6380) |
+| Authentication failed | Verify the access key is correct |
+| Firewall blocked | Add your IP or enable Azure services access |
+
+## Additional Configuration
+
+For production, consider:
+
+- **Premium tier**: For clustering, geo-replication, and data persistence
+- **Private endpoints**: For network isolation
+- **Managed Identity**: For password-less authentication (requires Premium tier)
+
+## Cleanup
+
+```bash
+# Delete the Azure Cache for Redis
+az redis delete \
+ --name \
+ --resource-group keycloak-events-rg
+
+# Or delete the entire resource group
+az group delete --name keycloak-events-rg
+```
diff --git a/quick-starts/redis-pubsub-azure-cache-for-redis/check-event-reception.ps1 b/quick-starts/redis-pubsub-azure-cache-for-redis/check-event-reception.ps1
new file mode 100644
index 00000000..fda0e1a5
--- /dev/null
+++ b/quick-starts/redis-pubsub-azure-cache-for-redis/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Azure Cache for Redis
+# Cannot be tested locally without real Azure resources
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Azure Cache for Redis Pub/Sub)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/redis-pubsub-azure-cache-for-redis/docker-compose.yml b/quick-starts/redis-pubsub-azure-cache-for-redis/docker-compose.yml
new file mode 100644
index 00000000..08455153
--- /dev/null
+++ b/quick-starts/redis-pubsub-azure-cache-for-redis/docker-compose.yml
@@ -0,0 +1,20 @@
+# Azure Cache for Redis (Redis Pub/Sub)
+# See GUIDE.md for setup instructions
+
+services:
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ # Azure Cache for Redis configuration
+ # Replace these values or use a .env file
+ kete.routes.quick-start.destination.kind: redis-pubsub
+ kete.routes.quick-start.destination.host: ${AZURE_REDIS_HOST:-.redis.cache.windows.net}
+ kete.routes.quick-start.destination.port: 6380
+ kete.routes.quick-start.destination.channel: keycloak-events
+ kete.routes.quick-start.destination.tls.enabled: "true"
+ kete.routes.quick-start.destination.password: ${AZURE_REDIS_KEY:-}
diff --git a/quick-starts/redis-pubsub-azure-cache-for-redis/dont-run-this-quickstart b/quick-starts/redis-pubsub-azure-cache-for-redis/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/redis-pubsub-dragonfly/check-event-reception.ps1 b/quick-starts/redis-pubsub-dragonfly/check-event-reception.ps1
new file mode 100644
index 00000000..55a258b5
--- /dev/null
+++ b/quick-starts/redis-pubsub-dragonfly/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs redis-pubsub-dragonfly-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/redis-pubsub-dragonfly/docker-compose.yml b/quick-starts/redis-pubsub-dragonfly/docker-compose.yml
new file mode 100644
index 00000000..eb1f149f
--- /dev/null
+++ b/quick-starts/redis-pubsub-dragonfly/docker-compose.yml
@@ -0,0 +1,40 @@
+services:
+
+ dragonfly:
+ image: ghcr.io/fortunen/kete/quick-start-dragonfly
+ ports:
+ - 6379:6379
+ command: ["--logtostderr"]
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-redis-subscriber
+ environment:
+ REDIS_HOST: dragonfly
+ REDIS_PORT: 6379
+ REDIS_CHANNEL: keycloak-events
+ depends_on:
+ dragonfly:
+ condition: service_healthy
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-pubsub
+ kete.routes.quick-start.destination.host: dragonfly
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.channel: keycloak-events
+ depends_on:
+ dragonfly:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/redis-pubsub-keydb/check-event-reception.ps1 b/quick-starts/redis-pubsub-keydb/check-event-reception.ps1
new file mode 100644
index 00000000..d2cf0688
--- /dev/null
+++ b/quick-starts/redis-pubsub-keydb/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs redis-pubsub-keydb-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/redis-pubsub-keydb/docker-compose.yml b/quick-starts/redis-pubsub-keydb/docker-compose.yml
new file mode 100644
index 00000000..ca6991ed
--- /dev/null
+++ b/quick-starts/redis-pubsub-keydb/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+
+ keydb:
+ image: ghcr.io/fortunen/kete/quick-start-keydb
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-redis-subscriber
+ environment:
+ REDIS_HOST: keydb
+ REDIS_PORT: 6379
+ REDIS_CHANNEL: keycloak-events
+ depends_on:
+ keydb:
+ condition: service_healthy
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-pubsub
+ kete.routes.quick-start.destination.host: keydb
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.channel: keycloak-events
+ depends_on:
+ keydb:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/redis-pubsub-redis/check-event-reception.ps1 b/quick-starts/redis-pubsub-redis/check-event-reception.ps1
new file mode 100644
index 00000000..164ca690
--- /dev/null
+++ b/quick-starts/redis-pubsub-redis/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs redis-pubsub-redis-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/redis-pubsub-redis/docker-compose.yml b/quick-starts/redis-pubsub-redis/docker-compose.yml
new file mode 100644
index 00000000..af2c873d
--- /dev/null
+++ b/quick-starts/redis-pubsub-redis/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+
+ redis:
+ image: ghcr.io/fortunen/kete/quick-start-redis
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-redis-subscriber
+ environment:
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ REDIS_CHANNEL: keycloak-events
+ depends_on:
+ redis:
+ condition: service_healthy
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-pubsub
+ kete.routes.quick-start.destination.host: redis
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.channel: keycloak-events
+ depends_on:
+ redis:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/redis-pubsub-upstash/GUIDE.md b/quick-starts/redis-pubsub-upstash/GUIDE.md
new file mode 100644
index 00000000..c0455fa8
--- /dev/null
+++ b/quick-starts/redis-pubsub-upstash/GUIDE.md
@@ -0,0 +1,121 @@
+# Upstash Redis (Pub/Sub) Quick Start
+
+This quick start demonstrates forwarding Keycloak events to **Upstash** serverless Redis using Redis Pub/Sub.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
+- An Upstash account ([sign up free](https://upstash.com/))
+
+## Upstash Setup
+
+### 1. Create a Redis Database
+
+1. Log in to [Upstash Console](https://console.upstash.com/)
+2. Click **Create Database**
+3. Configure:
+ - **Name**: `keycloak-events`
+ - **Region**: Choose the closest region
+ - **Type**: Regional (or Global for multi-region)
+ - **TLS**: Enabled (recommended)
+4. Click **Create**
+
+### 2. Get Connection Details
+
+After creation, navigate to your database and find:
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| **Endpoint** | Redis hostname | `evident-frog-12345.upstash.io` |
+| **Port** | Redis port (usually 6379) | `6379` |
+| **Password** | Authentication password | `AXk3AAIncDE...` |
+
+You can find these in the **REST API** or **Redis** tabs of your database details.
+
+## Configuration
+
+### Option 1: Edit docker-compose.yml directly
+
+Edit [docker-compose.yml](docker-compose.yml) and replace the placeholders:
+
+| Placeholder | Description | Example |
+|-------------|-------------|---------|
+| `` | Upstash endpoint (without `.upstash.io`) | `evident-frog-12345` |
+| `` | Upstash password | `AXk3AAIncDE...` |
+
+### Option 2: Use environment variables
+
+Create a `.env` file:
+
+```env
+UPSTASH_REDIS_HOST=evident-frog-12345.upstash.io
+UPSTASH_REDIS_PASSWORD=your-upstash-password
+```
+
+## Running
+
+```bash
+docker-compose up -d
+```
+
+## Testing
+
+1. Open Keycloak at http://localhost:8080
+2. Log in with `admin` / `admin`
+3. Perform actions (create users, login, etc.)
+4. Check Upstash for received messages:
+
+### Using Upstash Console
+
+1. Navigate to your database in [Upstash Console](https://console.upstash.com/)
+2. Go to the **CLI** tab
+3. Run:
+ ```
+ SUBSCRIBE keycloak-events
+ ```
+
+### Using redis-cli
+
+```bash
+# Connect with TLS
+redis-cli -h evident-frog-12345.upstash.io \
+ -p 6379 \
+ --tls \
+ -a your-upstash-password
+
+# Subscribe to the channel
+SUBSCRIBE keycloak-events
+```
+
+### Using Upstash REST API
+
+Upstash also provides a REST API for Redis commands:
+
+```bash
+curl "https://evident-frog-12345.upstash.io/pubsub/channels" \
+ -H "Authorization: Bearer your-rest-token"
+```
+
+## Upstash Features
+
+| Feature | Description |
+|---------|-------------|
+| **Serverless** | Pay-per-request pricing, no infrastructure management |
+| **Global** | Optional multi-region replication |
+| **REST API** | HTTP-based Redis access |
+| **TLS** | Encrypted connections by default |
+| **Free Tier** | 10,000 commands/day free |
+
+## Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| Connection timeout | Verify endpoint is correct |
+| Authentication failed | Check password (it's the long token, not the REST token) |
+| TLS errors | Ensure `tls.enabled=true` |
+
+## Cleanup
+
+1. Log in to [Upstash Console](https://console.upstash.com/)
+2. Navigate to your database
+3. Click **Delete Database**
diff --git a/quick-starts/redis-pubsub-upstash/check-event-reception.ps1 b/quick-starts/redis-pubsub-upstash/check-event-reception.ps1
new file mode 100644
index 00000000..26f3a01c
--- /dev/null
+++ b/quick-starts/redis-pubsub-upstash/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Upstash Redis
+# Cannot be tested locally without real Upstash account
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Upstash Redis Pub/Sub)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/redis-pubsub-upstash/docker-compose.yml b/quick-starts/redis-pubsub-upstash/docker-compose.yml
new file mode 100644
index 00000000..f4a99ac4
--- /dev/null
+++ b/quick-starts/redis-pubsub-upstash/docker-compose.yml
@@ -0,0 +1,20 @@
+# Upstash Redis (Serverless)
+# See GUIDE.md for setup instructions
+
+services:
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ # Upstash Redis configuration
+ # Replace these values or use a .env file
+ kete.routes.quick-start.destination.kind: redis-pubsub
+ kete.routes.quick-start.destination.host: ${UPSTASH_REDIS_HOST:-.upstash.io}
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.channel: keycloak-events
+ kete.routes.quick-start.destination.tls.enabled: "true"
+ kete.routes.quick-start.destination.password: ${UPSTASH_REDIS_PASSWORD:-}
diff --git a/quick-starts/redis-pubsub-upstash/dont-run-this-quickstart b/quick-starts/redis-pubsub-upstash/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/redis-pubsub-valkey/check-event-reception.ps1 b/quick-starts/redis-pubsub-valkey/check-event-reception.ps1
new file mode 100644
index 00000000..4fab1e14
--- /dev/null
+++ b/quick-starts/redis-pubsub-valkey/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs redis-pubsub-valkey-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/redis-pubsub-valkey/docker-compose.yml b/quick-starts/redis-pubsub-valkey/docker-compose.yml
new file mode 100644
index 00000000..f7031467
--- /dev/null
+++ b/quick-starts/redis-pubsub-valkey/docker-compose.yml
@@ -0,0 +1,39 @@
+services:
+
+ valkey:
+ image: ghcr.io/fortunen/kete/quick-start-valkey
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD", "valkey-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-redis-subscriber
+ environment:
+ REDIS_HOST: valkey
+ REDIS_PORT: 6379
+ REDIS_CHANNEL: keycloak-events
+ depends_on:
+ valkey:
+ condition: service_healthy
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-pubsub
+ kete.routes.quick-start.destination.host: valkey
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.channel: keycloak-events
+ depends_on:
+ valkey:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
+
diff --git a/quick-starts/redis-streams-azure-cache-for-redis/GUIDE.md b/quick-starts/redis-streams-azure-cache-for-redis/GUIDE.md
new file mode 100644
index 00000000..4e8ad2aa
--- /dev/null
+++ b/quick-starts/redis-streams-azure-cache-for-redis/GUIDE.md
@@ -0,0 +1,159 @@
+# Azure Cache for Redis (Streams) Quick Start
+
+This quick start demonstrates forwarding Keycloak events to **Azure Cache for Redis** using Redis Streams for persistent, ordered message storage.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
+- An Azure subscription
+- An Azure Cache for Redis instance (6.0 or higher for Streams support)
+
+## Azure Setup
+
+### 1. Create an Azure Cache for Redis
+
+```bash
+# Create a resource group (if needed)
+az group create --name keycloak-events-rg --location eastus
+
+# Create an Azure Cache for Redis (Basic C0 for testing)
+# Redis 6.0+ supports Streams
+az redis create \
+ --name \
+ --resource-group keycloak-events-rg \
+ --location eastus \
+ --sku Basic \
+ --vm-size c0 \
+ --redis-version 6 \
+ --enable-non-ssl-port false
+```
+
+> **Note**: Production workloads should use Standard or Premium tiers.
+
+### 2. Get the Access Key
+
+```bash
+# Get the primary access key
+az redis list-keys \
+ --name \
+ --resource-group keycloak-events-rg \
+ --query primaryKey -o tsv
+```
+
+### 3. Get the Host Name
+
+```bash
+# Get the host name
+az redis show \
+ --name \
+ --resource-group keycloak-events-rg \
+ --query hostName -o tsv
+```
+
+The hostname will be: `.redis.cache.windows.net`
+
+## Configuration
+
+### Option 1: Edit docker-compose.yml directly
+
+Edit [docker-compose.yml](docker-compose.yml) and replace the placeholders:
+
+| Placeholder | Description | Example |
+|-------------|-------------|---------|
+| `` | Azure Cache for Redis name | `my-keycloak-cache` |
+| `` | Primary or secondary access key | `abc123...` |
+
+### Option 2: Use environment variables
+
+Create a `.env` file:
+
+```env
+AZURE_REDIS_HOST=my-keycloak-cache.redis.cache.windows.net
+AZURE_REDIS_KEY=your-primary-access-key
+```
+
+## Running
+
+```bash
+docker-compose up -d
+```
+
+## Testing
+
+1. Open Keycloak at http://localhost:8080
+2. Log in with `admin` / `admin`
+3. Perform actions (create users, login, etc.)
+4. Check Azure Cache for Redis for received messages:
+
+### Using redis-cli with TLS
+
+```bash
+# Connect with TLS
+redis-cli -h .redis.cache.windows.net \
+ -p 6380 \
+ --tls \
+ -a
+
+# Read from the stream (all entries)
+XREAD STREAMS keycloak-events 0
+
+# Read the last 10 entries
+XREVRANGE keycloak-events + - COUNT 10
+
+# Get stream info
+XINFO STREAM keycloak-events
+```
+
+### Using Consumer Groups
+
+Redis Streams support consumer groups for load balancing:
+
+```bash
+# Create a consumer group
+XGROUP CREATE keycloak-events mygroup 0 MKSTREAM
+
+# Read as a consumer
+XREADGROUP GROUP mygroup consumer1 COUNT 10 STREAMS keycloak-events >
+
+# Acknowledge processed messages
+XACK keycloak-events mygroup
+```
+
+## Redis Streams Features
+
+| Feature | Description |
+|---------|-------------|
+| **Persistence** | Messages are stored until explicitly deleted |
+| **Ordering** | Messages are strictly ordered by ID |
+| **Consumer Groups** | Load balancing across multiple consumers |
+| **Acknowledgment** | At-least-once delivery semantics |
+| **Trimming** | Automatic size management with `max-len` |
+
+## Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| Connection refused | Ensure TLS is enabled (port 6380) |
+| Authentication failed | Verify the access key is correct |
+| Firewall blocked | Add your IP or enable Azure services access |
+| Streams not supported | Ensure Redis version is 6.0 or higher |
+
+## Additional Configuration
+
+For production, consider:
+
+- **Premium tier**: For clustering, geo-replication, and data persistence
+- **Private endpoints**: For network isolation
+- **Managed Identity**: For password-less authentication (requires Premium tier)
+
+## Cleanup
+
+```bash
+# Delete the Azure Cache for Redis
+az redis delete \
+ --name \
+ --resource-group keycloak-events-rg
+
+# Or delete the entire resource group
+az group delete --name keycloak-events-rg
+```
diff --git a/quick-starts/redis-streams-azure-cache-for-redis/check-event-reception.ps1 b/quick-starts/redis-streams-azure-cache-for-redis/check-event-reception.ps1
new file mode 100644
index 00000000..186d7418
--- /dev/null
+++ b/quick-starts/redis-streams-azure-cache-for-redis/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Azure Cache for Redis
+# Cannot be tested locally without real Azure resources
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Azure Cache for Redis Streams)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/redis-streams-azure-cache-for-redis/docker-compose.yml b/quick-starts/redis-streams-azure-cache-for-redis/docker-compose.yml
new file mode 100644
index 00000000..d7f3ad1e
--- /dev/null
+++ b/quick-starts/redis-streams-azure-cache-for-redis/docker-compose.yml
@@ -0,0 +1,21 @@
+# Azure Cache for Redis (Redis Streams)
+# See GUIDE.md for setup instructions
+
+services:
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ # Azure Cache for Redis configuration
+ # Replace these values or use a .env file
+ kete.routes.quick-start.destination.kind: redis-streams
+ kete.routes.quick-start.destination.host: ${AZURE_REDIS_HOST:-.redis.cache.windows.net}
+ kete.routes.quick-start.destination.port: 6380
+ kete.routes.quick-start.destination.stream: keycloak-events
+ kete.routes.quick-start.destination.max-len: 10000
+ kete.routes.quick-start.destination.tls.enabled: "true"
+ kete.routes.quick-start.destination.password: ${AZURE_REDIS_KEY:-}
diff --git a/quick-starts/redis-streams-azure-cache-for-redis/dont-run-this-quickstart b/quick-starts/redis-streams-azure-cache-for-redis/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/redis-streams-dragonfly/check-event-reception.ps1 b/quick-starts/redis-streams-dragonfly/check-event-reception.ps1
new file mode 100644
index 00000000..7fdac111
--- /dev/null
+++ b/quick-starts/redis-streams-dragonfly/check-event-reception.ps1
@@ -0,0 +1,6 @@
+# Check if event was received via Dragonfly XRANGE (streams)
+# Returns $true if message found, $false otherwise
+
+$result = docker exec redis-streams-dragonfly-dragonfly-1 redis-cli XRANGE keycloak-events - + COUNT 1 2>&1
+$matched = $result -match "LOGIN"
+return $matched.Count -gt 0
diff --git a/quick-starts/redis-streams-dragonfly/docker-compose.yml b/quick-starts/redis-streams-dragonfly/docker-compose.yml
new file mode 100644
index 00000000..287d3d79
--- /dev/null
+++ b/quick-starts/redis-streams-dragonfly/docker-compose.yml
@@ -0,0 +1,28 @@
+services:
+
+ dragonfly:
+ image: ghcr.io/fortunen/kete/quick-start-dragonfly
+ ports:
+ - 6379:6379
+ command: ["--logtostderr"]
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-streams
+ kete.routes.quick-start.destination.host: dragonfly
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.stream: keycloak-events
+ kete.routes.quick-start.destination.max-len: 10000
+ depends_on:
+ dragonfly:
+ condition: service_healthy
diff --git a/quick-starts/redis-streams-keydb/check-event-reception.ps1 b/quick-starts/redis-streams-keydb/check-event-reception.ps1
new file mode 100644
index 00000000..77de9982
--- /dev/null
+++ b/quick-starts/redis-streams-keydb/check-event-reception.ps1
@@ -0,0 +1,6 @@
+# Check if event was received via KeyDB XRANGE (streams)
+# Returns $true if message found, $false otherwise
+
+$result = docker exec redis-streams-keydb-keydb-1 keydb-cli XRANGE keycloak-events - + COUNT 1 2>&1
+$matched = $result -match "LOGIN"
+return $matched.Count -gt 0
diff --git a/quick-starts/redis-streams-keydb/docker-compose.yml b/quick-starts/redis-streams-keydb/docker-compose.yml
new file mode 100644
index 00000000..0f6ced4a
--- /dev/null
+++ b/quick-starts/redis-streams-keydb/docker-compose.yml
@@ -0,0 +1,27 @@
+services:
+
+ keydb:
+ image: ghcr.io/fortunen/kete/quick-start-keydb
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-streams
+ kete.routes.quick-start.destination.host: keydb
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.stream: keycloak-events
+ kete.routes.quick-start.destination.max-len: 10000
+ depends_on:
+ keydb:
+ condition: service_healthy
diff --git a/quick-starts/redis-streams-redis/check-event-reception.ps1 b/quick-starts/redis-streams-redis/check-event-reception.ps1
new file mode 100644
index 00000000..0a2211f1
--- /dev/null
+++ b/quick-starts/redis-streams-redis/check-event-reception.ps1
@@ -0,0 +1,6 @@
+# Check if event was received via Redis XRANGE (streams)
+# Returns $true if message found, $false otherwise
+
+$result = docker exec redis-streams-redis-redis-1 redis-cli XRANGE keycloak-events - + COUNT 1 2>&1
+$matched = $result -match "LOGIN"
+return $matched.Count -gt 0
diff --git a/quick-starts/redis-streams-redis/docker-compose.yml b/quick-starts/redis-streams-redis/docker-compose.yml
new file mode 100644
index 00000000..e9243556
--- /dev/null
+++ b/quick-starts/redis-streams-redis/docker-compose.yml
@@ -0,0 +1,27 @@
+services:
+
+ redis:
+ image: ghcr.io/fortunen/kete/quick-start-redis
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-streams
+ kete.routes.quick-start.destination.host: redis
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.stream: keycloak-events
+ kete.routes.quick-start.destination.max-len: 10000
+ depends_on:
+ redis:
+ condition: service_healthy
diff --git a/quick-starts/redis-streams-upstash/GUIDE.md b/quick-starts/redis-streams-upstash/GUIDE.md
new file mode 100644
index 00000000..dbe5d720
--- /dev/null
+++ b/quick-starts/redis-streams-upstash/GUIDE.md
@@ -0,0 +1,143 @@
+# Upstash Redis Streams Quick Start
+
+This quick start demonstrates forwarding Keycloak events to **Upstash** serverless Redis using Redis Streams for persistent, ordered message storage.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
+- An Upstash account ([sign up free](https://upstash.com/))
+
+## Upstash Setup
+
+### 1. Create a Redis Database
+
+1. Log in to [Upstash Console](https://console.upstash.com/)
+2. Click **Create Database**
+3. Configure:
+ - **Name**: `keycloak-events`
+ - **Region**: Choose the closest region
+ - **Type**: Regional (or Global for multi-region)
+ - **TLS**: Enabled (recommended)
+4. Click **Create**
+
+### 2. Get Connection Details
+
+After creation, navigate to your database and find:
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| **Endpoint** | Redis hostname | `evident-frog-12345.upstash.io` |
+| **Port** | Redis port (usually 6379) | `6379` |
+| **Password** | Authentication password | `AXk3AAIncDE...` |
+
+You can find these in the **REST API** or **Redis** tabs of your database details.
+
+## Configuration
+
+### Option 1: Edit docker-compose.yml directly
+
+Edit [docker-compose.yml](docker-compose.yml) and replace the placeholders:
+
+| Placeholder | Description | Example |
+|-------------|-------------|---------|
+| `` | Upstash endpoint (without `.upstash.io`) | `evident-frog-12345` |
+| `` | Upstash password | `AXk3AAIncDE...` |
+
+### Option 2: Use environment variables
+
+Create a `.env` file:
+
+```env
+UPSTASH_REDIS_HOST=evident-frog-12345.upstash.io
+UPSTASH_REDIS_PASSWORD=your-upstash-password
+```
+
+## Running
+
+```bash
+docker-compose up -d
+```
+
+## Testing
+
+1. Open Keycloak at http://localhost:8080
+2. Log in with `admin` / `admin`
+3. Perform actions (create users, login, etc.)
+4. Check Upstash for received messages:
+
+### Using Upstash Console
+
+1. Navigate to your database in [Upstash Console](https://console.upstash.com/)
+2. Go to the **CLI** tab
+3. Run:
+ ```
+ XREAD STREAMS keycloak-events 0
+ ```
+
+### Using redis-cli
+
+```bash
+# Connect with TLS
+redis-cli -h evident-frog-12345.upstash.io \
+ -p 6379 \
+ --tls \
+ -a your-upstash-password
+
+# Read from the stream
+XREAD STREAMS keycloak-events 0
+
+# Read the last 10 entries
+XREVRANGE keycloak-events + - COUNT 10
+
+# Get stream info
+XINFO STREAM keycloak-events
+```
+
+### Using Consumer Groups
+
+Redis Streams support consumer groups for load balancing:
+
+```bash
+# Create a consumer group
+XGROUP CREATE keycloak-events mygroup 0 MKSTREAM
+
+# Read as a consumer
+XREADGROUP GROUP mygroup consumer1 COUNT 10 STREAMS keycloak-events >
+
+# Acknowledge processed messages
+XACK keycloak-events mygroup
+```
+
+## Redis Streams Features
+
+| Feature | Description |
+|---------|-------------|
+| **Persistence** | Messages are stored until explicitly deleted |
+| **Ordering** | Messages are strictly ordered by ID |
+| **Consumer Groups** | Load balancing across multiple consumers |
+| **Acknowledgment** | At-least-once delivery semantics |
+| **Trimming** | Automatic size management with `max-len` |
+
+## Upstash Features
+
+| Feature | Description |
+|---------|-------------|
+| **Serverless** | Pay-per-request pricing, no infrastructure management |
+| **Global** | Optional multi-region replication |
+| **REST API** | HTTP-based Redis access |
+| **TLS** | Encrypted connections by default |
+| **Free Tier** | 10,000 commands/day free |
+
+## Troubleshooting
+
+| Issue | Solution |
+|-------|----------|
+| Connection timeout | Verify endpoint is correct |
+| Authentication failed | Check password (it's the long token, not the REST token) |
+| TLS errors | Ensure `tls.enabled=true` |
+
+## Cleanup
+
+1. Log in to [Upstash Console](https://console.upstash.com/)
+2. Navigate to your database
+3. Click **Delete Database**
diff --git a/quick-starts/redis-streams-upstash/check-event-reception.ps1 b/quick-starts/redis-streams-upstash/check-event-reception.ps1
new file mode 100644
index 00000000..1b4f3b3b
--- /dev/null
+++ b/quick-starts/redis-streams-upstash/check-event-reception.ps1
@@ -0,0 +1,5 @@
+# Cloud-only quickstart - requires Upstash Redis
+# Cannot be tested locally without real Upstash account
+# Mark as skipped
+Write-Host "SKIP: Cloud-only quickstart (Upstash Redis Streams)" -ForegroundColor Yellow
+return $null
diff --git a/quick-starts/redis-streams-upstash/docker-compose.yml b/quick-starts/redis-streams-upstash/docker-compose.yml
new file mode 100644
index 00000000..f0ecf06a
--- /dev/null
+++ b/quick-starts/redis-streams-upstash/docker-compose.yml
@@ -0,0 +1,21 @@
+# Upstash Redis (Serverless)
+# See GUIDE.md for setup instructions
+
+services:
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ # Upstash Redis configuration
+ # Replace these values or use a .env file
+ kete.routes.quick-start.destination.kind: redis-streams
+ kete.routes.quick-start.destination.host: ${UPSTASH_REDIS_HOST:-.upstash.io}
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.stream: keycloak-events
+ kete.routes.quick-start.destination.max-len: 10000
+ kete.routes.quick-start.destination.tls.enabled: "true"
+ kete.routes.quick-start.destination.password: ${UPSTASH_REDIS_PASSWORD:-}
diff --git a/quick-starts/redis-streams-upstash/dont-run-this-quickstart b/quick-starts/redis-streams-upstash/dont-run-this-quickstart
new file mode 100644
index 00000000..e69de29b
diff --git a/quick-starts/redis-streams-valkey/check-event-reception.ps1 b/quick-starts/redis-streams-valkey/check-event-reception.ps1
new file mode 100644
index 00000000..12cccb46
--- /dev/null
+++ b/quick-starts/redis-streams-valkey/check-event-reception.ps1
@@ -0,0 +1,6 @@
+# Check if event was received via Valkey XRANGE (streams)
+# Returns $true if message found, $false otherwise
+
+$result = docker exec redis-streams-valkey-valkey-1 valkey-cli XRANGE keycloak-events - + COUNT 1 2>&1
+$matched = $result -match "LOGIN"
+return $matched.Count -gt 0
diff --git a/quick-starts/redis-streams-valkey/docker-compose.yml b/quick-starts/redis-streams-valkey/docker-compose.yml
new file mode 100644
index 00000000..ff887c85
--- /dev/null
+++ b/quick-starts/redis-streams-valkey/docker-compose.yml
@@ -0,0 +1,26 @@
+services:
+
+ valkey:
+ image: ghcr.io/fortunen/kete/quick-start-valkey
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD", "valkey-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: redis-streams
+ kete.routes.quick-start.destination.host: valkey
+ kete.routes.quick-start.destination.port: 6379
+ kete.routes.quick-start.destination.stream: keycloak-events
+ depends_on:
+ valkey:
+ condition: service_healthy
diff --git a/quick-starts/stomp-activemq/check-event-reception.ps1 b/quick-starts/stomp-activemq/check-event-reception.ps1
new file mode 100644
index 00000000..da3d13d6
--- /dev/null
+++ b/quick-starts/stomp-activemq/check-event-reception.ps1
@@ -0,0 +1,12 @@
+# Check if event was received by ActiveMQ Classic via STOMP
+# Uses ActiveMQ's Jolokia API to check queue depth
+
+$queueName = "keycloak-events"
+
+$creds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("admin:admin"))
+$response = Invoke-RestMethod -Uri "http://localhost:8161/api/jolokia/read/org.apache.activemq:type=Broker,brokerName=localhost,destinationType=Queue,destinationName=$queueName/QueueSize" -Headers @{Authorization = "Basic $creds"; Origin = "http://localhost:8161" } -TimeoutSec 5 -ErrorAction Stop
+
+if ($response.value -gt 0) {
+ return $true
+}
+return $false
diff --git a/quick-starts/stomp-activemq/docker-compose.yml b/quick-starts/stomp-activemq/docker-compose.yml
index d3b2f399..f67296a4 100644
--- a/quick-starts/stomp-activemq/docker-compose.yml
+++ b/quick-starts/stomp-activemq/docker-compose.yml
@@ -1,7 +1,7 @@
services:
activemq:
- image: apache/activemq-classic:6.1.6
+ image: ghcr.io/fortunen/kete/quick-start-activemq-classic
ports:
- 61613:61613
- 8161:8161
@@ -19,6 +19,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: stomp
kete.routes.quick-start.destination.host: activemq
diff --git a/quick-starts/stomp-artemis/check-event-reception.ps1 b/quick-starts/stomp-artemis/check-event-reception.ps1
new file mode 100644
index 00000000..440c1473
--- /dev/null
+++ b/quick-starts/stomp-artemis/check-event-reception.ps1
@@ -0,0 +1,16 @@
+# Check if event was received by Artemis via STOMP
+# Uses Artemis Jolokia API to check queue message count
+
+$queueName = "keycloak-events"
+$creds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("admin:admin"))
+$response = Invoke-RestMethod -Uri "http://localhost:8161/console/jolokia/read/org.apache.activemq.artemis:broker=`"0.0.0.0`",component=addresses,address=`"$queueName`",subcomponent=queues,routing-type=`"anycast`",queue=`"$queueName`"/MessageCount" -Headers @{Authorization = "Basic $creds"} -TimeoutSec 5 -ErrorAction Stop
+
+if (-not $response.value -and $response.value -ne 0) {
+ throw "Artemis API returned empty MessageCount"
+}
+
+if ($response.value -gt 0) {
+ return $true
+}
+
+return $false
diff --git a/quick-starts/stomp-artemis/docker-compose.yml b/quick-starts/stomp-artemis/docker-compose.yml
new file mode 100644
index 00000000..994239c2
--- /dev/null
+++ b/quick-starts/stomp-artemis/docker-compose.yml
@@ -0,0 +1,32 @@
+services:
+
+ artemis:
+ image: ghcr.io/fortunen/kete/quick-start-activemq-artemis
+ ports:
+ - 8161:8161
+ - 61613:61613
+ environment:
+ ARTEMIS_USER: admin
+ ARTEMIS_PASSWORD: admin
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8161/console"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: stomp
+ kete.routes.quick-start.destination.host: artemis
+ kete.routes.quick-start.destination.port: 61613
+ kete.routes.quick-start.destination.username: admin
+ kete.routes.quick-start.destination.password: admin
+ kete.routes.quick-start.destination.destination: /queue/keycloak-events
+ depends_on:
+ artemis:
+ condition: service_healthy
diff --git a/quick-starts/stomp-emqx/check-event-reception.ps1 b/quick-starts/stomp-emqx/check-event-reception.ps1
new file mode 100644
index 00000000..757ff9ed
--- /dev/null
+++ b/quick-starts/stomp-emqx/check-event-reception.ps1
@@ -0,0 +1,3 @@
+# Check if subscriber received the event
+$output = docker logs stomp-emqx-subscriber-1 2>&1 | Select-String "EVENT-RECEIVED"
+return $output -ne $null
diff --git a/quick-starts/stomp-emqx/docker-compose.yml b/quick-starts/stomp-emqx/docker-compose.yml
new file mode 100644
index 00000000..66c3b5f8
--- /dev/null
+++ b/quick-starts/stomp-emqx/docker-compose.yml
@@ -0,0 +1,46 @@
+services:
+
+ subscriber:
+ image: ghcr.io/fortunen/kete/quick-start-stomp-subscriber
+ environment:
+ STOMP_HOST: emqx
+ STOMP_PORT: 61613
+ STOMP_DESTINATION: /topic/keycloak-events
+ depends_on:
+ emqx:
+ condition: service_healthy
+
+ emqx:
+ image: ghcr.io/fortunen/kete/quick-start-stomp-emqx
+ ports:
+ - 1883:1883
+ - 8083:8083
+ - 8084:8084
+ - 18083:18083
+ - 61613:61613
+ environment:
+ EMQX_GATEWAY__STOMP__LISTENERS__TCP__DEFAULT__BIND: 61613
+ EMQX_GATEWAY__STOMP__ENABLE: true
+ healthcheck:
+ test: ["CMD", "emqx", "ctl", "status"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ start_period: 10s
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: stomp
+ kete.routes.quick-start.destination.host: emqx
+ kete.routes.quick-start.destination.port: 61613
+ kete.routes.quick-start.destination.destination: /topic/keycloak-events
+ depends_on:
+ emqx:
+ condition: service_healthy
+ subscriber:
+ condition: service_started
diff --git a/quick-starts/stomp-rabbitmq/check-event-reception.ps1 b/quick-starts/stomp-rabbitmq/check-event-reception.ps1
new file mode 100644
index 00000000..3e1eae68
--- /dev/null
+++ b/quick-starts/stomp-rabbitmq/check-event-reception.ps1
@@ -0,0 +1,12 @@
+# Check if event was received by RabbitMQ via STOMP
+# Uses RabbitMQ Management API to check queue depth
+
+$queueName = "keycloak-events"
+
+$creds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("admin:admin"))
+$response = Invoke-RestMethod -Uri "http://localhost:15672/api/queues/%2f/$queueName" -Headers @{Authorization = "Basic $creds"} -TimeoutSec 5 -ErrorAction Stop
+
+if ($response.messages -gt 0) {
+ return $true
+}
+return $false
diff --git a/quick-starts/stomp-rabbitmq/docker-compose.yml b/quick-starts/stomp-rabbitmq/docker-compose.yml
new file mode 100644
index 00000000..42730983
--- /dev/null
+++ b/quick-starts/stomp-rabbitmq/docker-compose.yml
@@ -0,0 +1,45 @@
+services:
+
+ rabbitmq:
+ image: ghcr.io/fortunen/kete/quick-start-rabbitmq
+ ports:
+ - 61613:61613
+ - 15672:15672
+ environment:
+ RABBITMQ_DEFAULT_USER: admin
+ RABBITMQ_DEFAULT_PASS: admin
+ command: >
+ sh -c "rabbitmq-plugins enable rabbitmq_stomp && rabbitmq-server"
+ healthcheck:
+ test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ rabbitmq-init:
+ image: ghcr.io/fortunen/kete/quick-start-curl
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+ entrypoint: >
+ sh -c '
+ curl -s -u admin:admin -X PUT http://rabbitmq:15672/api/queues/%2f/keycloak-events -H "content-type: application/json" -d "{\"durable\":true}"
+ '
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start-dev
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ kete.routes.quick-start.destination.kind: stomp
+ kete.routes.quick-start.destination.host: rabbitmq
+ kete.routes.quick-start.destination.port: 61613
+ kete.routes.quick-start.destination.destination: /queue/keycloak-events
+ kete.routes.quick-start.destination.username: admin
+ kete.routes.quick-start.destination.password: admin
+ kete.routes.quick-start.destination.virtual-host: /
+ depends_on:
+ rabbitmq-init:
+ condition: service_completed_successfully
diff --git a/quick-starts/websocket-echo/check-event-reception.ps1 b/quick-starts/websocket-echo/check-event-reception.ps1
new file mode 100644
index 00000000..f76596e4
--- /dev/null
+++ b/quick-starts/websocket-echo/check-event-reception.ps1
@@ -0,0 +1,6 @@
+# Check if event was received via WebSocket Echo server logs
+# Returns $true if LOGIN event found in logs, $false otherwise
+
+$logs = docker logs websocket-echo-websocket-echo-1 2>&1
+$matched = $logs -match "LOGIN"
+return $matched.Count -gt 0
diff --git a/quick-starts/websocket-echo/docker-compose.yml b/quick-starts/websocket-echo/docker-compose.yml
index 88d6c77a..31c2d6af 100644
--- a/quick-starts/websocket-echo/docker-compose.yml
+++ b/quick-starts/websocket-echo/docker-compose.yml
@@ -1,7 +1,7 @@
services:
websocket-echo:
- image: jmalloc/echo-server:0.3.6
+ image: ghcr.io/fortunen/kete/quick-start-websocket-echo
ports:
- 8090:8080
environment:
@@ -12,6 +12,7 @@ services:
command: start-dev
ports:
- 8080:8080
+ - 9000:9000
environment:
kete.routes.quick-start.destination.kind: websocket
kete.routes.quick-start.destination.url: ws://websocket-echo:8080/.ws
diff --git a/run-on-develop-push.ps1 b/run-on-develop-push.ps1
index b17cfc77..70f2c13d 100644
--- a/run-on-develop-push.ps1
+++ b/run-on-develop-push.ps1
@@ -158,7 +158,6 @@ Write-StepHeader 2 "Build Docker Images (validation only)"
$stepStart = Get-Date
-# quick-start-keycloak
$imageName = "$script:Registry/quick-start-keycloak:$script:Tag"
Write-Task "Building $imageName"
docker build -q -t $imageName -f quick-starts/quick-start-keycloak/Dockerfile . 2>&1 | Out-Null
@@ -166,14 +165,6 @@ $buildSuccess = $LASTEXITCODE -eq 0
Write-TaskResult "quick-start-keycloak" $buildSuccess
$script:Results["Docker: quick-start-keycloak"] = $buildSuccess
-# quick-start-curl
-$imageName = "$script:Registry/quick-start-curl:$script:Tag"
-Write-Task "Building $imageName"
-docker build -q -t $imageName -f quick-starts/quick-start-curl/Dockerfile . 2>&1 | Out-Null
-$buildSuccess = $LASTEXITCODE -eq 0
-Write-TaskResult "quick-start-curl" $buildSuccess
-$script:Results["Docker: quick-start-curl"] = $buildSuccess
-
$duration = Format-Duration((Get-Date) - $stepStart)
Write-Host ""
Write-Host " Docker build completed in $duration" -ForegroundColor DarkGray
@@ -187,7 +178,7 @@ Write-StepHeader 3 "Build Documentation (Validation Only)"
$stepStart = Get-Date
Write-Task "Building MkDocs site with --strict validation..."
-$docsOutput = python -m mkdocs build --strict 2>&1
+python -m mkdocs build --strict 2>&1
$docsSuccess = $LASTEXITCODE -eq 0
$duration = Format-Duration((Get-Date) - $stepStart)
diff --git a/run-on-pull-request-push.ps1 b/run-on-pull-request-push.ps1
index 0adb8d54..bee0eaeb 100644
--- a/run-on-pull-request-push.ps1
+++ b/run-on-pull-request-push.ps1
@@ -82,7 +82,6 @@ function Write-SummaryTable {
$passed = @($Results.Values | Where-Object { $_ -eq $true }).Count
$failed = @($Results.Values | Where-Object { $_ -eq $false }).Count
- $total = $Results.Count
Write-Host ""
Write-Host " ββββββββββββββββββββββββββββββββββββββββββββββ¬βββββββββββ" -ForegroundColor DarkGray
@@ -148,20 +147,12 @@ Write-StepHeader 2 "Build Quick-Start Docker Images"
$stepStart = Get-Date
-# quick-start-keycloak
Write-Task "Building image: ghcr.io/fortunen/kete/quick-start-keycloak"
-$buildOutput = docker build -q -t ghcr.io/fortunen/kete/quick-start-keycloak -f quick-starts/quick-start-keycloak/Dockerfile . 2>&1
+docker build -q -t ghcr.io/fortunen/kete/quick-start-keycloak -f quick-starts/quick-start-keycloak/Dockerfile . 2>&1
$keycloakSuccess = $LASTEXITCODE -eq 0
Write-TaskResult "quick-start-keycloak" $keycloakSuccess
$script:Results["Docker: quick-start-keycloak"] = $keycloakSuccess
-# quick-start-curl
-Write-Task "Building image: ghcr.io/fortunen/kete/quick-start-curl"
-$buildOutput = docker build -q -t ghcr.io/fortunen/kete/quick-start-curl -f quick-starts/quick-start-curl/Dockerfile . 2>&1
-$curlSuccess = $LASTEXITCODE -eq 0
-Write-TaskResult "quick-start-curl" $curlSuccess
-$script:Results["Docker: quick-start-curl"] = $curlSuccess
-
$duration = Format-Duration((Get-Date) - $stepStart)
Write-Host ""
Write-Host " Docker builds completed in $duration" -ForegroundColor DarkGray
@@ -175,7 +166,7 @@ Write-StepHeader 3 "Build Documentation Site"
$stepStart = Get-Date
Write-Task "Building MkDocs site with --strict validation..."
-$docsOutput = python -m mkdocs build --strict 2>&1
+python -m mkdocs build --strict 2>&1
$docsSuccess = $LASTEXITCODE -eq 0
$duration = Format-Duration((Get-Date) - $stepStart)
diff --git a/run-on-release-push.ps1 b/run-on-release-push.ps1
index 4462a14f..c7f7496f 100644
--- a/run-on-release-push.ps1
+++ b/run-on-release-push.ps1
@@ -159,7 +159,7 @@ Write-Banner "KETE β Release Push" "Creating production release $($script:Vers
Write-Host ""
Write-Host " βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" -ForegroundColor DarkGreen
Write-Host " β β" -ForegroundColor DarkGreen
-Write-Host " β VERSION: $($script:Version) β" -ForegroundColor DarkGreen
+Write-Host " β VERSION: $($script:Version) β" -ForegroundColor DarkGreen
Write-Host " β β" -ForegroundColor DarkGreen
Write-Host " βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" -ForegroundColor DarkGreen
Write-Host ""
@@ -227,40 +227,23 @@ $script:Results["2. Package JAR"] = $jarSuccess
# Step 3: Build and Push Docker Images
# =============================================================================
-$script:QuickStartImages = @(
+# Build image list dynamically from quick-starts/$images folder
+$script:QuickStartImages = @()
- # Core images (multi-stage builds requiring repo root context)
+# Auto-discover all images from quick-starts/$images folder
+Get-ChildItem -Path 'quick-starts/$images' -Directory | ForEach-Object {
+ $imageName = $_.Name
+ $dockerfile = "quick-starts/`$images/$imageName/Dockerfile"
- @{ Name = "quick-start-keycloak"; Dockerfile = "quick-starts/quick-start-keycloak/Dockerfile"; Context = "." }
- @{ Name = "quick-start-curl"; Dockerfile = "quick-starts/quick-start-curl/Dockerfile"; Context = "." }
+ # Keycloak needs repo root context for multi-stage maven build
+ $context = if ($imageName -eq "keycloak") { "." } else { "quick-starts/`$images/$imageName" }
- # AMQP 0.9.1 images
-
- @{ Name = "quick-start-rabbitmq"; Dockerfile = "quick-starts/amqp-0.9.1-rabbitmq/rabbitmq/Dockerfile"; Context = "quick-starts/amqp-0.9.1-rabbitmq/rabbitmq" }
- @{ Name = "quick-start-lavinmq"; Dockerfile = "quick-starts/amqp-0.9.1-lavinmq/lavinmq/Dockerfile"; Context = "quick-starts/amqp-0.9.1-lavinmq/lavinmq" }
-
- # AMQP 1.0 images
-
- @{ Name = "quick-start-activemq"; Dockerfile = "quick-starts/amqp-1-activemq/activemq/Dockerfile"; Context = "quick-starts/amqp-1-activemq/activemq" }
- @{ Name = "quick-start-qpid"; Dockerfile = "quick-starts/amqp-1-qpid/qpid/Dockerfile"; Context = "quick-starts/amqp-1-qpid/qpid" }
-
- # Kafka images
-
- @{ Name = "quick-start-kafka"; Dockerfile = "quick-starts/kafka-apache/kafka/Dockerfile"; Context = "quick-starts/kafka-apache/kafka" }
- @{ Name = "quick-start-kafka-ui"; Dockerfile = "quick-starts/kafka-apache/kafka-ui/Dockerfile"; Context = "quick-starts/kafka-apache/kafka-ui" }
- @{ Name = "quick-start-redpanda"; Dockerfile = "quick-starts/kafka-redpanda/redpanda/Dockerfile"; Context = "quick-starts/kafka-redpanda/redpanda" }
- @{ Name = "quick-start-redpanda-console"; Dockerfile = "quick-starts/kafka-redpanda/redpanda-console/Dockerfile"; Context = "quick-starts/kafka-redpanda/redpanda-console" }
-
- # MQTT images
-
- @{ Name = "quick-start-emqx"; Dockerfile = "quick-starts/mqtt-3-emqx/emqx/Dockerfile"; Context = "quick-starts/mqtt-3-emqx/emqx" }
- @{ Name = "quick-start-mosquitto"; Dockerfile = "quick-starts/mqtt-3-mosquitto/mosquitto/Dockerfile"; Context = "quick-starts/mqtt-3-mosquitto/mosquitto" }
- @{ Name = "quick-start-hivemq"; Dockerfile = "quick-starts/mqtt-5-hivemq/hivemq/Dockerfile"; Context = "quick-starts/mqtt-5-hivemq/hivemq" }
-
- # HTTP images
-
- @{ Name = "quick-start-http-echo"; Dockerfile = "quick-starts/http-webhook/http-echo/Dockerfile"; Context = "quick-starts/http-webhook/http-echo" }
-)
+ $script:QuickStartImages += @{
+ Name = "quick-start-$imageName"
+ Dockerfile = $dockerfile
+ Context = $context
+ }
+}
function Build-And-Push-Image {
@@ -443,7 +426,7 @@ if ($failedCount -eq 0) {
Write-Host ""
Write-Host " ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" -ForegroundColor Green
Write-Host " β β" -ForegroundColor Green
- Write-Host " β β RELEASE $($script:Version) PUBLISHED SUCCESSFULLY β" -ForegroundColor Green
+ Write-Host " β β RELEASE $($script:Version) PUBLISHED SUCCESSFULLY β" -ForegroundColor Green
Write-Host " β β" -ForegroundColor Green
Write-Host " ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" -ForegroundColor Green
@@ -453,7 +436,7 @@ if ($failedCount -eq 0) {
Write-Host " β β" -ForegroundColor Red
Write-Host " β β RELEASE FAILED β" -ForegroundColor Red
Write-Host " β β" -ForegroundColor Red
- Write-Host " β $failedCount step(s) failed. Release was not completed. β" -ForegroundColor Red
+ Write-Host " β $failedCount step(s) failed. Release was not completed. β" -ForegroundColor Red
Write-Host " β β" -ForegroundColor Red
Write-Host " ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" -ForegroundColor Red
diff --git a/run-quick-starts.ps1 b/run-quick-starts.ps1
new file mode 100644
index 00000000..bb3f4e66
--- /dev/null
+++ b/run-quick-starts.ps1
@@ -0,0 +1,532 @@
+#!/usr/bin/env pwsh
+
+<#
+.SYNOPSIS
+ KETE Quick-Start Test Runner - Professional test automation for all quickstarts.
+
+.DESCRIPTION
+ Tests all quick-starts by:
+ 1. Starting containers
+ 2. Waiting for containers to be healthy
+ 3. Triggering a login event
+ 4. Verifying event was sent (via metrics)
+ 5. Checking event was received (via per-quickstart check script)
+
+.PARAMETER Filter
+ Wildcard filter for quickstart names (default: "*")
+
+.PARAMETER TimeoutSeconds
+ Timeout for event reception check (default: 120)
+
+.EXAMPLE
+ ./run-all-quickstarts.ps1
+ ./run-all-quickstarts.ps1 -Filter "kafka-*"
+#>
+
+param(
+ [string]$Filter = "*",
+ [int]$TimeoutSeconds = 60
+)
+
+$ErrorActionPreference = "Stop"
+Set-Location $PSScriptRoot
+
+# ----------------------------------------------------------------=============
+# Configuration
+# ----------------------------------------------------------------=============
+
+$script:KeycloakUrl = "http://localhost:8080"
+$script:MetricsEndpoint = "http://localhost:9000/metrics"
+$script:TokenEndpoint = "$script:KeycloakUrl/realms/master/protocol/openid-connect/token"
+
+# Animation frames
+$script:SpinnerFrames = @('β ', 'β ', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ', 'β ')
+$script:SpinnerIndex = 0
+
+# Colors
+$script:Colors = @{
+ Primary = "Cyan"
+ Success = "Green"
+ Error = "Red"
+ Warning = "Yellow"
+ Muted = "DarkGray"
+ Info = "White"
+ Highlight = "Magenta"
+}
+
+# Test results tracking
+$script:Results = @{
+ Passed = [System.Collections.ArrayList]::new()
+ Failed = [System.Collections.ArrayList]::new()
+ Skipped = [System.Collections.ArrayList]::new()
+}
+
+# ----------------------------------------------------------------=============
+# UI Components
+# ----------------------------------------------------------------=============
+
+function Format-Duration {
+ param([int]$Seconds)
+ if ($Seconds -ge 60) {
+ $m = [math]::Floor($Seconds / 60)
+ $s = $Seconds % 60
+ return "${m}m ${s}s"
+ }
+ return "${Seconds}s"
+}
+
+function Clear-CurrentLine {
+ Write-Host "`r$(' ' * 80)`r" -NoNewline
+}
+
+function Write-Spinner {
+ param([string]$Message)
+ $frame = $script:SpinnerFrames[$script:SpinnerIndex % $script:SpinnerFrames.Length]
+ $script:SpinnerIndex++
+ Write-Host "`r $frame " -NoNewline -ForegroundColor $script:Colors.Primary
+ Write-Host "$Message" -NoNewline -ForegroundColor $script:Colors.Muted
+}
+
+function Write-Logo {
+ $logo = @"
+
+ βββ ββββββββββββββββββββββββββββ
+ βββ βββββββββββββββββββββββββββββ
+ βββββββ ββββββ βββ ββββββ
+ βββββββ ββββββ βββ ββββββ
+ βββ βββββββββββ βββ ββββββββ
+ βββ βββββββββββ βββ ββββββββ
+
+"@
+ Write-Host $logo -ForegroundColor $script:Colors.Primary
+ Write-Host " Quick-Start Test Runner" -ForegroundColor $script:Colors.Muted
+ Write-Host " ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ" -ForegroundColor $script:Colors.Primary
+ Write-Host ""
+}
+
+function Write-SectionHeader {
+ param([string]$Title)
+ Write-Host ""
+ Write-Host " ----------------------------------------------------------------" -ForegroundColor $script:Colors.Primary
+ Write-Host " $Title" -ForegroundColor $script:Colors.Info
+ Write-Host " ----------------------------------------------------------------" -ForegroundColor $script:Colors.Primary
+}
+
+function Write-TestHeader {
+ param(
+ [string]$Name,
+ [int]$Current,
+ [int]$Total
+ )
+
+ Write-Host ""
+ Write-Host " ----------------------------------------------------------------" -ForegroundColor $script:Colors.Primary
+ Write-Host " " -NoNewline
+ Write-Host "$Current" -ForegroundColor $script:Colors.Highlight -NoNewline
+ Write-Host " of " -ForegroundColor $script:Colors.Muted -NoNewline
+ Write-Host "$Total" -ForegroundColor $script:Colors.Highlight -NoNewline
+ Write-Host " - " -ForegroundColor $script:Colors.Muted -NoNewline
+ Write-Host "$Name" -ForegroundColor $script:Colors.Info
+ Write-Host " ----------------------------------------------------------------" -ForegroundColor $script:Colors.Primary
+ Write-Host ""
+}
+
+function Write-Step {
+ param(
+ [string]$Message,
+ [ValidateSet("pending", "running", "success", "failed", "skipped", "info")]
+ [string]$Status = "pending"
+ )
+
+ $icon = switch ($Status) {
+ "pending" { "β"; $color = $script:Colors.Muted }
+ "running" { "β"; $color = $script:Colors.Primary }
+ "success" { "β"; $color = $script:Colors.Success }
+ "failed" { "β"; $color = $script:Colors.Error }
+ "skipped" { "β"; $color = $script:Colors.Warning }
+ "info" { "βΉ"; $color = $script:Colors.Info }
+ }
+
+ if ($Status -eq "running") {
+ Write-Host " $icon " -NoNewline -ForegroundColor $color
+ Write-Host $Message -ForegroundColor $script:Colors.Muted
+ } else {
+ Write-Host " $icon " -NoNewline -ForegroundColor $color
+ Write-Host $Message -ForegroundColor $(if ($Status -eq "success") { $script:Colors.Info } elseif ($Status -eq "failed") { $script:Colors.Error } else { $script:Colors.Muted })
+ }
+}
+
+function Write-ProgressBar {
+ param(
+ [int]$Current,
+ [int]$Total,
+ [string]$Label = ""
+ )
+
+ $percent = if ($Total -gt 0) { [math]::Round(($Current / $Total) * 100) } else { 0 }
+ $barWidth = 40
+ $filled = [math]::Round(($percent / 100) * $barWidth)
+ $empty = $barWidth - $filled
+
+ $bar = "β" * $filled + "β" * $empty
+
+ Write-Host "`r [$bar] $percent% $Label" -NoNewline -ForegroundColor $script:Colors.Primary
+}
+
+
+
+# ----------------------------------------------------------------=============
+# Core Functions
+# ----------------------------------------------------------------=============
+
+function Get-EventsSentCount {
+ try {
+ $response = Invoke-RestMethod -Uri $script:MetricsEndpoint -TimeoutSec 5 -ErrorAction SilentlyContinue
+ if ($response -match 'kete_events_forwarded_total\{[^}]*\}\s+([\d.]+)') {
+ return [int]$Matches[1]
+ }
+ return 0
+ } catch {
+ return 0
+ }
+}
+
+function Invoke-Login {
+ try {
+ $body = @{
+ grant_type = "password"
+ client_id = "admin-cli"
+ username = "admin"
+ password = "admin"
+ }
+ Invoke-RestMethod -Uri $script:TokenEndpoint -Method Post -Body $body -TimeoutSec 10 -ErrorAction SilentlyContinue | Out-Null
+ return $true
+ } catch {
+ return $false
+ }
+}
+
+function Wait-ForHealthy {
+ param([string]$QuickstartPath, [int]$TimeoutSeconds = 60)
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $spinnerIdx = 0
+
+ while ((Get-Date) -lt $deadline) {
+ $output = docker compose -f "$QuickstartPath/docker-compose.yml" ps --format json 2>$null | ConvertFrom-Json
+
+ if ($output) {
+ $services = @($output)
+ $allHealthy = $true
+ $healthyCount = 0
+ $totalCount = 0
+
+ foreach ($svc in $services) {
+ $health = $svc.Health
+ $state = $svc.State
+
+ # Skip init containers
+ if ($state -eq "exited" -and $svc.ExitCode -eq 0) {
+ continue
+ }
+
+ $totalCount++
+
+ if ($state -ne "running") {
+ $allHealthy = $false
+ } elseif ($health -and $health -ne "healthy" -and $health -ne "") {
+ $allHealthy = $false
+ } else {
+ $healthyCount++
+ }
+ }
+
+ # Show progress
+ $frame = $script:SpinnerFrames[$spinnerIdx % $script:SpinnerFrames.Length]
+ $spinnerIdx++
+ $remaining = [math]::Ceiling(($deadline - (Get-Date)).TotalSeconds)
+ $remainingStr = Format-Duration -Seconds $remaining
+ $message = " $frame Waiting for containers... ($healthyCount/$totalCount healthy, $remainingStr left)".PadRight(80)
+ Write-Host "`r$message" -NoNewline -ForegroundColor $script:Colors.Muted
+
+ if ($allHealthy -and $services.Count -gt 0) {
+ Clear-CurrentLine
+ return $true
+ }
+ }
+
+ Start-Sleep -Milliseconds 500
+ }
+
+ Clear-CurrentLine
+ return $false
+}
+
+function Wait-ForKeycloak {
+ param([int]$TimeoutSeconds = 60)
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $spinnerIdx = 0
+
+ while ((Get-Date) -lt $deadline) {
+ $frame = $script:SpinnerFrames[$spinnerIdx % $script:SpinnerFrames.Length]
+ $spinnerIdx++
+ $remaining = [math]::Ceiling(($deadline - (Get-Date)).TotalSeconds)
+ $remainingStr = Format-Duration -Seconds $remaining
+ $message = " $frame Waiting for Keycloak... ($remainingStr left)".PadRight(80)
+ Write-Host "`r$message" -NoNewline -ForegroundColor $script:Colors.Muted
+
+ try {
+ # Check if realm endpoint is ready (more reliable than /health/ready)
+ $response = Invoke-WebRequest -Uri "$script:KeycloakUrl/realms/master" -TimeoutSec 2 -ErrorAction SilentlyContinue
+ if ($response.StatusCode -eq 200) {
+ Clear-CurrentLine
+ return $true
+ }
+ } catch { }
+
+ Start-Sleep -Milliseconds 500
+ }
+
+ Clear-CurrentLine
+ return $false
+}
+
+function Wait-ForEventSent {
+ param([int]$BaselineCount, [int]$TimeoutSeconds = 60)
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $spinnerIdx = 0
+
+ while ((Get-Date) -lt $deadline) {
+ $frame = $script:SpinnerFrames[$spinnerIdx % $script:SpinnerFrames.Length]
+ $spinnerIdx++
+ $remaining = [math]::Ceiling(($deadline - (Get-Date)).TotalSeconds)
+ $remainingStr = Format-Duration -Seconds $remaining
+ $message = " $frame Checking metrics... ($remainingStr left)".PadRight(80)
+ Write-Host "`r$message" -NoNewline -ForegroundColor $script:Colors.Muted
+
+ $afterCount = Get-EventsSentCount
+ if ($afterCount -gt $BaselineCount) {
+ Clear-CurrentLine
+ return $afterCount
+ }
+
+ Start-Sleep -Milliseconds 500
+ }
+
+ Clear-CurrentLine
+ return $null
+}
+
+function Test-QuickStart {
+ param(
+ [string]$Name,
+ [string]$Path,
+ [int]$TimeoutSeconds
+ )
+
+ $startTime = Get-Date
+ $success = $false
+ $checkScript = "$Path/check-event-reception.ps1"
+
+ try {
+ # Step 1: Start containers
+ Write-Step "Starting containers..." "running"
+ $output = docker compose -f "$Path/docker-compose.yml" up -d 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "`r" -NoNewline
+ Write-Step "Failed to start containers" "failed"
+ Write-Host ""
+ Write-Host " Error output:" -ForegroundColor $script:Colors.Error
+ $output | ForEach-Object { Write-Host " $_" -ForegroundColor $script:Colors.Muted }
+ return $false
+ }
+ Write-Host "`r" -NoNewline
+ Write-Step "Containers started" "success"
+
+ # Step 2: Wait for healthy
+ Write-Step "Waiting for containers to be healthy..." "running"
+ if (-not (Wait-ForHealthy -QuickstartPath $Path -TimeoutSeconds 60)) {
+ Write-Step "Containers not healthy (timeout)" "failed"
+ return $false
+ }
+ Write-Step "All containers healthy" "success"
+
+ # Step 3: Wait for Keycloak
+ Write-Step "Waiting for Keycloak readiness..." "running"
+ if (-not (Wait-ForKeycloak -TimeoutSeconds 60)) {
+ Write-Step "Keycloak not ready (timeout)" "failed"
+ return $false
+ }
+ Write-Step "Keycloak ready" "success"
+
+ # Step 4: Trigger login event
+ Write-Step "Triggering login event..." "running"
+ Start-Sleep -Milliseconds 1000 # Small delay for stability
+
+ $beforeCount = Get-EventsSentCount
+
+ if (-not (Invoke-Login)) {
+ Write-Step "Login failed" "failed"
+ return $false
+ }
+ Write-Host "`r" -NoNewline
+ Write-Step "Login event triggered" "success"
+
+ # Give event time to propagate and check metrics
+ Start-Sleep -Seconds 2
+
+ # Step 4a: Verify event was sent via metrics
+ Write-Step "Verifying event was sent..." "running"
+ $afterCount = Get-EventsSentCount
+ $eventsSent = $afterCount - $beforeCount
+
+ if ($eventsSent -gt 0) {
+ Write-Host "`r" -NoNewline
+ Write-Step "Event sent confirmed" "success"
+ } else {
+ Write-Host "`r" -NoNewline
+ Write-Step "Event send NOT confirmed (metrics unavailable or no increase)" "failed"
+ return $false
+ }
+
+ # Step 5: Check event reception
+ if (Test-Path $checkScript) {
+ Write-Step "Verifying event reception..." "running"
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $received = $false
+ $skipCheck = $false
+ $spinnerIdx = 0
+ $lastError = $null
+ $retryDelay = 2 # seconds between retries
+
+ while ((Get-Date) -lt $deadline) {
+ $frame = $script:SpinnerFrames[$spinnerIdx % $script:SpinnerFrames.Length]
+ $spinnerIdx++
+ $remaining = [math]::Ceiling(($deadline - (Get-Date)).TotalSeconds)
+ $remainingStr = Format-Duration -Seconds $remaining
+ $message = " $frame Checking reception... ($remainingStr left)".PadRight(80)
+ Write-Host "`r$message" -NoNewline -ForegroundColor $script:Colors.Muted
+
+ try {
+ $result = & $checkScript
+ $lastError = $null # Clear error on successful execution
+
+ if ($null -eq $result) {
+ $skipCheck = $true
+ break
+ }
+ if ($result -eq $true) {
+ $received = $true
+ break
+ }
+ # Result is false, continue retrying
+ } catch {
+ # Capture error for reporting if we timeout
+ $lastError = $_
+ # Continue retrying - script may fail due to API not ready yet
+ }
+
+ Start-Sleep -Seconds $retryDelay
+ }
+
+ Clear-CurrentLine
+
+ if ($skipCheck) {
+ Write-Step "Reception check skipped (pub/sub or cloud-only)" "skipped"
+ } elseif (-not $received) {
+ Write-Step "Event not received (timeout)" "failed"
+ if ($lastError) {
+ Write-Host ""
+ Write-Host " Last error from check script:" -ForegroundColor $script:Colors.Error
+ Write-Host " $lastError" -ForegroundColor $script:Colors.Muted
+ }
+ return $false
+ } else {
+ Write-Step "Event reception confirmed" "success"
+ }
+ } else {
+ Write-Step "No reception check script" "skipped"
+ }
+
+ $success = $true
+
+ } finally {
+ # Cleanup with spinner
+ Write-Step "Cleaning up containers..." "running"
+ docker compose -f "$Path/docker-compose.yml" down -v 2>&1 | Out-Null
+ Write-Host "`r" -NoNewline
+ Write-Step "Cleanup complete" "info"
+ }
+
+ # Final result
+ $duration = (Get-Date) - $startTime
+ $durationStr = "{0:mm\:ss}" -f $duration
+
+ Write-Host ""
+ if ($success) {
+ Write-Host " " -NoNewline
+ Write-Host "Success" -ForegroundColor $script:Colors.Success -NoNewline
+ Write-Host " - Duration: $durationStr" -ForegroundColor $script:Colors.Muted
+ } else {
+ Write-Host " " -NoNewline
+ Write-Host "Failure" -ForegroundColor $script:Colors.Error -NoNewline
+ Write-Host " - Duration: $durationStr" -ForegroundColor $script:Colors.Muted
+ }
+
+ return $success
+}
+
+# ----------------------------------------------------------------=============
+# Main Execution
+# ----------------------------------------------------------------=============
+
+$totalStartTime = Get-Date
+
+# Get list of quickstarts
+$quickstarts = Get-ChildItem "quick-starts" -Directory |
+ Where-Object { $_.Name -ne "`$images" } |
+ Where-Object { $_.Name -notlike "quick-start-*" } |
+ Where-Object { $_.Name -like $Filter } |
+ Where-Object { -not (Test-Path "$($_.FullName)/dont-run-this-quickstart") } |
+ Sort-Object Name
+
+$total = $quickstarts.Count
+
+if ($total -eq 0) {
+ Write-Host " No quickstarts match the filter." -ForegroundColor $script:Colors.Warning
+ exit 0
+}
+
+# Run tests
+$current = 0
+foreach ($qs in $quickstarts) {
+ $current++
+ $name = $qs.Name
+ $path = $qs.FullName
+
+ # Check if cloud-only and should skip
+ if ($SkipCloudOnly -and $name -in $script:CloudOnlyQuickstarts) {
+ [void]$script:Results.Skipped.Add($name)
+ continue
+ }
+
+ Write-TestHeader -Name $name -Current $current -Total $total
+
+ $success = Test-QuickStart -Name $name -Path $path -TimeoutSeconds $TimeoutSeconds
+
+ if ($success) {
+ [void]$script:Results.Passed.Add($name)
+ } else {
+ [void]$script:Results.Failed.Add($name)
+ break
+ }
+}
+
+# Exit code
+if ($script:Results.Failed.Count -gt 0) {
+ exit 1
+}
+exit 0
diff --git a/src/main/java/io/github/fortunen/kete/DestinationConfig.java b/src/main/java/io/github/fortunen/kete/DestinationConfig.java
index 0f46ae4a..3eb06bc4 100644
--- a/src/main/java/io/github/fortunen/kete/DestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/DestinationConfig.java
@@ -1,5 +1,9 @@
package io.github.fortunen.kete;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
import org.apache.commons.configuration2.MapConfiguration;
import org.keycloak.models.KeycloakSession;
@@ -18,18 +22,61 @@
public abstract class DestinationConfig {
public static final String TLS = "tls";
- public static final int DEFAULT_MIN_POOL_SIZE = 5;
- public static final int DEFAULT_MAX_POOL_SIZE = 20;
- public static final String MIN_POOL_SIZE = "min-pool-size";
- public static final String MAX_POOL_SIZE = "max-pool-size";
- public static final String MESSAGE_HEADERS_ENABLED = "message-headers-enabled";
+ public static final String POOL = "pool";
+ public static final String POOL_LIFO = "lifo";
+ public static final String HEADERS = "headers";
+ public static final String POOL_MIN_IDLE = "min-idle";
+ public static final String POOL_MAX_IDLE = "max-idle";
+ public static final String POOL_FAIRNESS = "fairness";
+ public static final String POOL_MAX_TOTAL = "max-total";
+ public static final String POOL_TEST_ON_CREATE = "test-on-create";
+ public static final String POOL_TEST_ON_BORROW = "test-on-borrow";
+ public static final String POOL_TEST_ON_RETURN = "test-on-return";
+ public static final String POOL_MAX_WAIT_SECONDS = "max-wait-seconds";
+ public static final String POOL_TEST_WHILE_IDLE = "test-while-idle";
+ public static final String POOL_BLOCK_WHEN_EXHAUSTED = "block-when-exhausted";
+ public static final String POOL_NUM_TESTS_PER_EVICTION_RUN = "num-tests-per-eviction-run";
+ public static final String POOL_MIN_EVICTABLE_IDLE_TIME_SECONDS = "min-evictable-idle-time-seconds";
+ public static final String POOL_TIME_BETWEEN_EVICTION_RUNS_SECONDS = "time-between-eviction-runs-seconds";
+ public static final String POOL_SOFT_MIN_EVICTABLE_IDLE_TIME_SECONDS = "soft-min-evictable-idle-time-seconds";
+
+ public static final int DEFAULT_POOL_MIN_IDLE = 1;
+ public static final int DEFAULT_POOL_MAX_IDLE = 10;
+ public static final int DEFAULT_POOL_MAX_TOTAL = 20;
+ public static final boolean DEFAULT_POOL_LIFO = true;
+ public static final boolean DEFAULT_POOL_FAIRNESS = false;
+ public static final long DEFAULT_POOL_MAX_WAIT_SECONDS = -1;
+ public static final boolean DEFAULT_POOL_TEST_ON_CREATE = false;
+ public static final boolean DEFAULT_POOL_TEST_ON_BORROW = false;
+ public static final boolean DEFAULT_POOL_TEST_ON_RETURN = false;
+ public static final boolean DEFAULT_POOL_TEST_WHILE_IDLE = false;
+ public static final int DEFAULT_POOL_NUM_TESTS_PER_EVICTION_RUN = 3;
+ public static final boolean DEFAULT_POOL_BLOCK_WHEN_EXHAUSTED = true;
+ public static final long DEFAULT_POOL_TIME_BETWEEN_EVICTION_RUNS_SECONDS = -1;
+ public static final long DEFAULT_POOL_SOFT_MIN_EVICTABLE_IDLE_TIME_SECONDS = -1;
+ public static final long DEFAULT_POOL_MIN_EVICTABLE_IDLE_TIME_SECONDS = 1800;
protected TlsMaterial tls;
protected String keycloakRealm;
protected String destinationKind;
protected MapConfiguration configuration;
- protected int minPoolSize = DEFAULT_MIN_POOL_SIZE;
- protected int maxPoolSize = DEFAULT_MAX_POOL_SIZE;
+ protected boolean poolLifo = DEFAULT_POOL_LIFO;
+ protected int poolMinIdle = DEFAULT_POOL_MIN_IDLE;
+ protected int poolMaxIdle = DEFAULT_POOL_MAX_IDLE;
+ protected int poolMaxTotal = DEFAULT_POOL_MAX_TOTAL;
+ protected boolean poolFairness = DEFAULT_POOL_FAIRNESS;
+ protected Map customHeaders = new HashMap<>();
+ protected Set> customHeadersEntrySet;
+ protected long poolMaxWaitSeconds = DEFAULT_POOL_MAX_WAIT_SECONDS;
+ protected boolean poolTestOnCreate = DEFAULT_POOL_TEST_ON_CREATE;
+ protected boolean poolTestOnBorrow = DEFAULT_POOL_TEST_ON_BORROW;
+ protected boolean poolTestOnReturn = DEFAULT_POOL_TEST_ON_RETURN;
+ protected boolean poolTestWhileIdle = DEFAULT_POOL_TEST_WHILE_IDLE;
+ protected boolean poolBlockWhenExhausted = DEFAULT_POOL_BLOCK_WHEN_EXHAUSTED;
+ protected int poolNumTestsPerEvictionRun = DEFAULT_POOL_NUM_TESTS_PER_EVICTION_RUN;
+ protected long poolMinEvictableIdleTimeSeconds = DEFAULT_POOL_MIN_EVICTABLE_IDLE_TIME_SECONDS;
+ protected long poolTimeBetweenEvictionRunsSeconds = DEFAULT_POOL_TIME_BETWEEN_EVICTION_RUNS_SECONDS;
+ protected long poolSoftMinEvictableIdleTimeSeconds = DEFAULT_POOL_SOFT_MIN_EVICTABLE_IDLE_TIME_SECONDS;
@Setter
protected KeycloakSession keycloakSession;
@@ -40,14 +87,35 @@ public final void initialize() {
ValidationUtils.requireNonNull(configuration, "configuration is required");
- // pool sizes (optional)
+ // destinationKind
+
+ destinationKind = ValidationUtils.requireNonBlank(configuration.getString(Constants.KIND, "").trim(), Constants.KIND + " is required");
+
+ // pool configuration (optional)
- minPoolSize = configuration.getInt(MIN_POOL_SIZE, DEFAULT_MIN_POOL_SIZE);
- maxPoolSize = configuration.getInt(MAX_POOL_SIZE, DEFAULT_MAX_POOL_SIZE);
+ var poolConfig = ConfigurationUtils.getSubSet(configuration, POOL);
- ValidationUtils.requireGreaterThan(minPoolSize, 0, MIN_POOL_SIZE + " must be greater than 0");
- ValidationUtils.requireGreaterThan(maxPoolSize, 0, MAX_POOL_SIZE + " must be greater than 0");
- ValidationUtils.requireTrue(maxPoolSize >= minPoolSize, MAX_POOL_SIZE + " must be >= " + MIN_POOL_SIZE);
+ poolLifo = poolConfig.getBoolean(POOL_LIFO, DEFAULT_POOL_LIFO);
+ poolMinIdle = poolConfig.getInt(POOL_MIN_IDLE, DEFAULT_POOL_MIN_IDLE);
+ poolMaxIdle = poolConfig.getInt(POOL_MAX_IDLE, DEFAULT_POOL_MAX_IDLE);
+ poolMaxTotal = poolConfig.getInt(POOL_MAX_TOTAL, DEFAULT_POOL_MAX_TOTAL);
+ poolFairness = poolConfig.getBoolean(POOL_FAIRNESS, DEFAULT_POOL_FAIRNESS);
+ poolMaxWaitSeconds = poolConfig.getLong(POOL_MAX_WAIT_SECONDS, DEFAULT_POOL_MAX_WAIT_SECONDS);
+ poolTestOnCreate = poolConfig.getBoolean(POOL_TEST_ON_CREATE, DEFAULT_POOL_TEST_ON_CREATE);
+ poolTestOnBorrow = poolConfig.getBoolean(POOL_TEST_ON_BORROW, DEFAULT_POOL_TEST_ON_BORROW);
+ poolTestOnReturn = poolConfig.getBoolean(POOL_TEST_ON_RETURN, DEFAULT_POOL_TEST_ON_RETURN);
+ poolTestWhileIdle = poolConfig.getBoolean(POOL_TEST_WHILE_IDLE, DEFAULT_POOL_TEST_WHILE_IDLE);
+ poolBlockWhenExhausted = poolConfig.getBoolean(POOL_BLOCK_WHEN_EXHAUSTED, DEFAULT_POOL_BLOCK_WHEN_EXHAUSTED);
+ poolNumTestsPerEvictionRun = poolConfig.getInt(POOL_NUM_TESTS_PER_EVICTION_RUN, DEFAULT_POOL_NUM_TESTS_PER_EVICTION_RUN);
+ poolMinEvictableIdleTimeSeconds = poolConfig.getLong(POOL_MIN_EVICTABLE_IDLE_TIME_SECONDS, DEFAULT_POOL_MIN_EVICTABLE_IDLE_TIME_SECONDS);
+ poolTimeBetweenEvictionRunsSeconds = poolConfig.getLong(POOL_TIME_BETWEEN_EVICTION_RUNS_SECONDS, DEFAULT_POOL_TIME_BETWEEN_EVICTION_RUNS_SECONDS);
+ poolSoftMinEvictableIdleTimeSeconds = poolConfig.getLong(POOL_SOFT_MIN_EVICTABLE_IDLE_TIME_SECONDS, DEFAULT_POOL_SOFT_MIN_EVICTABLE_IDLE_TIME_SECONDS);
+
+ ValidationUtils.requireGreaterThan(poolMinIdle, 0, POOL_MIN_IDLE + " must be greater than 0");
+ ValidationUtils.requireGreaterThan(poolMaxIdle, 0, POOL_MAX_IDLE + " must be greater than 0");
+ ValidationUtils.requireGreaterThan(poolMaxTotal, 0, POOL_MAX_TOTAL + " must be greater than 0");
+ ValidationUtils.requireTrue(poolMaxTotal >= poolMinIdle, POOL_MAX_TOTAL + " must be >= " + POOL_MIN_IDLE);
+ ValidationUtils.requireGreaterThan(poolMaxWaitSeconds, -2, POOL_MAX_WAIT_SECONDS + " must be -1 or greater");
// tls
@@ -55,6 +123,27 @@ public final void initialize() {
.withConfiguration(ConfigurationUtils.getSubSet(configuration, TLS))
.build();
+ // headers (filter out reserved message header keys - message headers always take priority)
+
+ var headers = ConfigurationUtils.getSubSet(configuration, HEADERS);
+
+ headers.getKeys().forEachRemaining(key -> {
+
+ key = key.trim();
+
+ if (Constants.MESSAGE_HEADER_EVENT_KIND.equals(key) || Constants.MESSAGE_HEADER_EVENT_TYPE.equals(key) || Constants.MESSAGE_HEADER_CONTENT_TYPE.equals(key)) {
+ return;
+ }
+
+ var value = headers.getString(key, "").trim();
+
+ if (ValidationUtils.isNotBlank(value)) {
+ customHeaders.put(key, value);
+ }
+ });
+
+ customHeadersEntrySet = customHeaders.entrySet();
+
// initialize subclass
doInitialize();
diff --git a/src/main/java/io/github/fortunen/kete/NatsAuthMaterial.java b/src/main/java/io/github/fortunen/kete/NatsAuthMaterial.java
new file mode 100644
index 00000000..445c60db
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/NatsAuthMaterial.java
@@ -0,0 +1,208 @@
+package io.github.fortunen.kete;
+
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.nats.client.AuthHandler;
+import io.nats.client.Nats;
+import io.nats.client.NKey;
+import io.nats.client.Options;
+import io.nats.client.support.JwtUtils;
+import lombok.Data;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.configuration2.Configuration;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+@Data
+@Slf4j
+public class NatsAuthMaterial {
+
+ public static final String AUTH_NONE = "none";
+ public static final String AUTH_NKEY = "nkey";
+ public static final String AUTH_TOKEN = "token";
+ public static final String AUTHENTICATION_METHOD = "authentication-method";
+ public static final String AUTH_CREDENTIALS_FILE_PATH = "credentials-file-path";
+ public static final String AUTH_CREDENTIALS_FILE_TEXT = "credentials-file-text";
+ public static final String AUTH_USERNAME_AND_PASSWORD = "username-and-password";
+ public static final String AUTH_CREDENTIALS_FILE_BASE64 = "credentials-file-base64";
+
+ public static final String TOKEN = "token";
+ public static final String USERNAME = "username";
+ public static final String PASSWORD = "password";
+ public static final String NKEY_SEED = "nkey-seed";
+ public static final String CREDENTIALS_FILE_PATH = "credentials-file-path";
+ public static final String CREDENTIALS_FILE_TEXT = "credentials-file-text";
+ public static final String CREDENTIALS_FILE_BASE64 = "credentials-file-base64";
+
+ public static final int JWT_EXPIRY_WARNING_DAYS = 30;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private String token;
+ private String username;
+ private String nkeySeed;
+ private String password;
+ private String authenticationMethod;
+ private String credentialsFileContent;
+
+ @SneakyThrows
+ public void initialize(Configuration configuration) {
+
+ ValidationUtils.requireNonNull(configuration, "configuration is required");
+
+ authenticationMethod = ValidationUtils.requireNonBlank(configuration.getString(AUTHENTICATION_METHOD, "").trim(), AUTHENTICATION_METHOD + " is required (none, username-and-password, token, nkey, credentials-file-path, credentials-file-text, credentials-file-base64)");
+
+ switch (authenticationMethod) {
+
+ case AUTH_NONE:
+ break;
+
+ case AUTH_USERNAME_AND_PASSWORD:
+ username = ValidationUtils.requireNonBlank(configuration.getString(USERNAME, "").trim(), USERNAME + " is required when authentication-method is " + AUTH_USERNAME_AND_PASSWORD);
+ password = ValidationUtils.requireNonBlank(configuration.getString(PASSWORD, "").trim(), PASSWORD + " is required when authentication-method is " + AUTH_USERNAME_AND_PASSWORD);
+ break;
+
+ case AUTH_TOKEN:
+ token = ValidationUtils.requireNonBlank(configuration.getString(TOKEN, "").trim(), TOKEN + " is required when authentication-method is " + AUTH_TOKEN);
+ break;
+
+ case AUTH_NKEY:
+ nkeySeed = ValidationUtils.requireNonBlank(configuration.getString(NKEY_SEED, "").trim(), NKEY_SEED + " is required when authentication-method is " + AUTH_NKEY);
+ break;
+
+ case AUTH_CREDENTIALS_FILE_PATH:
+ var filePath = ValidationUtils.requireNonBlank(configuration.getString(CREDENTIALS_FILE_PATH, "").trim(), CREDENTIALS_FILE_PATH + " is required when authentication-method is " + AUTH_CREDENTIALS_FILE_PATH);
+ credentialsFileContent = Files.readString(Paths.get(filePath));
+ checkJwtExpiry(credentialsFileContent);
+ break;
+
+ case AUTH_CREDENTIALS_FILE_TEXT:
+ credentialsFileContent = ValidationUtils.requireNonBlank(configuration.getString(CREDENTIALS_FILE_TEXT, "").trim(), CREDENTIALS_FILE_TEXT + " is required when authentication-method is " + AUTH_CREDENTIALS_FILE_TEXT);
+ checkJwtExpiry(credentialsFileContent);
+ break;
+
+ case AUTH_CREDENTIALS_FILE_BASE64:
+ var fileBase64 = ValidationUtils.requireNonBlank(configuration.getString(CREDENTIALS_FILE_BASE64, "").trim(), CREDENTIALS_FILE_BASE64 + " is required when authentication-method is " + AUTH_CREDENTIALS_FILE_BASE64);
+ credentialsFileContent = new String(Base64.getDecoder().decode(fileBase64), StandardCharsets.UTF_8);
+ checkJwtExpiry(credentialsFileContent);
+ break;
+
+ default:
+ throw new IllegalStateException("invalid " + AUTHENTICATION_METHOD + ": " + authenticationMethod + " (valid values: none, username-and-password, token, nkey, credentials-file-path, credentials-file-text, credentials-file-base64)");
+ }
+ }
+
+ public void applyTo(Options.Builder builder) {
+
+ ValidationUtils.requireNonNull(builder, "builder is required");
+
+ switch (authenticationMethod) {
+
+ case AUTH_NONE:
+ break;
+
+ case AUTH_USERNAME_AND_PASSWORD:
+ builder.userInfo(username, password);
+ break;
+
+ case AUTH_TOKEN:
+ builder.token(token.toCharArray());
+ break;
+
+ case AUTH_NKEY:
+ builder.authHandler(createNKeyAuthHandler(nkeySeed));
+ break;
+
+ case AUTH_CREDENTIALS_FILE_PATH:
+ case AUTH_CREDENTIALS_FILE_TEXT:
+ case AUTH_CREDENTIALS_FILE_BASE64:
+ builder.authHandler(Nats.credentials(credentialsFileContent));
+ break;
+ }
+ }
+
+ private void checkJwtExpiry(String credsContent) {
+
+ try {
+
+ var jwtStart = credsContent.indexOf("-----BEGIN NATS USER JWT-----");
+ var jwtEnd = credsContent.indexOf("------END NATS USER JWT------");
+
+ if (jwtStart == -1 || jwtEnd == -1) {
+ return;
+ }
+
+ var jwt = credsContent.substring(jwtStart + "-----BEGIN NATS USER JWT-----".length(), jwtEnd).trim();
+ var claimBody = JwtUtils.getClaimBody(jwt);
+ JsonNode claims = MAPPER.readTree(claimBody);
+
+ if (!claims.has("exp")) {
+ return;
+ }
+
+ var expSeconds = claims.get("exp").asLong();
+ var expiry = Instant.ofEpochSecond(expSeconds);
+ var daysUntilExpiry = Duration.between(Instant.now(), expiry).toDays();
+
+ if (daysUntilExpiry <= JWT_EXPIRY_WARNING_DAYS) {
+ if (daysUntilExpiry < 0) {
+ log.warn("NATS JWT has EXPIRED {} days ago. Authentication will fail.", Math.abs(daysUntilExpiry));
+ } else if (daysUntilExpiry == 0) {
+ log.warn("NATS JWT expires TODAY. Consider using a longer-lived JWT for background services.");
+ } else {
+ log.warn("NATS JWT expires in {} days. Consider using a longer-lived JWT for background services.", daysUntilExpiry);
+ }
+ }
+
+ } catch (Exception e) {
+ log.warn("Failed to parse JWT expiry from credentials file: {}", e.getMessage());
+ }
+ }
+
+ private AuthHandler createNKeyAuthHandler(String seed) {
+
+ return new AuthHandler() {
+
+ private NKey nKey;
+
+ private NKey getNKey() throws GeneralSecurityException {
+ if (nKey == null) {
+ nKey = NKey.fromSeed(seed.toCharArray());
+ }
+ return nKey;
+ }
+
+ @Override
+ public char[] getID() {
+ try {
+ return getNKey().getPublicKey();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to get NKey public key", e);
+ }
+ }
+
+ @Override
+ public byte[] sign(byte[] nonce) {
+ try {
+ return getNKey().sign(nonce);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to sign nonce with NKey", e);
+ }
+ }
+
+ @Override
+ public char[] getJWT() {
+ return null;
+ }
+ };
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/ProviderFactory.java b/src/main/java/io/github/fortunen/kete/ProviderFactory.java
index a828c07a..8a92643c 100644
--- a/src/main/java/io/github/fortunen/kete/ProviderFactory.java
+++ b/src/main/java/io/github/fortunen/kete/ProviderFactory.java
@@ -142,8 +142,8 @@ public void run(KeycloakSession session) {
log.info("{} Route '{}' initialized: destination={}, serializer={}, realmMatchers={}, eventMatchers={}",
Constants.ID,
route.getName(),
- route.getDestinationConfig().getClass().getSimpleName(),
- route.getSerializer().getClass().getSimpleName(),
+ route.getSerializerKind(),
+ route.getDestinationKind(),
route.getRealmMatchers().length,
route.getEventMatchers().length);
diff --git a/src/main/java/io/github/fortunen/kete/Route.java b/src/main/java/io/github/fortunen/kete/Route.java
index 58f84a86..a700c292 100644
--- a/src/main/java/io/github/fortunen/kete/Route.java
+++ b/src/main/java/io/github/fortunen/kete/Route.java
@@ -22,12 +22,13 @@
@NoArgsConstructor(force = true)
public class Route implements AutoCloseable {
- public static final long BORROW_TIMEOUT_MS = 30_000;
public static final int ACCEPT_CACHE_MAX_SIZE = 1000;
private Retry retry;
private String name;
private Serializer serializer;
+ private String serializerKind;
+ private String destinationKind;
private DestinationConfig destinationConfig;
private Matcher[] realmMatchers = new Matcher[0];
private Matcher[] eventMatchers = new Matcher[0];
@@ -51,16 +52,31 @@ public void initialize(KeycloakSession session) {
destinationConfig.setKeycloakSession(session);
destinationConfig.initialize();
+ // destinationKind
+
+ destinationKind = destinationConfig.getDestinationKind();
+
// create destination pool (only if not already set - for testing)
if (ValidationUtils.isNull(destinationPool)) {
var poolConfig = new GenericObjectPoolConfig>();
- poolConfig.setMinIdle(destinationConfig.getMinPoolSize());
- poolConfig.setMaxTotal(destinationConfig.getMaxPoolSize());
- poolConfig.setBlockWhenExhausted(true);
- poolConfig.setMaxWait(Duration.ofMillis(BORROW_TIMEOUT_MS));
+ poolConfig.setLifo(destinationConfig.isPoolLifo());
+ poolConfig.setMinIdle(destinationConfig.getPoolMinIdle());
+ poolConfig.setMaxIdle(destinationConfig.getPoolMaxIdle());
+ poolConfig.setFairness(destinationConfig.isPoolFairness());
+ poolConfig.setMaxTotal(destinationConfig.getPoolMaxTotal());
+ poolConfig.setTestOnCreate(destinationConfig.isPoolTestOnCreate());
+ poolConfig.setTestOnBorrow(destinationConfig.isPoolTestOnBorrow());
+ poolConfig.setTestOnReturn(destinationConfig.isPoolTestOnReturn());
+ poolConfig.setTestWhileIdle(destinationConfig.isPoolTestWhileIdle());
+ poolConfig.setBlockWhenExhausted(destinationConfig.isPoolBlockWhenExhausted());
+ poolConfig.setMaxWait(Duration.ofSeconds(destinationConfig.getPoolMaxWaitSeconds()));
+ poolConfig.setNumTestsPerEvictionRun(destinationConfig.getPoolNumTestsPerEvictionRun());
+ poolConfig.setMinEvictableIdleDuration(Duration.ofSeconds(destinationConfig.getPoolMinEvictableIdleTimeSeconds()));
+ poolConfig.setTimeBetweenEvictionRuns(Duration.ofSeconds(destinationConfig.getPoolTimeBetweenEvictionRunsSeconds()));
+ poolConfig.setSoftMinEvictableIdleDuration(Duration.ofSeconds(destinationConfig.getPoolSoftMinEvictableIdleTimeSeconds()));
var poolFactory = new DestinationPooledObjectFactory();
diff --git a/src/main/java/io/github/fortunen/kete/TlsMaterial.java b/src/main/java/io/github/fortunen/kete/TlsMaterial.java
index b748bf43..17007418 100644
--- a/src/main/java/io/github/fortunen/kete/TlsMaterial.java
+++ b/src/main/java/io/github/fortunen/kete/TlsMaterial.java
@@ -102,6 +102,7 @@ public class TlsMaterial {
private String trustStoreFilePath;
private String trustManagerAlgorithm;
private SSLContext trustStoreSSLContext;
+ private TrustManagerFactory trustManagerFactory;
// keystore stuff
@@ -113,6 +114,7 @@ public class TlsMaterial {
private String keyStoreFilePath;
private String keyManagerAlgorithm;
private SSLContext keyStoreSSLContext;
+ private KeyManagerFactory keyManagerFactory;
private KeyStore keyStoreAndTrustStore;
// client stuff
@@ -212,7 +214,6 @@ public TlsMaterialBuilder withConfiguration(MapConfiguration configuration) {
material.trustStorePassword = trustStoreConfiguration.getString(PASSWORD, "").trim();
material.trustStoreType = trustStoreConfiguration.getString(TYPE, TRUST_STORE_DEFAULT_TYPE).trim();
material.trustManagerAlgorithm = trustStoreConfiguration.getString(TRUST_MANAGER_ALGORITHM, KEY_MANAGER_DEFAULT_ALGORITHM).trim();
-
material.trustStore = KeyStore.getInstance(material.trustStoreType);
var trustStoreLoaderConfiguration = ConfigurationUtils.getSubSet(trustStoreConfiguration, LOADER);
@@ -253,7 +254,6 @@ public TlsMaterialBuilder withConfiguration(MapConfiguration configuration) {
material.keyPassword = keyStoreConfiguration.getString(KEY_PASSWORD, "").trim();
material.keyStorePassword = keyStoreConfiguration.getString(PASSWORD, "").trim();
material.keyManagerAlgorithm = keyStoreConfiguration.getString(KEY_MANAGER_ALGORITHM, KEY_MANAGER_DEFAULT_ALGORITHM).trim();
-
material.keyStore = KeyStore.getInstance(material.keyStoreType);
var certificateLoaderConfiguration = ConfigurationUtils.getSubSet(keyStoreConfiguration, LOADER);
@@ -370,10 +370,8 @@ public TlsMaterial build() {
}
material.trustManagerAlgorithm = ValidationUtils.requireNonBlankElse(material.trustManagerAlgorithm, TRUST_MANAGER_DEFAULT_ALGORITHM).trim();
-
- var trustManagerFactory = TrustManagerFactory.getInstance(material.trustManagerAlgorithm);
-
- trustManagerFactory.init(material.trustStore);
+ material.trustManagerFactory = TrustManagerFactory.getInstance(material.trustManagerAlgorithm);
+ material.trustManagerFactory.init(material.trustStore);
// keystore stuff
@@ -407,12 +405,12 @@ public TlsMaterial build() {
material.keyManagerAlgorithm = ValidationUtils.requireNonBlankElse(material.keyManagerAlgorithm, KEY_MANAGER_DEFAULT_ALGORITHM).trim();
var keyManagerFactoryInitializedSuccessfully = false;
- var keyManagerFactory = KeyManagerFactory.getInstance(material.keyManagerAlgorithm);
+ material.keyManagerFactory = KeyManagerFactory.getInstance(material.keyManagerAlgorithm);
for (var password : new String[] { material.keyPassword, material.keyStorePassword, null, "", "changeit", "secret" }) {
try {
- keyManagerFactory.init(material.keyStore, ValidationUtils.isNotNull(password) ? password.toCharArray() : null);
+ material.keyManagerFactory.init(material.keyStore, ValidationUtils.isNotNull(password) ? password.toCharArray() : null);
material.keyPassword = password;
keyManagerFactoryInitializedSuccessfully = true;
@@ -424,20 +422,19 @@ public TlsMaterial build() {
}
if (!keyManagerFactoryInitializedSuccessfully) {
-
- keyManagerFactory.init(material.keyStore, ValidationUtils.isNotBlank(material.keyPassword) ? material.keyPassword.toCharArray() : null);
+ material.keyManagerFactory.init(material.keyStore, ValidationUtils.isNotBlank(material.keyPassword) ? material.keyPassword.toCharArray() : null);
}
// ssl contexts
material.trustStoreSSLContext = SSLContext.getInstance(material.version);
- material.trustStoreSSLContext.init(null, trustManagerFactory.getTrustManagers(), null);
+ material.trustStoreSSLContext.init(null, material.trustManagerFactory.getTrustManagers(), null);
material.keyStoreSSLContext = SSLContext.getInstance(material.version);
- material.keyStoreSSLContext.init(keyManagerFactory.getKeyManagers(), null, null);
+ material.keyStoreSSLContext.init(material.keyManagerFactory.getKeyManagers(), null, null);
material.keyStoreAndTrustStoreSSLContext = SSLContext.getInstance(material.version);
- material.keyStoreAndTrustStoreSSLContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
+ material.keyStoreAndTrustStoreSSLContext.init(material.keyManagerFactory.getKeyManagers(), material.trustManagerFactory.getTrustManagers(), null);
// server stuff
@@ -459,12 +456,11 @@ public TlsMaterial build() {
}
if (!serverKeyManagerFactoryInitializedSuccessfully) {
-
serverKeyManagerFactory.init(material.serverKeyStore, ValidationUtils.isNotBlank(material.keyPassword) ? material.keyPassword.toCharArray() : null);
}
material.serverKeyStoreSSLContext = SSLContext.getInstance(material.version);
- material.serverKeyStoreSSLContext.init(serverKeyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
+ material.serverKeyStoreSSLContext.init(serverKeyManagerFactory.getKeyManagers(), material.trustManagerFactory.getTrustManagers(), null);
}
material.keyStoreAndTrustStore = KeyStore.getInstance(material.keyStoreType);
diff --git a/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091Destination.java b/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091Destination.java
index ce8fb967..2c87450b 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091Destination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091Destination.java
@@ -48,18 +48,20 @@ public void doSend(EventMessage message) {
// headers
- if (config.isMessageHeadersEnabled()) {
+ builder.contentType(message.contentType());
- var headers = new HashMap();
+ var headers = new HashMap();
- builder.contentType(message.contentType());
- headers.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
- headers.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
-
- builder.headers(headers);
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ headers.put(entry.getKey(), entry.getValue());
}
- // delivery mode
+ headers.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ headers.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
+
+ builder.headers(headers);
+
+ // deliveryMode
builder.deliveryMode(config.getDeliveryMode());
@@ -67,10 +69,10 @@ public void doSend(EventMessage message) {
builder.priority(config.getPriority());
- // time-to-live (expiration)
+ // timeToLiveSeconds
- if (config.isHasTimeToLive() && config.getTimeToLive() > 0) {
- builder.expiration(String.valueOf(config.getTimeToLive()));
+ if (config.isHasTimeToLiveSeconds() && config.getTimeToLiveSeconds() > 0) {
+ builder.expiration(String.valueOf(config.getTimeToLiveSeconds() * 1000));
}
// publish
diff --git a/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091DestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091DestinationConfig.java
index 310704a3..9b45ef6e 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091DestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/amqp091/Amqp091DestinationConfig.java
@@ -32,45 +32,44 @@ public class Amqp091DestinationConfig extends DestinationConfig {
public static final int DEFAULT_PRIORITY = 4;
public static final String PRIORITY = "priority";
- public static final long DEFAULT_TIME_TO_LIVE = 0L;
- public static final String TIME_TO_LIVE = "time-to-live";
+ public static final long DEFAULT_TIME_TO_LIVE_SECONDS = 0L;
public static final String DELIVERY_MODE = "delivery-mode";
+ public static final String TIME_TO_LIVE_SECONDS = "time-to-live-seconds";
- public static final int DEFAULT_HANDSHAKE_TIMEOUT_MS = 10000;
- public static final int DEFAULT_CONNECTION_TIMEOUT_MS = 10000;
- public static final int DEFAULT_CHANNEL_RPC_TIMEOUT_MS = 10000;
- public static final String HANDSHAKE_TIMEOUT = "handshake-timeout";
- public static final String CONNECTION_TIMEOUT = "connection-timeout";
- public static final String CHANNEL_RPC_TIMEOUT = "channel-rpc-timeout";
+ public static final int DEFAULT_HANDSHAKE_TIMEOUT_SECONDS = 10;
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final int DEFAULT_CHANNEL_RPC_TIMEOUT_SECONDS = 10;
+ public static final String HANDSHAKE_TIMEOUT_SECONDS = "handshake-timeout-seconds";
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+ public static final String CHANNEL_RPC_TIMEOUT_SECONDS = "channel-rpc-timeout-seconds";
public static final int DEFAULT_REQUESTED_HEARTBEAT_SECONDS = 30;
- public static final int DEFAULT_NETWORK_RECOVERY_INTERVAL_MS = 5000;
- public static final String REQUESTED_HEARTBEAT = "requested-heartbeat";
- public static final String NETWORK_RECOVERY_INTERVAL = "network-recovery-interval";
+ public static final int DEFAULT_NETWORK_RECOVERY_INTERVAL_SECONDS = 5;
public static final String TOPOLOGY_RECOVERY_ENABLED = "topology-recovery-enabled";
public static final String AUTOMATIC_RECOVERY_ENABLED = "automatic-recovery-enabled";
+ public static final String REQUESTED_HEARTBEAT_SECONDS = "requested-heartbeat-seconds";
+ public static final String NETWORK_RECOVERY_INTERVAL_SECONDS = "network-recovery-interval-seconds";
private int port;
private String host;
private int priority;
- private long timeToLive;
- private int deliveryMode;
private String username;
private String password;
private String exchange;
+ private int deliveryMode;
private String routingKey;
private String virtualHost;
private boolean hasPriority;
- private boolean hasTimeToLive;
+ private long timeToLiveSeconds;
private String deliveryModeString;
- private int handshakeTimeout;
- private int connectionTimeout;
- private int channelRpcTimeout;
- private int requestedHeartbeat;
- private int networkRecoveryInterval;
- private boolean messageHeadersEnabled;
+ private int handshakeTimeoutSeconds;
+ private boolean hasTimeToLiveSeconds;
+ private int connectionTimeoutSeconds;
+ private int channelRpcTimeoutSeconds;
+ private int requestedHeartbeatSeconds;
private boolean topologyRecoveryEnabled;
private boolean automaticRecoveryEnabled;
+ private int networkRecoveryIntervalSeconds;
private ConnectionFactory connectionFactory;
@Override
@@ -87,7 +86,7 @@ protected void doInitialize() {
exchange = ValidationUtils.requireNonBlank(configuration.getString(EXCHANGE, "").trim(), EXCHANGE + " is required");
- // routing key
+ // routingKey
routingKey = configuration.getString(ROUTING_KEY, "").trim();
@@ -99,7 +98,7 @@ protected void doInitialize() {
password = configuration.getString(PASSWORD, "").trim();
- // virtual host (RabbitMQ default)
+ // virtualHost
virtualHost = configuration.getString(VIRTUAL_HOST, DEFAULT_VIRTUAL_HOST).trim();
@@ -111,16 +110,16 @@ protected void doInitialize() {
// timeouts
- handshakeTimeout = ValidationUtils.requireNonNegative(configuration.getInt(HANDSHAKE_TIMEOUT, DEFAULT_HANDSHAKE_TIMEOUT_MS), HANDSHAKE_TIMEOUT + " must be non-negative");
- connectionTimeout = ValidationUtils.requireNonNegative(configuration.getInt(CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT_MS), CONNECTION_TIMEOUT + " must be non-negative");
- channelRpcTimeout = ValidationUtils.requireNonNegative(configuration.getInt(CHANNEL_RPC_TIMEOUT, DEFAULT_CHANNEL_RPC_TIMEOUT_MS), CHANNEL_RPC_TIMEOUT + " must be non-negative");
+ handshakeTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(HANDSHAKE_TIMEOUT_SECONDS, DEFAULT_HANDSHAKE_TIMEOUT_SECONDS), HANDSHAKE_TIMEOUT_SECONDS + " must be non-negative");
+ connectionTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS), CONNECTION_TIMEOUT_SECONDS + " must be non-negative");
+ channelRpcTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(CHANNEL_RPC_TIMEOUT_SECONDS, DEFAULT_CHANNEL_RPC_TIMEOUT_SECONDS), CHANNEL_RPC_TIMEOUT_SECONDS + " must be non-negative");
- // heartbeat / recovery (RabbitMQ-friendly defaults)
+ // heartbeat
automaticRecoveryEnabled = configuration.getBoolean(AUTOMATIC_RECOVERY_ENABLED, true);
topologyRecoveryEnabled = configuration.getBoolean(TOPOLOGY_RECOVERY_ENABLED, automaticRecoveryEnabled);
- requestedHeartbeat = ValidationUtils.requireNonNegative(configuration.getInt(REQUESTED_HEARTBEAT, DEFAULT_REQUESTED_HEARTBEAT_SECONDS), REQUESTED_HEARTBEAT + " must be non-negative");
- networkRecoveryInterval = ValidationUtils.requirePositive(configuration.getInt(NETWORK_RECOVERY_INTERVAL, DEFAULT_NETWORK_RECOVERY_INTERVAL_MS), NETWORK_RECOVERY_INTERVAL + " must be positive");
+ requestedHeartbeatSeconds = ValidationUtils.requireNonNegative(configuration.getInt(REQUESTED_HEARTBEAT_SECONDS, DEFAULT_REQUESTED_HEARTBEAT_SECONDS), REQUESTED_HEARTBEAT_SECONDS + " must be non-negative");
+ networkRecoveryIntervalSeconds = ValidationUtils.requirePositive(configuration.getInt(NETWORK_RECOVERY_INTERVAL_SECONDS, DEFAULT_NETWORK_RECOVERY_INTERVAL_SECONDS), NETWORK_RECOVERY_INTERVAL_SECONDS + " must be positive");
// priority (optional)
@@ -144,34 +143,30 @@ protected void doInitialize() {
deliveryMode = deliveryModeString.equals("persistent") ? 2 : 1;
- // timeToLive
+ // timeToLiveSeconds
- if (configuration.containsKey(TIME_TO_LIVE)) {
- timeToLive = ValidationUtils.requireNonNegative(configuration.getLong(TIME_TO_LIVE, DEFAULT_TIME_TO_LIVE), TIME_TO_LIVE + " must be non-negative");
- hasTimeToLive = true;
+ if (configuration.containsKey(TIME_TO_LIVE_SECONDS)) {
+ timeToLiveSeconds = ValidationUtils.requireNonNegative(configuration.getLong(TIME_TO_LIVE_SECONDS, DEFAULT_TIME_TO_LIVE_SECONDS), TIME_TO_LIVE_SECONDS + " must be non-negative");
+ hasTimeToLiveSeconds = true;
} else {
- timeToLive = DEFAULT_TIME_TO_LIVE;
+ timeToLiveSeconds = DEFAULT_TIME_TO_LIVE_SECONDS;
}
- // messageHeadersEnabled
-
- messageHeadersEnabled = configuration.getBoolean(MESSAGE_HEADERS_ENABLED, true);
-
- // connection factory
+ // connectionFactory
connectionFactory = new ConnectionFactory();
connectionFactory.setHost(host);
connectionFactory.setPort(port);
- connectionFactory.setHandshakeTimeout(handshakeTimeout);
- connectionFactory.setConnectionTimeout(connectionTimeout);
- connectionFactory.setChannelRpcTimeout(channelRpcTimeout);
- connectionFactory.setRequestedHeartbeat(requestedHeartbeat);
+ connectionFactory.setRequestedHeartbeat(requestedHeartbeatSeconds);
+ connectionFactory.setHandshakeTimeout(handshakeTimeoutSeconds * 1000);
+ connectionFactory.setConnectionTimeout(connectionTimeoutSeconds * 1000);
+ connectionFactory.setChannelRpcTimeout(channelRpcTimeoutSeconds * 1000);
- connectionFactory.setNetworkRecoveryInterval(networkRecoveryInterval);
connectionFactory.setTopologyRecoveryEnabled(topologyRecoveryEnabled);
connectionFactory.setAutomaticRecoveryEnabled(automaticRecoveryEnabled);
+ connectionFactory.setNetworkRecoveryInterval(networkRecoveryIntervalSeconds * 1000);
if (ValidationUtils.isNotBlank(username)) {
connectionFactory.setUsername(username);
diff --git a/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1Destination.java b/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1Destination.java
index dca65485..b9da004f 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1Destination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1Destination.java
@@ -1,5 +1,7 @@
package io.github.fortunen.kete.destinations.amqp1;
+import java.util.concurrent.ConcurrentHashMap;
+
import io.github.fortunen.kete.Component;
import io.github.fortunen.kete.Constants;
import io.github.fortunen.kete.Destination;
@@ -7,6 +9,7 @@
import io.github.fortunen.kete.utils.TemplateUtils;
import io.github.fortunen.kete.utils.ValidationUtils;
import jakarta.jms.Connection;
+import jakarta.jms.JMSException;
import jakarta.jms.MessageProducer;
import jakarta.jms.Session;
import lombok.Data;
@@ -23,6 +26,7 @@ public class Amqp1Destination extends Destination {
private Session session;
private Connection connection;
private MessageProducer producer;
+ private ConcurrentHashMap destinationCache = new ConcurrentHashMap<>();
@Override
@SneakyThrows
@@ -37,8 +41,8 @@ public void doInitialize() {
producer = session.createProducer(null);
producer.setPriority(config.getPriority());
- producer.setTimeToLive(config.getTimeToLive());
producer.setDeliveryMode(config.getDeliveryMode());
+ producer.setTimeToLive(config.getTimeToLiveSeconds() * 1000);
}
@Override
@@ -50,7 +54,14 @@ public void doSend(EventMessage message) {
// destination
var actualQueueOrTopicName = TemplateUtils.substitute(config.getQueueOrTopicName(), message);
- var jmsDestination = config.isDestinationIsQueue() ? session.createQueue(actualQueueOrTopicName) : session.createTopic(actualQueueOrTopicName);
+
+ var jmsDestination = destinationCache.computeIfAbsent(actualQueueOrTopicName, name -> {
+ try {
+ return config.isDestinationIsQueue() ? session.createQueue(name) : session.createTopic(name);
+ } catch (JMSException exception) {
+ throw new RuntimeException(exception);
+ }
+ });
// message
@@ -58,12 +69,14 @@ public void doSend(EventMessage message) {
jmsMessage.writeBytes(message.eventBody());
- if (config.isMessageHeadersEnabled()) {
- jmsMessage.setStringProperty(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
- jmsMessage.setStringProperty(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
- jmsMessage.setStringProperty(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ jmsMessage.setStringProperty(entry.getKey(), entry.getValue());
}
+ jmsMessage.setStringProperty(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ jmsMessage.setStringProperty(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
+ jmsMessage.setStringProperty(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
+
// send
producer.send(jmsDestination, jmsMessage);
@@ -71,6 +84,7 @@ public void doSend(EventMessage message) {
@Override
public void close() {
+ destinationCache.clear();
ValidationUtils.tryClose(producer, "producer");
ValidationUtils.tryClose(session, "session");
ValidationUtils.tryClose(connection, "connection");
diff --git a/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1DestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1DestinationConfig.java
index 195cd2cc..1f050f35 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1DestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/amqp1/Amqp1DestinationConfig.java
@@ -1,11 +1,8 @@
package io.github.fortunen.kete.destinations.amqp1;
-import org.apache.commons.lang3.Strings;
import org.apache.qpid.jms.JmsConnectionFactory;
import io.github.fortunen.kete.DestinationConfig;
-import io.github.fortunen.kete.TlsMaterial;
-import io.github.fortunen.kete.utils.ConfigurationUtils;
import io.github.fortunen.kete.utils.ValidationUtils;
import jakarta.jms.DeliveryMode;
import lombok.Data;
@@ -30,8 +27,8 @@ public class Amqp1DestinationConfig extends DestinationConfig {
public static final String TLS = "tls";
public static final String TLS_ENABLED = TLS + ".enabled";
- public static final int DEFAULT_IDLE_TIMEOUT = 60000;
- public static final String IDLE_TIMEOUT = "idle-timeout";
+ public static final int DEFAULT_IDLE_TIMEOUT_SECONDS = 60;
+ public static final String IDLE_TIMEOUT_SECONDS = "idle-timeout-seconds";
public static final String DESTINATION_NAME = "destination-name";
public static final String DESTINATION_TYPE = "destination-type";
@@ -40,26 +37,25 @@ public class Amqp1DestinationConfig extends DestinationConfig {
public static final int MAX_PRIORITY = 9;
public static final int DEFAULT_PRIORITY = 4;
public static final String PRIORITY = "priority";
- public static final long DEFAULT_TIME_TO_LIVE = 0L;
- public static final String TIME_TO_LIVE = "time-to-live";
+ public static final long DEFAULT_TIME_TO_LIVE_SECONDS = 0L;
public static final String DELIVERY_MODE = "delivery-mode";
+ public static final String TIME_TO_LIVE_SECONDS = "time-to-live-seconds";
private int port;
private String url;
private String host;
private int priority;
private String scheme;
- private int idleTimeout;
- private long timeToLive;
private String username;
private String password;
private int deliveryMode;
private String transportType;
+ private long timeToLiveSeconds;
+ private int idleTimeoutSeconds;
private String destinationType;
private String queueOrTopicName;
private String deliveryModeString;
private boolean destinationIsQueue;
- private boolean messageHeadersEnabled;
private JmsConnectionFactory connectionFactory;
@Override
@@ -72,13 +68,6 @@ protected void doInitialize() {
host = ValidationUtils.requireNonBlank(configuration.getString(HOST, "").trim(), HOST + " is required");
- // tls - auto-enable for Azure Service Bus
-
- if (!tls.isEnabled() && Strings.CS.contains(host, "servicebus")) {
- configuration.setProperty(TLS_ENABLED, "true");
- tls = TlsMaterial.builder().withConfiguration(ConfigurationUtils.getSubSet(configuration, TLS)).build();
- }
-
// transportType
transportType = configuration.getString(TRANSPORT_TYPE, "amqp").trim().toLowerCase();
@@ -105,7 +94,7 @@ protected void doInitialize() {
destinationIsQueue = destinationType.equals("queue");
- // scheme and port
+ // scheme
if (transportType.equals("amqp-web-sockets")) {
scheme = tls.isEnabled() ? "amqpwss" : "amqpws";
@@ -117,14 +106,14 @@ protected void doInitialize() {
ValidationUtils.requireValidPort(port, PORT);
- // idleTimeout (AMQP heartbeat/keepalive in milliseconds, 0 = disabled)
+ // idleTimeoutSeconds
- idleTimeout = ValidationUtils.requireNonNegative(configuration.getInt(IDLE_TIMEOUT, DEFAULT_IDLE_TIMEOUT), IDLE_TIMEOUT + " must be non-negative");
+ idleTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(IDLE_TIMEOUT_SECONDS, DEFAULT_IDLE_TIMEOUT_SECONDS), IDLE_TIMEOUT_SECONDS + " must be non-negative");
- // url (with optional amqp.idleTimeout parameter for keep-alive)
+ // url (AMQP protocol expects idle timeout in milliseconds)
- if (idleTimeout > 0) {
- url = scheme + "://" + host + ":" + port + "?amqp.idleTimeout=" + idleTimeout;
+ if (idleTimeoutSeconds > 0) {
+ url = scheme + "://" + host + ":" + port + "?amqp.idleTimeout=" + (idleTimeoutSeconds * 1000);
} else {
url = scheme + "://" + host + ":" + port + "?amqp.idleTimeout=0";
}
@@ -141,13 +130,9 @@ protected void doInitialize() {
priority = ValidationUtils.requireInRange(configuration.getInt(PRIORITY, DEFAULT_PRIORITY), MIN_PRIORITY, MAX_PRIORITY, PRIORITY + " must be between " + MIN_PRIORITY + " and " + MAX_PRIORITY);
- // timeToLive
-
- timeToLive = ValidationUtils.requireNonNegative(configuration.getLong(TIME_TO_LIVE, DEFAULT_TIME_TO_LIVE), TIME_TO_LIVE + " must be non-negative");
-
- // messageHeadersEnabled
+ // timeToLiveSeconds
- messageHeadersEnabled = configuration.getBoolean(MESSAGE_HEADERS_ENABLED, true);
+ timeToLiveSeconds = ValidationUtils.requireNonNegative(configuration.getLong(TIME_TO_LIVE_SECONDS, DEFAULT_TIME_TO_LIVE_SECONDS), TIME_TO_LIVE_SECONDS + " must be non-negative");
// connectionFactory
diff --git a/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestination.java b/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestination.java
index 90ed49bc..3ef9dcd5 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestination.java
@@ -7,6 +7,8 @@
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
import io.github.fortunen.kete.Component;
import io.github.fortunen.kete.Constants;
@@ -31,6 +33,7 @@ public class HttpDestination extends Destination {
public static final String MESSAGE_HEADER_EVENT_KIND = "x-" + Constants.MESSAGE_HEADER_EVENT_KIND;
private HttpClient httpClient;
+ private Set> customHeaders;
@Override
@SneakyThrows
@@ -38,16 +41,18 @@ public void doInitialize() {
ValidationUtils.requireNonNull(config, "config is required");
- var clientBuilder = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()));
+ // customHeaders
- if (config.getSslContext() != null) {
- clientBuilder.sslContext(config.getSslContext());
- }
+ customHeaders = config.getCustomHeadersEntrySet().stream()
+ .filter(entry -> {
+ var key = entry.getKey();
+ return !MESSAGE_HEADER_CONTENT_TYPE.equalsIgnoreCase(key) && !MESSAGE_HEADER_EVENT_KIND.equalsIgnoreCase(key) && !MESSAGE_HEADER_EVENT_TYPE.equalsIgnoreCase(key);
+ })
+ .collect(java.util.stream.Collectors.toSet());
- httpClient = clientBuilder.build();
+ httpClient = config.getClientBuilder().build();
- // test
+ // verify connection
var testRequest = HttpRequest.newBuilder()
.uri(URI.create(config.getScheme() + "://" + config.getHost() + ":" + config.getPort()))
@@ -79,29 +84,28 @@ public void doSend(EventMessage message) {
.uri(URI.create(actualUrl))
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()));
- if (config.isMessageHeadersEnabled()) {
- requestBuilder
- .header(MESSAGE_HEADER_CONTENT_TYPE, message.contentType())
- .header(MESSAGE_HEADER_EVENT_TYPE, message.eventType())
- .header(MESSAGE_HEADER_EVENT_KIND, message.kind());
- }
-
- if (ValidationUtils.isNotNull(config.getOauth()) && config.getOauth().isEnabled()) {
+ if (config.isOauthEnabled()) {
requestBuilder.header(AUTHORIZATION, config.getOauth().getAccessToken().toAuthorizationHeader());
}
- if (ValidationUtils.isNotNull(config.getHeaders()) && !config.getHeaders().isEmpty()) {
- config.getHeaders().forEach(requestBuilder::header);
+ // headers
+
+ for (var entry : customHeaders) {
+ requestBuilder.header(entry.getKey(), entry.getValue());
}
+ requestBuilder
+ .header(MESSAGE_HEADER_EVENT_KIND, message.kind())
+ .header(MESSAGE_HEADER_EVENT_TYPE, message.eventType())
+ .header(MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
+
if (config.isMethodIsPost()) {
requestBuilder.POST(HttpRequest.BodyPublishers.ofString(body));
} else {
requestBuilder.PUT(HttpRequest.BodyPublishers.ofString(body));
}
- var request = requestBuilder.build();
- var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ var response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
ValidationUtils.requireTrue(response.statusCode() >= 200 && response.statusCode() < 300, () -> new IOException("HTTP " + response.statusCode() + " : " + response.body()));
}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestinationConfig.java
index 0fa13d65..2cc9641a 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/http/HttpDestinationConfig.java
@@ -1,10 +1,8 @@
package io.github.fortunen.kete.destinations.http;
import java.net.URI;
-import java.util.HashMap;
-import java.util.Map;
-
-import javax.net.ssl.SSLContext;
+import java.net.http.HttpClient;
+import java.time.Duration;
import org.apache.commons.configuration2.MapConfiguration;
@@ -37,7 +35,6 @@ public class HttpDestinationConfig extends DestinationConfig {
public static final String PUT = "PUT";
public static final String POST = "POST";
public static final String METHOD = "method";
- public static final String HEADERS = "headers";
public static final int DEFAULT_TIMEOUT_SECONDS = 10;
public static final String TIMEOUT_SECONDS = "timeout-seconds";
@@ -60,11 +57,9 @@ public class HttpDestinationConfig extends DestinationConfig {
private String urlFromConfig;
private boolean methodIsPost;
private boolean hasTlsEnabled;
+ private boolean oauthEnabled;
private MapConfiguration tlsConfig;
- private boolean messageHeadersEnabled;
- private MapConfiguration headersConfig;
- private SSLContext sslContext;
- private Map headers = new HashMap<>();
+ private HttpClient.Builder clientBuilder;
@Override
@SneakyThrows
@@ -162,10 +157,6 @@ protected void doInitialize() {
}
}
- // messageHeadersEnabled
-
- messageHeadersEnabled = configuration.getBoolean(MESSAGE_HEADERS_ENABLED, true);
-
// baseUrl
if ("/".equals(pathAndQuery)) {
@@ -190,23 +181,14 @@ protected void doInitialize() {
oauth = OAuthMaterial.builder().withKeycloakRealm(keycloakRealm).withKeycloakSession(keycloakSession).withConfiguration(ConfigurationUtils.getSubSet(configuration, OAUTH)).build();
- // headers
+ oauthEnabled = ValidationUtils.isNotNull(oauth) && oauth.isEnabled();
- headersConfig = ConfigurationUtils.getSubSet(configuration, HEADERS);
+ // clientBuilder
- headersConfig.getKeys().forEachRemaining(key ->
- {
- var headerValue = headersConfig.getString(key, "").trim();
+ clientBuilder = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(timeoutSeconds));
- if (ValidationUtils.isNotBlank(headerValue)) {
- headers.put(key, headerValue);
- }
- });
-
- // sslContext
-
- sslContext = tls.isEnabled()
- ? tls.getKeyStoreAndTrustStoreSSLContext()
- : null;
+ if (tls.isEnabled()) {
+ clientBuilder.sslContext(tls.getKeyStoreAndTrustStoreSSLContext());
+ }
}
}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestination.java b/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestination.java
index 70ccd9a2..888aa6b5 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestination.java
@@ -59,15 +59,16 @@ public void doSend(EventMessage message) {
// headers
- if (config.isMessageHeadersEnabled()) {
+ var headers = producerRecord.headers();
- var headers = producerRecord.headers();
-
- headers.add(Constants.MESSAGE_HEADER_EVENT_KIND, message.kindBytes());
- headers.add(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventTypeBytes());
- headers.add(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentTypeBytes());
+ for (var entry : config.getCustomHeadersBytesEntrySet()) {
+ headers.add(entry.getKey(), entry.getValue());
}
+ headers.add(Constants.MESSAGE_HEADER_EVENT_KIND, message.kindBytes());
+ headers.add(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventTypeBytes());
+ headers.add(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentTypeBytes());
+
// send
producer.send(producerRecord).get();
diff --git a/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestinationConfig.java
index 0db7c7a4..5d68348e 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/kafka/KafkaDestinationConfig.java
@@ -1,6 +1,10 @@
package io.github.fortunen.kete.destinations.kafka;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
import java.util.Properties;
+import java.util.Set;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.ByteArraySerializer;
@@ -31,8 +35,9 @@ public class KafkaDestinationConfig extends DestinationConfig {
private String topic;
private boolean transactional;
- private boolean messageHeadersEnabled;
private Properties producerConfiguration;
+ private Set> customHeadersBytesEntrySet;
+ private Map customHeadersBytes = new LinkedHashMap<>();
@Override
@SneakyThrows
@@ -74,21 +79,25 @@ protected void doInitialize() {
producerConfiguration.putIfAbsent(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
producerConfiguration.putIfAbsent(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName());
- // messageHeadersEnabled
-
- messageHeadersEnabled = configuration.getBoolean(MESSAGE_HEADERS_ENABLED, true);
-
- // tls - rebuild with writeFiles only if enabled (Kafka needs file paths)
+ // tls
if (tls.isEnabled()) {
tls = TlsMaterial.builder().withConfiguration(ConfigurationUtils.getSubSet(configuration, TLS)).withWriteFiles(true).build();
tls.updateKafkaConfiguration(producerConfiguration);
}
- // transactional producer - not supported with connection pooling
+ // transactional
transactional = producerConfiguration.containsKey(ProducerConfig.TRANSACTIONAL_ID_CONFIG);
ValidationUtils.requireFalse(transactional, "transactional producers are not supported with connection pooling");
+
+ // customHeadersBytes
+
+ getCustomHeaders().forEach((key, value) -> {
+ customHeadersBytes.put(key, value.getBytes(StandardCharsets.UTF_8));
+ });
+
+ customHeadersBytesEntrySet = customHeadersBytes.entrySet();
}
}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3Destination.java b/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3Destination.java
index 839815ee..99fa60df 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3Destination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3Destination.java
@@ -13,8 +13,10 @@
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
@Data
+@Slf4j
@Component(name = "mqtt-3")
@NoArgsConstructor(force = true)
@EqualsAndHashCode(callSuper = true)
@@ -29,6 +31,7 @@ public void doInitialize() {
ValidationUtils.requireNonNull(config, "config is required");
var clientId = config.getClientIdPrefix() + "-" + config.getClientIdCounter().incrementAndGet();
+
client = new MqttClient(config.getUrl(), clientId, new MemoryPersistence());
client.connect(config.getConnectOptions());
}
@@ -45,10 +48,11 @@ public void doSend(EventMessage message) {
// mqttMessage
- var mqttMessage = new MqttMessage(message.eventBody());
+ var mqttMessage = new MqttMessage();
mqttMessage.setQos(config.getQos());
mqttMessage.setRetained(config.isRetained());
+ mqttMessage.setPayload(message.eventBody());
// publish
@@ -58,6 +62,14 @@ public void doSend(EventMessage message) {
@Override
@SneakyThrows
public void close() {
- ValidationUtils.tryClose(client, "client");
+ try {
+ if (client != null && client.isConnected()) {
+ client.disconnect();
+ }
+ } catch (Exception e) {
+ log.warn("Failed to disconnect MQTT3 client: {}", e.getMessage());
+ } finally {
+ ValidationUtils.tryClose(client, "client");
+ }
}
}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3DestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3DestinationConfig.java
index f55d50ee..853f6eda 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3DestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/mqtt3/Mqtt3DestinationConfig.java
@@ -39,10 +39,12 @@ public class Mqtt3DestinationConfig extends DestinationConfig {
public static final int DEFAULT_QOS = 1;
public static final String RETAINED = "retained";
- public static final int DEFAULT_CONNECTION_TIMEOUT = 10;
- public static final int DEFAULT_KEEP_ALIVE_INTERVAL = 60;
- public static final String CONNECTION_TIMEOUT = "connection-timeout";
- public static final String KEEP_ALIVE_INTERVAL = "keep-alive-interval";
+ public static final int DEFAULT_MAX_INFLIGHT = 2048;
+ public static final String MAX_INFLIGHT = "max-inflight";
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final int DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS = 60;
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+ public static final String KEEP_ALIVE_INTERVAL_SECONDS = "keep-alive-interval-seconds";
private int qos;
private int port;
@@ -52,13 +54,13 @@ public class Mqtt3DestinationConfig extends DestinationConfig {
private String scheme;
private String username;
private String password;
+ private int maxInflight;
private boolean retained;
private boolean cleanSession;
private String transportType;
- private int connectionTimeout;
private String clientIdPrefix;
- private int keepAliveInterval;
- private boolean messageHeadersEnabled;
+ private int connectionTimeoutSeconds;
+ private int keepAliveIntervalSeconds;
private MqttConnectOptions connectOptions;
private AtomicInteger clientIdCounter = new AtomicInteger(0);
@@ -106,12 +108,6 @@ protected void doInitialize() {
retained = configuration.getBoolean(RETAINED, false);
- // messageHeadersEnabled
-
- messageHeadersEnabled = configuration.getBoolean(MESSAGE_HEADERS_ENABLED, false);
-
- ValidationUtils.requireFalse(messageHeadersEnabled, "message headers are not supported in MQTT 3");
-
// clientIdPrefix
clientIdPrefix = configuration.getString(CLIENT_ID_PREFIX, "").trim();
@@ -124,13 +120,17 @@ protected void doInitialize() {
cleanSession = configuration.getBoolean(CLEAN_SESSION, true);
- // connectionTimeout
+ // connectionTimeoutSeconds
+
+ connectionTimeoutSeconds = configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+
+ // keepAliveIntervalSeconds
- connectionTimeout = configuration.getInt(CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT);
+ keepAliveIntervalSeconds = configuration.getInt(KEEP_ALIVE_INTERVAL_SECONDS, DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS);
- // keepAliveInterval
+ // maxInflight
- keepAliveInterval = configuration.getInt(KEEP_ALIVE_INTERVAL, DEFAULT_KEEP_ALIVE_INTERVAL);
+ maxInflight = configuration.getInt(MAX_INFLIGHT, DEFAULT_MAX_INFLIGHT);
// username
@@ -143,9 +143,10 @@ protected void doInitialize() {
// connectOptions
connectOptions = new MqttConnectOptions();
+ connectOptions.setMaxInflight(maxInflight);
connectOptions.setCleanSession(cleanSession);
- connectOptions.setConnectionTimeout(connectionTimeout);
- connectOptions.setKeepAliveInterval(keepAliveInterval);
+ connectOptions.setConnectionTimeout(connectionTimeoutSeconds);
+ connectOptions.setKeepAliveInterval(keepAliveIntervalSeconds);
connectOptions.setAutomaticReconnect(true);
if (ValidationUtils.isNotBlank(username)) {
diff --git a/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5Destination.java b/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5Destination.java
index fa4c1233..5b8aed0f 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5Destination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5Destination.java
@@ -1,6 +1,7 @@
package io.github.fortunen.kete.destinations.mqtt5;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import org.eclipse.paho.mqttv5.client.MqttClient;
import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence;
@@ -18,8 +19,10 @@
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
@Data
+@Slf4j
@Component(name = "mqtt-5")
@NoArgsConstructor(force = true)
@EqualsAndHashCode(callSuper = true)
@@ -34,6 +37,7 @@ public void doInitialize() {
ValidationUtils.requireNonNull(config, "config is required");
var clientId = config.getClientIdPrefix() + "-" + config.getClientIdCounter().incrementAndGet();
+
client = new MqttClient(config.getUrl(), clientId, new MemoryPersistence());
client.connect(config.getConnectOptions());
}
@@ -48,27 +52,36 @@ public void doSend(EventMessage message) {
var actualTopic = TemplateUtils.substitute(config.getTopic(), message);
- // mqttMessage
+ // userProperties (message headers take priority over custom headers)
- var mqttMessage = new MqttMessage(message.eventBody());
+ var headerMap = new LinkedHashMap();
- mqttMessage.setQos(config.getQos());
- mqttMessage.setRetained(config.isRetained());
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ headerMap.put(entry.getKey(), entry.getValue());
+ }
- // headers
+ headerMap.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ headerMap.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
- if (config.isMessageHeadersEnabled()) {
+ var userProperties = new ArrayList();
- var properties = new MqttProperties();
- var userProperties = new ArrayList();
+ headerMap.forEach((key, value) -> userProperties.add(new UserProperty(key, value)));
- properties.setContentType(message.contentType());
- userProperties.add(new UserProperty(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType()));
- userProperties.add(new UserProperty(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind()));
+ // properties
- mqttMessage.setProperties(properties);
- properties.setUserProperties(userProperties);
- }
+ var properties = new MqttProperties();
+
+ properties.setContentType(message.contentType());
+ properties.setUserProperties(userProperties);
+
+ // mqttMessage
+
+ var mqttMessage = new MqttMessage();
+
+ mqttMessage.setQos(config.getQos());
+ mqttMessage.setProperties(properties);
+ mqttMessage.setPayload(message.eventBody());
+ mqttMessage.setRetained(config.isRetained());
// publish
@@ -78,6 +91,14 @@ public void doSend(EventMessage message) {
@Override
@SneakyThrows
public void close() {
- ValidationUtils.tryClose(client, "client");
+ try {
+ if (client != null && client.isConnected()) {
+ client.disconnect();
+ }
+ } catch (Exception e) {
+ log.warn("Failed to disconnect MQTT5 client: {}", e.getMessage());
+ } finally {
+ ValidationUtils.tryClose(client, "client");
+ }
}
}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5DestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5DestinationConfig.java
index f8107f99..5283326d 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5DestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/mqtt5/Mqtt5DestinationConfig.java
@@ -40,10 +40,13 @@ public class Mqtt5DestinationConfig extends DestinationConfig {
public static final int DEFAULT_QOS = 1;
public static final String RETAINED = "retained";
- public static final int DEFAULT_CONNECTION_TIMEOUT = 10;
- public static final int DEFAULT_KEEP_ALIVE_INTERVAL = 60;
- public static final String CONNECTION_TIMEOUT = "connection-timeout";
- public static final String KEEP_ALIVE_INTERVAL = "keep-alive-interval";
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final int DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS = 60;
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+ public static final String KEEP_ALIVE_INTERVAL_SECONDS = "keep-alive-interval-seconds";
+
+ public static final int DEFAULT_MAX_INFLIGHT = 2048;
+ public static final String MAX_INFLIGHT = "max-inflight";
private int qos;
private int port;
@@ -53,13 +56,13 @@ public class Mqtt5DestinationConfig extends DestinationConfig {
private String scheme;
private String username;
private String password;
+ private int maxInflight;
private boolean retained;
private boolean cleanSession;
private String transportType;
- private int keepAliveInterval;
- private int connectionTimeout;
private String clientIdPrefix;
- private boolean messageHeadersEnabled;
+ private int keepAliveIntervalSeconds;
+ private int connectionTimeoutSeconds;
private MqttConnectionOptions connectOptions;
private AtomicInteger clientIdCounter = new AtomicInteger(0);
@@ -107,10 +110,6 @@ protected void doInitialize() {
retained = configuration.getBoolean(RETAINED, false);
- // messageHeadersEnabled
-
- messageHeadersEnabled = configuration.getBoolean(MESSAGE_HEADERS_ENABLED, true);
-
// clientIdPrefix
clientIdPrefix = configuration.getString(CLIENT_ID_PREFIX, "").trim();
@@ -123,13 +122,17 @@ protected void doInitialize() {
cleanSession = configuration.getBoolean(CLEAN_SESSION, true);
- // connectionTimeout
+ // connectionTimeoutSeconds
+
+ connectionTimeoutSeconds = configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+
+ // keepAliveIntervalSeconds
- connectionTimeout = configuration.getInt(CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT);
+ keepAliveIntervalSeconds = configuration.getInt(KEEP_ALIVE_INTERVAL_SECONDS, DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS);
- // keepAliveInterval
+ // maxInflight
- keepAliveInterval = configuration.getInt(KEEP_ALIVE_INTERVAL, DEFAULT_KEEP_ALIVE_INTERVAL);
+ maxInflight = configuration.getInt(MAX_INFLIGHT, DEFAULT_MAX_INFLIGHT);
// username
@@ -143,8 +146,9 @@ protected void doInitialize() {
connectOptions = new MqttConnectionOptions();
connectOptions.setCleanStart(cleanSession);
- connectOptions.setConnectionTimeout(connectionTimeout);
- connectOptions.setKeepAliveInterval(keepAliveInterval);
+ connectOptions.setReceiveMaximum(maxInflight);
+ connectOptions.setConnectionTimeout(connectionTimeoutSeconds);
+ connectOptions.setKeepAliveInterval(keepAliveIntervalSeconds);
connectOptions.setAutomaticReconnect(true);
if (ValidationUtils.isNotBlank(username)) {
diff --git a/src/main/java/io/github/fortunen/kete/destinations/nats/NatsDestination.java b/src/main/java/io/github/fortunen/kete/destinations/nats/NatsDestination.java
new file mode 100644
index 00000000..7fb627ac
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/nats/NatsDestination.java
@@ -0,0 +1,69 @@
+package io.github.fortunen.kete.destinations.nats;
+
+import io.github.fortunen.kete.Component;
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.Destination;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.utils.TemplateUtils;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.nats.client.Connection;
+import io.nats.client.Nats;
+import io.nats.client.impl.Headers;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+@Data
+@Component(name = "nats")
+@NoArgsConstructor(force = true)
+@EqualsAndHashCode(callSuper = true)
+public class NatsDestination extends Destination {
+
+ private Connection connection;
+
+ @Override
+ @SneakyThrows
+ public void doInitialize() {
+
+ ValidationUtils.requireNonNull(config, "config is required");
+
+ connection = Nats.connect(config.getNatsOptions());
+
+ // verify connection
+
+ ValidationUtils.requireTrue(connection.getStatus() == Connection.Status.CONNECTED, "failed to connect to NATS server");
+ }
+
+ @Override
+ @SneakyThrows
+ public void doSend(EventMessage message) {
+
+ ValidationUtils.requireNonNull(message, "message is required");
+
+ // subject
+
+ var actualSubject = TemplateUtils.substitute(config.getSubject(), message);
+
+ // headers (message headers take priority over custom headers)
+
+ var headers = new Headers();
+
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ headers.add(entry.getKey(), entry.getValue());
+ }
+
+ headers.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ headers.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
+ headers.put(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
+
+ // publish
+
+ connection.publish(actualSubject, headers, message.eventBody());
+ }
+
+ @Override
+ public void close() {
+ ValidationUtils.tryClose(connection, "connection");
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/nats/NatsDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/nats/NatsDestinationConfig.java
new file mode 100644
index 00000000..f709ad17
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/nats/NatsDestinationConfig.java
@@ -0,0 +1,85 @@
+package io.github.fortunen.kete.destinations.nats;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.DestinationConfig;
+import io.github.fortunen.kete.NatsAuthMaterial;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.nats.client.Options;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+import java.time.Duration;
+import java.util.Arrays;
+
+@Data
+@NoArgsConstructor(force = true)
+@EqualsAndHashCode(callSuper = true)
+public class NatsDestinationConfig extends DestinationConfig {
+
+ public static final String SERVERS = "servers";
+ public static final String SUBJECT = "subject";
+ public static final int DEFAULT_PING_INTERVAL_SECONDS = 60;
+ public static final String CONNECTION_NAME = "connection-name";
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final String PING_INTERVAL_SECONDS = "ping-interval-seconds";
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+
+ private String subject;
+ private String[] servers;
+ private Options natsOptions;
+ private String connectionName;
+ private int pingIntervalSeconds;
+ private int connectionTimeoutSeconds;
+ private NatsAuthMaterial authMaterial;
+
+ @Override
+ @SneakyThrows
+ protected void doInitialize() {
+
+ ValidationUtils.requireNonNull(configuration, "configuration is required");
+
+ // servers
+
+ servers = Arrays.stream(ValidationUtils.requireNonBlank(configuration.getString(SERVERS, "").trim(), SERVERS + " is required").split(",")).map(String::trim).filter(ValidationUtils::isNotBlank).toArray(String[]::new);
+
+ // subject
+
+ subject = ValidationUtils.requireNonBlank(configuration.getString(SUBJECT, "").trim(), SUBJECT + " is required");
+
+ // connectionTimeoutSeconds
+
+ connectionTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS), CONNECTION_TIMEOUT_SECONDS + " must be non-negative");
+
+ // pingIntervalSeconds
+
+ pingIntervalSeconds = ValidationUtils.requireNonNegative(configuration.getInt(PING_INTERVAL_SECONDS, DEFAULT_PING_INTERVAL_SECONDS), PING_INTERVAL_SECONDS + " must be non-negative");
+
+ // connectionName
+
+ connectionName = configuration.getString(CONNECTION_NAME, Constants.ID).trim();
+
+ // authMaterial
+
+ authMaterial = new NatsAuthMaterial();
+ authMaterial.initialize(configuration);
+
+ // natsOptions
+
+ var builder = new Options.Builder()
+ .servers(servers)
+ .connectionTimeout(Duration.ofSeconds(connectionTimeoutSeconds))
+ .pingInterval(Duration.ofSeconds(pingIntervalSeconds))
+ .connectionName(connectionName)
+ .maxReconnects(-1);
+
+ if (tls.isEnabled()) {
+ builder.sslContext(tls.getKeyStoreAndTrustStoreSSLContext());
+ }
+
+ authMaterial.applyTo(builder);
+
+ natsOptions = builder.build();
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/natsjetstream/NatsJetStreamDestination.java b/src/main/java/io/github/fortunen/kete/destinations/natsjetstream/NatsJetStreamDestination.java
new file mode 100644
index 00000000..894afa77
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/natsjetstream/NatsJetStreamDestination.java
@@ -0,0 +1,98 @@
+package io.github.fortunen.kete.destinations.natsjetstream;
+
+import io.github.fortunen.kete.Component;
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.Destination;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.utils.TemplateUtils;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.nats.client.Connection;
+import io.nats.client.JetStream;
+import io.nats.client.JetStreamOptions;
+import io.nats.client.Nats;
+import io.nats.client.impl.Headers;
+import io.nats.client.impl.NatsMessage;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+@Data
+@NoArgsConstructor(force = true)
+@Component(name = "nats-jetstream")
+@EqualsAndHashCode(callSuper = true)
+public class NatsJetStreamDestination extends Destination {
+
+ private JetStream jetStream;
+ private Connection connection;
+
+ @Override
+ @SneakyThrows
+ public void doInitialize() {
+
+ ValidationUtils.requireNonNull(config, "config is required");
+
+ connection = Nats.connect(config.getNatsOptions());
+
+ // verify connection
+
+ ValidationUtils.requireTrue(connection.getStatus() == Connection.Status.CONNECTED, "failed to connect to NATS server");
+
+ // verify stream exists
+
+ var jsm = connection.jetStreamManagement();
+
+ if (jsm.getStreamInfo(config.getStream()) == null) {
+ throw new IllegalStateException("stream '" + config.getStream() + "' does not exist on the NATS server");
+ }
+
+ // jetStream
+
+ var jsOptions = JetStreamOptions.builder()
+ .publishNoAck(false)
+ .requestTimeout(config.getPublishTimeout())
+ .build();
+
+ jetStream = connection.jetStream(jsOptions);
+ }
+
+ @Override
+ @SneakyThrows
+ public void doSend(EventMessage message) {
+
+ ValidationUtils.requireNonNull(message, "message is required");
+
+ // subject
+
+ var actualSubject = TemplateUtils.substitute(config.getSubject(), message);
+
+ // message
+
+ var messageBuilder = NatsMessage.builder()
+ .subject(actualSubject)
+ .data(message.eventBody());
+
+ // headers (message headers take priority over custom headers)
+
+ var headers = new Headers();
+
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ headers.add(entry.getKey(), entry.getValue());
+ }
+
+ headers.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ headers.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
+ headers.put(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
+
+ messageBuilder.headers(headers);
+
+ // publish with ack (blocks until server acknowledges or timeout)
+
+ jetStream.publish(messageBuilder.build());
+ }
+
+ @Override
+ public void close() {
+ ValidationUtils.tryClose(connection, "connection");
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/natsjetstream/NatsJetStreamDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/natsjetstream/NatsJetStreamDestinationConfig.java
new file mode 100644
index 00000000..f863889b
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/natsjetstream/NatsJetStreamDestinationConfig.java
@@ -0,0 +1,104 @@
+package io.github.fortunen.kete.destinations.natsjetstream;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.DestinationConfig;
+import io.github.fortunen.kete.NatsAuthMaterial;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.nats.client.Options;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+import java.time.Duration;
+import java.util.Arrays;
+
+@Data
+@NoArgsConstructor(force = true)
+@EqualsAndHashCode(callSuper = true)
+public class NatsJetStreamDestinationConfig extends DestinationConfig {
+
+ public static final String SERVERS = "servers";
+ public static final String SUBJECT = "subject";
+ public static final int DEFAULT_PING_INTERVAL_SECONDS = 60;
+ public static final String CONNECTION_NAME = "connection-name";
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final String PING_INTERVAL_SECONDS = "ping-interval-seconds";
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+
+ public static final String STREAM = "stream";
+ public static final int DEFAULT_PUBLISH_TIMEOUT_SECONDS = 10;
+ public static final String PUBLISH_TIMEOUT_SECONDS = "publish-timeout-seconds";
+
+ private String stream;
+ private String subject;
+ private String[] servers;
+ private Options natsOptions;
+ private String connectionName;
+ private int pingIntervalSeconds;
+ private Duration publishTimeout;
+ private int publishTimeoutSeconds;
+ private int connectionTimeoutSeconds;
+ private NatsAuthMaterial authMaterial;
+
+ @Override
+ @SneakyThrows
+ protected void doInitialize() {
+
+ ValidationUtils.requireNonNull(configuration, "configuration is required");
+
+ // servers
+
+ servers = Arrays.stream(ValidationUtils.requireNonBlank(configuration.getString(SERVERS, "").trim(), SERVERS + " is required").split(",")).map(String::trim).filter(ValidationUtils::isNotBlank).toArray(String[]::new);
+
+ // subject
+
+ subject = ValidationUtils.requireNonBlank(configuration.getString(SUBJECT, "").trim(), SUBJECT + " is required");
+
+ // connectionTimeoutSeconds
+
+ connectionTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS), CONNECTION_TIMEOUT_SECONDS + " must be non-negative");
+
+ // pingIntervalSeconds
+
+ pingIntervalSeconds = ValidationUtils.requireNonNegative(configuration.getInt(PING_INTERVAL_SECONDS, DEFAULT_PING_INTERVAL_SECONDS), PING_INTERVAL_SECONDS + " must be non-negative");
+
+ // connectionName
+
+ connectionName = configuration.getString(CONNECTION_NAME, Constants.ID).trim();
+
+ // stream
+
+ stream = ValidationUtils.requireNonBlank(configuration.getString(STREAM, "").trim(), STREAM + " is required for JetStream destination");
+
+ // publishTimeoutSeconds
+
+ publishTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(PUBLISH_TIMEOUT_SECONDS, DEFAULT_PUBLISH_TIMEOUT_SECONDS), PUBLISH_TIMEOUT_SECONDS + " must be non-negative");
+
+ // publishTimeout
+
+ publishTimeout = Duration.ofSeconds(publishTimeoutSeconds);
+
+ // authMaterial
+
+ authMaterial = new NatsAuthMaterial();
+ authMaterial.initialize(configuration);
+
+ // natsOptions
+
+ var builder = new Options.Builder()
+ .servers(servers)
+ .connectionTimeout(Duration.ofSeconds(connectionTimeoutSeconds))
+ .pingInterval(Duration.ofSeconds(pingIntervalSeconds))
+ .connectionName(connectionName)
+ .maxReconnects(-1);
+
+ if (tls.isEnabled()) {
+ builder.sslContext(tls.getKeyStoreAndTrustStoreSSLContext());
+ }
+
+ authMaterial.applyTo(builder);
+
+ natsOptions = builder.build();
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/pulsar/PulsarDestination.java b/src/main/java/io/github/fortunen/kete/destinations/pulsar/PulsarDestination.java
new file mode 100644
index 00000000..c2d8e48d
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/pulsar/PulsarDestination.java
@@ -0,0 +1,106 @@
+package io.github.fortunen.kete.destinations.pulsar;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.client.api.PulsarClient;
+
+import io.github.fortunen.kete.Component;
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.Destination;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.utils.TemplateUtils;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+@Data
+@Component(name = "pulsar")
+@NoArgsConstructor(force = true)
+@EqualsAndHashCode(callSuper = true)
+public class PulsarDestination extends Destination {
+
+ private PulsarClient client;
+ private ConcurrentHashMap> producerCache = new ConcurrentHashMap<>();
+
+ @Override
+ @SneakyThrows
+ public void doInitialize() {
+
+ ValidationUtils.requireNonNull(config, "config is required");
+
+ // client
+
+ client = config.getClientBuilder().build();
+
+ // verify connection
+
+ client.getPartitionsForTopic(config.getTopic(), true).get(config.getOperationTimeoutSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Override
+ @SneakyThrows
+ public void doSend(EventMessage message) {
+
+ ValidationUtils.requireNonNull(message, "message is required");
+
+ // producer
+
+ var actualTopic = TemplateUtils.substitute(config.getTopic(), message);
+
+ var producer = producerCache.computeIfAbsent(actualTopic, topic -> {
+ try {
+
+ var producerBuilder = client.newProducer()
+ .topic(topic)
+ .compressionType(config.getCompressionType())
+ .blockIfQueueFull(config.isBlockIfQueueFull())
+ .maxPendingMessages(config.getMaxPendingMessages())
+ .batchingMaxMessages(config.getBatchingMaxMessages())
+ .sendTimeout(config.getSendTimeoutSeconds(), TimeUnit.SECONDS)
+ .batchingMaxPublishDelay(config.getBatchingMaxPublishDelay(), config.getBatchingMaxPublishDelayUnit());
+
+ if (ValidationUtils.isNotBlank(config.getProducerName())) {
+ producerBuilder.producerName(config.getProducerName());
+ }
+
+ return producerBuilder.create();
+
+ } catch (Exception exception) {
+ throw new RuntimeException(exception);
+ }
+ });
+
+ // message builder
+
+ var messageBuilder = producer.newMessage()
+ .value(message.eventBody())
+ .key(message.eventType());
+
+ // headers
+
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ messageBuilder.property(entry.getKey(), entry.getValue());
+ }
+
+ messageBuilder
+ .property(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind())
+ .property(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType())
+ .property(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
+
+ // send
+
+ messageBuilder.send();
+ }
+
+ @Override
+ @SneakyThrows
+ public void close() {
+ producerCache.forEach((topic, producer) -> ValidationUtils.tryClose(producer, "producer for " + topic));
+ producerCache.clear();
+ ValidationUtils.tryClose(client, "client");
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/pulsar/PulsarDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/pulsar/PulsarDestinationConfig.java
new file mode 100644
index 00000000..bd8c1670
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/pulsar/PulsarDestinationConfig.java
@@ -0,0 +1,184 @@
+package io.github.fortunen.kete.destinations.pulsar;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.pulsar.client.api.AuthenticationFactory;
+import org.apache.pulsar.client.api.ClientBuilder;
+import org.apache.pulsar.client.api.CompressionType;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.impl.auth.AuthenticationBasic;
+
+import io.github.fortunen.kete.DestinationConfig;
+import io.github.fortunen.kete.TlsMaterial;
+import io.github.fortunen.kete.utils.ConfigurationUtils;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+@Data
+@NoArgsConstructor(force = true)
+@EqualsAndHashCode(callSuper = true)
+public class PulsarDestinationConfig extends DestinationConfig {
+
+ public static final String TOPIC = "topic";
+ public static final String SERVICE_URL = "service-url";
+ public static final String COMPRESSION_TYPE = "compression-type";
+ public static final String BLOCK_IF_QUEUE_FULL = "block-if-queue-full";
+ public static final String MAX_PENDING_MESSAGES = "max-pending-messages";
+ public static final String BATCHING_MAX_MESSAGES = "batching-max-messages";
+ public static final String BATCHING_MAX_PUBLISH_DELAY_SECONDS = "batching-max-publish-delay-seconds";
+
+ public static final String TOKEN = "token";
+ public static final String USERNAME = "username";
+ public static final String PASSWORD = "password";
+
+ public static final String PRODUCER_NAME = "producer-name";
+ public static final String LISTENER_NAME = "listener-name";
+ public static final String SEND_TIMEOUT_SECONDS = "send-timeout-seconds";
+ public static final String OPERATION_TIMEOUT_SECONDS = "operation-timeout-seconds";
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+ public static final String KEEP_ALIVE_INTERVAL_SECONDS = "keep-alive-interval-seconds";
+
+ public static final int DEFAULT_SEND_TIMEOUT_SECONDS = 30;
+ public static final String DEFAULT_COMPRESSION_TYPE = "LZ4";
+ public static final int DEFAULT_MAX_PENDING_MESSAGES = 1_000;
+ public static final int DEFAULT_BATCHING_MAX_MESSAGES = 1_000;
+ public static final boolean DEFAULT_BLOCK_IF_QUEUE_FULL = true;
+ public static final int DEFAULT_OPERATION_TIMEOUT_SECONDS = 30;
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final int DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS = 30;
+ public static final long DEFAULT_BATCHING_MAX_PUBLISH_DELAY_SECONDS = 1;
+
+ private String topic;
+ private String token;
+ private String serviceUrl;
+ private String username;
+ private String password;
+ private String producerName;
+ private String listenerName;
+ private int sendTimeoutSeconds;
+ private int maxPendingMessages;
+ private int batchingMaxMessages;
+ private boolean blockIfQueueFull;
+ private int operationTimeoutSeconds;
+ private ClientBuilder clientBuilder;
+ private int connectionTimeoutSeconds;
+ private long batchingMaxPublishDelay;
+ private int keepAliveIntervalSeconds;
+ private CompressionType compressionType;
+ private TimeUnit batchingMaxPublishDelayUnit;
+
+ @Override
+ @SneakyThrows
+ protected void doInitialize() {
+
+ ValidationUtils.requireNonNull(configuration, "configuration is required");
+
+ // serviceUrl
+
+ serviceUrl = ValidationUtils.requireNonBlank(configuration.getString(SERVICE_URL, "").trim(), SERVICE_URL + " is required");
+
+ // topic
+
+ topic = ValidationUtils.requireNonBlank(configuration.getString(TOPIC, "").trim(), TOPIC + " is required");
+
+ // compressionType
+
+ var compressionTypeStr = configuration.getString(COMPRESSION_TYPE, DEFAULT_COMPRESSION_TYPE).trim().toUpperCase();
+
+ try {
+ compressionType = CompressionType.valueOf(compressionTypeStr);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalStateException(COMPRESSION_TYPE + " must be one of: NONE, LZ4, ZLIB, ZSTD, SNAPPY");
+ }
+
+ // batchingMaxPublishDelay
+
+ var batchingMaxPublishDelaySeconds = ValidationUtils.requireGreaterThan(configuration.getLong(BATCHING_MAX_PUBLISH_DELAY_SECONDS, DEFAULT_BATCHING_MAX_PUBLISH_DELAY_SECONDS), 0, BATCHING_MAX_PUBLISH_DELAY_SECONDS + " must be greater than 0");
+ batchingMaxPublishDelay = batchingMaxPublishDelaySeconds * 1000;
+ batchingMaxPublishDelayUnit = TimeUnit.MILLISECONDS;
+
+ // batchingMaxMessages
+
+ batchingMaxMessages = ValidationUtils.requireGreaterThan(configuration.getInt(BATCHING_MAX_MESSAGES, DEFAULT_BATCHING_MAX_MESSAGES), 0, BATCHING_MAX_MESSAGES + " must be greater than 0");
+
+ // blockIfQueueFull
+
+ blockIfQueueFull = configuration.getBoolean(BLOCK_IF_QUEUE_FULL, DEFAULT_BLOCK_IF_QUEUE_FULL);
+
+ // maxPendingMessages
+
+ maxPendingMessages = ValidationUtils.requireGreaterThan(configuration.getInt(MAX_PENDING_MESSAGES, DEFAULT_MAX_PENDING_MESSAGES), 0, MAX_PENDING_MESSAGES + " must be greater than 0");
+
+ // authentication
+
+ token = configuration.getString(TOKEN, "").trim();
+ username = configuration.getString(USERNAME, "").trim();
+ password = configuration.getString(PASSWORD, "").trim();
+
+ // timeouts
+
+ sendTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(SEND_TIMEOUT_SECONDS, DEFAULT_SEND_TIMEOUT_SECONDS), SEND_TIMEOUT_SECONDS + " must be non-negative");
+ operationTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(OPERATION_TIMEOUT_SECONDS, DEFAULT_OPERATION_TIMEOUT_SECONDS), OPERATION_TIMEOUT_SECONDS + " must be non-negative");
+ connectionTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS), CONNECTION_TIMEOUT_SECONDS + " must be non-negative");
+ keepAliveIntervalSeconds = ValidationUtils.requireNonNegative(configuration.getInt(KEEP_ALIVE_INTERVAL_SECONDS, DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS), KEEP_ALIVE_INTERVAL_SECONDS + " must be non-negative");
+
+ // optional settings
+
+ producerName = configuration.getString(PRODUCER_NAME, "").trim();
+ listenerName = configuration.getString(LISTENER_NAME, "").trim();
+
+ // clientBuilder
+
+ clientBuilder = PulsarClient.builder()
+ .serviceUrl(serviceUrl)
+ .operationTimeout(operationTimeoutSeconds, TimeUnit.SECONDS)
+ .connectionTimeout(connectionTimeoutSeconds, TimeUnit.SECONDS)
+ .keepAliveInterval(keepAliveIntervalSeconds, TimeUnit.SECONDS);
+
+ // listenerName (for multi-listener clusters)
+
+ if (ValidationUtils.isNotBlank(listenerName)) {
+ clientBuilder.listenerName(listenerName);
+ }
+
+ // authentication
+
+ if (ValidationUtils.isNotBlank(token)) {
+ clientBuilder.authentication(AuthenticationFactory.token(token));
+ } else if (ValidationUtils.isNotBlank(username)) {
+ var basicAuth = new AuthenticationBasic();
+ basicAuth.configure("{\"userId\":\"" + username + "\",\"password\":\"" + password + "\"}");
+ clientBuilder.authentication(basicAuth);
+ }
+
+ // tls
+
+ if (tls.isEnabled()) {
+
+ tls = TlsMaterial.builder()
+ .withConfiguration(ConfigurationUtils.getSubSet(configuration, TLS))
+ .withWriteFiles(true)
+ .build();
+
+ // enable
+
+ clientBuilder.enableTlsHostnameVerification(true);
+
+ // truststore
+
+ clientBuilder.tlsTrustStoreType(tls.getTrustStoreType());
+ clientBuilder.tlsTrustStorePath(tls.getTrustStoreFilePath());
+ clientBuilder.tlsTrustStorePassword(tls.getTrustStorePassword());
+
+ // keystore
+
+ clientBuilder.useKeyStoreTls(true);
+ clientBuilder.tlsKeyStoreType(tls.getKeyStoreType());
+ clientBuilder.tlsKeyStorePath(tls.getKeyStoreFilePath());
+ clientBuilder.tlsKeyStorePassword(tls.getKeyStorePassword());
+ }
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/redispubsub/RedisPubSubDestination.java b/src/main/java/io/github/fortunen/kete/destinations/redispubsub/RedisPubSubDestination.java
new file mode 100644
index 00000000..9c99ca89
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/redispubsub/RedisPubSubDestination.java
@@ -0,0 +1,72 @@
+package io.github.fortunen.kete.destinations.redispubsub;
+
+import io.github.fortunen.kete.Component;
+import io.github.fortunen.kete.Destination;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.utils.TemplateUtils;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.api.sync.RedisCommands;
+import io.lettuce.core.codec.StringCodec;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+import java.nio.charset.StandardCharsets;
+
+@Data
+@NoArgsConstructor(force = true)
+@Component(name = "redis-pubsub")
+@EqualsAndHashCode(callSuper = true)
+public class RedisPubSubDestination extends Destination {
+
+ private RedisClient client;
+ private RedisCommands commands;
+ private StatefulRedisConnection connection;
+
+ @Override
+ @SneakyThrows
+ public void doInitialize() {
+
+ ValidationUtils.requireNonNull(config, "config is required");
+
+ // client
+
+ client = RedisClient.create();
+ client.setOptions(config.getClientOptions());
+
+ // connection
+
+ connection = client.connect(StringCodec.UTF8, config.getRedisUri());
+ commands = connection.sync();
+
+ // ping
+
+ var pong = commands.ping();
+
+ ValidationUtils.requireTrue("PONG".equalsIgnoreCase(pong), "Redis connection test failed - expected PONG, got: " + pong);
+ }
+
+ @Override
+ @SneakyThrows
+ public void doSend(EventMessage message) {
+
+ ValidationUtils.requireNonNull(message, "message is required");
+
+ // actualChannel
+
+ var actualChannel = TemplateUtils.substitute(config.getChannel(), message);
+
+ // publish
+
+ commands.publish(actualChannel, new String(message.eventBody(), StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public void close() {
+ ValidationUtils.tryClose(connection, "connection");
+ ValidationUtils.tryClose(client, "client");
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/redispubsub/RedisPubSubDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/redispubsub/RedisPubSubDestinationConfig.java
new file mode 100644
index 00000000..86b80fb7
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/redispubsub/RedisPubSubDestinationConfig.java
@@ -0,0 +1,140 @@
+package io.github.fortunen.kete.destinations.redispubsub;
+
+import io.github.fortunen.kete.DestinationConfig;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.lettuce.core.ClientOptions;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.SslOptions;
+import io.lettuce.core.TimeoutOptions;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+import java.time.Duration;
+
+@Data
+@NoArgsConstructor(force = true)
+@EqualsAndHashCode(callSuper = true)
+public class RedisPubSubDestinationConfig extends DestinationConfig {
+
+ public static final String HOST = "host";
+ public static final String PORT = "port";
+ public static final int DEFAULT_PORT = 6379;
+ public static final int DEFAULT_TLS_PORT = 6380;
+
+ public static final String CHANNEL = "channel";
+
+ public static final int DEFAULT_DATABASE = 0;
+ public static final String USERNAME = "username";
+ public static final String PASSWORD = "password";
+ public static final String DATABASE = "database";
+
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+
+ public static final int DEFAULT_COMMAND_TIMEOUT_SECONDS = 60;
+ public static final String COMMAND_TIMEOUT_SECONDS = "command-timeout-seconds";
+
+ public static final String CLIENT_NAME = "client-name";
+ public static final String DEFAULT_CLIENT_NAME = "kete";
+
+ private int port;
+ private String host;
+ private int database;
+ private String channel;
+ private String username;
+ private String password;
+ private String clientName;
+ private RedisURI redisUri;
+ private int commandTimeoutSeconds;
+ private ClientOptions clientOptions;
+ private int connectionTimeoutSeconds;
+
+ @Override
+ @SneakyThrows
+ protected void doInitialize() {
+
+ ValidationUtils.requireNonNull(configuration, "configuration is required");
+
+ // host
+
+ host = ValidationUtils.requireNonBlank(configuration.getString(HOST, "").trim(), HOST + " is required");
+
+ // channel
+
+ channel = ValidationUtils.requireNonBlank(configuration.getString(CHANNEL, "").trim(), CHANNEL + " is required");
+
+ // port
+
+ port = configuration.getInt(PORT, tls.isEnabled() ? DEFAULT_TLS_PORT : DEFAULT_PORT);
+
+ ValidationUtils.requireValidPort(port, PORT);
+
+ // database
+
+ database = ValidationUtils.requireNonNegative(configuration.getInt(DATABASE, DEFAULT_DATABASE), DATABASE + " must be non-negative");
+
+ // username
+
+ username = configuration.getString(USERNAME, "").trim();
+
+ // password
+
+ password = configuration.getString(PASSWORD, "").trim();
+
+ // clientName
+
+ clientName = configuration.getString(CLIENT_NAME, DEFAULT_CLIENT_NAME).trim();
+
+ // connectionTimeoutSeconds
+
+ connectionTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS), CONNECTION_TIMEOUT_SECONDS + " must be non-negative");
+
+ // commandTimeoutSeconds
+
+ commandTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(COMMAND_TIMEOUT_SECONDS, DEFAULT_COMMAND_TIMEOUT_SECONDS), COMMAND_TIMEOUT_SECONDS + " must be non-negative");
+
+ // redisUri
+
+ var uriBuilder = RedisURI.builder()
+ .withHost(host)
+ .withPort(port)
+ .withDatabase(database)
+ .withClientName(clientName)
+ .withTimeout(Duration.ofSeconds(commandTimeoutSeconds));
+
+ if (ValidationUtils.isNotBlank(password)) {
+ if (ValidationUtils.isNotBlank(username)) {
+ uriBuilder.withAuthentication(username, password.toCharArray());
+ } else {
+ uriBuilder.withPassword(password.toCharArray());
+ }
+ }
+
+ if (tls.isEnabled()) {
+ uriBuilder.withSsl(true);
+ }
+
+ redisUri = uriBuilder.build();
+
+ // clientOptions
+
+ var clientOptionsBuilder = ClientOptions.builder()
+ .autoReconnect(true)
+ .timeoutOptions(TimeoutOptions.builder().fixedTimeout(Duration.ofSeconds(connectionTimeoutSeconds)).build());
+
+ if (tls.isEnabled()) {
+
+ var sslOptions = SslOptions.builder()
+ .jdkSslProvider()
+ .keyManager(tls.getKeyManagerFactory())
+ .trustManager(tls.getTrustManagerFactory())
+ .build();
+
+ clientOptionsBuilder.sslOptions(sslOptions);
+ }
+
+ clientOptions = clientOptionsBuilder.build();
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/redisstreams/RedisStreamsDestination.java b/src/main/java/io/github/fortunen/kete/destinations/redisstreams/RedisStreamsDestination.java
new file mode 100644
index 00000000..3b30043f
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/redisstreams/RedisStreamsDestination.java
@@ -0,0 +1,102 @@
+package io.github.fortunen.kete.destinations.redisstreams;
+
+import io.github.fortunen.kete.Component;
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.Destination;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.utils.TemplateUtils;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.XAddArgs;
+import io.lettuce.core.api.StatefulRedisConnection;
+import io.lettuce.core.api.sync.RedisCommands;
+import io.lettuce.core.codec.StringCodec;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+
+@Data
+@NoArgsConstructor(force = true)
+@Component(name = "redis-streams")
+@EqualsAndHashCode(callSuper = true)
+public class RedisStreamsDestination extends Destination {
+
+ public static final String FIELD_BODY = "body";
+
+ private RedisClient client;
+ private RedisCommands commands;
+ private StatefulRedisConnection connection;
+
+ @Override
+ @SneakyThrows
+ public void doInitialize() {
+
+ ValidationUtils.requireNonNull(config, "config is required");
+
+ // client
+
+ client = RedisClient.create();
+ client.setOptions(config.getClientOptions());
+
+ // connection
+
+ connection = client.connect(StringCodec.UTF8, config.getRedisUri());
+ commands = connection.sync();
+
+ // ping
+
+ var pong = commands.ping();
+
+ ValidationUtils.requireTrue("PONG".equalsIgnoreCase(pong), "Redis connection test failed - expected PONG, got: " + pong);
+ }
+
+ @Override
+ @SneakyThrows
+ public void doSend(EventMessage message) {
+
+ ValidationUtils.requireNonNull(message, "message is required");
+
+ // actualStream
+
+ var actualStream = TemplateUtils.substitute(config.getStream(), message);
+
+ // fields
+
+ var fields = new LinkedHashMap();
+
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ fields.put(entry.getKey(), entry.getValue());
+ }
+
+ fields.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ fields.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
+ fields.put(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
+ fields.put(FIELD_BODY, new String(message.eventBody(), StandardCharsets.UTF_8));
+
+ // xadd
+
+ var xaddArgs = new XAddArgs();
+
+ // maxlen
+
+ if (config.getMaxLen() > 0) {
+ if (config.isApproximateTrimming()) {
+ xaddArgs.maxlen(config.getMaxLen()).approximateTrimming();
+ } else {
+ xaddArgs.maxlen(config.getMaxLen());
+ }
+ }
+
+ commands.xadd(actualStream, xaddArgs, fields);
+ }
+
+ @Override
+ public void close() {
+ ValidationUtils.tryClose(connection, "connection");
+ ValidationUtils.tryClose(client, "client");
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/redisstreams/RedisStreamsDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/redisstreams/RedisStreamsDestinationConfig.java
new file mode 100644
index 00000000..30ac59a9
--- /dev/null
+++ b/src/main/java/io/github/fortunen/kete/destinations/redisstreams/RedisStreamsDestinationConfig.java
@@ -0,0 +1,156 @@
+package io.github.fortunen.kete.destinations.redisstreams;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.DestinationConfig;
+import io.github.fortunen.kete.utils.ValidationUtils;
+import io.lettuce.core.ClientOptions;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.SslOptions;
+import io.lettuce.core.TimeoutOptions;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.SneakyThrows;
+
+import java.time.Duration;
+
+@Data
+@NoArgsConstructor(force = true)
+@EqualsAndHashCode(callSuper = true)
+public class RedisStreamsDestinationConfig extends DestinationConfig {
+
+ public static final String HOST = "host";
+ public static final String PORT = "port";
+ public static final int DEFAULT_PORT = 6379;
+ public static final int DEFAULT_TLS_PORT = 6380;
+
+ public static final String STREAM = "stream";
+
+ public static final String MAX_LEN = "max-len";
+ public static final long DEFAULT_MAX_LEN = 0;
+
+ public static final boolean DEFAULT_APPROXIMATE_TRIMMING = true;
+ public static final String APPROXIMATE_TRIMMING = "approximate-trimming";
+
+ public static final String USERNAME = "username";
+ public static final String PASSWORD = "password";
+ public static final String DATABASE = "database";
+ public static final int DEFAULT_DATABASE = 0;
+
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
+
+ public static final int DEFAULT_COMMAND_TIMEOUT_SECONDS = 60;
+ public static final String COMMAND_TIMEOUT_SECONDS = "command-timeout-seconds";
+
+ public static final String CLIENT_NAME = "client-name";
+
+ private int port;
+ private String host;
+ private long maxLen;
+ private int database;
+ private String stream;
+ private String username;
+ private String password;
+ private String clientName;
+ private RedisURI redisUri;
+ private int commandTimeoutSeconds;
+ private boolean approximateTrimming;
+ private ClientOptions clientOptions;
+ private int connectionTimeoutSeconds;
+
+ @Override
+ @SneakyThrows
+ protected void doInitialize() {
+
+ ValidationUtils.requireNonNull(configuration, "configuration is required");
+
+ // host
+
+ host = ValidationUtils.requireNonBlank(configuration.getString(HOST, "").trim(), HOST + " is required");
+
+ // stream
+
+ stream = ValidationUtils.requireNonBlank(configuration.getString(STREAM, "").trim(), STREAM + " is required");
+
+ // port
+
+ port = configuration.getInt(PORT, tls.isEnabled() ? DEFAULT_TLS_PORT : DEFAULT_PORT);
+
+ ValidationUtils.requireValidPort(port, PORT);
+
+ // maxLen
+
+ maxLen = ValidationUtils.requireNonNegative(configuration.getLong(MAX_LEN, DEFAULT_MAX_LEN), MAX_LEN + " must be non-negative");
+
+ // approximateTrimming
+
+ approximateTrimming = configuration.getBoolean(APPROXIMATE_TRIMMING, DEFAULT_APPROXIMATE_TRIMMING);
+
+ // database
+
+ database = ValidationUtils.requireNonNegative(configuration.getInt(DATABASE, DEFAULT_DATABASE), DATABASE + " must be non-negative");
+
+ // username
+
+ username = configuration.getString(USERNAME, "").trim();
+
+ // password
+
+ password = configuration.getString(PASSWORD, "").trim();
+
+ // clientName
+
+ clientName = configuration.getString(CLIENT_NAME, Constants.ID).trim();
+
+ // connectionTimeoutSeconds
+
+ connectionTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS), CONNECTION_TIMEOUT_SECONDS + " must be non-negative");
+
+ // commandTimeoutSeconds
+
+ commandTimeoutSeconds = ValidationUtils.requireNonNegative(configuration.getInt(COMMAND_TIMEOUT_SECONDS, DEFAULT_COMMAND_TIMEOUT_SECONDS), COMMAND_TIMEOUT_SECONDS + " must be non-negative");
+
+ // redisUri
+
+ var uriBuilder = RedisURI.builder()
+ .withHost(host)
+ .withPort(port)
+ .withDatabase(database)
+ .withClientName(clientName)
+ .withTimeout(Duration.ofSeconds(commandTimeoutSeconds));
+
+ if (ValidationUtils.isNotBlank(password)) {
+ if (ValidationUtils.isNotBlank(username)) {
+ uriBuilder.withAuthentication(username, password.toCharArray());
+ } else {
+ uriBuilder.withPassword(password.toCharArray());
+ }
+ }
+
+ if (tls.isEnabled()) {
+ uriBuilder.withSsl(true);
+ }
+
+ redisUri = uriBuilder.build();
+
+ // clientOptions
+
+ var clientOptionsBuilder = ClientOptions.builder()
+ .autoReconnect(true)
+ .timeoutOptions(TimeoutOptions.builder().fixedTimeout(Duration.ofSeconds(connectionTimeoutSeconds)).build());
+
+ if (tls.isEnabled()) {
+
+ var sslOptions = SslOptions.builder()
+ .jdkSslProvider()
+ .trustManager(tls.getTrustManagerFactory())
+ .keyManager(tls.getKeyManagerFactory())
+ .build();
+
+ clientOptionsBuilder.sslOptions(sslOptions);
+ }
+
+ clientOptions = clientOptionsBuilder.build();
+ }
+}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestination.java b/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestination.java
index b8f0d34d..09620864 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestination.java
@@ -32,28 +32,10 @@ public void doInitialize() {
connection = new StompConnection();
var socket = config.getSocketFactory().createSocket(config.getHost(), config.getPort());
- socket.setSoTimeout(config.getReadTimeoutMillis());
- connection.open(socket);
-
- var connectHeaders = new HashMap();
-
- if (ValidationUtils.isNotBlank(config.getUsername())) {
- connectHeaders.put("login", config.getUsername());
- }
-
- if (ValidationUtils.isNotBlank(config.getPassword())) {
- connectHeaders.put("passcode", config.getPassword());
- }
-
- connectHeaders.put("accept-version", "1.1,1.2");
-
- if (ValidationUtils.isNotBlank(config.getVirtualHost())) {
- connectHeaders.put("host", config.getVirtualHost());
- }
+ socket.setSoTimeout(config.getReadTimeoutSeconds() * 1000);
- connectHeaders.put("heart-beat", config.getHeartBeatOutgoing() + "," + config.getHeartBeatIncoming());
-
- connection.connect(connectHeaders);
+ connection.open(socket);
+ connection.connect(config.getConnectHeaders());
}
@Override
@@ -62,17 +44,14 @@ public void doSend(EventMessage message) {
ValidationUtils.requireNonNull(message, "message is required");
- var actualDestination = TemplateUtils.substitute(config.getDestination(), message);
var body = message.eventBody();
-
var headers = new HashMap();
+ var actualDestination = TemplateUtils.substitute(config.getDestination(), message);
+
headers.put("content-type", message.contentType());
headers.put("content-length", String.valueOf(body.length));
-
- if (config.isMessageHeadersEnabled()) {
- headers.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
- headers.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
- }
+ headers.put(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ headers.put(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
if (config.isReceiptEnabled()) {
headers.put("receipt", message.eventId());
diff --git a/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestinationConfig.java
index ab1c62b8..9dbb74e0 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/stomp/StompDestinationConfig.java
@@ -1,5 +1,7 @@
package io.github.fortunen.kete.destinations.stomp;
+import java.util.HashMap;
+
import javax.net.SocketFactory;
import io.github.fortunen.kete.DestinationConfig;
@@ -25,27 +27,28 @@ public class StompDestinationConfig extends DestinationConfig {
public static final String USERNAME = "username";
public static final String PASSWORD = "password";
+ public static final boolean DEFAULT_RECEIPT_ENABLED = true;
public static final String RECEIPT_ENABLED = "receipt-enabled";
- public static final int DEFAULT_HEART_BEAT = 30000;
- public static final String HEART_BEAT_OUTGOING = "heart-beat-outgoing";
- public static final String HEART_BEAT_INCOMING = "heart-beat-incoming";
+ public static final int DEFAULT_HEART_BEAT_SECONDS = 30;
+ public static final String HEART_BEAT_OUTGOING_SECONDS = "heart-beat-outgoing-seconds";
+ public static final String HEART_BEAT_INCOMING_SECONDS = "heart-beat-incoming-seconds";
- public static final int DEFAULT_READ_TIMEOUT_MILLIS = 30000;
- public static final String READ_TIMEOUT_MILLIS = "read-timeout-millis";
+ public static final int DEFAULT_READ_TIMEOUT_SECONDS = 30;
+ public static final String READ_TIMEOUT_SECONDS = "read-timeout-seconds";
private int port;
private String host;
- private String destination;
- private String virtualHost;
private String username;
private String password;
+ private String destination;
+ private String virtualHost;
private boolean receiptEnabled;
- private int heartBeatOutgoing;
- private int heartBeatIncoming;
- private int readTimeoutMillis;
- private boolean messageHeadersEnabled;
+ private int readTimeoutSeconds;
private SocketFactory socketFactory;
+ private int heartBeatOutgoingSeconds;
+ private int heartBeatIncomingSeconds;
+ private HashMap connectHeaders;
@Override
@SneakyThrows
@@ -81,28 +84,48 @@ protected void doInitialize() {
// receiptEnabled
- receiptEnabled = configuration.getBoolean(RECEIPT_ENABLED, false);
+ receiptEnabled = configuration.getBoolean(RECEIPT_ENABLED, DEFAULT_RECEIPT_ENABLED);
+
+ // heartBeatOutgoingSeconds
- // heartBeat
+ heartBeatOutgoingSeconds = configuration.getInt(HEART_BEAT_OUTGOING_SECONDS, DEFAULT_HEART_BEAT_SECONDS);
- heartBeatOutgoing = configuration.getInt(HEART_BEAT_OUTGOING, DEFAULT_HEART_BEAT);
- heartBeatIncoming = configuration.getInt(HEART_BEAT_INCOMING, DEFAULT_HEART_BEAT);
+ ValidationUtils.requireNonNegative(heartBeatOutgoingSeconds, HEART_BEAT_OUTGOING_SECONDS + " must be non-negative");
- ValidationUtils.requireNonNegative(heartBeatOutgoing, HEART_BEAT_OUTGOING + " must be non-negative");
- ValidationUtils.requireNonNegative(heartBeatIncoming, HEART_BEAT_INCOMING + " must be non-negative");
+ // heartBeatIncomingSeconds
- // readTimeoutMillis
+ heartBeatIncomingSeconds = configuration.getInt(HEART_BEAT_INCOMING_SECONDS, DEFAULT_HEART_BEAT_SECONDS);
- readTimeoutMillis = configuration.getInt(READ_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS);
+ ValidationUtils.requireNonNegative(heartBeatIncomingSeconds, HEART_BEAT_INCOMING_SECONDS + " must be non-negative");
- ValidationUtils.requireGreaterThan(readTimeoutMillis, 0, READ_TIMEOUT_MILLIS + " must be greater than 0");
+ // readTimeoutSeconds
- // messageHeadersEnabled
+ readTimeoutSeconds = configuration.getInt(READ_TIMEOUT_SECONDS, DEFAULT_READ_TIMEOUT_SECONDS);
- messageHeadersEnabled = configuration.getBoolean(MESSAGE_HEADERS_ENABLED, true);
+ ValidationUtils.requireGreaterThan(readTimeoutSeconds, 0, READ_TIMEOUT_SECONDS + " must be greater than 0");
// socketFactory
socketFactory = tls.isEnabled() ? tls.getKeyStoreAndTrustStoreSSLContext().getSocketFactory() : SocketFactory.getDefault();
+
+ // connectHeaders
+
+ connectHeaders = new HashMap();
+
+ if (ValidationUtils.isNotBlank(username)) {
+ connectHeaders.put("login", username);
+ }
+
+ if (ValidationUtils.isNotBlank(password)) {
+ connectHeaders.put("passcode", password);
+ }
+
+ connectHeaders.put("accept-version", "1.1,1.2");
+
+ if (ValidationUtils.isNotBlank(virtualHost)) {
+ connectHeaders.put("host", virtualHost);
+ }
+
+ connectHeaders.put("heart-beat", (heartBeatOutgoingSeconds * 1000) + "," + (heartBeatIncomingSeconds * 1000));
}
}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestination.java b/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestination.java
index 080bad6d..0801e03b 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestination.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestination.java
@@ -1,14 +1,19 @@
package io.github.fortunen.kete.destinations.websocket;
+import java.net.InetSocketAddress;
+import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ConcurrentHashMap;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;
import io.github.fortunen.kete.Component;
+import io.github.fortunen.kete.Constants;
import io.github.fortunen.kete.Destination;
import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.utils.TemplateUtils;
import io.github.fortunen.kete.utils.ValidationUtils;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -16,14 +21,14 @@
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
-@Slf4j
@Data
+@Slf4j
@Component(name = "websocket")
@NoArgsConstructor(force = true)
@EqualsAndHashCode(callSuper = true)
public class WebSocketDestination extends Destination {
- private WebSocketClient client;
+ private ConcurrentHashMap clientCache = new ConcurrentHashMap<>();
@Override
@SneakyThrows
@@ -31,42 +36,76 @@ public void doInitialize() {
ValidationUtils.requireNonNull(config, "config is required");
- client = new WebSocketClient(URI.create(config.getUrl()), config.getHeaders()) {
+ // verify connection
- @Override
- public void onOpen(ServerHandshake handshake) {}
+ try (var socket = new Socket()) {
+ socket.connect(new InetSocketAddress(config.getHost(), config.getPort()), config.getConnectionTimeoutSeconds() * 1000);
+ }
+ }
- @Override
- public void onMessage(String message) {}
+ @Override
+ @SneakyThrows
+ public void doSend(EventMessage message) {
- @Override
- public void onClose(int code, String reason, boolean remote) {
- log.debug("WebSocket closed: code={}, reason={}, remote={}", code, reason, remote);
- }
+ ValidationUtils.requireNonNull(message, "message is required");
- @Override
- public void onError(Exception exception) {
- log.warn("WebSocket error", exception);
- }
- };
+ // client
- if (config.getSocketFactory() != null) {
- client.setSocketFactory(config.getSocketFactory());
- }
+ var actualUrl = TemplateUtils.substitute(config.getUrl(), message);
- // connectionLostTimeout enables ping/pong heartbeat (0 = disabled)
- client.setConnectionLostTimeout(config.getConnectionLostTimeout());
+ var client = clientCache.computeIfAbsent(actualUrl, url -> {
+ try {
+ var wsClient = new WebSocketClient(URI.create(url), config.getCustomHeaders()) {
- var connected = client.connectBlocking(config.getConnectionTimeout(), config.getConnectionTimeoutUnit());
+ @Override
+ public void onOpen(ServerHandshake handshake) {}
- ValidationUtils.requireTrue(connected, "failed to connect to WebSocket server at " + config.getUrl());
- }
+ @Override
+ public void onMessage(String msg) {}
- @Override
- @SneakyThrows
- public void doSend(EventMessage message) {
+ @Override
+ public void onClose(int code, String reason, boolean remote) {
+ log.debug("WebSocket closed: code={}, reason={}, remote={}", code, reason, remote);
+ }
- ValidationUtils.requireNonNull(message, "message is required");
+ @Override
+ public void onError(Exception exception) {
+ log.warn("WebSocket error", exception);
+ }
+ };
+
+ if (config.getSocketFactory() != null) {
+ wsClient.setSocketFactory(config.getSocketFactory());
+ }
+
+ wsClient.setConnectionLostTimeout(config.getConnectionLostTimeoutSeconds());
+
+ var connected = wsClient.connectBlocking(config.getConnectionTimeoutSeconds(), config.getConnectionTimeoutUnit());
+
+ if (!connected) {
+ throw new RuntimeException("failed to connect to WebSocket server at " + url);
+ }
+
+ return wsClient;
+
+ } catch (Exception exception) {
+ throw new RuntimeException(exception);
+ }
+ });
+
+ client.clearHeaders();
+
+ if (config.isOauthEnabled()) {
+ client.addHeader("Authorization", config.getOauth().getAccessToken().toAuthorizationHeader());
+ }
+
+ for (var entry : config.getCustomHeadersEntrySet()) {
+ client.addHeader(entry.getKey(), entry.getValue());
+ }
+
+ client.addHeader(Constants.MESSAGE_HEADER_EVENT_KIND, message.kind());
+ client.addHeader(Constants.MESSAGE_HEADER_EVENT_TYPE, message.eventType());
+ client.addHeader(Constants.MESSAGE_HEADER_CONTENT_TYPE, message.contentType());
if (config.isBinaryMode()) {
client.send(message.eventBody());
@@ -77,6 +116,7 @@ public void doSend(EventMessage message) {
@Override
public void close() {
- ValidationUtils.tryClose(client, "client");
+ clientCache.forEach((url, client) -> ValidationUtils.tryClose(client, "client for " + url));
+ clientCache.clear();
}
}
diff --git a/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestinationConfig.java b/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestinationConfig.java
index ef4405b4..86a38857 100644
--- a/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestinationConfig.java
+++ b/src/main/java/io/github/fortunen/kete/destinations/websocket/WebSocketDestinationConfig.java
@@ -1,12 +1,12 @@
package io.github.fortunen.kete.destinations.websocket;
-import java.util.HashMap;
-import java.util.Map;
+import java.net.URI;
import java.util.concurrent.TimeUnit;
import javax.net.SocketFactory;
import io.github.fortunen.kete.DestinationConfig;
+import io.github.fortunen.kete.OAuthMaterial;
import io.github.fortunen.kete.TlsMaterial;
import io.github.fortunen.kete.utils.ConfigurationUtils;
import io.github.fortunen.kete.utils.ValidationUtils;
@@ -23,18 +23,19 @@ public class WebSocketDestinationConfig extends DestinationConfig {
public static final String URL = "url";
public static final String HOST = "host";
public static final String PORT = "port";
+ public static final String PATH = "path";
public static final int DEFAULT_WS_PORT = 80;
public static final int DEFAULT_WSS_PORT = 443;
- public static final String PATH = "path";
public static final String BINARY_MODE = "binary-mode";
- public static final String HEADERS = "headers";
- public static final int DEFAULT_CONNECTION_TIMEOUT = 10;
- public static final String CONNECTION_TIMEOUT = "connection-timeout";
+ public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 10;
+ public static final String CONNECTION_TIMEOUT_SECONDS = "connection-timeout-seconds";
- public static final int DEFAULT_CONNECTION_LOST_TIMEOUT = 60;
- public static final String CONNECTION_LOST_TIMEOUT = "connection-lost-timeout";
+ public static final int DEFAULT_CONNECTION_LOST_TIMEOUT_SECONDS = 60;
+ public static final String CONNECTION_LOST_TIMEOUT_SECONDS = "connection-lost-timeout-seconds";
+
+ public static final String OAUTH = "oauth";
private int port;
private String url;
@@ -42,11 +43,12 @@ public class WebSocketDestinationConfig extends DestinationConfig {
private String path;
private String scheme;
private boolean binaryMode;
- private int connectionTimeout;
- private int connectionLostTimeout;
+ private OAuthMaterial oauth;
+ private boolean oauthEnabled;
private SocketFactory socketFactory;
+ private int connectionTimeoutSeconds;
+ private int connectionLostTimeoutSeconds;
private TimeUnit connectionTimeoutUnit = TimeUnit.SECONDS;
- private Map headers = new HashMap<>();
@Override
@SneakyThrows
@@ -62,12 +64,22 @@ protected void doInitialize() {
url = urlFromConfig;
- // parse scheme from url
+ var parsedUri = URI.create(url);
+
+ // host
+
+ host = ValidationUtils.requireNonBlank(parsedUri.getHost(), "url must contain a valid host");
+
+ // scheme
if (url.startsWith("wss://")) {
scheme = "wss";
+ // port
+
+ port = parsedUri.getPort() > 0 ? parsedUri.getPort() : DEFAULT_WSS_PORT;
+
if (!tls.isEnabled()) {
var tlsConfig = ConfigurationUtils.getSubSet(configuration, TLS);
tlsConfig.getMap().put("enabled", "true");
@@ -75,7 +87,13 @@ protected void doInitialize() {
}
} else if (url.startsWith("ws://")) {
+
scheme = "ws";
+
+ // port
+
+ port = parsedUri.getPort() > 0 ? parsedUri.getPort() : DEFAULT_WS_PORT;
+
} else {
throw new IllegalStateException("url must start with 'ws://' or 'wss://'");
}
@@ -104,7 +122,7 @@ protected void doInitialize() {
path = "/" + path;
}
- // build url
+ // url
url = scheme + "://" + host + ":" + port + path;
}
@@ -113,27 +131,23 @@ protected void doInitialize() {
binaryMode = configuration.getBoolean(BINARY_MODE, false);
- // connectionTimeout
+ // connectionTimeoutSeconds
- connectionTimeout = configuration.getInt(CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT);
+ connectionTimeoutSeconds = configuration.getInt(CONNECTION_TIMEOUT_SECONDS, DEFAULT_CONNECTION_TIMEOUT_SECONDS);
- ValidationUtils.requireGreaterThan(connectionTimeout, 0, CONNECTION_TIMEOUT + " must be greater than 0");
+ ValidationUtils.requireGreaterThan(connectionTimeoutSeconds, 0, CONNECTION_TIMEOUT_SECONDS + " must be greater than 0");
- // connectionLostTimeout
+ // connectionLostTimeoutSeconds
- connectionLostTimeout = configuration.getInt(CONNECTION_LOST_TIMEOUT, DEFAULT_CONNECTION_LOST_TIMEOUT);
+ connectionLostTimeoutSeconds = configuration.getInt(CONNECTION_LOST_TIMEOUT_SECONDS, DEFAULT_CONNECTION_LOST_TIMEOUT_SECONDS);
- ValidationUtils.requireNonNegative(connectionLostTimeout, CONNECTION_LOST_TIMEOUT + " must be non-negative");
+ ValidationUtils.requireNonNegative(connectionLostTimeoutSeconds, CONNECTION_LOST_TIMEOUT_SECONDS + " must be non-negative");
- // headers
+ // oauth
- var headersConfig = ConfigurationUtils.getSubSet(configuration, HEADERS);
- var keysIterator = headersConfig.getKeys();
+ oauth = OAuthMaterial.builder().withKeycloakRealm(keycloakRealm).withKeycloakSession(keycloakSession).withConfiguration(ConfigurationUtils.getSubSet(configuration, OAUTH)).build();
- while (keysIterator.hasNext()) {
- var key = keysIterator.next();
- headers.put(key, headersConfig.getString(key));
- }
+ oauthEnabled = ValidationUtils.isNotNull(oauth) && oauth.isEnabled();
// socketFactory
diff --git a/src/main/java/io/github/fortunen/kete/utils/RouteUtils.java b/src/main/java/io/github/fortunen/kete/utils/RouteUtils.java
index 8bb07390..b3ee3d46 100644
--- a/src/main/java/io/github/fortunen/kete/utils/RouteUtils.java
+++ b/src/main/java/io/github/fortunen/kete/utils/RouteUtils.java
@@ -105,6 +105,7 @@ public static Route createRoute(String name, MapConfiguration configuration) {
var serializer = SerializerUtils.createSerializer(serializerConfig);
route.setSerializer(serializer);
+ route.setSerializerKind(serializerKind);
// destinationConfig
diff --git a/src/main/java/io/github/fortunen/kete/utils/ValidationUtils.java b/src/main/java/io/github/fortunen/kete/utils/ValidationUtils.java
index 7215c0c4..ec567de7 100644
--- a/src/main/java/io/github/fortunen/kete/utils/ValidationUtils.java
+++ b/src/main/java/io/github/fortunen/kete/utils/ValidationUtils.java
@@ -948,19 +948,19 @@ public static Optional tryParseDuration(String value) {
return Optional.of(Duration.parse(value));
}
- // milliseconds (digits only), e.g. "1500"
+// seconds (digits only), e.g. "1500"
- var allDigits = true;
+ var allDigits = true;
- for (var i = 0; i < lower.length(); i++) {
- if (!Character.isDigit(lower.charAt(i))) {
- allDigits = false;
- break;
- }
+ for (var i = 0; i < lower.length(); i++) {
+ if (!Character.isDigit(lower.charAt(i))) {
+ allDigits = false;
+ break;
}
+ }
- if (allDigits) {
- return Optional.of(Duration.ofMillis(Long.parseLong(lower)));
+ if (allDigits) {
+ return Optional.of(Duration.ofSeconds(Long.parseLong(lower)));
}
// nanoseconds, e.g. "10ns"
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/EndToEndTestBase.java b/src/test/java/io/github/fortunen/kete/endtoendtests/EndToEndTestBase.java
index b772300c..5a4f1dc6 100644
--- a/src/test/java/io/github/fortunen/kete/endtoendtests/EndToEndTestBase.java
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/EndToEndTestBase.java
@@ -13,8 +13,7 @@
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import lombok.extern.slf4j.Slf4j;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.output.Slf4jLogConsumer;
@@ -25,11 +24,10 @@
import static org.awaitility.Awaitility.await;
+@Slf4j
@SuppressWarnings("resource")
public abstract class EndToEndTestBase {
- private static final Logger LOG = LoggerFactory.getLogger(EndToEndTestBase.class);
-
private static final File SHADED_JAR_FILE = new File("target/kete.jar");
private static boolean SHADED_JAR_BUILT = false;
@@ -52,19 +50,19 @@ private static void ensureShadedJarBuilt() {
return;
}
- LOG.info("Building shaded JAR with 'mvn package -DskipTests'...");
+ log.info("Building shaded JAR with 'mvn package -DskipTests'...");
if (buildWithMavenInvoker() && SHADED_JAR_FILE.exists()) {
SHADED_JAR_BUILT = true;
- LOG.info("Shaded JAR built successfully at {}", SHADED_JAR_FILE.getAbsolutePath());
+ log.info("Shaded JAR built successfully at {}", SHADED_JAR_FILE.getAbsolutePath());
return;
}
- LOG.info("Maven Invoker failed, falling back to CLI...");
+ log.info("Maven Invoker failed, falling back to CLI...");
if (buildWithCli() && SHADED_JAR_FILE.exists()) {
SHADED_JAR_BUILT = true;
- LOG.info("Shaded JAR built successfully at {}", SHADED_JAR_FILE.getAbsolutePath());
+ log.info("Shaded JAR built successfully at {}", SHADED_JAR_FILE.getAbsolutePath());
return;
}
@@ -79,7 +77,7 @@ private static boolean buildWithMavenInvoker() {
var pomFile = new File("pom.xml").getAbsoluteFile();
if (!pomFile.isFile()) {
- LOG.warn("pom.xml not found at {}", pomFile.getAbsolutePath());
+ log.warn("pom.xml not found at {}", pomFile.getAbsolutePath());
return false;
}
@@ -94,8 +92,8 @@ private static boolean buildWithMavenInvoker() {
var invoker = new org.apache.maven.shared.invoker.DefaultInvoker();
- invoker.setOutputHandler(line -> LOG.info("[mvn] {}", line));
- invoker.setErrorHandler(line -> LOG.warn("[mvn] {}", line));
+ invoker.setOutputHandler(line -> log.info("[mvn] {}", line));
+ invoker.setErrorHandler(line -> log.warn("[mvn] {}", line));
var mavenHome = System.getenv("MAVEN_HOME");
@@ -117,15 +115,15 @@ private static boolean buildWithMavenInvoker() {
if (result.getExitCode() == 0) {
- LOG.info("Shaded JAR built successfully via Maven Invoker at {}", SHADED_JAR_FILE.getAbsolutePath());
+ log.info("Shaded JAR built successfully via Maven Invoker at {}", SHADED_JAR_FILE.getAbsolutePath());
return true;
} else {
- LOG.warn("Maven Invoker build failed with exit code {}", result.getExitCode());
+ log.warn("Maven Invoker build failed with exit code {}", result.getExitCode());
if (result.getExecutionException() != null) {
- LOG.warn("Execution exception:", result.getExecutionException());
+ log.warn("Execution exception:", result.getExecutionException());
}
return false;
@@ -133,12 +131,12 @@ private static boolean buildWithMavenInvoker() {
} catch (NoSuchMethodError e) {
- LOG.warn("Maven Invoker API incompatibility detected: {}", e.getMessage());
+ log.warn("Maven Invoker API incompatibility detected: {}", e.getMessage());
return false;
} catch (Exception e) {
- LOG.warn("Maven Invoker failed:", e);
+ log.warn("Maven Invoker failed:", e);
return false;
}
@@ -155,26 +153,26 @@ private static boolean buildWithCli() {
if (exitCode != 0) {
- LOG.error("Maven CLI build failed with exit code {}", exitCode);
+ log.error("Maven CLI build failed with exit code {}", exitCode);
return false;
}
if (SHADED_JAR_FILE.exists()) {
- LOG.info("Shaded JAR built successfully via CLI at {}", SHADED_JAR_FILE.getAbsolutePath());
+ log.info("Shaded JAR built successfully via CLI at {}", SHADED_JAR_FILE.getAbsolutePath());
return true;
} else {
- LOG.error("Maven CLI build completed but shaded JAR still not found at {}", SHADED_JAR_FILE.getAbsolutePath());
+ log.error("Maven CLI build completed but shaded JAR still not found at {}", SHADED_JAR_FILE.getAbsolutePath());
return false;
}
} catch (Exception e) {
- LOG.error("Failed to build shaded JAR via CLI: {}", e.getMessage(), e);
+ log.error("Failed to build shaded JAR via CLI: {}", e.getMessage(), e);
return false;
}
@@ -198,7 +196,7 @@ protected KeycloakContainer createKeycloakContainer(Map envVars)
.withAdminPassword("admin")
.withProviderLibsFrom(List.of(SHADED_JAR_FILE))
.withStartupTimeout(CONTAINER_STARTUP_TIMEOUT)
- .withLogConsumer(new Slf4jLogConsumer(LOG));
+ .withLogConsumer(new Slf4jLogConsumer(log));
envVars.forEach(keycloak::withEnv);
@@ -214,7 +212,7 @@ protected KeycloakContainer createKeycloakContainerWithMetrics(Map {
try (Socket socket = new Socket(host, port)) {
- LOG.debug("Port {}:{} is ready", host, port);
+ log.debug("Port {}:{} is ready", host, port);
return true;
} catch (Exception e) {
return false;
@@ -339,7 +337,7 @@ protected void waitForKafkaReady(KafkaContainer kafka) throws Exception {
await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> {
try (var adminClient = org.apache.kafka.clients.admin.AdminClient.create(props)) {
adminClient.listTopics().names().get(5, TimeUnit.SECONDS);
- LOG.debug("Kafka broker is ready at {}", kafka.getBootstrapServers());
+ log.debug("Kafka broker is ready at {}", kafka.getBootstrapServers());
return true;
} catch (Exception e) {
return false;
@@ -354,7 +352,7 @@ protected void waitForAmqpReady(GenericContainer> container, int port) throws
protected void waitForRabbitMqReady(com.rabbitmq.client.ConnectionFactory factory) throws Exception {
await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> {
try (var connection = factory.newConnection()) {
- LOG.debug("RabbitMQ is ready at {}:{}", factory.getHost(), factory.getPort());
+ log.debug("RabbitMQ is ready at {}:{}", factory.getHost(), factory.getPort());
return true;
} catch (Exception e) {
return false;
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt3DestinationE2ETests.java b/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt3DestinationE2ETests.java
index bf4d9053..27fbbf78 100644
--- a/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt3DestinationE2ETests.java
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt3DestinationE2ETests.java
@@ -3,6 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.concurrent.atomic.AtomicReference;
@@ -14,6 +15,7 @@
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.Keycloak;
import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.images.builder.Transferable;
import org.testcontainers.utility.DockerImageName;
@SuppressWarnings("resource")
@@ -36,7 +38,10 @@ void shouldForwardLoginEventToMqtt3Broker() throws Exception {
// arrange
- mosquitto = new GenericContainer<>(DockerImageName.parse("eclipse-mosquitto:2.0")).withNetwork(createNetwork()).withNetworkAliases("mosquitto").withExposedPorts(MQTT_PORT).withCommand("mosquitto", "-c", "/mosquitto-no-auth.conf").withCopyToContainer(org.testcontainers.images.builder.Transferable.of("listener 1883\nallow_anonymous true\n"), "/mosquitto-no-auth.conf");
+ // Create in-memory mosquitto config
+ var configContent = "listener 1883\nallow_anonymous true\n".getBytes(StandardCharsets.UTF_8);
+
+ mosquitto = new GenericContainer<>(DockerImageName.parse("eclipse-mosquitto:2.0")).withNetwork(createNetwork()).withNetworkAliases("mosquitto").withExposedPorts(MQTT_PORT).withCommand("mosquitto", "-c", "/mosquitto-no-auth.conf").withCopyToContainer(Transferable.of(configContent, 0777), "/mosquitto-no-auth.conf");
mosquitto.start();
waitForMqttReady(mosquitto, MQTT_PORT);
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt5DestinationE2ETests.java b/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt5DestinationE2ETests.java
index 68fed077..2220792c 100644
--- a/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt5DestinationE2ETests.java
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/Mqtt5DestinationE2ETests.java
@@ -3,6 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashMap;
import java.util.concurrent.atomic.AtomicReference;
@@ -16,6 +17,7 @@
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.Keycloak;
import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.images.builder.Transferable;
import org.testcontainers.utility.DockerImageName;
@SuppressWarnings("resource")
@@ -38,7 +40,10 @@ void shouldForwardLoginEventToMqtt5Broker() throws Exception {
// arrange
- mosquitto = new GenericContainer<>(DockerImageName.parse("eclipse-mosquitto:2.0")).withNetwork(createNetwork()).withNetworkAliases("mosquitto").withExposedPorts(MQTT_PORT).withCommand("mosquitto", "-c", "/mosquitto-no-auth.conf").withCopyToContainer(org.testcontainers.images.builder.Transferable.of("listener 1883\nallow_anonymous true\n"), "/mosquitto-no-auth.conf");
+ // Create in-memory mosquitto config
+ var configContent = "listener 1883\nallow_anonymous true\n".getBytes(StandardCharsets.UTF_8);
+
+ mosquitto = new GenericContainer<>(DockerImageName.parse("eclipse-mosquitto:2.0")).withNetwork(createNetwork()).withNetworkAliases("mosquitto").withExposedPorts(MQTT_PORT).withCommand("mosquitto", "-c", "/mosquitto-no-auth.conf").withCopyToContainer(Transferable.of(configContent, 0777), "/mosquitto-no-auth.conf");
mosquitto.start();
waitForMqttReady(mosquitto, MQTT_PORT);
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/NatsDestinationE2ETests.java b/src/test/java/io/github/fortunen/kete/endtoendtests/NatsDestinationE2ETests.java
new file mode 100644
index 00000000..2f87cb60
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/NatsDestinationE2ETests.java
@@ -0,0 +1,135 @@
+package io.github.fortunen.kete.endtoendtests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.admin.client.Keycloak;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import io.nats.client.Connection;
+import io.nats.client.Nats;
+import io.nats.client.Options;
+
+@SuppressWarnings("resource")
+class NatsDestinationE2ETests extends EndToEndTestBase {
+
+ private static final String NATS_SUBJECT = "keycloak.events";
+ private static final int NATS_PORT = 4222;
+ private static final int NATS_MONITORING_PORT = 8222;
+ private GenericContainer> natsServer;
+
+ @AfterEach
+ void tearDown() {
+ if (natsServer != null) {
+ natsServer.stop();
+ }
+ cleanupNetwork();
+ }
+
+ @Test
+ void shouldForwardLoginEventToNatsSubject() throws Exception {
+
+ // arrange
+
+ natsServer = new GenericContainer<>(DockerImageName.parse("nats:2.10-alpine"))
+ .withNetwork(createNetwork())
+ .withNetworkAliases("nats")
+ .withExposedPorts(NATS_PORT, NATS_MONITORING_PORT)
+ .withCommand("--http_port", "8222")
+ .withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
+ natsServer.start();
+
+ waitForNatsReady(natsServer);
+
+ var envVars = new HashMap();
+ envVars.put("kete.enabled", "true");
+ envVars.put("kete.routes.nats-test.realm-matchers.filter", "list:" + TEST_REALM);
+ envVars.put("kete.routes.nats-test.destination.kind", "nats");
+ envVars.put("kete.routes.nats-test.destination.servers", "nats://nats:" + NATS_PORT);
+ envVars.put("kete.routes.nats-test.destination.subject", NATS_SUBJECT);
+ envVars.put("kete.routes.nats-test.destination.authentication-method", "none");
+ envVars.put("kete.routes.nats-test.serializer.kind", "json");
+
+ var receivedMessages = new ArrayBlockingQueue(10);
+
+ // Set up NATS subscriber
+ var natsUrl = "nats://" + natsServer.getHost() + ":" + natsServer.getMappedPort(NATS_PORT);
+ var options = new Options.Builder()
+ .server(natsUrl)
+ .connectionTimeout(Duration.ofSeconds(10))
+ .build();
+
+ Connection connection = Nats.connect(options);
+ var dispatcher = connection.createDispatcher((msg) -> {
+ var body = new String(msg.getData(), StandardCharsets.UTF_8);
+ receivedMessages.offer(body);
+ });
+ dispatcher.subscribe(NATS_SUBJECT);
+
+ try (var keycloak = createKeycloakContainer(envVars)) {
+ keycloak.start();
+
+ try (var adminClient = Keycloak.getInstance(keycloak.getAuthServerUrl(), "master", keycloak.getAdminUsername(), keycloak.getAdminPassword(), "admin-cli")) {
+ createTestRealm(adminClient);
+
+ // act
+
+ triggerLoginEvent(keycloak);
+
+ // assert - wait for message using Awaitility
+
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> !receivedMessages.isEmpty());
+
+ var body = receivedMessages.poll(1, TimeUnit.SECONDS);
+
+ // JSON serializer assertions
+ assertThat(body).isNotEmpty();
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("\"type\""),
+ b -> assertThat(b).contains("\"operationType\"")
+ );
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("\"realmId\""),
+ b -> assertThat(b).contains("\"authDetails\"")
+ );
+ assertThat(body).contains(TEST_REALM);
+
+ // cleanup
+ cleanupTestRealm(adminClient);
+ }
+ } finally {
+ try {
+ connection.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+ }
+
+ private void waitForNatsReady(GenericContainer> container) {
+ await().atMost(Duration.ofMinutes(2))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> {
+ try {
+ var url = "http://" + container.getHost() + ":" + container.getMappedPort(NATS_MONITORING_PORT) + "/varz";
+ var conn = new java.net.URL(url).openConnection();
+ conn.setConnectTimeout(1000);
+ conn.setReadTimeout(1000);
+ conn.connect();
+ var responseCode = ((java.net.HttpURLConnection) conn).getResponseCode();
+ return responseCode == 200;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/NatsJetStreamDestinationE2ETests.java b/src/test/java/io/github/fortunen/kete/endtoendtests/NatsJetStreamDestinationE2ETests.java
new file mode 100644
index 00000000..6bc88eaa
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/NatsJetStreamDestinationE2ETests.java
@@ -0,0 +1,165 @@
+package io.github.fortunen.kete.endtoendtests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.admin.client.Keycloak;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import io.nats.client.Connection;
+import io.nats.client.JetStream;
+import io.nats.client.Nats;
+import io.nats.client.Options;
+import io.nats.client.api.StreamConfiguration;
+import io.nats.client.api.StorageType;
+
+@SuppressWarnings("resource")
+class NatsJetStreamDestinationE2ETests extends EndToEndTestBase {
+
+ private static final String STREAM_NAME = "KEYCLOAK_EVENTS";
+ private static final String NATS_SUBJECT = "keycloak.events";
+ private static final int NATS_PORT = 4222;
+ private static final int NATS_MONITORING_PORT = 8222;
+ private GenericContainer> natsServer;
+
+ @AfterEach
+ void tearDown() {
+ if (natsServer != null) {
+ natsServer.stop();
+ }
+ cleanupNetwork();
+ }
+
+ @Test
+ void shouldForwardLoginEventToNatsJetStreamSubject() throws Exception {
+
+ // arrange
+
+ natsServer = new GenericContainer<>(DockerImageName.parse("nats:2.10-alpine"))
+ .withNetwork(createNetwork())
+ .withNetworkAliases("nats")
+ .withExposedPorts(NATS_PORT, NATS_MONITORING_PORT)
+ .withCommand("--http_port", "8222", "--jetstream")
+ .withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
+ natsServer.start();
+
+ waitForNatsReady(natsServer);
+
+ // Create JetStream stream
+ var natsUrl = "nats://" + natsServer.getHost() + ":" + natsServer.getMappedPort(NATS_PORT);
+ var options = new Options.Builder()
+ .server(natsUrl)
+ .connectionTimeout(Duration.ofSeconds(10))
+ .build();
+
+ Connection connection = Nats.connect(options);
+ JetStream js = connection.jetStream();
+
+ // Create stream
+ var streamConfig = StreamConfiguration.builder()
+ .name(STREAM_NAME)
+ .subjects(NATS_SUBJECT)
+ .storageType(StorageType.Memory)
+ .build();
+ connection.jetStreamManagement().addStream(streamConfig);
+
+ var envVars = new HashMap();
+ envVars.put("kete.enabled", "true");
+ envVars.put("kete.routes.nats-jetstream-test.realm-matchers.filter", "list:" + TEST_REALM);
+ envVars.put("kete.routes.nats-jetstream-test.destination.kind", "nats-jetstream");
+ envVars.put("kete.routes.nats-jetstream-test.destination.servers", "nats://nats:" + NATS_PORT);
+ envVars.put("kete.routes.nats-jetstream-test.destination.subject", NATS_SUBJECT);
+ envVars.put("kete.routes.nats-jetstream-test.destination.stream", STREAM_NAME);
+ envVars.put("kete.routes.nats-jetstream-test.destination.authentication-method", "none");
+ envVars.put("kete.routes.nats-jetstream-test.serializer.kind", "json");
+
+ var receivedMessages = new ArrayBlockingQueue(10);
+
+ // Set up JetStream subscriber
+ var consumer = js.subscribe(NATS_SUBJECT);
+
+ var subscriberThread = new Thread(() -> {
+ try {
+ while (!Thread.currentThread().isInterrupted()) {
+ var msg = consumer.nextMessage(1000);
+ if (msg != null) {
+ var body = new String(msg.getData(), StandardCharsets.UTF_8);
+ receivedMessages.offer(body);
+ msg.ack();
+ }
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+ subscriberThread.setDaemon(true);
+ subscriberThread.start();
+
+ try (var keycloak = createKeycloakContainer(envVars)) {
+ keycloak.start();
+
+ try (var adminClient = Keycloak.getInstance(keycloak.getAuthServerUrl(), "master", keycloak.getAdminUsername(), keycloak.getAdminPassword(), "admin-cli")) {
+ createTestRealm(adminClient);
+
+ // act
+
+ triggerLoginEvent(keycloak);
+
+ // assert - wait for message using Awaitility
+
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> !receivedMessages.isEmpty());
+
+ var body = receivedMessages.poll(1, TimeUnit.SECONDS);
+
+ // JSON serializer assertions
+ assertThat(body).isNotEmpty();
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("\"type\""),
+ b -> assertThat(b).contains("\"operationType\"")
+ );
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("\"realmId\""),
+ b -> assertThat(b).contains("\"authDetails\"")
+ );
+ assertThat(body).contains(TEST_REALM);
+
+ // cleanup
+ cleanupTestRealm(adminClient);
+ }
+ } finally {
+ subscriberThread.interrupt();
+ try {
+ connection.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+ }
+
+ private void waitForNatsReady(GenericContainer> container) {
+ await().atMost(Duration.ofMinutes(2))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> {
+ try {
+ var url = "http://" + container.getHost() + ":" + container.getMappedPort(NATS_MONITORING_PORT) + "/varz";
+ var conn = new java.net.URL(url).openConnection();
+ conn.setConnectTimeout(1000);
+ conn.setReadTimeout(1000);
+ conn.connect();
+ var responseCode = ((java.net.HttpURLConnection) conn).getResponseCode();
+ return responseCode == 200;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/PulsarDestinationE2ETests.java b/src/test/java/io/github/fortunen/kete/endtoendtests/PulsarDestinationE2ETests.java
new file mode 100644
index 00000000..f3e18652
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/PulsarDestinationE2ETests.java
@@ -0,0 +1,132 @@
+package io.github.fortunen.kete.endtoendtests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.admin.client.Keycloak;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+
+@SuppressWarnings("resource")
+class PulsarDestinationE2ETests extends EndToEndTestBase {
+
+ private static final String PULSAR_TOPIC = "persistent://public/default/keycloak-events";
+ private static final int PULSAR_PORT = 6650;
+ private static final int PULSAR_HTTP_PORT = 8080;
+ private GenericContainer> pulsarServer;
+
+ @AfterEach
+ void tearDown() {
+ if (pulsarServer != null) {
+ pulsarServer.stop();
+ }
+ cleanupNetwork();
+ }
+
+ @Test
+ void shouldForwardLoginEventToPulsarTopic() throws Exception {
+
+ // arrange
+
+ pulsarServer = new GenericContainer<>(DockerImageName.parse("apachepulsar/pulsar:3.3.2"))
+ .withNetwork(createNetwork())
+ .withNetworkAliases("pulsar")
+ .withExposedPorts(PULSAR_PORT, PULSAR_HTTP_PORT)
+ .withCommand("/bin/bash", "-c", "bin/apply-config-from-env.py conf/standalone.conf && bin/pulsar standalone")
+ .waitingFor(Wait.forLogMessage(".*messaging service is ready.*", 1))
+ .withStartupTimeout(CONTAINER_STARTUP_TIMEOUT);
+ pulsarServer.start();
+
+ var envVars = new HashMap();
+ envVars.put("kete.enabled", "true");
+ envVars.put("kete.routes.pulsar-test.realm-matchers.filter", "list:" + TEST_REALM);
+ envVars.put("kete.routes.pulsar-test.destination.kind", "pulsar");
+ envVars.put("kete.routes.pulsar-test.destination.service-url", "pulsar://pulsar:" + PULSAR_PORT);
+ envVars.put("kete.routes.pulsar-test.destination.topic", PULSAR_TOPIC);
+ envVars.put("kete.routes.pulsar-test.serializer.kind", "json");
+
+ var receivedMessages = new ArrayBlockingQueue(10);
+
+ // Set up Pulsar subscriber
+ var pulsarUrl = "pulsar://" + pulsarServer.getHost() + ":" + pulsarServer.getMappedPort(PULSAR_PORT);
+ PulsarClient client = PulsarClient.builder()
+ .serviceUrl(pulsarUrl)
+ .connectionTimeout(10, TimeUnit.SECONDS)
+ .operationTimeout(30, TimeUnit.SECONDS)
+ .build();
+
+ Consumer consumer = client.newConsumer()
+ .topic(PULSAR_TOPIC)
+ .subscriptionName("test-subscription")
+ .subscribe();
+
+ var subscriberThread = new Thread(() -> {
+ try {
+ while (!Thread.currentThread().isInterrupted()) {
+ var msg = consumer.receive(1, TimeUnit.SECONDS);
+ if (msg != null) {
+ var body = new String(msg.getData(), StandardCharsets.UTF_8);
+ receivedMessages.offer(body);
+ consumer.acknowledge(msg);
+ }
+ }
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+ subscriberThread.setDaemon(true);
+ subscriberThread.start();
+
+ try (var keycloak = createKeycloakContainer(envVars)) {
+ keycloak.start();
+
+ try (var adminClient = Keycloak.getInstance(keycloak.getAuthServerUrl(), "master", keycloak.getAdminUsername(), keycloak.getAdminPassword(), "admin-cli")) {
+ createTestRealm(adminClient);
+
+ // act
+
+ triggerLoginEvent(keycloak);
+
+ // assert - wait for message using Awaitility
+
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> !receivedMessages.isEmpty());
+
+ var body = receivedMessages.poll(1, TimeUnit.SECONDS);
+
+ // JSON serializer assertions
+ assertThat(body).isNotEmpty();
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("\"type\""),
+ b -> assertThat(b).contains("\"operationType\"")
+ );
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("\"realmId\""),
+ b -> assertThat(b).contains("\"authDetails\"")
+ );
+ assertThat(body).contains(TEST_REALM);
+
+ // cleanup
+ cleanupTestRealm(adminClient);
+ }
+ } finally {
+ subscriberThread.interrupt();
+ try {
+ consumer.close();
+ client.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/RedisPubSubDestinationE2ETests.java b/src/test/java/io/github/fortunen/kete/endtoendtests/RedisPubSubDestinationE2ETests.java
new file mode 100644
index 00000000..3fb365b1
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/RedisPubSubDestinationE2ETests.java
@@ -0,0 +1,149 @@
+package io.github.fortunen.kete.endtoendtests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.admin.client.Keycloak;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.pubsub.RedisPubSubAdapter;
+
+@SuppressWarnings("resource")
+class RedisPubSubDestinationE2ETests extends EndToEndTestBase {
+
+ private static final String REDIS_CHANNEL_TEMPLATE = "keycloak-events-${realmLowerCase}";
+ private static final String REDIS_CHANNEL_RESOLVED = "keycloak-events-" + TEST_REALM.toLowerCase();
+ private GenericContainer> redis;
+
+ @AfterEach
+ void tearDown() {
+ if (redis != null) {
+ redis.stop();
+ }
+ cleanupNetwork();
+ }
+
+ @Test
+ void shouldForwardLoginEventToRedisPubSub() throws Exception {
+
+ // arrange
+
+ redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
+ .withNetwork(createNetwork())
+ .withNetworkAliases("redis")
+ .withExposedPorts(6379);
+ redis.start();
+ waitForRedisReady();
+
+ var envVars = new HashMap();
+ envVars.put("kete.enabled", "true");
+ envVars.put("kete.routes.redis-test.realm-matchers.filter", "list:" + TEST_REALM);
+ envVars.put("kete.routes.redis-test.destination.kind", "redis-pubsub");
+ envVars.put("kete.routes.redis-test.destination.host", "redis");
+ envVars.put("kete.routes.redis-test.destination.port", "6379");
+ envVars.put("kete.routes.redis-test.destination.channel", REDIS_CHANNEL_TEMPLATE);
+ envVars.put("kete.routes.redis-test.serializer.kind", "yaml");
+
+ try (var keycloak = createKeycloakContainer(envVars)) {
+ keycloak.start();
+
+ try (var adminClient = Keycloak.getInstance(keycloak.getAuthServerUrl(), "master", keycloak.getAdminUsername(), keycloak.getAdminPassword(), "admin-cli")) {
+ createTestRealm(adminClient);
+
+ // subscribe before triggering event
+ var collector = new MessageCollector();
+ var subscriber = createSubscriber(REDIS_CHANNEL_RESOLVED, collector);
+
+ Thread.sleep(500);
+
+ // act
+
+ triggerLoginEvent(keycloak);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(30)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSizeGreaterThan(0);
+ var message = collector.getMessages().get(0);
+ // YAML serializer assertions
+ assertThat(message).satisfiesAnyOf(
+ b -> assertThat(b).contains("type:"),
+ b -> assertThat(b).contains("operationType:")
+ );
+ assertThat(message).satisfiesAnyOf(
+ b -> assertThat(b).contains("realmName:"),
+ b -> assertThat(b).contains("realmId:")
+ );
+ assertThat(message).contains(TEST_REALM);
+
+ subscriber.close();
+
+ // cleanup
+ cleanupTestRealm(adminClient);
+ }
+ }
+ }
+
+ private void waitForRedisReady() {
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var uri = RedisURI.builder()
+ .withHost(redis.getHost())
+ .withPort(redis.getMappedPort(6379))
+ .build();
+ var client = RedisClient.create(uri);
+ var connection = client.connect();
+ var pong = connection.sync().ping();
+ connection.close();
+ client.close();
+ return "PONG".equalsIgnoreCase(pong);
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ private static class MessageCollector extends RedisPubSubAdapter {
+ private final CopyOnWriteArrayList messages = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void message(String channel, String message) {
+ messages.add(message);
+ }
+
+ public java.util.List getMessages() {
+ return messages;
+ }
+ }
+
+ private AutoCloseable createSubscriber(String channel, MessageCollector collector) {
+ var uri = RedisURI.builder()
+ .withHost(redis.getHost())
+ .withPort(redis.getMappedPort(6379))
+ .build();
+
+ var client = RedisClient.create(uri);
+ var connection = client.connectPubSub();
+
+ connection.addListener(collector);
+ connection.sync().subscribe(channel);
+
+ return () -> {
+ try {
+ connection.close();
+ client.close();
+ } catch (Exception ignored) {
+ }
+ };
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/endtoendtests/RedisStreamsDestinationE2ETests.java b/src/test/java/io/github/fortunen/kete/endtoendtests/RedisStreamsDestinationE2ETests.java
new file mode 100644
index 00000000..8aad946c
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/endtoendtests/RedisStreamsDestinationE2ETests.java
@@ -0,0 +1,129 @@
+package io.github.fortunen.kete.endtoendtests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.time.Duration;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.keycloak.admin.client.Keycloak;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import io.lettuce.core.Range;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+
+@SuppressWarnings("resource")
+class RedisStreamsDestinationE2ETests extends EndToEndTestBase {
+
+ private static final String REDIS_STREAM_TEMPLATE = "keycloak-events-${realmLowerCase}";
+ private static final String REDIS_STREAM_RESOLVED = "keycloak-events-" + TEST_REALM.toLowerCase();
+ private GenericContainer> redis;
+
+ @AfterEach
+ void tearDown() {
+ if (redis != null) {
+ redis.stop();
+ }
+ cleanupNetwork();
+ }
+
+ @Test
+ void shouldForwardLoginEventToRedisStream() throws Exception {
+
+ // arrange
+
+ redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
+ .withNetwork(createNetwork())
+ .withNetworkAliases("redis")
+ .withExposedPorts(6379);
+ redis.start();
+ waitForRedisReady();
+
+ var envVars = new HashMap();
+ envVars.put("kete.enabled", "true");
+ envVars.put("kete.routes.redis-test.realm-matchers.filter", "list:" + TEST_REALM);
+ envVars.put("kete.routes.redis-test.destination.kind", "redis-streams");
+ envVars.put("kete.routes.redis-test.destination.host", "redis");
+ envVars.put("kete.routes.redis-test.destination.port", "6379");
+ envVars.put("kete.routes.redis-test.destination.stream", REDIS_STREAM_TEMPLATE);
+ envVars.put("kete.routes.redis-test.serializer.kind", "yaml");
+
+ try (var keycloak = createKeycloakContainer(envVars)) {
+ keycloak.start();
+
+ try (var adminClient = Keycloak.getInstance(keycloak.getAuthServerUrl(), "master", keycloak.getAdminUsername(), keycloak.getAdminPassword(), "admin-cli")) {
+ createTestRealm(adminClient);
+
+ // act
+
+ triggerLoginEvent(keycloak);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(30)).until(() -> !readFromStream(REDIS_STREAM_RESOLVED).isEmpty());
+
+ var messages = readFromStream(REDIS_STREAM_RESOLVED);
+ assertThat(messages).hasSizeGreaterThan(0);
+
+ var message = messages.get(0);
+ var body = message.getBody().get("body");
+
+ // YAML serializer assertions
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("type:"),
+ b -> assertThat(b).contains("operationType:")
+ );
+ assertThat(body).satisfiesAnyOf(
+ b -> assertThat(b).contains("realmName:"),
+ b -> assertThat(b).contains("realmId:")
+ );
+ assertThat(body).contains(TEST_REALM);
+
+ // Check headers are present
+ assertThat(message.getBody()).containsKey("eventtype");
+ assertThat(message.getBody()).containsKey("contenttype");
+
+ // cleanup
+ cleanupTestRealm(adminClient);
+ }
+ }
+ }
+
+ private void waitForRedisReady() {
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var uri = RedisURI.builder()
+ .withHost(redis.getHost())
+ .withPort(redis.getMappedPort(6379))
+ .build();
+ var client = RedisClient.create(uri);
+ var connection = client.connect();
+ var pong = connection.sync().ping();
+ connection.close();
+ client.close();
+ return "PONG".equalsIgnoreCase(pong);
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ private java.util.List> readFromStream(String stream) {
+ var uri = RedisURI.builder()
+ .withHost(redis.getHost())
+ .withPort(redis.getMappedPort(6379))
+ .build();
+
+ var client = RedisClient.create(uri);
+
+ try (var connection = client.connect()) {
+ return connection.sync().xrange(stream, Range.unbounded());
+ } finally {
+ client.close();
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/amqp091destination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/amqp091destination/TestBase.java
index 1876d266..faf17c72 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/amqp091destination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/amqp091destination/TestBase.java
@@ -3,14 +3,16 @@
import static org.awaitility.Awaitility.await;
import java.io.File;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Duration;
import org.apache.commons.configuration2.MapConfiguration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.testcontainers.containers.RabbitMQContainer;
-import org.testcontainers.utility.MountableFile;
+import org.testcontainers.images.builder.Transferable;
import com.rabbitmq.client.ConnectionFactory;
@@ -37,6 +39,7 @@ void setUp() {
}
protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "amqp-0.9.1");
config.setConfiguration(mapConfig);
config.initialize();
destination.setConfig(config);
@@ -112,16 +115,11 @@ private RabbitMQContainer startRabbitMqWithTls(TlsMaterial tls, boolean requireC
configBuilder.append("\n"); // RabbitMQ config file needs empty line at end
var configContent = configBuilder.toString();
- // Create temp config file
- var configFile = File.createTempFile("rabbitmq", ".conf");
- configFile.deleteOnExit();
- Files.writeString(configFile.toPath(), configContent);
-
var rabbitContainer = new RabbitMQContainer("rabbitmq:3.13-management")
- .withCopyFileToContainer(MountableFile.forHostPath(tls.getServerPrivateKeyPkcs1PemFilePath(), 0644), "/etc/rabbitmq/rabbitmq_key.pem")
- .withCopyFileToContainer(MountableFile.forHostPath(tls.getServerCertificatePemFilePath(), 0644), "/etc/rabbitmq/rabbitmq_cert.pem")
- .withCopyFileToContainer(MountableFile.forHostPath(tls.getCaCertificatePemFilePath(), 0644), "/etc/rabbitmq/ca_cert.pem")
- .withRabbitMQConfig(MountableFile.forHostPath(configFile.getAbsolutePath()))
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerPrivateKeyPkcs1PemFilePath())), 0777), "/etc/rabbitmq/rabbitmq_key.pem")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath())), 0777), "/etc/rabbitmq/rabbitmq_cert.pem")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath())), 0777), "/etc/rabbitmq/ca_cert.pem")
+ .withCopyToContainer(Transferable.of(configContent.getBytes(StandardCharsets.UTF_8), 0777), "/etc/rabbitmq/rabbitmq.conf")
.withExposedPorts(AMQP_TLS_PORT)
.withStartupTimeout(Duration.ofMinutes(10))
.withLogConsumer(outputFrame -> System.out.println("[RABBITMQ] " + outputFrame.getUtf8String()));
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/amqp091destination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/amqp091destination/closeTests.java
deleted file mode 100644
index 95b3c323..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/amqp091destination/closeTests.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package io.github.fortunen.kete.integrationtests.amqp091destination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import java.util.HashMap;
-
-import org.apache.commons.configuration2.MapConfiguration;
-import org.junit.jupiter.api.Test;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutException() throws Exception {
-
- // arrange
-
- startRabbitMq();
- var map = new HashMap();
- map.put("host", container.getHost());
- map.put("port", String.valueOf(container.getAmqpPort()));
- map.put("exchange", "test-exchange");
- map.put("routing-key", "test-key");
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/amqp1destination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/amqp1destination/TestBase.java
index e7b3ff41..2d1de62e 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/amqp1destination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/amqp1destination/TestBase.java
@@ -40,6 +40,7 @@ void setUpTest() {
}
protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "amqp-1");
config.setConfiguration(mapConfig);
config.initialize();
destination.setConfig(config);
@@ -120,25 +121,33 @@ private void startActiveMqArtemisWithTls(TlsMaterial tls, boolean requireClientA
requireClientAuth
);
- // Write broker.xml to temp file for etc-override
- var brokerXmlPath = Files.createTempFile("broker", ".xml");
+ // Create temp directory and copy files for mounting
+ var tempDir = Files.createTempDirectory("artemis-tls-");
+ tempDir.toFile().deleteOnExit();
+
+ // Write broker.xml to temp directory
+ var brokerXmlPath = tempDir.resolve("broker.xml");
Files.writeString(brokerXmlPath, brokerXml);
brokerXmlPath.toFile().deleteOnExit();
- // Read keystore and truststore file bytes
+ // Copy keystore and truststore to temp directory
// Use serverKeyStoreFilePath for the container (server-side TLS) - it contains only the server key
- var keyStoreBytes = Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath()));
- var trustStoreBytes = Files.readAllBytes(Path.of(tls.getTrustStoreFilePath()));
+ var keyStorePath = tempDir.resolve("keystore.jks");
+ Files.copy(Path.of(tls.getServerKeyStoreFilePath()), keyStorePath);
+ keyStorePath.toFile().deleteOnExit();
+
+ var trustStorePath = tempDir.resolve("truststore.jks");
+ Files.copy(Path.of(tls.getTrustStoreFilePath()), trustStorePath);
+ trustStorePath.toFile().deleteOnExit();
container = new GenericContainer<>(DockerImageName.parse("apache/activemq-artemis:2.40.0-alpine"))
.withEnv("ARTEMIS_USER", DEFAULT_USERNAME)
.withEnv("ARTEMIS_PASSWORD", DEFAULT_PASSWORD)
.withEnv("ANONYMOUS_LOGIN", "true")
- // Copy broker.xml to etc-override directory for SSL configuration
- .withCopyToContainer(Transferable.of(brokerXml), "/var/lib/artemis-instance/etc-override/broker.xml")
- // Copy keystore and truststore files to etc-override - they will be copied to etc after instance creation
- .withCopyToContainer(Transferable.of(keyStoreBytes), "/var/lib/artemis-instance/etc-override/keystore.jks")
- .withCopyToContainer(Transferable.of(trustStoreBytes), "/var/lib/artemis-instance/etc-override/truststore.jks")
+ // Copy files to container (in-memory)
+ .withCopyToContainer(Transferable.of(brokerXml, 0777), "/var/lib/artemis-instance/etc-override/broker.xml")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath())), 0777), "/var/lib/artemis-instance/etc-override/keystore.jks")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getTrustStoreFilePath())), 0777), "/var/lib/artemis-instance/etc-override/truststore.jks")
.withExposedPorts(AMQP_PORT, AMQPS_PORT, 8161)
.withLogConsumer(outputFrame -> System.out.println("[ARTEMIS] " + outputFrame.getUtf8String()));
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/amqp1destination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/amqp1destination/closeTests.java
deleted file mode 100644
index 3d96c0eb..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/amqp1destination/closeTests.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package io.github.fortunen.kete.integrationtests.amqp1destination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import java.util.HashMap;
-
-import org.apache.commons.configuration2.MapConfiguration;
-import org.junit.jupiter.api.Test;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutException() throws Exception {
-
- // arrange
-
- startActiveMqArtemis();
- var map = new HashMap();
- map.put("host", getHost());
- map.put("port", String.valueOf(getMappedPort()));
- map.put("destination-name", "test-queue");
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/TestBase.java
index 8e03fbdf..e074df03 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/TestBase.java
@@ -82,6 +82,7 @@ protected void cleanUpMockServer() {
protected void configureDestinationWithMockServer() {
var map = new HashMap();
+ map.put("kind", "http");
map.put("host", mockServer.getHostName());
map.put("port", mockServer.getPort());
var mapConfig = new MapConfiguration(map);
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/closeTests.java
deleted file mode 100644
index b93df98d..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/closeTests.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package io.github.fortunen.kete.integrationtests.httpdestination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import org.junit.jupiter.api.Test;
-
-import okhttp3.mockwebserver.MockResponse;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutException() throws Exception {
-
- // arrange
-
- startMockServer();
- mockServer.enqueue(new MockResponse().setResponseCode(200));
- configureDestinationWithMockServer();
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/sendTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/sendTests.java
index 9e28f0fe..8c1863a6 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/sendTests.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/httpdestination/sendTests.java
@@ -63,6 +63,7 @@ public void shouldSend_Tls() throws Exception {
mockServer.enqueue(new MockResponse().setResponseCode(200)); // for send() request
var map = new HashMap();
+ map.put("kind", "http");
map.put("host", mockServer.getHostName());
map.put("port", mockServer.getPort());
map.put("tls.enabled", true);
@@ -114,6 +115,7 @@ public void shouldSend_mTls() throws Exception {
mockServer.enqueue(new MockResponse().setResponseCode(200)); // for send() request
var map = new HashMap();
+ map.put("kind", "http");
map.put("host", mockServer.getHostName());
map.put("port", mockServer.getPort());
map.put("tls.enabled", true);
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/kafkadestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/kafkadestination/TestBase.java
index b86ac311..32fa7738 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/kafkadestination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/kafkadestination/TestBase.java
@@ -19,6 +19,7 @@
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.output.OutputFrame;
import org.testcontainers.images.builder.Transferable;
@@ -55,6 +56,7 @@ void setUp() {
}
protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "kafka");
config.setConfiguration(mapConfig);
config.initialize();
destination.setConfig(config);
@@ -333,13 +335,13 @@ private static class SslKafkaContainer extends KafkaContainer {
// Add SSL listener configuration
this.withExposedPorts(KAFKA_PLAINTEXT_PORT, KAFKA_SSL_PORT);
- // Mount the keystore, truststore, and password files
+ // Copy keystore, truststore, and password files to container (in-memory)
// Use serverKeyStoreFilePath for the container (server-side TLS) - it contains only the server key
- this.withCopyFileToContainer(MountableFile.forHostPath(tls.getServerKeyStoreFilePath(), 0644), "/etc/kafka/secrets/keystore.jks");
- this.withCopyFileToContainer(MountableFile.forHostPath(tls.getTrustStoreFilePath(), 0644), "/etc/kafka/secrets/truststore.jks");
- this.withCopyFileToContainer(MountableFile.forHostPath(keyPasswordFile.toAbsolutePath().toString(), 0644), "/etc/kafka/secrets/key-password");
- this.withCopyFileToContainer(MountableFile.forHostPath(keystorePasswordFile.toAbsolutePath().toString(), 0644), "/etc/kafka/secrets/keystore-password");
- this.withCopyFileToContainer(MountableFile.forHostPath(truststorePasswordFile.toAbsolutePath().toString(), 0644), "/etc/kafka/secrets/truststore-password");
+ this.withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath())), 0777), "/etc/kafka/secrets/keystore.jks");
+ this.withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getTrustStoreFilePath())), 0777), "/etc/kafka/secrets/truststore.jks");
+ this.withCopyToContainer(Transferable.of(Files.readAllBytes(keyPasswordFile), 0777), "/etc/kafka/secrets/key-password");
+ this.withCopyToContainer(Transferable.of(Files.readAllBytes(keystorePasswordFile), 0777), "/etc/kafka/secrets/keystore-password");
+ this.withCopyToContainer(Transferable.of(Files.readAllBytes(truststorePasswordFile), 0777), "/etc/kafka/secrets/truststore-password");
// SSL configuration using FILENAME and CREDENTIALS pattern expected by apache/kafka image
this.withEnv("KAFKA_SSL_KEYSTORE_FILENAME", "keystore.jks");
@@ -389,7 +391,7 @@ protected void containerIsStarting(InspectContainerResponse containerInfo) {
// Override KAFKA_LISTENER_SECURITY_PROTOCOL_MAP to include SSL
var kafkaListenerSecurityProtocolMap = "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT,SSL:SSL,CONTROLLER:PLAINTEXT";
- // Build the startup script
+ // Build the startup script and write to temp file
var command = "#!/bin/bash\n";
command += String.format("export KAFKA_LISTENERS='%s'\n", kafkaListeners);
command += String.format("export KAFKA_LISTENER_SECURITY_PROTOCOL_MAP='%s'\n", kafkaListenerSecurityProtocolMap);
@@ -434,7 +436,7 @@ private static class SaslKafkaContainer extends KafkaContainer {
Files.writeString(jaasConfigFile, jaasConfig);
this.withExposedPorts(KAFKA_PLAINTEXT_PORT, KAFKA_SASL_PORT);
- this.withCopyFileToContainer(MountableFile.forHostPath(jaasConfigFile.toAbsolutePath().toString(), 0644), "/etc/kafka/kafka_server_jaas.conf");
+ this.withCopyToContainer(Transferable.of(Files.readAllBytes(jaasConfigFile), 0777), "/etc/kafka/kafka_server_jaas.conf");
// SASL configuration
this.withEnv("KAFKA_OPTS", "-Djava.security.auth.login.config=/etc/kafka/kafka_server_jaas.conf");
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/kafkadestination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/kafkadestination/closeTests.java
deleted file mode 100644
index 61d55e1d..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/kafkadestination/closeTests.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package io.github.fortunen.kete.integrationtests.kafkadestination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import java.util.HashMap;
-
-import org.apache.commons.configuration2.MapConfiguration;
-import org.apache.kafka.clients.producer.ProducerConfig;
-import org.junit.jupiter.api.Test;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutException() throws Exception {
-
- // arrange
-
- startKafka();
- var map = new HashMap();
- map.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, getBootstrapServers());
- map.put("topic", "test-topic");
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt3destination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/mqtt3destination/TestBase.java
index 2c98731d..98eafcdb 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt3destination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/mqtt3destination/TestBase.java
@@ -2,7 +2,9 @@
import static org.awaitility.Awaitility.await;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
@@ -14,8 +16,8 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.testcontainers.hivemq.HiveMQContainer;
+import org.testcontainers.images.builder.Transferable;
import org.testcontainers.utility.DockerImageName;
-import org.testcontainers.utility.MountableFile;
import io.github.fortunen.kete.Constants;
import io.github.fortunen.kete.EventMessage;
@@ -41,6 +43,7 @@ void setUp() {
}
protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "mqtt-3");
config.setConfiguration(mapConfig);
config.initialize();
destination.setConfig(config);
@@ -52,11 +55,9 @@ protected HiveMQContainer startMqtt() throws Exception {
// Create HiveMQ config with WebSocket listener
var configXml = createHiveMqConfigWithWebSocket();
- var configFile = Files.createTempFile("hivemq-config", ".xml");
- Files.writeString(configFile, configXml);
container = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce:2024.3"))
- .withHiveMQConfig(MountableFile.forHostPath(configFile))
+ .withCopyToContainer(Transferable.of(configXml.getBytes(StandardCharsets.UTF_8), 0777), "/opt/hivemq/conf/config.xml")
.withExposedPorts(1883, MQTT_WS_PORT);
container.start();
@@ -114,15 +115,14 @@ private HiveMQContainer startMqttWithTls(TlsMaterial tls, boolean requireClientC
clientAuthMode
);
- // Write config to temp file
- var configFile = Files.createTempFile("hivemq-tls-config", ".xml");
- Files.writeString(configFile, configXml);
+ // Read certificate files into memory
+ var keystoreBytes = Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath()));
+ var truststoreBytes = Files.readAllBytes(Path.of(tls.getTrustStoreFilePath()));
container = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce:2024.3"))
- .withHiveMQConfig(MountableFile.forHostPath(configFile))
- // Use serverKeyStoreFilePath for the container (server-side TLS) - it contains only the server key
- .withFileInHomeFolder(MountableFile.forHostPath(tls.getServerKeyStoreFilePath()), "conf/keystore.jks")
- .withFileInHomeFolder(MountableFile.forHostPath(tls.getTrustStoreFilePath()), "conf/truststore.jks")
+ .withCopyToContainer(Transferable.of(configXml.getBytes(StandardCharsets.UTF_8), 0777), "/opt/hivemq/conf/config.xml")
+ .withCopyToContainer(Transferable.of(keystoreBytes, 0777), "/opt/hivemq/conf/keystore.jks")
+ .withCopyToContainer(Transferable.of(truststoreBytes, 0777), "/opt/hivemq/conf/truststore.jks")
.withExposedPorts(1883, MQTT_TLS_PORT);
container.start();
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt3destination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/mqtt3destination/closeTests.java
deleted file mode 100644
index 7278b0c8..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt3destination/closeTests.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package io.github.fortunen.kete.integrationtests.mqtt3destination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import java.util.HashMap;
-
-import org.apache.commons.configuration2.MapConfiguration;
-import org.junit.jupiter.api.Test;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutException() throws Exception {
-
- // arrange
-
- startMqtt();
- var map = new HashMap();
- map.put("host", container.getHost());
- map.put("port", String.valueOf(container.getMqttPort()));
- map.put("topic", "test/topic");
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt5destination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/mqtt5destination/TestBase.java
index 9a2453e4..2d941f08 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt5destination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/mqtt5destination/TestBase.java
@@ -2,7 +2,9 @@
import static org.awaitility.Awaitility.await;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
@@ -16,8 +18,8 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.testcontainers.hivemq.HiveMQContainer;
+import org.testcontainers.images.builder.Transferable;
import org.testcontainers.utility.DockerImageName;
-import org.testcontainers.utility.MountableFile;
import io.github.fortunen.kete.Constants;
import io.github.fortunen.kete.EventMessage;
@@ -43,6 +45,7 @@ void setUp() {
}
protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "mqtt-5");
config.setConfiguration(mapConfig);
config.initialize();
destination.setConfig(config);
@@ -54,11 +57,9 @@ protected HiveMQContainer startMqtt() throws Exception {
// Create HiveMQ config with WebSocket listener
var configXml = createHiveMqConfigWithWebSocket();
- var configFile = Files.createTempFile("hivemq-config", ".xml");
- Files.writeString(configFile, configXml);
container = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce:2024.3"))
- .withHiveMQConfig(MountableFile.forHostPath(configFile))
+ .withCopyToContainer(Transferable.of(configXml.getBytes(StandardCharsets.UTF_8), 0777), "/opt/hivemq/conf/config.xml")
.withExposedPorts(1883, MQTT_WS_PORT);
container.start();
@@ -116,15 +117,14 @@ private HiveMQContainer startMqttWithTls(TlsMaterial tls, boolean requireClientC
clientAuthMode
);
- // Write config to temp file
- var configFile = Files.createTempFile("hivemq-tls-config", ".xml");
- Files.writeString(configFile, configXml);
+ // Read certificate files into memory
+ var keystoreBytes = Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath()));
+ var truststoreBytes = Files.readAllBytes(Path.of(tls.getTrustStoreFilePath()));
container = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce:2024.3"))
- .withHiveMQConfig(MountableFile.forHostPath(configFile))
- // Use serverKeyStoreFilePath for the container (server-side TLS) - it contains only the server key
- .withFileInHomeFolder(MountableFile.forHostPath(tls.getServerKeyStoreFilePath()), "conf/keystore.jks")
- .withFileInHomeFolder(MountableFile.forHostPath(tls.getTrustStoreFilePath()), "conf/truststore.jks")
+ .withCopyToContainer(Transferable.of(configXml.getBytes(StandardCharsets.UTF_8), 0777), "/opt/hivemq/conf/config.xml")
+ .withCopyToContainer(Transferable.of(keystoreBytes, 0777), "/opt/hivemq/conf/keystore.jks")
+ .withCopyToContainer(Transferable.of(truststoreBytes, 0777), "/opt/hivemq/conf/truststore.jks")
.withExposedPorts(1883, MQTT_TLS_PORT);
container.start();
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt5destination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/mqtt5destination/closeTests.java
deleted file mode 100644
index 023cfed9..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/mqtt5destination/closeTests.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package io.github.fortunen.kete.integrationtests.mqtt5destination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import java.util.HashMap;
-
-import org.apache.commons.configuration2.MapConfiguration;
-import org.junit.jupiter.api.Test;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutException() throws Exception {
-
- // arrange
-
- startMqtt();
- var map = new HashMap();
- map.put("host", container.getHost());
- map.put("port", String.valueOf(container.getMqttPort()));
- map.put("topic", "test/topic");
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/natsdestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/natsdestination/TestBase.java
new file mode 100644
index 00000000..4b3630b2
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/natsdestination/TestBase.java
@@ -0,0 +1,253 @@
+package io.github.fortunen.kete.integrationtests.natsdestination;
+
+import static org.awaitility.Awaitility.await;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.images.builder.Transferable;
+import org.testcontainers.utility.DockerImageName;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.TlsMaterial;
+import io.github.fortunen.kete.destinations.nats.NatsDestination;
+import io.github.fortunen.kete.destinations.nats.NatsDestinationConfig;
+import io.nats.client.Connection;
+import io.nats.client.Dispatcher;
+import io.nats.client.Nats;
+import io.nats.client.Options;
+
+@SuppressWarnings("resource")
+public class TestBase {
+
+ protected static final byte[] EMPTY_BYTES = new byte[0];
+ protected static final int NATS_PORT = 4222;
+ protected static final int NATS_MONITORING_PORT = 8222;
+
+ protected GenericContainer> container;
+ protected NatsDestination destination;
+ protected NatsDestinationConfig config;
+ protected TlsMaterial currentTls;
+
+ @BeforeEach
+ void setUp() {
+ destination = new NatsDestination();
+ config = new NatsDestinationConfig();
+ }
+
+ protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "nats");
+ config.setConfiguration(mapConfig);
+ config.initialize();
+ destination.setConfig(config);
+ }
+
+ protected GenericContainer> startNats() throws Exception {
+
+ cleanUpContainer();
+
+ container = new GenericContainer<>(DockerImageName.parse("nats:2.10-alpine"))
+ .withExposedPorts(NATS_PORT, NATS_MONITORING_PORT)
+ .withCommand("--http_port", "8222");
+ container.start();
+
+ waitForNatsReady();
+
+ return container;
+ }
+
+ protected GenericContainer> startWithServerOnlyTLS(TlsMaterial tls) throws Exception {
+ return startNatsWithTls(tls, false);
+ }
+
+ protected GenericContainer> startWithClientAndServerTLS(TlsMaterial tls) throws Exception {
+ return startNatsWithTls(tls, true);
+ }
+
+ @SuppressWarnings("resource")
+ private GenericContainer> startNatsWithTls(TlsMaterial tls, boolean requireClientCert) throws Exception {
+
+ if (tls == null) {
+ throw new IllegalArgumentException("TLS material cannot be null");
+ }
+
+ if (!tls.isEnabled()) {
+ throw new IllegalArgumentException("TLS must be enabled");
+ }
+
+ if (tls.getServerCertificatePemFilePath() == null) {
+ throw new IllegalStateException("Server certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getServerPrivateKeyPemFilePath() == null) {
+ throw new IllegalStateException("Server private key PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getCaCertificatePemFilePath() == null) {
+ throw new IllegalStateException("CA certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ cleanUpContainer();
+ currentTls = tls;
+
+ var tlsVerifyOption = requireClientCert ? "true" : "false";
+
+ container = new GenericContainer<>(DockerImageName.parse("nats:2.10-alpine"))
+ .withExposedPorts(NATS_PORT, NATS_MONITORING_PORT)
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath())), 0777), "/certs/server.crt")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath())), 0777), "/certs/server.key")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath())), 0777), "/certs/ca.crt")
+ .withCommand(
+ "--http_port", "8222",
+ "--tls",
+ "--tlscert=/certs/server.crt",
+ "--tlskey=/certs/server.key",
+ "--tlsverify=" + tlsVerifyOption,
+ "--tlscacert=/certs/ca.crt"
+ )
+ .withStartupTimeout(java.time.Duration.ofMinutes(10));
+
+ container.start();
+
+ waitForNatsReady();
+
+ return container;
+ }
+
+ protected String getHost() {
+ return container.getHost();
+ }
+
+ protected int getPort() {
+ return container.getMappedPort(NATS_PORT);
+ }
+
+ protected String getNatsUrl() {
+ return "nats://" + getHost() + ":" + getPort();
+ }
+
+ protected String getNatsTlsUrl() {
+ return "tls://" + getHost() + ":" + getPort();
+ }
+
+ private void waitForNatsReady() throws Exception {
+ await().atMost(Duration.ofMinutes(2)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var url = "http://" + container.getHost() + ":" + container.getMappedPort(NATS_MONITORING_PORT) + "/varz";
+ var connection = new java.net.URL(url).openConnection();
+ connection.setConnectTimeout(1000);
+ connection.setReadTimeout(1000);
+ connection.connect();
+ var responseCode = ((java.net.HttpURLConnection) connection).getResponseCode();
+ return responseCode == 200;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ protected void cleanUpContainer() {
+
+ if (container != null) {
+ try {
+ container.stop();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ container = null;
+ }
+
+ @AfterEach
+ protected void cleanUp() {
+
+ if (destination != null) {
+ try {
+ destination.close();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ destination = null;
+
+ cleanUpContainer();
+ }
+
+ protected static EventMessage createMessage(
+ String eventId,
+ String realm,
+ boolean isAdminEvent,
+ String eventType,
+ String contentType,
+ byte[] eventBody,
+ String resourceType,
+ String operationType
+ ) {
+ return new EventMessage(
+ realm != null ? realm : "",
+ eventId != null ? eventId : "",
+ eventBody != null ? eventBody : EMPTY_BYTES,
+ eventType != null ? eventType : "",
+ contentType != null ? contentType : "",
+ resourceType != null ? resourceType : "",
+ isAdminEvent ? Constants.ADMIN_EVENT : Constants.EVENT,
+ operationType != null ? operationType : "",
+ "SUCCESS"
+ );
+ }
+
+ protected static class MessageCollector {
+
+ private final CopyOnWriteArrayList messages = new CopyOnWriteArrayList<>();
+
+ public void onMessage(io.nats.client.Message msg) {
+ messages.add(new String(msg.getData()));
+ }
+
+ public java.util.List getMessages() {
+ return messages;
+ }
+ }
+
+ protected AutoCloseable createSubscriber(String subject, MessageCollector collector) throws Exception {
+ return createSubscriber(subject, collector, null);
+ }
+
+ protected AutoCloseable createSubscriberWithTls(String subject, MessageCollector collector, TlsMaterial tls) throws Exception {
+ return createSubscriber(subject, collector, tls);
+ }
+
+ private AutoCloseable createSubscriber(String subject, MessageCollector collector, TlsMaterial tls) throws Exception {
+
+ var optionsBuilder = new Options.Builder()
+ .server(tls != null && tls.isEnabled() ? getNatsTlsUrl() : getNatsUrl());
+
+ if (tls != null && tls.isEnabled()) {
+ optionsBuilder.sslContext(tls.getServerKeyStoreSSLContext());
+ }
+
+ var options = optionsBuilder.build();
+ var connection = Nats.connect(options);
+
+ var dispatcher = connection.createDispatcher(collector::onMessage);
+ dispatcher.subscribe(subject);
+
+ return () -> {
+ try {
+ dispatcher.unsubscribe(subject);
+ connection.close();
+ } catch (Exception ignored) {
+ }
+ };
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/natsdestination/sendTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/natsdestination/sendTests.java
new file mode 100644
index 00000000..c0df539e
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/natsdestination/sendTests.java
@@ -0,0 +1,175 @@
+package io.github.fortunen.kete.integrationtests.natsdestination;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.TlsMaterial;
+
+public class sendTests extends TestBase {
+
+ @Test
+ public void shouldSend_NonTls() throws Exception {
+
+ // arrange
+
+ startNats();
+ var map = new HashMap();
+ map.put("servers", getNatsUrl());
+ map.put("subject", "test-subject");
+ map.put("authentication-method", "none");
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriber("test-subject", collector)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+
+ @Test
+ public void shouldSend_Tls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithServerOnlyTLS(tls);
+ var map = new HashMap();
+ map.put("servers", getNatsTlsUrl());
+ map.put("subject", "test-subject");
+ map.put("authentication-method", "none");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriberWithTls("test-subject", collector, tls)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+
+ @Test
+ public void shouldSend_Mtls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithClientAndServerTLS(tls);
+ var map = new HashMap();
+ map.put("servers", getNatsTlsUrl());
+ map.put("subject", "test-subject");
+ map.put("authentication-method", "none");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ map.put("tls.key-store.loader.kind", "jks-file-path");
+ map.put("tls.key-store.loader.path", tls.getKeyStoreFilePath());
+ map.put("tls.key-store.password", tls.getKeyStorePassword());
+ map.put("tls.key-password", tls.getKeyPassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriberWithTls("test-subject", collector, tls)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/natsjetstreamdestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/natsjetstreamdestination/TestBase.java
new file mode 100644
index 00000000..13a75838
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/natsjetstreamdestination/TestBase.java
@@ -0,0 +1,305 @@
+package io.github.fortunen.kete.integrationtests.natsjetstreamdestination;
+
+import static org.awaitility.Awaitility.await;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.images.builder.Transferable;
+import org.testcontainers.utility.DockerImageName;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.TlsMaterial;
+import io.github.fortunen.kete.destinations.natsjetstream.NatsJetStreamDestination;
+import io.github.fortunen.kete.destinations.natsjetstream.NatsJetStreamDestinationConfig;
+import io.nats.client.Connection;
+import io.nats.client.JetStreamManagement;
+import io.nats.client.Nats;
+import io.nats.client.Options;
+import io.nats.client.api.StorageType;
+import io.nats.client.api.StreamConfiguration;
+
+@SuppressWarnings("resource")
+public class TestBase {
+
+ protected static final byte[] EMPTY_BYTES = new byte[0];
+ protected static final int NATS_PORT = 4222;
+ protected static final int NATS_MONITORING_PORT = 8222;
+ protected static final String STREAM_NAME = "TEST_STREAM";
+
+ protected GenericContainer> container;
+ protected NatsJetStreamDestination destination;
+ protected NatsJetStreamDestinationConfig config;
+ protected TlsMaterial currentTls;
+
+ @BeforeEach
+ void setUp() {
+ destination = new NatsJetStreamDestination();
+ config = new NatsJetStreamDestinationConfig();
+ }
+
+ protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "nats-jetstream");
+ config.setConfiguration(mapConfig);
+ config.initialize();
+ destination.setConfig(config);
+ }
+
+ protected GenericContainer> startNatsJetStream() throws Exception {
+
+ cleanUpContainer();
+
+ container = new GenericContainer<>(DockerImageName.parse("nats:2.10-alpine"))
+ .withExposedPorts(NATS_PORT, NATS_MONITORING_PORT)
+ .withCommand("--jetstream", "--http_port", "8222");
+ container.start();
+
+ waitForNatsReady();
+ createStream(null);
+
+ return container;
+ }
+
+ protected GenericContainer> startWithServerOnlyTLS(TlsMaterial tls) throws Exception {
+ return startNatsJetStreamWithTls(tls, false);
+ }
+
+ protected GenericContainer> startWithClientAndServerTLS(TlsMaterial tls) throws Exception {
+ return startNatsJetStreamWithTls(tls, true);
+ }
+
+ @SuppressWarnings("resource")
+ private GenericContainer> startNatsJetStreamWithTls(TlsMaterial tls, boolean requireClientCert) throws Exception {
+
+ if (tls == null) {
+ throw new IllegalArgumentException("TLS material cannot be null");
+ }
+
+ if (!tls.isEnabled()) {
+ throw new IllegalArgumentException("TLS must be enabled");
+ }
+
+ if (tls.getServerCertificatePemFilePath() == null) {
+ throw new IllegalStateException("Server certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getServerPrivateKeyPemFilePath() == null) {
+ throw new IllegalStateException("Server private key PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getCaCertificatePemFilePath() == null) {
+ throw new IllegalStateException("CA certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ cleanUpContainer();
+ currentTls = tls;
+
+ var tlsVerifyOption = requireClientCert ? "true" : "false";
+
+ container = new GenericContainer<>(DockerImageName.parse("nats:2.10-alpine"))
+ .withExposedPorts(NATS_PORT, NATS_MONITORING_PORT)
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath())), 0777), "/certs/server.crt")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath())), 0777), "/certs/server.key")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath())), 0777), "/certs/ca.crt")
+ .withCommand(
+ "--jetstream",
+ "--http_port", "8222",
+ "--tls",
+ "--tlscert=/certs/server.crt",
+ "--tlskey=/certs/server.key",
+ "--tlsverify=" + tlsVerifyOption,
+ "--tlscacert=/certs/ca.crt"
+ )
+ .withStartupTimeout(java.time.Duration.ofMinutes(10));
+
+ container.start();
+
+ waitForNatsReady();
+ createStream(tls);
+
+ return container;
+ }
+
+ protected String getHost() {
+ return container.getHost();
+ }
+
+ protected int getPort() {
+ return container.getMappedPort(NATS_PORT);
+ }
+
+ protected String getNatsUrl() {
+ return "nats://" + getHost() + ":" + getPort();
+ }
+
+ protected String getNatsTlsUrl() {
+ return "tls://" + getHost() + ":" + getPort();
+ }
+
+ private void waitForNatsReady() throws Exception {
+ await().atMost(Duration.ofMinutes(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var url = "http://" + container.getHost() + ":" + container.getMappedPort(NATS_MONITORING_PORT) + "/varz";
+ var connection = new java.net.URL(url).openConnection();
+ connection.setConnectTimeout(1000);
+ connection.setReadTimeout(1000);
+ connection.connect();
+ var responseCode = ((java.net.HttpURLConnection) connection).getResponseCode();
+ return responseCode == 200;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ private void createStream(TlsMaterial tls) throws Exception {
+
+ var optionsBuilder = new Options.Builder()
+ .server(tls != null && tls.isEnabled() ? getNatsTlsUrl() : getNatsUrl());
+
+ if (tls != null && tls.isEnabled()) {
+ optionsBuilder.sslContext(tls.getServerKeyStoreSSLContext());
+ }
+
+ var options = optionsBuilder.build();
+
+ try (var connection = Nats.connect(options)) {
+
+ var jsm = connection.jetStreamManagement();
+
+ var streamConfig = StreamConfiguration.builder()
+ .name(STREAM_NAME)
+ .subjects("test.>")
+ .storageType(StorageType.Memory)
+ .build();
+
+ jsm.addStream(streamConfig);
+ }
+ }
+
+ protected void cleanUpContainer() {
+
+ if (container != null) {
+ try {
+ container.stop();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ container = null;
+ }
+
+ @AfterEach
+ protected void cleanUp() {
+
+ if (destination != null) {
+ try {
+ destination.close();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ destination = null;
+
+ cleanUpContainer();
+ }
+
+ protected static EventMessage createMessage(
+ String eventId,
+ String realm,
+ boolean isAdminEvent,
+ String eventType,
+ String contentType,
+ byte[] eventBody,
+ String resourceType,
+ String operationType
+ ) {
+ return new EventMessage(
+ realm != null ? realm : "",
+ eventId != null ? eventId : "",
+ eventBody != null ? eventBody : EMPTY_BYTES,
+ eventType != null ? eventType : "",
+ contentType != null ? contentType : "",
+ resourceType != null ? resourceType : "",
+ isAdminEvent ? Constants.ADMIN_EVENT : Constants.EVENT,
+ operationType != null ? operationType : "",
+ "SUCCESS"
+ );
+ }
+
+ protected static class MessageCollector {
+
+ private final CopyOnWriteArrayList messages = new CopyOnWriteArrayList<>();
+
+ public void onMessage(io.nats.client.Message msg) {
+ messages.add(new String(msg.getData()));
+ }
+
+ public java.util.List getMessages() {
+ return messages;
+ }
+ }
+
+ protected AutoCloseable createSubscriber(String subject, MessageCollector collector) throws Exception {
+ return createSubscriber(subject, collector, null);
+ }
+
+ protected AutoCloseable createSubscriberWithTls(String subject, MessageCollector collector, TlsMaterial tls) throws Exception {
+ return createSubscriber(subject, collector, tls);
+ }
+
+ private AutoCloseable createSubscriber(String subject, MessageCollector collector, TlsMaterial tls) throws Exception {
+
+ var optionsBuilder = new Options.Builder()
+ .server(tls != null && tls.isEnabled() ? getNatsTlsUrl() : getNatsUrl());
+
+ if (tls != null && tls.isEnabled()) {
+ optionsBuilder.sslContext(tls.getServerKeyStoreSSLContext());
+ }
+
+ var options = optionsBuilder.build();
+ var connection = Nats.connect(options);
+
+ var jetStream = connection.jetStream();
+
+ var pushSubscribeOptions = io.nats.client.PushSubscribeOptions.builder()
+ .stream(STREAM_NAME)
+ .build();
+
+ var subscription = jetStream.subscribe(subject, pushSubscribeOptions);
+
+ // Start consuming messages in background
+ var thread = new Thread(() -> {
+ try {
+ while (!Thread.currentThread().isInterrupted()) {
+ var msg = subscription.nextMessage(Duration.ofSeconds(1));
+ if (msg != null) {
+ collector.onMessage(msg);
+ msg.ack();
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ });
+ thread.start();
+
+ return () -> {
+ try {
+ thread.interrupt();
+ thread.join(1000);
+ subscription.unsubscribe();
+ connection.close();
+ } catch (Exception ignored) {
+ }
+ };
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/natsjetstreamdestination/sendTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/natsjetstreamdestination/sendTests.java
new file mode 100644
index 00000000..c6ba1d13
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/natsjetstreamdestination/sendTests.java
@@ -0,0 +1,178 @@
+package io.github.fortunen.kete.integrationtests.natsjetstreamdestination;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.TlsMaterial;
+
+public class sendTests extends TestBase {
+
+ @Test
+ public void shouldSend_NonTls() throws Exception {
+
+ // arrange
+
+ startNatsJetStream();
+ var map = new HashMap();
+ map.put("servers", getNatsUrl());
+ map.put("subject", "test.subject");
+ map.put("stream", STREAM_NAME);
+ map.put("authentication-method", "none");
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriber("test.subject", collector)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+
+ @Test
+ public void shouldSend_Tls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithServerOnlyTLS(tls);
+ var map = new HashMap();
+ map.put("servers", getNatsTlsUrl());
+ map.put("subject", "test.subject");
+ map.put("stream", STREAM_NAME);
+ map.put("authentication-method", "none");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriberWithTls("test.subject", collector, tls)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+
+ @Test
+ public void shouldSend_Mtls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithClientAndServerTLS(tls);
+ var map = new HashMap();
+ map.put("servers", getNatsTlsUrl());
+ map.put("subject", "test.subject");
+ map.put("stream", STREAM_NAME);
+ map.put("authentication-method", "none");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ map.put("tls.key-store.loader.kind", "jks-file-path");
+ map.put("tls.key-store.loader.path", tls.getKeyStoreFilePath());
+ map.put("tls.key-store.password", tls.getKeyStorePassword());
+ map.put("tls.key-password", tls.getKeyPassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriberWithTls("test.subject", collector, tls)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/pulsardestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/pulsardestination/TestBase.java
new file mode 100644
index 00000000..a18620a5
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/pulsardestination/TestBase.java
@@ -0,0 +1,282 @@
+package io.github.fortunen.kete.integrationtests.pulsardestination;
+
+import static org.awaitility.Awaitility.await;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.builder.Transferable;
+import org.testcontainers.utility.DockerImageName;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.TlsMaterial;
+import io.github.fortunen.kete.destinations.pulsar.PulsarDestination;
+import io.github.fortunen.kete.destinations.pulsar.PulsarDestinationConfig;
+
+@SuppressWarnings("resource")
+public class TestBase {
+
+ protected static final byte[] EMPTY_BYTES = new byte[0];
+ protected static final int PULSAR_PORT = 6650;
+ protected static final int PULSAR_TLS_PORT = 6651;
+ protected static final int PULSAR_HTTP_PORT = 8080;
+
+ protected GenericContainer> container;
+ protected PulsarDestination destination;
+ protected PulsarDestinationConfig config;
+ protected TlsMaterial currentTls;
+
+ @BeforeEach
+ void setUp() {
+ destination = new PulsarDestination();
+ config = new PulsarDestinationConfig();
+ }
+
+ protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "pulsar");
+ config.setConfiguration(mapConfig);
+ config.initialize();
+ destination.setConfig(config);
+ }
+
+ protected GenericContainer> startPulsar() throws Exception {
+
+ cleanUpContainer();
+
+ container = new GenericContainer<>(DockerImageName.parse("apachepulsar/pulsar:3.3.2"))
+ .withExposedPorts(PULSAR_PORT, PULSAR_HTTP_PORT)
+ .withCommand("bin/pulsar", "standalone", "--no-functions-worker");
+
+ container.start();
+ waitForBrokerHealthy();
+
+ return container;
+ }
+
+ private void waitForBrokerHealthy() {
+
+ // Wait for broker healthcheck first
+
+ await().atMost(Duration.ofMinutes(10)).pollInterval(Duration.ofSeconds(2)).until(() -> {
+ try {
+ var result = container.execInContainer("curl", "-sf", "http://localhost:8080/admin/v2/brokers/healthcheck");
+ return result.getExitCode() == 0;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+
+ // Then wait for the default namespace to be ready
+
+ await().atMost(Duration.ofMinutes(10)).pollInterval(Duration.ofSeconds(2)).until(() -> {
+ try {
+ var result = container.execInContainer("curl", "-sf", "http://localhost:8080/admin/v2/namespaces/public/default");
+ return result.getExitCode() == 0;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+
+ // Wait for BookKeeper to be ready by verifying topic creation works (this is the actual readiness check)
+
+ await().atMost(Duration.ofMinutes(10)).pollInterval(Duration.ofSeconds(2)).until(() -> {
+ try {
+ var result = container.execInContainer(
+ "bin/pulsar-admin", "topics", "create", "persistent://public/default/readiness-check"
+ );
+ if (result.getExitCode() == 0) {
+ // Clean up the test topic
+ container.execInContainer("bin/pulsar-admin", "topics", "delete", "persistent://public/default/readiness-check");
+ return true;
+ }
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ protected GenericContainer> startWithServerOnlyTLS(TlsMaterial tls) throws Exception {
+ return startPulsarWithTls(tls, false);
+ }
+
+ protected GenericContainer> startWithClientAndServerTLS(TlsMaterial tls) throws Exception {
+ return startPulsarWithTls(tls, true);
+ }
+
+ @SuppressWarnings("resource")
+ private GenericContainer> startPulsarWithTls(TlsMaterial tls, boolean requireClientCert) throws Exception {
+
+ if (tls == null) {
+ throw new IllegalArgumentException("TLS material cannot be null");
+ }
+
+ if (!tls.isEnabled()) {
+ throw new IllegalArgumentException("TLS must be enabled");
+ }
+
+ cleanUpContainer();
+ currentTls = tls;
+
+ // Use a startup script that appends TLS settings to the default standalone.conf
+ // This preserves all essential standalone defaults while adding TLS configuration
+ var startupScript = createStartupScript(requireClientCert);
+
+ container = new GenericContainer<>(DockerImageName.parse("apachepulsar/pulsar:3.3.2"))
+ .withExposedPorts(PULSAR_PORT, PULSAR_TLS_PORT, PULSAR_HTTP_PORT)
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath())), 0777), "/pulsar/server-cert.pem")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath())), 0777), "/pulsar/server-key.pem")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath())), 0777), "/pulsar/ca-cert.pem")
+ .withCommand("/bin/bash", "-c", startupScript);
+
+ container.start();
+ waitForBrokerHealthy();
+
+ return container;
+ }
+
+ private String createStartupScript(boolean requireClientCert) {
+ // Append TLS settings to the default standalone.conf, then start Pulsar
+ // This preserves all essential standalone defaults while adding TLS configuration
+ return """
+ cat >> /pulsar/conf/standalone.conf << 'EOF'
+ # TLS Configuration (PEM-based)
+ brokerServicePortTls=%d
+ webServicePortTls=8443
+ tlsEnabled=true
+ tlsCertificateFilePath=/pulsar/server-cert.pem
+ tlsKeyFilePath=/pulsar/server-key.pem
+ tlsTrustCertsFilePath=/pulsar/ca-cert.pem
+ tlsRequireTrustedClientCertOnConnect=%s
+ brokerClientTlsEnabled=false
+ functionsWorkerEnabled=false
+ EOF
+ bin/pulsar standalone --no-functions-worker
+ """.formatted(PULSAR_TLS_PORT, requireClientCert);
+ }
+
+ protected String getHost() {
+ return container.getHost();
+ }
+
+ protected int getPort() {
+ return container.getMappedPort(PULSAR_PORT);
+ }
+
+ protected String getPulsarUrl() {
+ return "pulsar://" + getHost() + ":" + getPort();
+ }
+
+ protected String getPulsarTlsUrl() {
+ return "pulsar+ssl://" + getHost() + ":" + container.getMappedPort(PULSAR_TLS_PORT);
+ }
+
+ protected Consumer createSubscriber(String topic) throws Exception {
+ return createSubscriber(topic, null);
+ }
+
+ protected Consumer createSubscriberWithTls(String topic, TlsMaterial tls) throws Exception {
+ return createSubscriber(topic, tls);
+ }
+
+ @SuppressWarnings("deprecation")
+ private Consumer createSubscriber(String topic, TlsMaterial tls) throws Exception {
+
+ var clientBuilder = PulsarClient.builder()
+ .connectionTimeout(10, TimeUnit.SECONDS)
+ .operationTimeout(30, TimeUnit.SECONDS);
+
+ if (tls != null && tls.isEnabled()) {
+ clientBuilder.serviceUrl(getPulsarTlsUrl())
+ .tlsTrustCertsFilePath(tls.getCaCertificatePemFilePath())
+ .enableTls(true)
+ .allowTlsInsecureConnection(false);
+
+ // Add client auth if keystore provided (for mTLS)
+ if (tls.getKeyStoreFilePath() != null) {
+ clientBuilder.authentication(
+ "org.apache.pulsar.client.impl.auth.AuthenticationTls",
+ "tlsCertFile:" + tls.getClientCertificatePemFilePath() + ",tlsKeyFile:" + tls.getClientPrivateKeyPemFilePath()
+ );
+ }
+ } else {
+ clientBuilder.serviceUrl(getPulsarUrl());
+ }
+
+ var client = clientBuilder.build();
+
+ return client.newConsumer()
+ .topic(topic)
+ .subscriptionName("test-subscription")
+ .subscribe();
+ }
+
+ protected List receiveMessages(Consumer consumer, int count, int timeoutSeconds) throws Exception {
+ var messages = new ArrayList();
+ var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
+
+ while (messages.size() < count && System.currentTimeMillis() < deadline) {
+ var msg = consumer.receive((int) (deadline - System.currentTimeMillis()), TimeUnit.MILLISECONDS);
+ if (msg != null) {
+ messages.add(msg.getData());
+ consumer.acknowledge(msg);
+ }
+ }
+
+ return messages;
+ }
+
+ protected EventMessage createMessage(String eventId, String realm, boolean isAdminEvent,
+ String eventType, String contentType, byte[] eventBody,
+ String resourceType, String operationType) {
+
+ return new EventMessage(
+ realm != null ? realm : "",
+ eventId != null ? eventId : "",
+ eventBody != null ? eventBody : EMPTY_BYTES,
+ eventType != null ? eventType : "",
+ contentType != null ? contentType : "",
+ resourceType != null ? resourceType : "",
+ isAdminEvent ? Constants.ADMIN_EVENT : Constants.EVENT,
+ operationType != null ? operationType : "",
+ null
+ );
+ }
+
+ protected void cleanUpContainer() {
+ if (container != null) {
+ try {
+ container.stop();
+ } catch (Exception e) {
+ // ignore
+ }
+ container = null;
+ }
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (destination != null) {
+ try {
+ destination.close();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+
+ cleanUpContainer();
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/pulsardestination/sendTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/pulsardestination/sendTests.java
new file mode 100644
index 00000000..bcaa6fa4
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/pulsardestination/sendTests.java
@@ -0,0 +1,192 @@
+package io.github.fortunen.kete.integrationtests.pulsardestination;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.TlsMaterial;
+
+public class sendTests extends TestBase {
+
+ @Test
+ public void shouldSend_NonTls() throws Exception {
+
+ // arrange
+
+ startPulsar();
+ var topic = "persistent://public/default/test-events";
+ var map = new HashMap();
+ map.put("service-url", getPulsarUrl());
+ map.put("topic", topic);
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ try (var subscriber = createSubscriber(topic)) {
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var messages = receiveMessages(subscriber, 1, 1);
+ if (!messages.isEmpty()) {
+ var received = new String(messages.get(0), StandardCharsets.UTF_8);
+ return received.contains("{\"type\":\"LOGIN\"}");
+ }
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Test
+ public void shouldSend_Tls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1" })
+ .build();
+
+ startWithServerOnlyTLS(tls);
+
+ var topic = "persistent://public/default/test-events";
+ var map = new HashMap();
+ map.put("service-url", getPulsarTlsUrl());
+ map.put("topic", topic);
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ try (var subscriber = createSubscriberWithTls(topic, tls)) {
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var messages = receiveMessages(subscriber, 1, 1);
+ if (!messages.isEmpty()) {
+ var received = new String(messages.get(0), StandardCharsets.UTF_8);
+ return received.contains("{\"type\":\"LOGIN\"}");
+ }
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+ }
+
+ @Test
+ public void shouldSend_mTls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1" })
+ .build();
+
+ startWithClientAndServerTLS(tls);
+
+ var topic = "persistent://public/default/test-events";
+ var map = new HashMap();
+ map.put("service-url", getPulsarTlsUrl());
+ map.put("topic", topic);
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ map.put("tls.key-store.loader.kind", "jks-file-path");
+ map.put("tls.key-store.loader.path", tls.getKeyStoreFilePath());
+ map.put("tls.key-store.password", tls.getKeyStorePassword());
+ map.put("tls.key-store.key-password", tls.getKeyPassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ try (var subscriber = createSubscriberWithTls(topic, tls)) {
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofMinutes(1)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var messages = receiveMessages(subscriber, 1, 1);
+ if (!messages.isEmpty()) {
+ var received = new String(messages.get(0), StandardCharsets.UTF_8);
+ return received.contains("{\"type\":\"LOGIN\"}");
+ }
+ return false;
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/redispubsubdestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/redispubsubdestination/TestBase.java
new file mode 100644
index 00000000..47dfd307
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/redispubsubdestination/TestBase.java
@@ -0,0 +1,302 @@
+package io.github.fortunen.kete.integrationtests.redispubsubdestination;
+
+import static org.awaitility.Awaitility.await;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.images.builder.Transferable;
+import org.testcontainers.utility.DockerImageName;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.TlsMaterial;
+import io.github.fortunen.kete.destinations.redispubsub.RedisPubSubDestination;
+import io.github.fortunen.kete.destinations.redispubsub.RedisPubSubDestinationConfig;
+import io.lettuce.core.ClientOptions;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.SslOptions;
+import io.lettuce.core.pubsub.RedisPubSubAdapter;
+
+@SuppressWarnings("resource")
+public class TestBase {
+
+ protected static final byte[] EMPTY_BYTES = new byte[0];
+ protected static final int REDIS_PORT = 6379;
+ protected static final int REDIS_TLS_PORT = 6380;
+
+ protected GenericContainer> container;
+ protected RedisPubSubDestination destination;
+ protected RedisPubSubDestinationConfig config;
+ protected TlsMaterial currentTls;
+
+ @BeforeEach
+ void setUp() {
+ destination = new RedisPubSubDestination();
+ config = new RedisPubSubDestinationConfig();
+ }
+
+ protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "redis-pubsub");
+ config.setConfiguration(mapConfig);
+ config.initialize();
+ destination.setConfig(config);
+ }
+
+ protected GenericContainer> startRedis() throws Exception {
+
+ cleanUpContainer();
+
+ container = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
+ .withExposedPorts(REDIS_PORT);
+ container.start();
+
+ waitForRedisReady(REDIS_PORT, null);
+
+ return container;
+ }
+
+ protected GenericContainer> startRedisWithPassword(String password) throws Exception {
+
+ cleanUpContainer();
+
+ container = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
+ .withExposedPorts(REDIS_PORT)
+ .withCommand("redis-server", "--requirepass", password);
+ container.start();
+
+ waitForRedisReady(REDIS_PORT, null);
+
+ return container;
+ }
+
+ protected GenericContainer> startWithServerOnlyTLS(TlsMaterial tls) throws Exception {
+ return startRedisWithTls(tls, false);
+ }
+
+ protected GenericContainer> startWithClientAndServerTLS(TlsMaterial tls) throws Exception {
+ return startRedisWithTls(tls, true);
+ }
+
+ @SuppressWarnings("resource")
+ private GenericContainer> startRedisWithTls(TlsMaterial tls, boolean requireClientCert) throws Exception {
+
+ if (tls == null) {
+ throw new IllegalArgumentException("TLS material cannot be null");
+ }
+
+ if (!tls.isEnabled()) {
+ throw new IllegalArgumentException("TLS must be enabled");
+ }
+
+ if (tls.getServerCertificatePemFilePath() == null) {
+ throw new IllegalStateException("Server certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getServerPrivateKeyPemFilePath() == null) {
+ throw new IllegalStateException("Server private key PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getCaCertificatePemFilePath() == null) {
+ throw new IllegalStateException("CA certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ cleanUpContainer();
+ currentTls = tls;
+
+ var tlsAuthClients = requireClientCert ? "yes" : "no";
+
+ container = new GenericContainer<>(DockerImageName.parse("bitnami/redis:latest"))
+ .withExposedPorts(REDIS_PORT)
+ .withEnv("ALLOW_EMPTY_PASSWORD", "yes")
+ .withEnv("REDIS_TLS_ENABLED", "yes")
+ .withEnv("REDIS_TLS_CERT_FILE", "/certs/server.crt")
+ .withEnv("REDIS_TLS_KEY_FILE", "/certs/server.key")
+ .withEnv("REDIS_TLS_CA_FILE", "/certs/ca.crt")
+ .withEnv("REDIS_TLS_AUTH_CLIENTS", tlsAuthClients)
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath())), 0777), "/certs/server.crt")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath())), 0777), "/certs/server.key")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath())), 0777), "/certs/ca.crt")
+ .withStartupTimeout(java.time.Duration.ofMinutes(10));
+
+ container.start();
+
+ waitForRedisReady(REDIS_PORT, tls);
+
+ return container;
+ }
+
+ protected String getHost() {
+ return container.getHost();
+ }
+
+ protected int getPort() {
+ return container.getMappedPort(REDIS_PORT);
+ }
+
+ protected int getTlsPort() {
+ // In bitnami/redis:latest (Redis 8.x), TLS and non-TLS share the same port
+ return container.getMappedPort(REDIS_PORT);
+ }
+
+ private void waitForRedisReady(int port, TlsMaterial tls) throws Exception {
+ await().atMost(Duration.ofMinutes(2)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var uriBuilder = RedisURI.builder()
+ .withHost(container.getHost())
+ .withPort(container.getMappedPort(port));
+
+ if (tls != null && tls.isEnabled()) {
+ uriBuilder.withSsl(true);
+ }
+
+ var uri = uriBuilder.build();
+ var client = RedisClient.create(uri);
+
+ if (tls != null && tls.isEnabled()) {
+ var sslOptionsBuilder = SslOptions.builder()
+ .jdkSslProvider()
+ .trustManager(tls.getTrustManagerFactory());
+
+ // Add key manager for mTLS (client certificate)
+ if (tls.getKeyManagerFactory() != null) {
+ sslOptionsBuilder.keyManager(tls.getKeyManagerFactory());
+ }
+
+ client.setOptions(ClientOptions.builder().sslOptions(sslOptionsBuilder.build()).build());
+ }
+
+ var connection = client.connect();
+ var pong = connection.sync().ping();
+ connection.close();
+ client.close();
+ return "PONG".equalsIgnoreCase(pong);
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ protected void cleanUpContainer() {
+
+ if (container != null) {
+ try {
+ container.stop();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ container = null;
+ }
+
+ @AfterEach
+ protected void cleanUp() {
+
+ if (destination != null) {
+ try {
+ destination.close();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ destination = null;
+
+ cleanUpContainer();
+ }
+
+ protected static EventMessage createMessage(
+ String eventId,
+ String realm,
+ boolean isAdminEvent,
+ String eventType,
+ String contentType,
+ byte[] eventBody,
+ String resourceType,
+ String operationType
+ ) {
+ return new EventMessage(
+ realm != null ? realm : "",
+ eventId != null ? eventId : "",
+ eventBody != null ? eventBody : EMPTY_BYTES,
+ eventType != null ? eventType : "",
+ contentType != null ? contentType : "",
+ resourceType != null ? resourceType : "",
+ isAdminEvent ? Constants.ADMIN_EVENT : Constants.EVENT,
+ operationType != null ? operationType : "",
+ "SUCCESS"
+ );
+ }
+
+ protected static class MessageCollector extends RedisPubSubAdapter {
+
+ private final CopyOnWriteArrayList messages = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void message(String channel, String message) {
+ messages.add(message);
+ }
+
+ public java.util.List getMessages() {
+ return messages;
+ }
+ }
+
+ protected AutoCloseable createSubscriber(String channel, MessageCollector collector) throws Exception {
+ return createSubscriber(channel, collector, null);
+ }
+
+ protected AutoCloseable createSubscriberWithTls(String channel, MessageCollector collector, TlsMaterial tls) throws Exception {
+ return createSubscriber(channel, collector, tls);
+ }
+
+ private AutoCloseable createSubscriber(String channel, MessageCollector collector, TlsMaterial tls) throws Exception {
+
+ // In bitnami/redis:latest (Redis 8.x), TLS and non-TLS share the same port
+ var port = REDIS_PORT;
+
+ var uriBuilder = RedisURI.builder()
+ .withHost(container.getHost())
+ .withPort(container.getMappedPort(port));
+
+ if (tls != null && tls.isEnabled()) {
+ uriBuilder.withSsl(true);
+ }
+
+ var uri = uriBuilder.build();
+ var client = RedisClient.create(uri);
+
+ if (tls != null && tls.isEnabled()) {
+ var sslOptionsBuilder = SslOptions.builder()
+ .jdkSslProvider()
+ .trustManager(tls.getTrustManagerFactory());
+
+ // Add key manager for mTLS (client certificate)
+ if (tls.getKeyManagerFactory() != null) {
+ sslOptionsBuilder.keyManager(tls.getKeyManagerFactory());
+ }
+
+ client.setOptions(ClientOptions.builder().sslOptions(sslOptionsBuilder.build()).build());
+ }
+
+ var connection = client.connectPubSub();
+ connection.addListener(collector);
+ connection.sync().subscribe(channel);
+
+ return () -> {
+ try {
+ connection.close();
+ client.close();
+ } catch (Exception ignored) {
+ }
+ };
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/redispubsubdestination/sendTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/redispubsubdestination/sendTests.java
new file mode 100644
index 00000000..2e45a6ce
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/redispubsubdestination/sendTests.java
@@ -0,0 +1,175 @@
+package io.github.fortunen.kete.integrationtests.redispubsubdestination;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.TlsMaterial;
+
+public class sendTests extends TestBase {
+
+ @Test
+ public void shouldSend_NonTls() throws Exception {
+
+ // arrange
+
+ startRedis();
+ var map = new HashMap();
+ map.put("host", getHost());
+ map.put("port", getPort());
+ map.put("channel", "test-channel");
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriber("test-channel", collector)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+
+ @Test
+ public void shouldSend_Tls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithServerOnlyTLS(tls);
+ var map = new HashMap();
+ map.put("host", getHost());
+ map.put("port", getTlsPort());
+ map.put("channel", "test-channel");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriberWithTls("test-channel", collector, tls)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+
+ @Test
+ public void shouldSend_Mtls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithClientAndServerTLS(tls);
+ var map = new HashMap();
+ map.put("host", getHost());
+ map.put("port", getTlsPort());
+ map.put("channel", "test-channel");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ map.put("tls.key-store.loader.kind", "jks-file-path");
+ map.put("tls.key-store.loader.path", tls.getKeyStoreFilePath());
+ map.put("tls.key-store.password", tls.getKeyStorePassword());
+ map.put("tls.key-password", tls.getKeyPassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var collector = new MessageCollector();
+ try (var subscriber = createSubscriberWithTls("test-channel", collector, tls)) {
+
+ Thread.sleep(500);
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !collector.getMessages().isEmpty());
+
+ assertThat(collector.getMessages()).hasSize(1);
+ assertThat(collector.getMessages().get(0)).isEqualTo("{\"type\":\"LOGIN\"}");
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/redisstreamsdestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/redisstreamsdestination/TestBase.java
new file mode 100644
index 00000000..727c582d
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/redisstreamsdestination/TestBase.java
@@ -0,0 +1,269 @@
+package io.github.fortunen.kete.integrationtests.redisstreamsdestination;
+
+import static org.awaitility.Awaitility.await;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.images.builder.Transferable;
+import org.testcontainers.utility.DockerImageName;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.EventMessage;
+import io.github.fortunen.kete.TlsMaterial;
+import io.github.fortunen.kete.destinations.redisstreams.RedisStreamsDestination;
+import io.github.fortunen.kete.destinations.redisstreams.RedisStreamsDestinationConfig;
+import io.lettuce.core.ClientOptions;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.SslOptions;
+import io.lettuce.core.StreamMessage;
+
+@SuppressWarnings("resource")
+public class TestBase {
+
+ protected static final byte[] EMPTY_BYTES = new byte[0];
+ protected static final int REDIS_PORT = 6379;
+ protected static final int REDIS_TLS_PORT = 6380;
+
+ protected GenericContainer> container;
+ protected RedisStreamsDestination destination;
+ protected RedisStreamsDestinationConfig config;
+ protected TlsMaterial currentTls;
+
+ @BeforeEach
+ void setUp() {
+ destination = new RedisStreamsDestination();
+ config = new RedisStreamsDestinationConfig();
+ }
+
+ protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(Constants.KIND, "redis-streams");
+ config.setConfiguration(mapConfig);
+ config.initialize();
+ destination.setConfig(config);
+ }
+
+ protected GenericContainer> startRedis() throws Exception {
+
+ cleanUpContainer();
+
+ container = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
+ .withExposedPorts(REDIS_PORT);
+ container.start();
+
+ waitForRedisReady(REDIS_PORT, null);
+
+ return container;
+ }
+
+ protected GenericContainer> startWithServerOnlyTLS(TlsMaterial tls) throws Exception {
+ return startRedisWithTls(tls, false);
+ }
+
+ protected GenericContainer> startWithClientAndServerTLS(TlsMaterial tls) throws Exception {
+ return startRedisWithTls(tls, true);
+ }
+
+ @SuppressWarnings("resource")
+ private GenericContainer> startRedisWithTls(TlsMaterial tls, boolean requireClientCert) throws Exception {
+
+ if (tls == null) {
+ throw new IllegalArgumentException("TLS material cannot be null");
+ }
+
+ if (!tls.isEnabled()) {
+ throw new IllegalArgumentException("TLS must be enabled");
+ }
+
+ if (tls.getServerCertificatePemFilePath() == null) {
+ throw new IllegalStateException("Server certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getServerPrivateKeyPemFilePath() == null) {
+ throw new IllegalStateException("Server private key PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ if (tls.getCaCertificatePemFilePath() == null) {
+ throw new IllegalStateException("CA certificate PEM file path is null - ensure TLS material is built with withWriteFiles(true)");
+ }
+
+ cleanUpContainer();
+ currentTls = tls;
+
+ var tlsAuthClients = requireClientCert ? "yes" : "no";
+
+ container = new GenericContainer<>(DockerImageName.parse("bitnami/redis:latest"))
+ .withExposedPorts(REDIS_PORT)
+ .withEnv("ALLOW_EMPTY_PASSWORD", "yes")
+ .withEnv("REDIS_TLS_ENABLED", "yes")
+ .withEnv("REDIS_TLS_CERT_FILE", "/certs/server.crt")
+ .withEnv("REDIS_TLS_KEY_FILE", "/certs/server.key")
+ .withEnv("REDIS_TLS_CA_FILE", "/certs/ca.crt")
+ .withEnv("REDIS_TLS_AUTH_CLIENTS", tlsAuthClients)
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath())), 0777), "/certs/server.crt")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath())), 0777), "/certs/server.key")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath())), 0777), "/certs/ca.crt")
+ .withStartupTimeout(java.time.Duration.ofMinutes(10));
+
+ container.start();
+
+ waitForRedisReady(REDIS_PORT, tls);
+
+ return container;
+ }
+
+ protected String getHost() {
+ return container.getHost();
+ }
+
+ protected int getPort() {
+ return container.getMappedPort(REDIS_PORT);
+ }
+
+ protected int getTlsPort() {
+ // In bitnami/redis:latest (Redis 8.x), TLS and non-TLS share the same port
+ return container.getMappedPort(REDIS_PORT);
+ }
+
+ private void waitForRedisReady(int port, TlsMaterial tls) throws Exception {
+ await().atMost(Duration.ofMinutes(2)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ try {
+ var uriBuilder = RedisURI.builder()
+ .withHost(container.getHost())
+ .withPort(container.getMappedPort(port));
+
+ if (tls != null && tls.isEnabled()) {
+ uriBuilder.withSsl(true);
+ }
+
+ var uri = uriBuilder.build();
+ var client = RedisClient.create(uri);
+
+ if (tls != null && tls.isEnabled()) {
+ var sslOptionsBuilder = SslOptions.builder()
+ .jdkSslProvider()
+ .trustManager(tls.getTrustManagerFactory());
+
+ // Add key manager for mTLS (client certificate)
+ if (tls.getKeyManagerFactory() != null) {
+ sslOptionsBuilder.keyManager(tls.getKeyManagerFactory());
+ }
+
+ client.setOptions(ClientOptions.builder().sslOptions(sslOptionsBuilder.build()).build());
+ }
+
+ var connection = client.connect();
+ var pong = connection.sync().ping();
+ connection.close();
+ client.close();
+ return "PONG".equalsIgnoreCase(pong);
+ } catch (Exception e) {
+ return false;
+ }
+ });
+ }
+
+ protected void cleanUpContainer() {
+
+ if (container != null) {
+ try {
+ container.stop();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ container = null;
+ }
+
+ @AfterEach
+ protected void cleanUp() {
+
+ if (destination != null) {
+ try {
+ destination.close();
+ } catch (Exception exception) {
+ // ignore
+ }
+ }
+
+ destination = null;
+
+ cleanUpContainer();
+ }
+
+ protected static EventMessage createMessage(
+ String eventId,
+ String realm,
+ boolean isAdminEvent,
+ String eventType,
+ String contentType,
+ byte[] eventBody,
+ String resourceType,
+ String operationType
+ ) {
+ return new EventMessage(
+ realm != null ? realm : "",
+ eventId != null ? eventId : "",
+ eventBody != null ? eventBody : EMPTY_BYTES,
+ eventType != null ? eventType : "",
+ contentType != null ? contentType : "",
+ resourceType != null ? resourceType : "",
+ isAdminEvent ? Constants.ADMIN_EVENT : Constants.EVENT,
+ operationType != null ? operationType : "",
+ "SUCCESS"
+ );
+ }
+
+ protected List> readFromStream(String stream) throws Exception {
+ return readFromStream(stream, null);
+ }
+
+ protected List> readFromStreamWithTls(String stream, TlsMaterial tls) throws Exception {
+ return readFromStream(stream, tls);
+ }
+
+ private List> readFromStream(String stream, TlsMaterial tls) throws Exception {
+
+ // In bitnami/redis:latest (Redis 8.x), TLS and non-TLS share the same port
+ var port = REDIS_PORT;
+
+ var uriBuilder = RedisURI.builder()
+ .withHost(container.getHost())
+ .withPort(container.getMappedPort(port));
+
+ if (tls != null && tls.isEnabled()) {
+ uriBuilder.withSsl(true);
+ }
+
+ var uri = uriBuilder.build();
+ var client = RedisClient.create(uri);
+
+ if (tls != null && tls.isEnabled()) {
+ var sslOptionsBuilder = SslOptions.builder()
+ .jdkSslProvider()
+ .trustManager(tls.getTrustManagerFactory());
+
+ // Add key manager for mTLS (client certificate)
+ if (tls.getKeyManagerFactory() != null) {
+ sslOptionsBuilder.keyManager(tls.getKeyManagerFactory());
+ }
+
+ client.setOptions(ClientOptions.builder().sslOptions(sslOptionsBuilder.build()).build());
+ }
+
+ try (var connection = client.connect()) {
+ var messages = connection.sync().xrange(stream, io.lettuce.core.Range.unbounded());
+ return messages;
+ } finally {
+ client.close();
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/redisstreamsdestination/sendTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/redisstreamsdestination/sendTests.java
new file mode 100644
index 00000000..5d3c18b1
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/redisstreamsdestination/sendTests.java
@@ -0,0 +1,166 @@
+package io.github.fortunen.kete.integrationtests.redisstreamsdestination;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.HashMap;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.TlsMaterial;
+
+public class sendTests extends TestBase {
+
+ @Test
+ public void shouldSend_NonTls() throws Exception {
+
+ // arrange
+
+ startRedis();
+ var map = new HashMap();
+ map.put("host", getHost());
+ map.put("port", getPort());
+ map.put("stream", "test-stream");
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !readFromStream("test-stream").isEmpty());
+
+ var messages = readFromStream("test-stream");
+ assertThat(messages).hasSize(1);
+ assertThat(messages.get(0).getBody().get("body")).isEqualTo("{\"type\":\"LOGIN\"}");
+ assertThat(messages.get(0).getBody().get("eventtype")).isEqualTo("LOGIN");
+ assertThat(messages.get(0).getBody().get("contenttype")).isEqualTo("application/json");
+ }
+
+ @Test
+ public void shouldSend_Tls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithServerOnlyTLS(tls);
+ var map = new HashMap();
+ map.put("host", getHost());
+ map.put("port", getTlsPort());
+ map.put("stream", "test-stream");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !readFromStreamWithTls("test-stream", tls).isEmpty());
+
+ var messages = readFromStreamWithTls("test-stream", tls);
+ assertThat(messages).hasSize(1);
+ assertThat(messages.get(0).getBody().get("body")).isEqualTo("{\"type\":\"LOGIN\"}");
+ assertThat(messages.get(0).getBody().get("eventtype")).isEqualTo("LOGIN");
+ assertThat(messages.get(0).getBody().get("contenttype")).isEqualTo("application/json");
+ }
+
+ @Test
+ public void shouldSend_Mtls() throws Exception {
+
+ // arrange
+
+ var tls = TlsMaterial.builder()
+ .withEnabled(true)
+ .withWriteFiles(true)
+ .withTrustStorePassword("changeit")
+ .withKeyStorePassword("changeit")
+ .withKeyPassword("changeit")
+ .withServerHostNames(new String[] { "localhost", "127.0.0.1", "host.docker.internal" })
+ .build();
+
+ startWithClientAndServerTLS(tls);
+ var map = new HashMap();
+ map.put("host", getHost());
+ map.put("port", getTlsPort());
+ map.put("stream", "test-stream");
+ map.put("tls.enabled", true);
+ map.put("tls.trust-store.loader.kind", "jks-file-path");
+ map.put("tls.trust-store.loader.path", tls.getTrustStoreFilePath());
+ map.put("tls.trust-store.password", tls.getTrustStorePassword());
+ map.put("tls.key-store.loader.kind", "jks-file-path");
+ map.put("tls.key-store.loader.path", tls.getKeyStoreFilePath());
+ map.put("tls.key-store.password", tls.getKeyStorePassword());
+ map.put("tls.key-password", tls.getKeyPassword());
+ var mapConfig = new MapConfiguration(map);
+ configureDestination(mapConfig);
+ destination.initialize();
+
+ var message = createMessage(
+ "test-event-id",
+ "test-realm",
+ false,
+ "LOGIN",
+ "application/json",
+ "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8),
+ null,
+ null
+ );
+
+ // act
+
+ destination.send(message);
+
+ // assert
+
+ await().atMost(Duration.ofSeconds(10)).until(() -> !readFromStreamWithTls("test-stream", tls).isEmpty());
+
+ var messages = readFromStreamWithTls("test-stream", tls);
+ assertThat(messages).hasSize(1);
+ assertThat(messages.get(0).getBody().get("body")).isEqualTo("{\"type\":\"LOGIN\"}");
+ assertThat(messages.get(0).getBody().get("eventtype")).isEqualTo("LOGIN");
+ assertThat(messages.get(0).getBody().get("contenttype")).isEqualTo("application/json");
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/stompdestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/stompdestination/TestBase.java
index 3a50b455..87ec36e0 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/stompdestination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/stompdestination/TestBase.java
@@ -60,6 +60,7 @@ void tearDown() throws Exception {
}
protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(io.github.fortunen.kete.Constants.KIND, "stomp");
config.setConfiguration(mapConfig);
config.initialize();
destination.setConfig(config);
@@ -101,18 +102,16 @@ private void startActiveMqWithTls(TlsMaterial tls, boolean requireClientAuth) th
throw new IllegalArgumentException("TLS must be enabled");
}
- // Read keystore and truststore file bytes
- var keyStoreBytes = Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath()));
- var trustStoreBytes = Files.readAllBytes(Path.of(tls.getTrustStoreFilePath()));
-
// Create ActiveMQ XML config with STOMP+SSL connector
var activeMqXml = createActiveMqXml(tls.getKeyStorePassword(), tls.getTrustStorePassword(), requireClientAuth);
+ var keyStoreBytes = Files.readAllBytes(Path.of(tls.getServerKeyStoreFilePath()));
+ var trustStoreBytes = Files.readAllBytes(Path.of(tls.getTrustStoreFilePath()));
container = new GenericContainer<>(DockerImageName.parse("apache/activemq-classic:6.1.6"))
.withExposedPorts(STOMP_PORT, STOMPS_PORT, 61616)
- .withCopyToContainer(Transferable.of(activeMqXml), "/opt/apache-activemq/conf/activemq.xml")
- .withCopyToContainer(Transferable.of(keyStoreBytes), "/opt/apache-activemq/conf/keystore.jks")
- .withCopyToContainer(Transferable.of(trustStoreBytes), "/opt/apache-activemq/conf/truststore.jks")
+ .withCopyToContainer(Transferable.of(activeMqXml, 0777), "/opt/apache-activemq/conf/activemq.xml")
+ .withCopyToContainer(Transferable.of(keyStoreBytes, 0777), "/opt/apache-activemq/conf/keystore.jks")
+ .withCopyToContainer(Transferable.of(trustStoreBytes, 0777), "/opt/apache-activemq/conf/truststore.jks")
.waitingFor(Wait.forLogMessage(".*Apache ActiveMQ.*started.*", 1))
.withStartupTimeout(Duration.ofMinutes(2));
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/stompdestination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/stompdestination/closeTests.java
deleted file mode 100644
index dde7ed2a..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/stompdestination/closeTests.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package io.github.fortunen.kete.integrationtests.stompdestination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import java.util.HashMap;
-
-import org.apache.commons.configuration2.MapConfiguration;
-import org.junit.jupiter.api.Test;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutError() throws Exception {
-
- // arrange
-
- startActiveMq();
- var stompDestination = "/queue/test-events";
- var map = new HashMap();
- map.put("host", container.getHost());
- map.put("port", String.valueOf(getStompPort()));
- map.put("destination", stompDestination);
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/TestBase.java b/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/TestBase.java
index 1a9880e2..259466d7 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/TestBase.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/TestBase.java
@@ -55,6 +55,7 @@ void tearDown() throws Exception {
}
protected void configureDestination(MapConfiguration mapConfig) {
+ mapConfig.setProperty(io.github.fortunen.kete.Constants.KIND, "websocket");
config.setConfiguration(mapConfig);
config.initialize();
destination.setConfig(config);
@@ -69,7 +70,7 @@ protected GenericContainer> startWebSocketEchoServer() throws Exception {
.withExposedPorts(WEBSOCKET_PORT)
.withEnv("PORT", String.valueOf(WEBSOCKET_PORT))
.waitingFor(Wait.forHttp("/").forPort(WEBSOCKET_PORT).forStatusCode(200))
- .withStartupTimeout(Duration.ofMinutes(1));
+ .withStartupTimeout(Duration.ofMinutes(10));
container.start();
@@ -109,29 +110,45 @@ private void startWebSocketWithTls(TlsMaterial tls, boolean requireClientAuth) t
.withExposedPorts(WEBSOCKET_PORT)
.withEnv("PORT", String.valueOf(WEBSOCKET_PORT))
.waitingFor(Wait.forListeningPort())
- .withStartupTimeout(Duration.ofMinutes(1));
+ .withStartupTimeout(Duration.ofMinutes(10));
container.start();
- // Read certificate and key files (PEM format required for nginx)
- var serverCertBytes = Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath()));
- var serverKeyBytes = Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath()));
- var caCertBytes = Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath()));
+ // Create temp directory and copy files for mounting
+ var tempDir = Files.createTempDirectory("nginx-tls-");
+ tempDir.toFile().deleteOnExit();
// Create nginx config for WebSocket TLS termination
var nginxConf = createNginxConfig(requireClientAuth);
+ // Write nginx.conf to temp directory
+ var nginxConfPath = tempDir.resolve("nginx.conf");
+ Files.writeString(nginxConfPath, nginxConf);
+ nginxConfPath.toFile().deleteOnExit();
+
+ // Copy certificate files (PEM format required for nginx) to temp directory
+ var serverCertPath = tempDir.resolve("server.crt");
+ Files.copy(Path.of(tls.getServerCertificatePemFilePath()), serverCertPath);
+ serverCertPath.toFile().deleteOnExit();
+
+ var serverKeyPath = tempDir.resolve("server.key");
+ Files.copy(Path.of(tls.getServerPrivateKeyPemFilePath()), serverKeyPath);
+ serverKeyPath.toFile().deleteOnExit();
+
+ var caCertPath = tempDir.resolve("ca.crt");
+ Files.copy(Path.of(tls.getCaCertificatePemFilePath()), caCertPath);
+ caCertPath.toFile().deleteOnExit();
+
// Start nginx as TLS termination proxy
- // Put SSL files in /etc/nginx/ (not a subdirectory) to avoid directory creation issues
nginxProxy = new GenericContainer<>(DockerImageName.parse("nginx:1.27-alpine"))
.withNetwork(network)
.withExposedPorts(WEBSOCKET_TLS_PORT)
- .withCopyToContainer(Transferable.of(nginxConf), "/etc/nginx/nginx.conf")
- .withCopyToContainer(Transferable.of(serverCertBytes), "/etc/nginx/server.crt")
- .withCopyToContainer(Transferable.of(serverKeyBytes), "/etc/nginx/server.key")
- .withCopyToContainer(Transferable.of(caCertBytes), "/etc/nginx/ca.crt")
+ .withCopyToContainer(Transferable.of(nginxConf, 0777), "/etc/nginx/nginx.conf")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerCertificatePemFilePath())), 0777), "/etc/nginx/server.crt")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getServerPrivateKeyPemFilePath())), 0777), "/etc/nginx/server.key")
+ .withCopyToContainer(Transferable.of(Files.readAllBytes(Path.of(tls.getCaCertificatePemFilePath())), 0777), "/etc/nginx/ca.crt")
.waitingFor(Wait.forListeningPort())
- .withStartupTimeout(Duration.ofMinutes(1));
+ .withStartupTimeout(Duration.ofMinutes(10));
nginxProxy.start();
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/closeTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/closeTests.java
deleted file mode 100644
index 0afa9b9c..00000000
--- a/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/closeTests.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package io.github.fortunen.kete.integrationtests.websocketdestination;
-
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-import java.util.HashMap;
-
-import org.apache.commons.configuration2.MapConfiguration;
-import org.junit.jupiter.api.Test;
-
-public class closeTests extends TestBase {
-
- @Test
- public void shouldCloseWithoutError() throws Exception {
-
- // arrange
-
- startWebSocketEchoServer();
- var map = new HashMap();
- map.put("host", container.getHost());
- map.put("port", String.valueOf(getWebSocketPort()));
- map.put("path", "/.ws");
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- // act & assert
-
- assertThatCode(() -> destination.close()).doesNotThrowAnyException();
- }
-}
diff --git a/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/sendTests.java b/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/sendTests.java
index 9c23e441..0c89b682 100644
--- a/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/sendTests.java
+++ b/src/test/java/io/github/fortunen/kete/integrationtests/websocketdestination/sendTests.java
@@ -13,7 +13,7 @@
public class sendTests extends TestBase {
@Test
- public void shouldSend_TextNonTls() throws Exception {
+ public void shouldSend_NonTls() throws Exception {
// arrange
@@ -38,33 +38,6 @@ public void shouldSend_TextNonTls() throws Exception {
assertThatCode(() -> destination.send(message)).doesNotThrowAnyException();
}
- @Test
- public void shouldSend_BinaryNonTls() throws Exception {
-
- // arrange
-
- startWebSocketEchoServer();
- var map = new HashMap();
- map.put("host", container.getHost());
- map.put("port", String.valueOf(getWebSocketPort()));
- map.put("path", "/.ws");
- map.put("binary-mode", "true");
- var mapConfig = new MapConfiguration(map);
- configureDestination(mapConfig);
- destination.initialize();
-
- var message = createMessage(
- "test-event-id",
- "LOGIN",
- "application/json",
- "{\"type\":\"LOGIN\"}".getBytes(StandardCharsets.UTF_8)
- );
-
- // act & assert - echo server accepts binary messages
-
- assertThatCode(() -> destination.send(message)).doesNotThrowAnyException();
- }
-
@Test
public void shouldSend_Tls() throws Exception {
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinationconfig/initializeHeadersTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfig/initializeHeadersTests.java
new file mode 100644
index 00000000..f31ba255
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfig/initializeHeadersTests.java
@@ -0,0 +1,391 @@
+package io.github.fortunen.kete.unittests.destinationconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+import org.keycloak.models.KeycloakSession;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.DestinationConfig;
+
+public class initializeHeadersTests {
+
+ // =========================================================================
+ // Custom Headers Parsing
+ // =========================================================================
+
+ @Test
+ public void shouldParseCustomHeaders() {
+
+ // arrange
+
+ var config = createConfigWithHeaders(Map.of(
+ "headers.X-Custom-Header", "custom-value",
+ "headers.Authorization", "Bearer token123"
+ ));
+
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .containsEntry("X-Custom-Header", "custom-value")
+ .containsEntry("Authorization", "Bearer token123")
+ .hasSize(2);
+ }
+
+ @Test
+ public void shouldTrimHeaderKeys() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers. X-Padded-Key ", "value");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .containsKey("X-Padded-Key")
+ .doesNotContainKey(" X-Padded-Key ");
+ }
+
+ @Test
+ public void shouldTrimHeaderValues() {
+
+ // arrange
+
+ var config = createConfigWithHeaders(Map.of(
+ "headers.Trimmed", " value with spaces "
+ ));
+
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .containsEntry("Trimmed", "value with spaces");
+ }
+
+ @Test
+ public void shouldIgnoreEmptyHeaders() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers.Empty-Header", "");
+ configMap.put("headers.Valid-Header", "value");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .doesNotContainKey("Empty-Header")
+ .containsEntry("Valid-Header", "value");
+ }
+
+ @Test
+ public void shouldIgnoreBlankHeaders() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers.Blank-Header", " ");
+ configMap.put("headers.Valid-Header", "value");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .doesNotContainKey("Blank-Header")
+ .containsEntry("Valid-Header", "value");
+ }
+
+ @Test
+ public void shouldHaveEmptyHeadersByDefault() {
+
+ // arrange
+
+ var config = new MapConfiguration(Map.of("kind", "test"));
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders()).isEmpty();
+ }
+
+ // =========================================================================
+ // Custom Headers Entry Set
+ // =========================================================================
+
+ @Test
+ public void shouldPopulateCustomHeadersEntrySet() {
+
+ // arrange
+
+ var config = createConfigWithHeaders(Map.of(
+ "headers.X-Header-1", "value1",
+ "headers.X-Header-2", "value2"
+ ));
+
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeadersEntrySet())
+ .isNotNull()
+ .hasSize(2);
+
+ var keys = destinationConfig.getCustomHeadersEntrySet().stream()
+ .map(Map.Entry::getKey)
+ .toList();
+
+ assertThat(keys).containsExactlyInAnyOrder("X-Header-1", "X-Header-2");
+ }
+
+ @Test
+ public void shouldHaveEmptyCustomHeadersEntrySetWhenNoHeaders() {
+
+ // arrange
+
+ var config = new MapConfiguration(Map.of("kind", "test"));
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeadersEntrySet())
+ .isNotNull()
+ .isEmpty();
+ }
+
+ // =========================================================================
+ // Reserved Header Key Filtering
+ // =========================================================================
+
+ @Test
+ public void shouldFilterOutEventKindReservedHeader() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers." + Constants.MESSAGE_HEADER_EVENT_KIND, "should-be-ignored");
+ configMap.put("headers.X-Custom", "allowed");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .doesNotContainKey(Constants.MESSAGE_HEADER_EVENT_KIND)
+ .containsEntry("X-Custom", "allowed")
+ .hasSize(1);
+ }
+
+ @Test
+ public void shouldFilterOutEventTypeReservedHeader() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers." + Constants.MESSAGE_HEADER_EVENT_TYPE, "should-be-ignored");
+ configMap.put("headers.X-Custom", "allowed");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .doesNotContainKey(Constants.MESSAGE_HEADER_EVENT_TYPE)
+ .containsEntry("X-Custom", "allowed")
+ .hasSize(1);
+ }
+
+ @Test
+ public void shouldFilterOutContentTypeReservedHeader() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers." + Constants.MESSAGE_HEADER_CONTENT_TYPE, "should-be-ignored");
+ configMap.put("headers.X-Custom", "allowed");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .doesNotContainKey(Constants.MESSAGE_HEADER_CONTENT_TYPE)
+ .containsEntry("X-Custom", "allowed")
+ .hasSize(1);
+ }
+
+ @Test
+ public void shouldFilterOutAllReservedHeaders() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers." + Constants.MESSAGE_HEADER_EVENT_KIND, "ignored1");
+ configMap.put("headers." + Constants.MESSAGE_HEADER_EVENT_TYPE, "ignored2");
+ configMap.put("headers." + Constants.MESSAGE_HEADER_CONTENT_TYPE, "ignored3");
+ configMap.put("headers.X-Custom-1", "allowed1");
+ configMap.put("headers.X-Custom-2", "allowed2");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .doesNotContainKey(Constants.MESSAGE_HEADER_EVENT_KIND)
+ .doesNotContainKey(Constants.MESSAGE_HEADER_EVENT_TYPE)
+ .doesNotContainKey(Constants.MESSAGE_HEADER_CONTENT_TYPE)
+ .containsEntry("X-Custom-1", "allowed1")
+ .containsEntry("X-Custom-2", "allowed2")
+ .hasSize(2);
+ }
+
+ @Test
+ public void shouldNotFilterSimilarButDifferentHeaderNames() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers.eventkind-custom", "allowed1");
+ configMap.put("headers.my-eventtype", "allowed2");
+ configMap.put("headers.x-contenttype", "allowed3");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .containsEntry("eventkind-custom", "allowed1")
+ .containsEntry("my-eventtype", "allowed2")
+ .containsEntry("x-contenttype", "allowed3")
+ .hasSize(3);
+ }
+
+ @Test
+ public void shouldBeCaseSensitiveForReservedHeaders() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "test");
+ configMap.put("headers.EVENTKIND", "allowed1");
+ configMap.put("headers.EventType", "allowed2");
+ configMap.put("headers.ContentType", "allowed3");
+ configMap.put("headers." + Constants.MESSAGE_HEADER_EVENT_KIND, "filtered");
+
+ var destinationConfig = createTestDestinationConfig(new MapConfiguration(configMap));
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getCustomHeaders())
+ .containsEntry("EVENTKIND", "allowed1")
+ .containsEntry("EventType", "allowed2")
+ .containsEntry("ContentType", "allowed3")
+ .doesNotContainKey(Constants.MESSAGE_HEADER_EVENT_KIND)
+ .hasSize(3);
+ }
+
+ // Helper methods
+
+ private MapConfiguration createConfigWithHeaders(Map headers) {
+
+ var config = new HashMap();
+ config.put("kind", "test");
+ config.putAll(headers);
+ return new MapConfiguration(config);
+ }
+
+ private DestinationConfig createTestDestinationConfig(MapConfiguration config) {
+
+ var destinationConfig = new TestDestinationConfig();
+ destinationConfig.setConfiguration(config);
+ destinationConfig.setKeycloakSession(mock(KeycloakSession.class));
+ return destinationConfig;
+ }
+
+ // Test implementation of abstract DestinationConfig
+
+ private static class TestDestinationConfig extends DestinationConfig {
+
+ @Override
+ protected void doInitialize() {
+ // No-op for testing
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinationconfig/initializePoolPropertiesTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfig/initializePoolPropertiesTests.java
new file mode 100644
index 00000000..1171645a
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfig/initializePoolPropertiesTests.java
@@ -0,0 +1,749 @@
+ package io.github.fortunen.kete.unittests.destinationconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+import static org.mockito.Mockito.mock;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+import org.keycloak.models.KeycloakSession;
+
+import io.github.fortunen.kete.DestinationConfig;
+
+public class initializePoolPropertiesTests {
+
+ @Test
+ public void shouldReadDefaultPoolMinIdle() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMinIdle()).isEqualTo(1);
+ }
+
+ @Test
+ public void shouldReadDefaultPoolMaxIdle() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMaxIdle()).isEqualTo(10);
+ }
+
+ @Test
+ public void shouldReadDefaultPoolMaxTotal() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMaxTotal()).isEqualTo(20);
+ }
+
+ @Test
+ public void shouldReadDefaultPoolMaxWaitSeconds() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMaxWaitSeconds()).isEqualTo(-1);
+ }
+
+ @Test
+ public void shouldReadDefaultPoolBlockWhenExhausted() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolBlockWhenExhausted()).isTrue();
+ }
+
+ @Test
+ public void shouldReadDefaultPoolLifo() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolLifo()).isTrue();
+ }
+
+ @Test
+ public void shouldReadDefaultPoolFairness() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolFairness()).isFalse();
+ }
+
+ @Test
+ public void shouldReadDefaultPoolTestOnCreate() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestOnCreate()).isFalse();
+ }
+
+ @Test
+ public void shouldReadDefaultPoolTestOnBorrow() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestOnBorrow()).isFalse();
+ }
+
+ @Test
+ public void shouldReadDefaultPoolTestOnReturn() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestOnReturn()).isFalse();
+ }
+
+ @Test
+ public void shouldReadDefaultPoolTestWhileIdle() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestWhileIdle()).isFalse();
+ }
+
+ @Test
+ public void shouldReadDefaultPoolTimeBetweenEvictionRunsSeconds() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolTimeBetweenEvictionRunsSeconds()).isEqualTo(-1);
+ }
+
+ @Test
+ public void shouldReadDefaultPoolMinEvictableIdleTimeSeconds() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMinEvictableIdleTimeSeconds()).isEqualTo(1800);
+ }
+
+ @Test
+ public void shouldReadDefaultPoolSoftMinEvictableIdleTimeSeconds() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolSoftMinEvictableIdleTimeSeconds()).isEqualTo(-1);
+ }
+
+ @Test
+ public void shouldReadDefaultPoolNumTestsPerEvictionRun() {
+
+ // arrange
+
+ var config = createConfigWithoutPoolProperties();
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolNumTestsPerEvictionRun()).isEqualTo(3);
+ }
+
+ @Test
+ public void shouldReadCustomPoolMinIdle() {
+
+ // arrange
+
+ var poolProps = new HashMap();
+ poolProps.put("min-idle", 50);
+ poolProps.put("max-total", 50); // max-total must be >= min-idle
+ var config = createConfigWithPoolProperties(poolProps);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMinIdle()).isEqualTo(50);
+ }
+
+ @Test
+ public void shouldReadCustomPoolMaxIdle() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("max-idle", 100);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMaxIdle()).isEqualTo(100);
+ }
+
+ @Test
+ public void shouldReadCustomPoolMaxTotal() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("max-total", 200);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMaxTotal()).isEqualTo(200);
+ }
+
+ @Test
+ public void shouldReadCustomPoolMaxWaitSeconds() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("max-wait-seconds", 5L);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMaxWaitSeconds()).isEqualTo(5);
+ }
+
+ @Test
+ public void shouldReadCustomPoolBlockWhenExhausted() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("block-when-exhausted", false);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolBlockWhenExhausted()).isFalse();
+ }
+
+ @Test
+ public void shouldReadCustomPoolLifo() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("lifo", false);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolLifo()).isFalse();
+ }
+
+ @Test
+ public void shouldReadCustomPoolFairness() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("fairness", true);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolFairness()).isTrue();
+ }
+
+ @Test
+ public void shouldReadCustomPoolTestOnCreate() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("test-on-create", true);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestOnCreate()).isTrue();
+ }
+
+ @Test
+ public void shouldReadCustomPoolTestOnBorrow() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("test-on-borrow", true);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestOnBorrow()).isTrue();
+ }
+
+ @Test
+ public void shouldReadCustomPoolTestOnReturn() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("test-on-return", true);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestOnReturn()).isTrue();
+ }
+
+ @Test
+ public void shouldReadCustomPoolTestWhileIdle() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("test-while-idle", true);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.isPoolTestWhileIdle()).isTrue();
+ }
+
+ @Test
+ public void shouldReadCustomPoolTimeBetweenEvictionRunsSeconds() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("time-between-eviction-runs-seconds", 60L);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolTimeBetweenEvictionRunsSeconds()).isEqualTo(60);
+ }
+
+ @Test
+ public void shouldReadCustomPoolMinEvictableIdleTimeSeconds() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("min-evictable-idle-time-seconds", 300L);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMinEvictableIdleTimeSeconds()).isEqualTo(300);
+ }
+
+ @Test
+ public void shouldReadCustomPoolSoftMinEvictableIdleTimeSeconds() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("soft-min-evictable-idle-time-seconds", 120L);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolSoftMinEvictableIdleTimeSeconds()).isEqualTo(120);
+ }
+
+ @Test
+ public void shouldReadCustomPoolNumTestsPerEvictionRun() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("num-tests-per-eviction-run", 10);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolNumTestsPerEvictionRun()).isEqualTo(10);
+ }
+
+ @Test
+ public void shouldReadAllCustomPoolProperties() {
+
+ // arrange
+
+ var poolProps = new HashMap();
+ poolProps.put("min-idle", 50);
+ poolProps.put("max-idle", 150);
+ poolProps.put("max-total", 200);
+ poolProps.put("max-wait-seconds", 5L);
+ poolProps.put("block-when-exhausted", false);
+ poolProps.put("lifo", false);
+ poolProps.put("fairness", true);
+ poolProps.put("test-on-create", true);
+ poolProps.put("test-on-borrow", true);
+ poolProps.put("test-on-return", true);
+ poolProps.put("test-while-idle", true);
+ poolProps.put("time-between-eviction-runs-seconds", 30L);
+ poolProps.put("min-evictable-idle-time-seconds", 600L);
+ poolProps.put("soft-min-evictable-idle-time-seconds", 300L);
+ poolProps.put("num-tests-per-eviction-run", 5);
+
+ var config = createConfigWithPoolProperties(poolProps);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ destinationConfig.initialize();
+
+ // assert
+
+ assertThat(destinationConfig.getPoolMinIdle()).isEqualTo(50);
+ assertThat(destinationConfig.getPoolMaxIdle()).isEqualTo(150);
+ assertThat(destinationConfig.getPoolMaxTotal()).isEqualTo(200);
+ assertThat(destinationConfig.getPoolMaxWaitSeconds()).isEqualTo(5);
+ assertThat(destinationConfig.isPoolBlockWhenExhausted()).isFalse();
+ assertThat(destinationConfig.isPoolLifo()).isFalse();
+ assertThat(destinationConfig.isPoolFairness()).isTrue();
+ assertThat(destinationConfig.isPoolTestOnCreate()).isTrue();
+ assertThat(destinationConfig.isPoolTestOnBorrow()).isTrue();
+ assertThat(destinationConfig.isPoolTestOnReturn()).isTrue();
+ assertThat(destinationConfig.isPoolTestWhileIdle()).isTrue();
+ assertThat(destinationConfig.getPoolTimeBetweenEvictionRunsSeconds()).isEqualTo(30);
+ assertThat(destinationConfig.getPoolMinEvictableIdleTimeSeconds()).isEqualTo(600);
+ assertThat(destinationConfig.getPoolSoftMinEvictableIdleTimeSeconds()).isEqualTo(300);
+ }
+
+ @Test
+ public void shouldThrowWhenPoolMinIdleIsZero() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("min-idle", 0);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ var thrown = catchThrowable(() -> destinationConfig.initialize());
+
+ // assert
+
+ assertThat(thrown).isInstanceOf(IllegalStateException.class);
+ assertThat(thrown.getMessage()).isEqualTo("min-idle must be greater than 0");
+ }
+
+ @Test
+ public void shouldThrowWhenPoolMinIdleIsNegative() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("min-idle", -5);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ var thrown = catchThrowable(() -> destinationConfig.initialize());
+
+ // assert
+
+ assertThat(thrown).isInstanceOf(IllegalStateException.class);
+ assertThat(thrown.getMessage()).isEqualTo("min-idle must be greater than 0");
+ }
+
+ @Test
+ public void shouldThrowWhenPoolMaxIdleIsZero() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("max-idle", 0);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ var thrown = catchThrowable(() -> destinationConfig.initialize());
+
+ // assert
+
+ assertThat(thrown).isInstanceOf(IllegalStateException.class);
+ assertThat(thrown.getMessage()).isEqualTo("max-idle must be greater than 0");
+ }
+
+ @Test
+ public void shouldThrowWhenPoolMaxTotalIsZero() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("max-total", 0);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ var thrown = catchThrowable(() -> destinationConfig.initialize());
+
+ // assert
+
+ assertThat(thrown).isInstanceOf(IllegalStateException.class);
+ assertThat(thrown.getMessage()).isEqualTo("max-total must be greater than 0");
+ }
+
+ @Test
+ public void shouldThrowWhenPoolMaxTotalIsLessThanMinIdle() {
+
+ // arrange
+
+ var poolProps = new HashMap();
+ poolProps.put("min-idle", 50);
+ poolProps.put("max-total", 30);
+ var config = createConfigWithPoolProperties(poolProps);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ var thrown = catchThrowable(() -> destinationConfig.initialize());
+
+ // assert
+
+ assertThat(thrown).isInstanceOf(IllegalStateException.class);
+ assertThat(thrown.getMessage()).isEqualTo("max-total must be >= min-idle");
+ }
+
+ @Test
+ public void shouldThrowWhenPoolMaxWaitSecondsIsLessThanMinusOne() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("max-wait-seconds", -2L);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ var thrown = catchThrowable(() -> destinationConfig.initialize());
+
+ // assert
+
+ assertThat(thrown).isInstanceOf(IllegalStateException.class);
+ assertThat(thrown.getMessage()).isEqualTo("max-wait-seconds must be -1 or greater");
+ }
+
+ @Test
+ public void shouldThrowWhenPoolMaxWaitSecondsIsNegative() {
+
+ // arrange
+
+ var config = createConfigWithPoolProperty("max-wait-seconds", -1000L);
+ var destinationConfig = createTestDestinationConfig(config);
+
+ // act
+
+ var thrown = catchThrowable(() -> destinationConfig.initialize());
+
+ // assert
+
+ assertThat(thrown).isInstanceOf(IllegalStateException.class);
+ assertThat(thrown.getMessage()).isEqualTo("max-wait-seconds must be -1 or greater");
+ }
+
+ // Helper methods
+
+ private MapConfiguration createConfigWithoutPoolProperties() {
+
+ var config = new HashMap();
+ config.put("kind", "test");
+ return new MapConfiguration(config);
+ }
+
+ private MapConfiguration createConfigWithPoolProperty(String key, Object value) {
+
+ var config = new HashMap();
+ config.put("kind", "test");
+ config.put("pool." + key, value);
+ return new MapConfiguration(config);
+ }
+
+ private MapConfiguration createConfigWithPoolProperties(Map poolProps) {
+
+ var config = new HashMap();
+ config.put("kind", "test");
+ poolProps.forEach((key, value) -> config.put("pool." + key, value));
+ return new MapConfiguration(config);
+ }
+
+ private DestinationConfig createTestDestinationConfig(MapConfiguration config) {
+
+ var destinationConfig = new TestDestinationConfig();
+ destinationConfig.setConfiguration(config);
+ destinationConfig.setKeycloakSession(mock(KeycloakSession.class));
+ return destinationConfig;
+ }
+
+ // Test implementation of abstract DestinationConfig
+
+ private static class TestDestinationConfig extends DestinationConfig {
+
+ @Override
+ protected void doInitialize() {
+ // No-op for testing
+ }
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/amqp091destinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/amqp091destinationconfig/initializeTests.java
similarity index 84%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/amqp091destinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/amqp091destinationconfig/initializeTests.java
index d9601b84..b3c36064 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/amqp091destinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/amqp091destinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.amqp091destinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.amqp091destinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -24,6 +24,7 @@ public void shouldThrowWhenHostIsMissing() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"exchange", "test-exchange"
)));
@@ -45,6 +46,7 @@ public void shouldThrowWhenHostIsEmpty() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "",
"exchange", "test-exchange"
)));
@@ -67,6 +69,7 @@ public void shouldThrowWhenHostIsBlank() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", " ",
"exchange", "test-exchange"
)));
@@ -93,6 +96,7 @@ public void shouldThrowWhenExchangeIsMissing() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost"
)));
@@ -114,6 +118,7 @@ public void shouldThrowWhenExchangeIsEmpty() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", ""
)));
@@ -136,6 +141,7 @@ public void shouldThrowWhenExchangeIsBlank() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", " "
)));
@@ -162,6 +168,7 @@ public void shouldUseDefaultPortWhenNotSpecified() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange"
)));
@@ -182,6 +189,7 @@ public void shouldUseSpecifiedPort() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"port", 5673,
"exchange", "test-exchange"
@@ -203,6 +211,7 @@ public void shouldThrowWhenPortIsZero() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"port", 0,
"exchange", "test-exchange"
@@ -226,6 +235,7 @@ public void shouldThrowWhenPortIsNegative() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"port", -1,
"exchange", "test-exchange"
@@ -249,6 +259,7 @@ public void shouldThrowWhenPortIsTooHigh() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"port", 65536,
"exchange", "test-exchange"
@@ -276,6 +287,7 @@ public void shouldInitializeWithMinimalConfiguration() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange"
)));
@@ -295,17 +307,16 @@ public void shouldInitializeWithMinimalConfiguration() {
assertThat(config.getPassword()).isEmpty();
assertThat(config.getPriority()).isEqualTo(Amqp091DestinationConfig.DEFAULT_PRIORITY);
assertThat(config.isHasPriority()).isFalse();
- assertThat(config.getTimeToLive()).isEqualTo(Amqp091DestinationConfig.DEFAULT_TIME_TO_LIVE);
- assertThat(config.isHasTimeToLive()).isFalse();
+ assertThat(config.getTimeToLiveSeconds()).isEqualTo(Amqp091DestinationConfig.DEFAULT_TIME_TO_LIVE_SECONDS);
+ assertThat(config.isHasTimeToLiveSeconds()).isFalse();
assertThat(config.getDeliveryMode()).isEqualTo(2); // persistent
- assertThat(config.getHandshakeTimeout()).isEqualTo(Amqp091DestinationConfig.DEFAULT_HANDSHAKE_TIMEOUT_MS);
- assertThat(config.getConnectionTimeout()).isEqualTo(Amqp091DestinationConfig.DEFAULT_CONNECTION_TIMEOUT_MS);
- assertThat(config.getChannelRpcTimeout()).isEqualTo(Amqp091DestinationConfig.DEFAULT_CHANNEL_RPC_TIMEOUT_MS);
- assertThat(config.getRequestedHeartbeat()).isEqualTo(Amqp091DestinationConfig.DEFAULT_REQUESTED_HEARTBEAT_SECONDS);
- assertThat(config.getNetworkRecoveryInterval()).isEqualTo(Amqp091DestinationConfig.DEFAULT_NETWORK_RECOVERY_INTERVAL_MS);
+ assertThat(config.getHandshakeTimeoutSeconds()).isEqualTo(Amqp091DestinationConfig.DEFAULT_HANDSHAKE_TIMEOUT_SECONDS);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(Amqp091DestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ assertThat(config.getChannelRpcTimeoutSeconds()).isEqualTo(Amqp091DestinationConfig.DEFAULT_CHANNEL_RPC_TIMEOUT_SECONDS);
+ assertThat(config.getRequestedHeartbeatSeconds()).isEqualTo(Amqp091DestinationConfig.DEFAULT_REQUESTED_HEARTBEAT_SECONDS);
+ assertThat(config.getNetworkRecoveryIntervalSeconds()).isEqualTo(Amqp091DestinationConfig.DEFAULT_NETWORK_RECOVERY_INTERVAL_SECONDS);
assertThat(config.isAutomaticRecoveryEnabled()).isTrue();
assertThat(config.isTopologyRecoveryEnabled()).isTrue();
- assertThat(config.isMessageHeadersEnabled()).isTrue();
assertThat(config.getTls().isEnabled()).isFalse();
assertThat(config.getConnectionFactory()).isNotNull();
}
@@ -321,6 +332,7 @@ public void shouldParseRoutingKey() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"routing-key", "my.routing.key"
@@ -342,6 +354,7 @@ public void shouldParseUsername() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"username", "admin"
@@ -363,6 +376,7 @@ public void shouldParsePassword() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"password", "secret"
@@ -384,6 +398,7 @@ public void shouldParseVirtualHost() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"virtual-host", "/my-vhost"
@@ -409,6 +424,7 @@ public void shouldParsePriority() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"priority", 7
@@ -431,6 +447,7 @@ public void shouldAcceptMinPriority() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"priority", 0
@@ -453,6 +470,7 @@ public void shouldAcceptMaxPriority() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"priority", 9
@@ -475,6 +493,7 @@ public void shouldThrowWhenPriorityIsTooLow() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"priority", -1
@@ -498,6 +517,7 @@ public void shouldThrowWhenPriorityIsTooHigh() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"priority", 10
@@ -525,6 +545,7 @@ public void shouldDefaultToPersistentDeliveryMode() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange"
)));
@@ -546,6 +567,7 @@ public void shouldParsePersistentDeliveryMode() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"delivery-mode", "persistent"
@@ -568,6 +590,7 @@ public void shouldParseNonPersistentDeliveryMode() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"delivery-mode", "non-persistent"
@@ -590,6 +613,7 @@ public void shouldThrowWhenDeliveryModeIsInvalid() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
"delivery-mode", "invalid"
@@ -617,19 +641,17 @@ public void shouldParseTimeToLive() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
- "time-to-live", 60000L
+ "time-to-live-seconds", 60L
)));
-
- // act
-
config.initialize();
// assert
- assertThat(config.getTimeToLive()).isEqualTo(60000L);
- assertThat(config.isHasTimeToLive()).isTrue();
+ assertThat(config.getTimeToLiveSeconds()).isEqualTo(60L);
+ assertThat(config.isHasTimeToLiveSeconds()).isTrue();
}
@Test
@@ -639,9 +661,10 @@ public void shouldThrowWhenTimeToLiveIsNegative() {
var config = new Amqp091DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-0.9.1",
"host", "localhost",
"exchange", "test-exchange",
- "time-to-live", -1L
+ "time-to-live-seconds", -1L
)));
// act
@@ -666,11 +689,12 @@ public void shouldParseTimeouts() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("handshake-timeout", 5000);
- configMap.put("connection-timeout", 6000);
- configMap.put("channel-rpc-timeout", 7000);
+ configMap.put("handshake-timeout-seconds", 5);
+ configMap.put("connection-timeout-seconds", 6);
+ configMap.put("channel-rpc-timeout-seconds", 7);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -679,9 +703,9 @@ public void shouldParseTimeouts() {
// assert
- assertThat(config.getHandshakeTimeout()).isEqualTo(5000);
- assertThat(config.getConnectionTimeout()).isEqualTo(6000);
- assertThat(config.getChannelRpcTimeout()).isEqualTo(7000);
+ assertThat(config.getHandshakeTimeoutSeconds()).isEqualTo(5);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(6);
+ assertThat(config.getChannelRpcTimeoutSeconds()).isEqualTo(7);
}
@Test
@@ -691,9 +715,10 @@ public void shouldThrowWhenHandshakeTimeoutIsNegative() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("handshake-timeout", -1);
+ configMap.put("handshake-timeout-seconds", -1);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -704,7 +729,7 @@ public void shouldThrowWhenHandshakeTimeoutIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("handshake-timeout");
+ .hasMessageContaining("handshake-timeout-seconds");
}
@Test
@@ -714,9 +739,10 @@ public void shouldThrowWhenConnectionTimeoutIsNegative() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("connection-timeout", -1);
+ configMap.put("connection-timeout-seconds", -1);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -727,7 +753,7 @@ public void shouldThrowWhenConnectionTimeoutIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("connection-timeout");
+ .hasMessageContaining("connection-timeout-seconds");
}
@Test
@@ -737,9 +763,10 @@ public void shouldThrowWhenChannelRpcTimeoutIsNegative() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("channel-rpc-timeout", -1);
+ configMap.put("channel-rpc-timeout-seconds", -1);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -750,7 +777,7 @@ public void shouldThrowWhenChannelRpcTimeoutIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("channel-rpc-timeout");
+ .hasMessageContaining("channel-rpc-timeout-seconds");
}
// =========================================================================
@@ -764,12 +791,13 @@ public void shouldParseRecoverySettings() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
configMap.put("automatic-recovery-enabled", false);
configMap.put("topology-recovery-enabled", false);
- configMap.put("requested-heartbeat", 60);
- configMap.put("network-recovery-interval", 10000);
+ configMap.put("requested-heartbeat-seconds", 60);
+ configMap.put("network-recovery-interval-seconds", 10);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -780,8 +808,8 @@ public void shouldParseRecoverySettings() {
assertThat(config.isAutomaticRecoveryEnabled()).isFalse();
assertThat(config.isTopologyRecoveryEnabled()).isFalse();
- assertThat(config.getRequestedHeartbeat()).isEqualTo(60);
- assertThat(config.getNetworkRecoveryInterval()).isEqualTo(10000);
+ assertThat(config.getRequestedHeartbeatSeconds()).isEqualTo(60);
+ assertThat(config.getNetworkRecoveryIntervalSeconds()).isEqualTo(10);
}
@Test
@@ -791,9 +819,10 @@ public void shouldThrowWhenRequestedHeartbeatIsNegative() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("requested-heartbeat", -1);
+ configMap.put("requested-heartbeat-seconds", -1);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -804,7 +833,7 @@ public void shouldThrowWhenRequestedHeartbeatIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("requested-heartbeat");
+ .hasMessageContaining("requested-heartbeat-seconds");
}
@Test
@@ -814,9 +843,10 @@ public void shouldThrowWhenNetworkRecoveryIntervalIsZero() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("network-recovery-interval", 0);
+ configMap.put("network-recovery-interval-seconds", 0);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -827,7 +857,7 @@ public void shouldThrowWhenNetworkRecoveryIntervalIsZero() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("network-recovery-interval");
+ .hasMessageContaining("network-recovery-interval-seconds");
}
@Test
@@ -837,9 +867,10 @@ public void shouldThrowWhenNetworkRecoveryIntervalIsNegative() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("network-recovery-interval", -1);
+ configMap.put("network-recovery-interval-seconds", -1);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -850,32 +881,7 @@ public void shouldThrowWhenNetworkRecoveryIntervalIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("network-recovery-interval");
- }
-
- // =========================================================================
- // Message Headers
- // =========================================================================
-
- @Test
- public void shouldDisableMessageHeaders() {
-
- // arrange
-
- var config = new Amqp091DestinationConfig();
- var configMap = new HashMap();
- configMap.put("host", "localhost");
- configMap.put("exchange", "test-exchange");
- configMap.put("message-headers-enabled", false);
- config.setConfiguration(new MapConfiguration(configMap));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isFalse();
+ .hasMessageContaining("network-recovery-interval-seconds");
}
// =========================================================================
@@ -889,10 +895,11 @@ public void shouldParsePoolSizes() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("min-pool-size", 10);
- configMap.put("max-pool-size", 50);
+ configMap.put("pool.min-idle", 10);
+ configMap.put("pool.max-total", 50);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -901,8 +908,8 @@ public void shouldParsePoolSizes() {
// assert
- assertThat(config.getMinPoolSize()).isEqualTo(10);
- assertThat(config.getMaxPoolSize()).isEqualTo(50);
+ assertThat(config.getPoolMinIdle()).isEqualTo(10);
+ assertThat(config.getPoolMaxTotal()).isEqualTo(50);
}
@Test
@@ -912,9 +919,10 @@ public void shouldThrowWhenMinPoolSizeIsZero() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("min-pool-size", 0);
+ configMap.put("pool.min-idle", 0);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -925,7 +933,7 @@ public void shouldThrowWhenMinPoolSizeIsZero() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("min-pool-size");
+ .hasMessageContaining("min-idle");
}
@Test
@@ -935,10 +943,11 @@ public void shouldThrowWhenMaxPoolSizeLessThanMinPoolSize() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
- configMap.put("min-pool-size", 20);
- configMap.put("max-pool-size", 10);
+ configMap.put("pool.min-idle", 20);
+ configMap.put("pool.max-total", 10);
config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -949,7 +958,7 @@ public void shouldThrowWhenMaxPoolSizeLessThanMinPoolSize() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("max-pool-size");
+ .hasMessageContaining("max-total");
}
// =========================================================================
@@ -963,6 +972,7 @@ public void shouldCreateConnectionFactoryWithCorrectSettings() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "rabbitmq.example.com");
configMap.put("port", 5673);
configMap.put("exchange", "test-exchange");
@@ -997,6 +1007,7 @@ public void shouldParseTlsEnabled() {
var config = new Amqp091DestinationConfig();
var configMap = new HashMap();
+ configMap.put("kind", "amqp-0.9.1");
configMap.put("host", "localhost");
configMap.put("exchange", "test-exchange");
configMap.put("tls.enabled", "true");
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/amqp1destinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/amqp1destinationconfig/initializeTests.java
similarity index 88%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/amqp1destinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/amqp1destinationconfig/initializeTests.java
index b1ae5989..90fdbe84 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/amqp1destinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/amqp1destinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.amqp1destinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.amqp1destinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -24,6 +24,7 @@ public void shouldThrowWhenHostIsMissing() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"destination-name", "test-queue"
)));
@@ -45,6 +46,7 @@ public void shouldThrowWhenHostIsEmpty() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "",
"destination-name", "test-queue"
)));
@@ -67,6 +69,7 @@ public void shouldThrowWhenHostIsBlank() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", " ",
"destination-name", "test-queue"
)));
@@ -93,6 +96,7 @@ public void shouldThrowWhenDestinationNameIsMissing() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost"
)));
@@ -114,6 +118,7 @@ public void shouldThrowWhenDestinationNameIsEmpty() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", ""
)));
@@ -136,6 +141,7 @@ public void shouldThrowWhenDestinationNameIsBlank() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", " "
)));
@@ -162,6 +168,7 @@ public void shouldUseDefaultTcpPortWhenTlsDisabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -182,6 +189,7 @@ public void shouldUseDefaultTlsPortWhenTlsEnabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"tls.enabled", "true"
@@ -203,6 +211,7 @@ public void shouldUseDefaultWebSocketPortWhenWebSocketAndTlsDisabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"transport-type", "amqp-web-sockets"
@@ -224,6 +233,7 @@ public void shouldUseDefaultWebSocketPortWhenWebSocketAndTlsEnabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"transport-type", "amqp-web-sockets",
@@ -246,6 +256,7 @@ public void shouldUseCustomPort() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"port", "15672"
@@ -271,6 +282,7 @@ public void shouldThrowWhenPortIsZero() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"port", "0"
@@ -294,6 +306,7 @@ public void shouldThrowWhenPortIsNegative() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"port", "-1"
@@ -317,6 +330,7 @@ public void shouldThrowWhenPortIsTooLarge() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"port", "65536"
@@ -340,6 +354,7 @@ public void shouldAcceptMinimumPort() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"port", "1"
@@ -361,6 +376,7 @@ public void shouldAcceptMaximumPort() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"port", "65535"
@@ -386,6 +402,7 @@ public void shouldDefaultToAmqpTransportType() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -406,6 +423,7 @@ public void shouldAcceptAmqpWebSocketsTransportType() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"transport-type", "amqp-web-sockets"
@@ -427,6 +445,7 @@ public void shouldThrowWhenTransportTypeIsInvalid() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"transport-type", "invalid"
@@ -450,6 +469,7 @@ public void shouldNormalizeTransportTypeToLowerCase() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"transport-type", "AMQP-WEB-SOCKETS"
@@ -475,6 +495,7 @@ public void shouldDefaultToQueueDestinationType() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -496,6 +517,7 @@ public void shouldAcceptTopicDestinationType() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-topic",
"destination-type", "topic"
@@ -518,6 +540,7 @@ public void shouldThrowWhenDestinationTypeIsInvalid() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-dest",
"destination-type", "invalid"
@@ -541,6 +564,7 @@ public void shouldNormalizeDestinationTypeToLowerCase() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-topic",
"destination-type", "TOPIC"
@@ -566,6 +590,7 @@ public void shouldConstructAmqpUrlWhenTlsDisabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -587,6 +612,7 @@ public void shouldConstructAmqpsUrlWhenTlsEnabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"tls.enabled", "true"
@@ -609,6 +635,7 @@ public void shouldConstructAmqpwsUrlWhenWebSocketsAndTlsDisabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"transport-type", "amqp-web-sockets"
@@ -631,6 +658,7 @@ public void shouldConstructAmqpwssUrlWhenWebSocketsAndTlsEnabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"transport-type", "amqp-web-sockets",
@@ -654,6 +682,7 @@ public void shouldConstructUrlWithCustomPort() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "my-broker.example.com",
"destination-name", "test-queue",
"port", "15672"
@@ -668,57 +697,6 @@ public void shouldConstructUrlWithCustomPort() {
assertThat(config.getUrl()).isEqualTo("amqp://my-broker.example.com:15672?amqp.idleTimeout=60000");
}
- // =========================================================================
- // Azure Service Bus Auto-TLS
- // =========================================================================
-
- @Test
- public void shouldAutoEnableTlsForAzureServiceBus() {
-
- // arrange
-
- var configMap = new HashMap();
- configMap.put("host", "my-namespace.servicebus.windows.net");
- configMap.put("destination-name", "test-queue");
-
- var config = new Amqp1DestinationConfig();
- config.setConfiguration(new MapConfiguration(configMap));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.getTls().isEnabled()).isTrue();
- assertThat(config.getScheme()).isEqualTo("amqps");
- assertThat(config.getUrl()).isEqualTo("amqps://my-namespace.servicebus.windows.net:5671?amqp.idleTimeout=60000");
- }
-
- @Test
- public void shouldAutoEnableTlsForAzureServiceBusWithWebSockets() {
-
- // arrange
-
- var configMap = new HashMap();
- configMap.put("host", "my-namespace.servicebus.windows.net");
- configMap.put("destination-name", "test-queue");
- configMap.put("transport-type", "amqp-web-sockets");
-
- var config = new Amqp1DestinationConfig();
- config.setConfiguration(new MapConfiguration(configMap));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.getTls().isEnabled()).isTrue();
- assertThat(config.getScheme()).isEqualTo("amqpwss");
- assertThat(config.getUrl()).isEqualTo("amqpwss://my-namespace.servicebus.windows.net:443?amqp.idleTimeout=60000");
- }
-
// =========================================================================
// Delivery Mode
// =========================================================================
@@ -730,6 +708,7 @@ public void shouldDefaultToPersistentDeliveryMode() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -751,6 +730,7 @@ public void shouldAcceptNonPersistentDeliveryMode() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"delivery-mode", "non-persistent"
@@ -773,6 +753,7 @@ public void shouldThrowWhenDeliveryModeIsInvalid() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"delivery-mode", "invalid"
@@ -796,6 +777,7 @@ public void shouldNormalizeDeliveryModeToLowerCase() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"delivery-mode", "NON-PERSISTENT"
@@ -821,6 +803,7 @@ public void shouldDefaultToMidRangePriority() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -841,6 +824,7 @@ public void shouldAcceptMinimumPriority() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"priority", "0"
@@ -862,6 +846,7 @@ public void shouldAcceptMaximumPriority() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"priority", "9"
@@ -883,6 +868,7 @@ public void shouldThrowWhenPriorityIsBelowMinimum() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"priority", "-1"
@@ -906,6 +892,7 @@ public void shouldThrowWhenPriorityIsAboveMaximum() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"priority", "10"
@@ -933,6 +920,7 @@ public void shouldDefaultToZeroTimeToLive() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -943,7 +931,7 @@ public void shouldDefaultToZeroTimeToLive() {
// assert
- assertThat(config.getTimeToLive()).isEqualTo(0L);
+ assertThat(config.getTimeToLiveSeconds()).isEqualTo(0L);
}
@Test
@@ -953,18 +941,16 @@ public void shouldAcceptPositiveTimeToLive() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
- "time-to-live", "60000"
+ "time-to-live-seconds", "60"
)));
-
- // act
-
config.initialize();
// assert
- assertThat(config.getTimeToLive()).isEqualTo(60000L);
+ assertThat(config.getTimeToLiveSeconds()).isEqualTo(60L);
}
@Test
@@ -974,9 +960,10 @@ public void shouldThrowWhenTimeToLiveIsNegative() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
- "time-to-live", "-1"
+ "time-to-live-seconds", "-1"
)));
// act
@@ -987,56 +974,11 @@ public void shouldThrowWhenTimeToLiveIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("time-to-live must be non-negative");
- }
-
- // =========================================================================
- // Message Headers
- // =========================================================================
-
- @Test
- public void shouldDefaultToMessageHeadersEnabled() {
-
- // arrange
-
- var config = new Amqp1DestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "localhost",
- "destination-name", "test-queue"
- )));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isTrue();
- }
-
- @Test
- public void shouldDisableMessageHeaders() {
-
- // arrange
-
- var config = new Amqp1DestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "localhost",
- "destination-name", "test-queue",
- "message-headers-enabled", "false"
- )));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isFalse();
+ .hasMessage("time-to-live-seconds must be non-negative");
}
// =========================================================================
- // Optional Fields - Username and Password
+ // Username & Password
// =========================================================================
@Test
@@ -1046,6 +988,7 @@ public void shouldDefaultToEmptyUsername() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -1066,6 +1009,7 @@ public void shouldDefaultToEmptyPassword() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -1086,6 +1030,7 @@ public void shouldAcceptUsernameAndPassword() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"username", "myuser",
@@ -1109,6 +1054,7 @@ public void shouldTrimUsername() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"username", " myuser "
@@ -1130,6 +1076,7 @@ public void shouldTrimPassword() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"password", " mypass "
@@ -1155,6 +1102,7 @@ public void shouldCreateConnectionFactory() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -1175,6 +1123,7 @@ public void shouldSetUsernameOnConnectionFactory() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"username", "testuser"
@@ -1201,6 +1150,7 @@ public void shouldDefaultToTlsDisabled() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -1221,6 +1171,7 @@ public void shouldEnableTls() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
"tls.enabled", "true"
@@ -1246,6 +1197,7 @@ public void shouldUseDefaultIdleTimeout() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue"
)));
@@ -1256,7 +1208,7 @@ public void shouldUseDefaultIdleTimeout() {
// assert
- assertThat(config.getIdleTimeout()).isEqualTo(60000);
+ assertThat(config.getIdleTimeoutSeconds()).isEqualTo(Amqp1DestinationConfig.DEFAULT_IDLE_TIMEOUT_SECONDS);
}
@Test
@@ -1266,9 +1218,10 @@ public void shouldUseCustomIdleTimeout() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
- "idle-timeout", "30000"
+ "idle-timeout-seconds", "30"
)));
// act
@@ -1277,7 +1230,7 @@ public void shouldUseCustomIdleTimeout() {
// assert
- assertThat(config.getIdleTimeout()).isEqualTo(30000);
+ assertThat(config.getIdleTimeoutSeconds()).isEqualTo(30);
}
@Test
@@ -1287,9 +1240,10 @@ public void shouldDisableIdleTimeoutWhenSetToZero() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
- "idle-timeout", "0"
+ "idle-timeout-seconds", "0"
)));
// act
@@ -1298,7 +1252,7 @@ public void shouldDisableIdleTimeoutWhenSetToZero() {
// assert
- assertThat(config.getIdleTimeout()).isEqualTo(0);
+ assertThat(config.getIdleTimeoutSeconds()).isEqualTo(0);
}
@Test
@@ -1308,9 +1262,10 @@ public void shouldThrowWhenIdleTimeoutIsNegative() {
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "amqp-1",
"host", "localhost",
"destination-name", "test-queue",
- "idle-timeout", "-1"
+ "idle-timeout-seconds", "-1"
)));
// act
@@ -1321,7 +1276,7 @@ public void shouldThrowWhenIdleTimeoutIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("idle-timeout must be non-negative");
+ .hasMessage("idle-timeout-seconds must be non-negative");
}
// =========================================================================
@@ -1334,6 +1289,7 @@ public void shouldInitializeWithFullConfiguration() {
// arrange
var configMap = new HashMap();
+ configMap.put("kind", "amqp-1");
configMap.put("host", "my-broker.example.com");
configMap.put("port", "5673");
configMap.put("destination-name", "my-queue");
@@ -1343,8 +1299,7 @@ public void shouldInitializeWithFullConfiguration() {
configMap.put("password", "secret");
configMap.put("delivery-mode", "non-persistent");
configMap.put("priority", "7");
- configMap.put("time-to-live", "30000");
- configMap.put("message-headers-enabled", "false");
+ configMap.put("time-to-live-seconds", "30");
var config = new Amqp1DestinationConfig();
config.setConfiguration(new MapConfiguration(configMap));
@@ -1366,8 +1321,7 @@ public void shouldInitializeWithFullConfiguration() {
assertThat(config.getDeliveryModeString()).isEqualTo("non-persistent");
assertThat(config.getDeliveryMode()).isEqualTo(1);
assertThat(config.getPriority()).isEqualTo(7);
- assertThat(config.getTimeToLive()).isEqualTo(30000L);
- assertThat(config.isMessageHeadersEnabled()).isFalse();
+ assertThat(config.getTimeToLiveSeconds()).isEqualTo(30L);
assertThat(config.getUrl()).isEqualTo("amqp://my-broker.example.com:5673?amqp.idleTimeout=60000");
assertThat(config.getScheme()).isEqualTo("amqp");
assertThat(config.getConnectionFactory()).isNotNull();
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/httpdestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/httpdestinationconfig/initializeTests.java
similarity index 88%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/httpdestinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/httpdestinationconfig/initializeTests.java
index 08ed7daa..367bc20a 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/httpdestinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/httpdestinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.httpdestinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.httpdestinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -23,7 +23,7 @@ public void shouldThrowWhenHostIsMissing() {
// arrange
var config = new HttpDestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of()));
+ config.setConfiguration(new MapConfiguration(Map.of("kind", "http")));
// act
@@ -43,6 +43,7 @@ public void shouldThrowWhenHostIsEmpty() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", ""
)));
@@ -64,6 +65,7 @@ public void shouldThrowWhenHostIsBlank() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", " "
)));
@@ -89,6 +91,7 @@ public void shouldParseFullHttpUrl() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http://example.com:8080/api/events"
)));
@@ -112,6 +115,7 @@ public void shouldParseFullHttpsUrl() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "https://secure.example.com/webhook"
)));
@@ -135,6 +139,7 @@ public void shouldParseUrlWithQueryString() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http://example.com/api?key=value&foo=bar"
)));
@@ -154,6 +159,7 @@ public void shouldUseDefaultPortForHttp() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http://example.com/events"
)));
@@ -173,6 +179,7 @@ public void shouldUseDefaultPortForHttps() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "https://example.com/events"
)));
@@ -192,6 +199,7 @@ public void shouldThrowWhenUrlSchemeIsInvalid() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "ftp://example.com/files"
)));
@@ -213,6 +221,7 @@ public void shouldThrowWhenUrlHostIsMissing() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http:///path"
)));
@@ -234,6 +243,7 @@ public void shouldUseRootPathWhenUrlHasNoPath() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http://example.com"
)));
@@ -258,6 +268,7 @@ public void shouldBuildUrlFromHostOnly() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com"
)));
@@ -280,6 +291,7 @@ public void shouldBuildUrlWithCustomPort() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"port", "8080"
)));
@@ -301,6 +313,7 @@ public void shouldBuildUrlWithPathAndQuery() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"path-and-query", "/api/events?source=keycloak"
)));
@@ -322,6 +335,7 @@ public void shouldPrependSlashToPath() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"path-and-query", "api/events"
)));
@@ -342,6 +356,7 @@ public void shouldUseHttpsWhenTlsEnabled() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"tls.enabled", "true"
)));
@@ -368,6 +383,7 @@ public void shouldThrowWhenPortIsZero() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"port", "0"
)));
@@ -390,6 +406,7 @@ public void shouldThrowWhenPortIsNegative() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"port", "-1"
)));
@@ -412,6 +429,7 @@ public void shouldThrowWhenPortIsTooLarge() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"port", "65536"
)));
@@ -434,6 +452,7 @@ public void shouldAcceptMinimumPort() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"port", "1"
)));
@@ -454,6 +473,7 @@ public void shouldAcceptMaximumPort() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"port", "65535"
)));
@@ -478,6 +498,7 @@ public void shouldDefaultToPostMethod() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com"
)));
@@ -498,6 +519,7 @@ public void shouldAcceptPutMethod() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"method", "PUT"
)));
@@ -519,6 +541,7 @@ public void shouldNormalizeMethodToUpperCase() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"method", "post"
)));
@@ -539,6 +562,7 @@ public void shouldThrowWhenMethodIsInvalid() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"method", "GET"
)));
@@ -561,6 +585,7 @@ public void shouldThrowWhenMethodIsDelete() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"method", "DELETE"
)));
@@ -587,6 +612,7 @@ public void shouldDefaultToTenSecondsTimeout() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com"
)));
@@ -606,6 +632,7 @@ public void shouldAcceptCustomTimeout() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"timeout-seconds", "30"
)));
@@ -626,6 +653,7 @@ public void shouldThrowWhenTimeoutIsZero() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"timeout-seconds", "0"
)));
@@ -648,6 +676,7 @@ public void shouldThrowWhenTimeoutIsNegative() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"timeout-seconds", "-1"
)));
@@ -670,6 +699,7 @@ public void shouldAcceptMinimumTimeout() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"timeout-seconds", "1"
)));
@@ -684,17 +714,20 @@ public void shouldAcceptMinimumTimeout() {
}
// =========================================================================
- // Message Headers
+ // Custom Headers
// =========================================================================
@Test
- public void shouldDefaultToMessageHeadersEnabled() {
+ public void shouldParseCustomHeaders() {
// arrange
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
- "host", "example.com"
+ "kind", "http",
+ "host", "example.com",
+ "headers.X-Custom-Header", "custom-value",
+ "headers.Authorization", "Bearer token123"
)));
// act
@@ -703,18 +736,44 @@ public void shouldDefaultToMessageHeadersEnabled() {
// assert
- assertThat(config.isMessageHeadersEnabled()).isTrue();
+ assertThat(config.getCustomHeaders()).containsEntry("X-Custom-Header", "custom-value");
+ assertThat(config.getCustomHeaders()).containsEntry("Authorization", "Bearer token123");
+ }
+
+ @Test
+ public void shouldIgnoreEmptyHeaders() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "http");
+ configMap.put("host", "example.com");
+ configMap.put("headers.Empty-Header", "");
+ configMap.put("headers.Valid-Header", "value");
+
+ var config = new HttpDestinationConfig();
+ config.setConfiguration(new MapConfiguration(configMap));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCustomHeaders()).doesNotContainKey("Empty-Header");
+ assertThat(config.getCustomHeaders()).containsEntry("Valid-Header", "value");
}
@Test
- public void shouldDisableMessageHeaders() {
+ public void shouldTrimHeaderValues() {
// arrange
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
- "message-headers-enabled", "false"
+ "headers.Trimmed", " value with spaces "
)));
// act
@@ -723,23 +782,22 @@ public void shouldDisableMessageHeaders() {
// assert
- assertThat(config.isMessageHeadersEnabled()).isFalse();
+ assertThat(config.getCustomHeaders()).containsEntry("Trimmed", "value with spaces");
}
// =========================================================================
- // Custom Headers
+ // OAuth
// =========================================================================
@Test
- public void shouldParseCustomHeaders() {
+ public void shouldCreateOAuthMaterial() {
// arrange
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
- "host", "example.com",
- "headers.X-Custom-Header", "custom-value",
- "headers.Authorization", "Bearer token123"
+ "kind", "http",
+ "host", "example.com"
)));
// act
@@ -748,22 +806,19 @@ public void shouldParseCustomHeaders() {
// assert
- assertThat(config.getHeaders()).containsEntry("X-Custom-Header", "custom-value");
- assertThat(config.getHeaders()).containsEntry("Authorization", "Bearer token123");
+ assertThat(config.getOauth()).isNotNull();
}
@Test
- public void shouldIgnoreEmptyHeaders() {
+ public void shouldSetOauthEnabledToFalseByDefault() {
// arrange
- var configMap = new HashMap();
- configMap.put("host", "example.com");
- configMap.put("headers.Empty-Header", "");
- configMap.put("headers.Valid-Header", "value");
-
var config = new HttpDestinationConfig();
- config.setConfiguration(new MapConfiguration(configMap));
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
+ "host", "example.com"
+ )));
// act
@@ -771,20 +826,24 @@ public void shouldIgnoreEmptyHeaders() {
// assert
- assertThat(config.getHeaders()).doesNotContainKey("Empty-Header");
- assertThat(config.getHeaders()).containsEntry("Valid-Header", "value");
+ assertThat(config.isOauthEnabled()).isFalse();
}
@Test
- public void shouldTrimHeaderValues() {
+ public void shouldSetOauthEnabledWhenOauthConfigured() {
// arrange
+ var configMap = new HashMap();
+ configMap.put("kind", "http");
+ configMap.put("host", "example.com");
+ configMap.put("oauth.enabled", "true");
+ configMap.put("oauth.token-url", "http://localhost/token");
+ configMap.put("oauth.client-id", "test-client");
+ configMap.put("oauth.client-secret", "test-secret");
+
var config = new HttpDestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "example.com",
- "headers.Trimmed", " value with spaces "
- )));
+ config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -792,22 +851,23 @@ public void shouldTrimHeaderValues() {
// assert
- assertThat(config.getHeaders()).containsEntry("Trimmed", "value with spaces");
+ assertThat(config.isOauthEnabled()).isTrue();
+ assertThat(config.getOauth()).isNotNull();
+ assertThat(config.getOauth().isEnabled()).isTrue();
}
- // =========================================================================
- // OAuth
- // =========================================================================
-
@Test
- public void shouldCreateOAuthMaterial() {
+ public void shouldSetOauthEnabledToFalseWhenOauthExplicitlyDisabled() {
// arrange
+ var configMap = new HashMap();
+ configMap.put("kind", "http");
+ configMap.put("host", "example.com");
+ configMap.put("oauth.enabled", "false");
+
var config = new HttpDestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "example.com"
- )));
+ config.setConfiguration(new MapConfiguration(configMap));
// act
@@ -815,7 +875,7 @@ public void shouldCreateOAuthMaterial() {
// assert
- assertThat(config.getOauth()).isNotNull();
+ assertThat(config.isOauthEnabled()).isFalse();
}
// =========================================================================
@@ -829,6 +889,7 @@ public void shouldDefaultToTlsDisabled() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com"
)));
@@ -848,6 +909,7 @@ public void shouldEnableTls() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"host", "example.com",
"tls.enabled", "true"
)));
@@ -868,6 +930,7 @@ public void shouldAutoEnableTlsFromHttpsUrl() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "https://example.com/events"
)));
@@ -891,6 +954,7 @@ public void shouldUseUrlOverHostWhenBothProvided() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http://from-url.com:9090/path",
"host", "from-host.com"
)));
@@ -913,6 +977,7 @@ public void shouldUseUrlOverPortWhenBothProvided() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http://example.com:9090",
"port", "8080"
)));
@@ -933,6 +998,7 @@ public void shouldUseUrlOverPathWhenBothProvided() {
var config = new HttpDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "http",
"url", "http://example.com/from-url",
"path-and-query", "/from-config"
)));
@@ -956,12 +1022,12 @@ public void shouldInitializeWithFullConfiguration() {
// arrange
var configMap = new HashMap();
+ configMap.put("kind", "http");
configMap.put("host", "api.example.com");
configMap.put("port", "8443");
configMap.put("path-and-query", "/events/keycloak?source=auth");
configMap.put("method", "PUT");
configMap.put("timeout-seconds", "30");
- configMap.put("message-headers-enabled", "false");
configMap.put("headers.X-API-Key", "secret-key");
configMap.put("tls.enabled", "true");
@@ -980,8 +1046,7 @@ public void shouldInitializeWithFullConfiguration() {
assertThat(config.getMethod()).isEqualTo("PUT");
assertThat(config.isMethodIsPost()).isFalse();
assertThat(config.getTimeoutSeconds()).isEqualTo(30);
- assertThat(config.isMessageHeadersEnabled()).isFalse();
- assertThat(config.getHeaders()).containsEntry("X-API-Key", "secret-key");
+ assertThat(config.getCustomHeaders()).containsEntry("X-API-Key", "secret-key");
assertThat(config.getScheme()).isEqualTo("https");
assertThat(config.getTls().isEnabled()).isTrue();
assertThat(config.getUrl()).isEqualTo("https://api.example.com:8443/events/keycloak?source=auth");
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/kafkadestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/kafkadestinationconfig/initializeTests.java
similarity index 79%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/kafkadestinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/kafkadestinationconfig/initializeTests.java
index 2d2b47a1..a0ac0242 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/kafkadestinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/kafkadestinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.kafkadestinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.kafkadestinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -27,6 +27,7 @@ public void shouldThrowWhenBootstrapServersIsMissing() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"topic", "test-topic"
)));
@@ -48,6 +49,7 @@ public void shouldThrowWhenBootstrapServersIsEmpty() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "",
"topic", "test-topic"
)));
@@ -70,6 +72,7 @@ public void shouldThrowWhenBootstrapServersIsBlank() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", " ",
"topic", "test-topic"
)));
@@ -96,6 +99,7 @@ public void shouldThrowWhenTopicIsMissing() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092"
)));
@@ -117,6 +121,7 @@ public void shouldThrowWhenTopicIsEmpty() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", ""
)));
@@ -139,6 +144,7 @@ public void shouldThrowWhenTopicIsBlank() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", " "
)));
@@ -165,6 +171,7 @@ public void shouldInitializeWithMinimumRequiredFields() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -187,6 +194,7 @@ public void shouldTrimTopic() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", " test-topic "
)));
@@ -207,6 +215,7 @@ public void shouldRemoveTopicFromProducerConfiguration() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -231,6 +240,7 @@ public void shouldDefaultAcksToAll() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -251,6 +261,7 @@ public void shouldDefaultLingerMsToFive() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -271,6 +282,7 @@ public void shouldDefaultBatchSizeTo32768() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -291,6 +303,7 @@ public void shouldDefaultCompressionTypeToLz4() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -311,6 +324,7 @@ public void shouldDefaultEnableIdempotenceToTrue() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -331,6 +345,7 @@ public void shouldDefaultMaxInFlightRequestsToFive() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -351,6 +366,7 @@ public void shouldDefaultKeySerializerToStringSerializer() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -371,6 +387,7 @@ public void shouldDefaultValueSerializerToByteArraySerializer() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -395,6 +412,7 @@ public void shouldOverrideDefaultAcks() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic",
"acks", "1"
@@ -416,6 +434,7 @@ public void shouldOverrideDefaultLingerMs() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic",
"linger.ms", "100"
@@ -437,6 +456,7 @@ public void shouldOverrideDefaultCompressionType() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic",
"compression.type", "snappy"
@@ -458,6 +478,7 @@ public void shouldPassThroughCustomProducerProperties() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic",
"buffer.memory", "67108864",
@@ -474,51 +495,6 @@ public void shouldPassThroughCustomProducerProperties() {
assertThat(config.getProducerConfiguration().getProperty("max.block.ms")).isEqualTo("60000");
}
- // =========================================================================
- // Message Headers
- // =========================================================================
-
- @Test
- public void shouldDefaultToMessageHeadersEnabled() {
-
- // arrange
-
- var config = new KafkaDestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "bootstrap.servers", "localhost:9092",
- "topic", "test-topic"
- )));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isTrue();
- }
-
- @Test
- public void shouldDisableMessageHeaders() {
-
- // arrange
-
- var config = new KafkaDestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "bootstrap.servers", "localhost:9092",
- "topic", "test-topic",
- "message-headers-enabled", "false"
- )));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isFalse();
- }
-
// =========================================================================
// TLS Configuration
// =========================================================================
@@ -530,6 +506,7 @@ public void shouldDefaultToTlsDisabled() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic"
)));
@@ -550,6 +527,7 @@ public void shouldEnableTls() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic",
"tls.enabled", "true",
@@ -576,6 +554,7 @@ public void shouldThrowWhenTransactionalIdIsProvided() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "localhost:9092",
"topic", "test-topic",
"transactional.id", "my-transactional-id"
@@ -603,6 +582,7 @@ public void shouldAcceptMultipleBootstrapServers() {
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "kafka",
"bootstrap.servers", "broker1:9092,broker2:9092,broker3:9092",
"topic", "test-topic"
)));
@@ -626,6 +606,7 @@ public void shouldFilterNullValues() {
// arrange
var configMap = new HashMap();
+ configMap.put("kind", "kafka");
configMap.put("bootstrap.servers", "localhost:9092");
configMap.put("topic", "test-topic");
configMap.put("null-property", null);
@@ -652,6 +633,7 @@ public void shouldInitializeWithFullConfiguration() {
// arrange
var configMap = new HashMap();
+ configMap.put("kind", "kafka");
configMap.put("bootstrap.servers", "broker1:9092,broker2:9092");
configMap.put("topic", "keycloak-events");
configMap.put("acks", "1");
@@ -660,7 +642,6 @@ public void shouldInitializeWithFullConfiguration() {
configMap.put("compression.type", "gzip");
configMap.put("enable.idempotence", "false");
configMap.put("max.in.flight.requests.per.connection", "10");
- configMap.put("message-headers-enabled", "false");
var config = new KafkaDestinationConfig();
config.setConfiguration(new MapConfiguration(configMap));
@@ -672,7 +653,6 @@ public void shouldInitializeWithFullConfiguration() {
// assert
assertThat(config.getTopic()).isEqualTo("keycloak-events");
- assertThat(config.isMessageHeadersEnabled()).isFalse();
assertThat(config.isTransactional()).isFalse();
var props = config.getProducerConfiguration();
@@ -685,4 +665,145 @@ public void shouldInitializeWithFullConfiguration() {
assertThat(props.getProperty("max.in.flight.requests.per.connection")).isEqualTo("10");
assertThat(props.containsKey("topic")).isFalse();
}
+
+ // =========================================================================
+ // Custom Headers Bytes
+ // =========================================================================
+
+ @Test
+ public void shouldConvertCustomHeadersToBytes() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "kafka");
+ configMap.put("bootstrap.servers", "localhost:9092");
+ configMap.put("topic", "test-topic");
+ configMap.put("headers.X-Custom-Header", "custom-value");
+ configMap.put("headers.Authorization", "Bearer token123");
+
+ var config = new KafkaDestinationConfig();
+ config.setConfiguration(new MapConfiguration(configMap));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCustomHeadersBytes())
+ .containsEntry("X-Custom-Header", "custom-value".getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .containsEntry("Authorization", "Bearer token123".getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .hasSize(2);
+ }
+
+ @Test
+ public void shouldPopulateCustomHeadersBytesEntrySet() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "kafka");
+ configMap.put("bootstrap.servers", "localhost:9092");
+ configMap.put("topic", "test-topic");
+ configMap.put("headers.X-Header-1", "value1");
+ configMap.put("headers.X-Header-2", "value2");
+
+ var config = new KafkaDestinationConfig();
+ config.setConfiguration(new MapConfiguration(configMap));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCustomHeadersBytesEntrySet())
+ .isNotNull()
+ .hasSize(2);
+
+ var keys = config.getCustomHeadersBytesEntrySet().stream()
+ .map(Map.Entry::getKey)
+ .toList();
+
+ assertThat(keys).containsExactlyInAnyOrder("X-Header-1", "X-Header-2");
+ }
+
+ @Test
+ public void shouldHaveEmptyCustomHeadersBytesWhenNoHeaders() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "kafka");
+ configMap.put("bootstrap.servers", "localhost:9092");
+ configMap.put("topic", "test-topic");
+
+ var config = new KafkaDestinationConfig();
+ config.setConfiguration(new MapConfiguration(configMap));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCustomHeadersBytes()).isEmpty();
+ assertThat(config.getCustomHeadersBytesEntrySet()).isEmpty();
+ }
+
+ @Test
+ public void shouldHandleUtf8InCustomHeadersBytes() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "kafka");
+ configMap.put("bootstrap.servers", "localhost:9092");
+ configMap.put("topic", "test-topic");
+ configMap.put("headers.X-Unicode", "hΓ©llo wΓΆrld δΈζ");
+
+ var config = new KafkaDestinationConfig();
+ config.setConfiguration(new MapConfiguration(configMap));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ var expectedBytes = "hΓ©llo wΓΆrld δΈζ".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ assertThat(config.getCustomHeadersBytes().get("X-Unicode")).isEqualTo(expectedBytes);
+ }
+
+ @Test
+ public void shouldNotIncludeReservedHeadersInCustomHeadersBytes() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "kafka");
+ configMap.put("bootstrap.servers", "localhost:9092");
+ configMap.put("topic", "test-topic");
+ configMap.put("headers.eventkind", "should-be-filtered");
+ configMap.put("headers.eventtype", "should-be-filtered");
+ configMap.put("headers.contenttype", "should-be-filtered");
+ configMap.put("headers.X-Custom", "allowed");
+
+ var config = new KafkaDestinationConfig();
+ config.setConfiguration(new MapConfiguration(configMap));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCustomHeadersBytes())
+ .doesNotContainKey("eventkind")
+ .doesNotContainKey("eventtype")
+ .doesNotContainKey("contenttype")
+ .containsKey("X-Custom")
+ .hasSize(1);
+ }
}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/mqtt3destinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/mqtt3destinationconfig/initializeTests.java
similarity index 92%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/mqtt3destinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/mqtt3destinationconfig/initializeTests.java
index c11adf9a..1c259390 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/mqtt3destinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/mqtt3destinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.mqtt3destinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.mqtt3destinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -24,6 +24,7 @@ public void shouldThrowWhenHostIsMissing() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"topic", "test-topic"
)));
@@ -45,6 +46,7 @@ public void shouldThrowWhenHostIsEmpty() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "",
"topic", "test-topic"
)));
@@ -67,6 +69,7 @@ public void shouldThrowWhenHostIsBlank() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", " ",
"topic", "test-topic"
)));
@@ -93,6 +96,7 @@ public void shouldThrowWhenTopicIsMissing() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost"
)));
@@ -114,6 +118,7 @@ public void shouldThrowWhenTopicIsEmpty() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", ""
)));
@@ -136,6 +141,7 @@ public void shouldThrowWhenTopicIsBlank() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", " "
)));
@@ -162,6 +168,7 @@ public void shouldDefaultToTcpTransportType() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -182,6 +189,7 @@ public void shouldAcceptWebsocketTransportType() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket"
@@ -203,6 +211,7 @@ public void shouldThrowWhenTransportTypeIsInvalid() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"transport-type", "invalid"
@@ -226,6 +235,7 @@ public void shouldNormalizeTransportTypeToLowerCase() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"transport-type", "TCP"
@@ -251,6 +261,7 @@ public void shouldUseDefaultTcpPortWhenTlsDisabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -271,6 +282,7 @@ public void shouldUseDefaultTlsPortWhenTlsEnabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"tls.enabled", "true"
@@ -292,6 +304,7 @@ public void shouldUseDefaultWsPortWhenWebSocketAndTlsDisabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket"
@@ -313,6 +326,7 @@ public void shouldUseDefaultWssPortWhenWebSocketAndTlsEnabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket",
@@ -335,6 +349,7 @@ public void shouldUseCustomPort() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"port", "11883"
@@ -360,6 +375,7 @@ public void shouldThrowWhenPortIsZero() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"port", "0"
@@ -383,6 +399,7 @@ public void shouldThrowWhenPortIsNegative() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"port", "-1"
@@ -406,6 +423,7 @@ public void shouldThrowWhenPortIsTooLarge() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"port", "65536"
@@ -429,6 +447,7 @@ public void shouldAcceptMinimumPort() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"port", "1"
@@ -450,6 +469,7 @@ public void shouldAcceptMaximumPort() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"port", "65535"
@@ -475,6 +495,7 @@ public void shouldConstructTcpUrlWhenTlsDisabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -496,6 +517,7 @@ public void shouldConstructSslUrlWhenTlsEnabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"tls.enabled", "true"
@@ -518,6 +540,7 @@ public void shouldConstructWsUrlWhenWebSocketAndTlsDisabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket"
@@ -540,6 +563,7 @@ public void shouldConstructWssUrlWhenWebSocketAndTlsEnabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket",
@@ -567,6 +591,7 @@ public void shouldDefaultToQosOne() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -587,6 +612,7 @@ public void shouldAcceptQosZero() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"qos", "0"
@@ -608,6 +634,7 @@ public void shouldAcceptQosTwo() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"qos", "2"
@@ -629,6 +656,7 @@ public void shouldThrowWhenQosIsBelowMinimum() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"qos", "-1"
@@ -652,6 +680,7 @@ public void shouldThrowWhenQosIsAboveMaximum() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"qos", "3"
@@ -679,6 +708,7 @@ public void shouldDefaultToRetainedFalse() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -699,6 +729,7 @@ public void shouldAcceptRetainedTrue() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"retained", "true"
@@ -717,49 +748,6 @@ public void shouldAcceptRetainedTrue() {
// Message Headers - Not Supported in MQTT 3
// =========================================================================
- @Test
- public void shouldDefaultToMessageHeadersDisabled() {
-
- // arrange
-
- var config = new Mqtt3DestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "localhost",
- "topic", "test-topic"
- )));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isFalse();
- }
-
- @Test
- public void shouldThrowWhenMessageHeadersEnabledForMqtt3() {
-
- // arrange
-
- var config = new Mqtt3DestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "localhost",
- "topic", "test-topic",
- "message-headers-enabled", "true"
- )));
-
- // act
-
- var thrown = catchThrowable(() -> config.initialize());
-
- // assert
-
- assertThat(thrown)
- .isInstanceOf(IllegalStateException.class)
- .hasMessage("message headers are not supported in MQTT 3");
- }
-
// =========================================================================
// Client ID Prefix
// =========================================================================
@@ -771,6 +759,7 @@ public void shouldGenerateClientIdPrefixWhenNotProvided() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -791,6 +780,7 @@ public void shouldUseProvidedClientIdPrefix() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"client-id-prefix", "kete-"
@@ -816,6 +806,7 @@ public void shouldDefaultToCleanSessionTrue() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -837,6 +828,7 @@ public void shouldAcceptCleanSessionFalse() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"clean-session", "false"
@@ -862,6 +854,7 @@ public void shouldDefaultToTenSecondsConnectionTimeout() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -872,8 +865,8 @@ public void shouldDefaultToTenSecondsConnectionTimeout() {
// assert
- assertThat(config.getConnectionTimeout()).isEqualTo(10);
- assertThat(config.getConnectOptions().getConnectionTimeout()).isEqualTo(10);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(Mqtt3DestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ assertThat(config.getConnectOptions().getConnectionTimeout()).isEqualTo(Mqtt3DestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
}
@Test
@@ -883,9 +876,10 @@ public void shouldAcceptCustomConnectionTimeout() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
- "connection-timeout", "30"
+ "connection-timeout-seconds", "30"
)));
// act
@@ -894,7 +888,7 @@ public void shouldAcceptCustomConnectionTimeout() {
// assert
- assertThat(config.getConnectionTimeout()).isEqualTo(30);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(30);
}
// =========================================================================
@@ -908,6 +902,7 @@ public void shouldDefaultToSixtySecondsKeepAlive() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -918,8 +913,8 @@ public void shouldDefaultToSixtySecondsKeepAlive() {
// assert
- assertThat(config.getKeepAliveInterval()).isEqualTo(60);
- assertThat(config.getConnectOptions().getKeepAliveInterval()).isEqualTo(60);
+ assertThat(config.getKeepAliveIntervalSeconds()).isEqualTo(Mqtt3DestinationConfig.DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS);
+ assertThat(config.getConnectOptions().getKeepAliveInterval()).isEqualTo(Mqtt3DestinationConfig.DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS);
}
@Test
@@ -929,9 +924,10 @@ public void shouldAcceptCustomKeepAliveInterval() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
- "keep-alive-interval", "120"
+ "keep-alive-interval-seconds", "120"
)));
// act
@@ -940,7 +936,7 @@ public void shouldAcceptCustomKeepAliveInterval() {
// assert
- assertThat(config.getKeepAliveInterval()).isEqualTo(120);
+ assertThat(config.getKeepAliveIntervalSeconds()).isEqualTo(120);
}
// =========================================================================
@@ -954,6 +950,7 @@ public void shouldDefaultToEmptyUsername() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -974,6 +971,7 @@ public void shouldDefaultToEmptyPassword() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -994,6 +992,7 @@ public void shouldAcceptUsernameAndPassword() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"username", "mqttuser",
@@ -1017,6 +1016,7 @@ public void shouldSetUsernameOnConnectOptions() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"username", "mqttuser"
@@ -1038,6 +1038,7 @@ public void shouldSetPasswordOnConnectOptions() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"password", "mqttpass"
@@ -1059,6 +1060,7 @@ public void shouldTrimUsername() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"username", " mqttuser "
@@ -1080,6 +1082,7 @@ public void shouldTrimPassword() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"password", " mqttpass "
@@ -1105,6 +1108,7 @@ public void shouldCreateConnectOptions() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -1125,6 +1129,7 @@ public void shouldEnableAutomaticReconnect() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -1149,6 +1154,7 @@ public void shouldDefaultToTlsDisabled() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic"
)));
@@ -1169,6 +1175,7 @@ public void shouldEnableTls() {
var config = new Mqtt3DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-3",
"host", "localhost",
"topic", "test-topic",
"tls.enabled", "true"
@@ -1193,6 +1200,7 @@ public void shouldInitializeWithFullConfiguration() {
// arrange
var configMap = new HashMap();
+ configMap.put("kind", "mqtt-3");
configMap.put("host", "mqtt.example.com");
configMap.put("port", "8884");
configMap.put("topic", "keycloak/events");
@@ -1203,8 +1211,8 @@ public void shouldInitializeWithFullConfiguration() {
configMap.put("password", "secret");
configMap.put("client-id-prefix", "kete-prod-");
configMap.put("clean-session", "false");
- configMap.put("connection-timeout", "15");
- configMap.put("keep-alive-interval", "45");
+ configMap.put("connection-timeout-seconds", "15");
+ configMap.put("keep-alive-interval-seconds", "45");
configMap.put("tls.enabled", "true");
var config = new Mqtt3DestinationConfig();
@@ -1226,12 +1234,11 @@ public void shouldInitializeWithFullConfiguration() {
assertThat(config.getPassword()).isEqualTo("secret");
assertThat(config.getClientIdPrefix()).isEqualTo("kete-prod-");
assertThat(config.isCleanSession()).isFalse();
- assertThat(config.getConnectionTimeout()).isEqualTo(15);
- assertThat(config.getKeepAliveInterval()).isEqualTo(45);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(15);
+ assertThat(config.getKeepAliveIntervalSeconds()).isEqualTo(45);
assertThat(config.getTls().isEnabled()).isTrue();
assertThat(config.getUrl()).isEqualTo("ssl://mqtt.example.com:8884");
assertThat(config.getScheme()).isEqualTo("ssl://");
assertThat(config.getConnectOptions()).isNotNull();
- assertThat(config.isMessageHeadersEnabled()).isFalse();
}
}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/mqtt5destinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/mqtt5destinationconfig/initializeTests.java
similarity index 92%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/mqtt5destinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/mqtt5destinationconfig/initializeTests.java
index 98f5ca62..298c1fdd 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/mqtt5destinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/mqtt5destinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.mqtt5destinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.mqtt5destinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -25,6 +25,7 @@ public void shouldThrowWhenHostIsMissing() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"topic", "test-topic"
)));
@@ -46,6 +47,7 @@ public void shouldThrowWhenHostIsEmpty() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "",
"topic", "test-topic"
)));
@@ -68,6 +70,7 @@ public void shouldThrowWhenHostIsBlank() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", " ",
"topic", "test-topic"
)));
@@ -94,6 +97,7 @@ public void shouldThrowWhenTopicIsMissing() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost"
)));
@@ -115,6 +119,7 @@ public void shouldThrowWhenTopicIsEmpty() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", ""
)));
@@ -137,6 +142,7 @@ public void shouldThrowWhenTopicIsBlank() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", " "
)));
@@ -163,6 +169,7 @@ public void shouldDefaultToTcpTransportType() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -183,6 +190,7 @@ public void shouldAcceptWebsocketTransportType() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket"
@@ -204,6 +212,7 @@ public void shouldThrowWhenTransportTypeIsInvalid() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"transport-type", "invalid"
@@ -227,6 +236,7 @@ public void shouldNormalizeTransportTypeToLowerCase() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"transport-type", "WEBSOCKET"
@@ -252,6 +262,7 @@ public void shouldUseDefaultTcpPortWhenTlsDisabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -272,6 +283,7 @@ public void shouldUseDefaultTlsPortWhenTlsEnabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"tls.enabled", "true"
@@ -293,6 +305,7 @@ public void shouldUseDefaultWsPortWhenWebSocketAndTlsDisabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket"
@@ -314,6 +327,7 @@ public void shouldUseDefaultWssPortWhenWebSocketAndTlsEnabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket",
@@ -336,6 +350,7 @@ public void shouldUseCustomPort() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"port", "11883"
@@ -361,6 +376,7 @@ public void shouldThrowWhenPortIsZero() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"port", "0"
@@ -384,6 +400,7 @@ public void shouldThrowWhenPortIsNegative() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"port", "-1"
@@ -407,6 +424,7 @@ public void shouldThrowWhenPortIsTooLarge() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"port", "65536"
@@ -430,6 +448,7 @@ public void shouldAcceptMinimumPort() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"port", "1"
@@ -451,6 +470,7 @@ public void shouldAcceptMaximumPort() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"port", "65535"
@@ -476,6 +496,7 @@ public void shouldConstructTcpUrlWhenTlsDisabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -497,6 +518,7 @@ public void shouldConstructSslUrlWhenTlsEnabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"tls.enabled", "true"
@@ -519,6 +541,7 @@ public void shouldConstructWsUrlWhenWebSocketAndTlsDisabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket"
@@ -541,6 +564,7 @@ public void shouldConstructWssUrlWhenWebSocketAndTlsEnabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"transport-type", "websocket",
@@ -568,6 +592,7 @@ public void shouldDefaultToQosOne() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -588,6 +613,7 @@ public void shouldAcceptQosZero() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"qos", "0"
@@ -609,6 +635,7 @@ public void shouldAcceptQosTwo() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"qos", "2"
@@ -630,6 +657,7 @@ public void shouldThrowWhenQosIsBelowMinimum() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"qos", "-1"
@@ -653,6 +681,7 @@ public void shouldThrowWhenQosIsAboveMaximum() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"qos", "3"
@@ -680,6 +709,7 @@ public void shouldDefaultToRetainedFalse() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -700,6 +730,7 @@ public void shouldAcceptRetainedTrue() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"retained", "true"
@@ -714,51 +745,6 @@ public void shouldAcceptRetainedTrue() {
assertThat(config.isRetained()).isTrue();
}
- // =========================================================================
- // Message Headers - Supported in MQTT 5
- // =========================================================================
-
- @Test
- public void shouldDefaultToMessageHeadersEnabled() {
-
- // arrange
-
- var config = new Mqtt5DestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "localhost",
- "topic", "test-topic"
- )));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isTrue();
- }
-
- @Test
- public void shouldDisableMessageHeaders() {
-
- // arrange
-
- var config = new Mqtt5DestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of(
- "host", "localhost",
- "topic", "test-topic",
- "message-headers-enabled", "false"
- )));
-
- // act
-
- config.initialize();
-
- // assert
-
- assertThat(config.isMessageHeadersEnabled()).isFalse();
- }
-
// =========================================================================
// Client ID Prefix
// =========================================================================
@@ -770,6 +756,7 @@ public void shouldGenerateClientIdPrefixWhenNotProvided() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -790,6 +777,7 @@ public void shouldUseProvidedClientIdPrefix() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"client-id-prefix", "kete-"
@@ -815,6 +803,7 @@ public void shouldDefaultToCleanSessionTrue() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -836,6 +825,7 @@ public void shouldAcceptCleanSessionFalse() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"clean-session", "false"
@@ -861,6 +851,7 @@ public void shouldDefaultToTenSecondsConnectionTimeout() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -871,8 +862,8 @@ public void shouldDefaultToTenSecondsConnectionTimeout() {
// assert
- assertThat(config.getConnectionTimeout()).isEqualTo(10);
- assertThat(config.getConnectOptions().getConnectionTimeout()).isEqualTo(10);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(Mqtt5DestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ assertThat(config.getConnectOptions().getConnectionTimeout()).isEqualTo(Mqtt5DestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
}
@Test
@@ -882,9 +873,10 @@ public void shouldAcceptCustomConnectionTimeout() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
- "connection-timeout", "30"
+ "connection-timeout-seconds", "30"
)));
// act
@@ -893,7 +885,7 @@ public void shouldAcceptCustomConnectionTimeout() {
// assert
- assertThat(config.getConnectionTimeout()).isEqualTo(30);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(30);
}
// =========================================================================
@@ -907,6 +899,7 @@ public void shouldDefaultToSixtySecondsKeepAlive() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -917,8 +910,8 @@ public void shouldDefaultToSixtySecondsKeepAlive() {
// assert
- assertThat(config.getKeepAliveInterval()).isEqualTo(60);
- assertThat(config.getConnectOptions().getKeepAliveInterval()).isEqualTo(60);
+ assertThat(config.getKeepAliveIntervalSeconds()).isEqualTo(Mqtt5DestinationConfig.DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS);
+ assertThat(config.getConnectOptions().getKeepAliveInterval()).isEqualTo(Mqtt5DestinationConfig.DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS);
}
@Test
@@ -928,9 +921,10 @@ public void shouldAcceptCustomKeepAliveInterval() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
- "keep-alive-interval", "120"
+ "keep-alive-interval-seconds", "120"
)));
// act
@@ -939,7 +933,7 @@ public void shouldAcceptCustomKeepAliveInterval() {
// assert
- assertThat(config.getKeepAliveInterval()).isEqualTo(120);
+ assertThat(config.getKeepAliveIntervalSeconds()).isEqualTo(120);
}
// =========================================================================
@@ -953,6 +947,7 @@ public void shouldDefaultToEmptyUsername() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -973,6 +968,7 @@ public void shouldDefaultToEmptyPassword() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -993,6 +989,7 @@ public void shouldAcceptUsernameAndPassword() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"username", "mqtt5user",
@@ -1016,6 +1013,7 @@ public void shouldSetUsernameOnConnectOptions() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"username", "mqtt5user"
@@ -1037,6 +1035,7 @@ public void shouldSetPasswordOnConnectOptionsAsBytes() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"password", "mqtt5pass"
@@ -1058,6 +1057,7 @@ public void shouldTrimUsername() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"username", " mqtt5user "
@@ -1079,6 +1079,7 @@ public void shouldTrimPassword() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"password", " mqtt5pass "
@@ -1104,6 +1105,7 @@ public void shouldCreateConnectOptions() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -1124,6 +1126,7 @@ public void shouldEnableAutomaticReconnect() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -1148,6 +1151,7 @@ public void shouldDefaultToTlsDisabled() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic"
)));
@@ -1168,6 +1172,7 @@ public void shouldEnableTls() {
var config = new Mqtt5DestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "mqtt-5",
"host", "localhost",
"topic", "test-topic",
"tls.enabled", "true"
@@ -1192,6 +1197,7 @@ public void shouldInitializeWithFullConfiguration() {
// arrange
var configMap = new HashMap();
+ configMap.put("kind", "mqtt-5");
configMap.put("host", "mqtt5.example.com");
configMap.put("port", "8885");
configMap.put("topic", "keycloak/events/v5");
@@ -1202,9 +1208,8 @@ public void shouldInitializeWithFullConfiguration() {
configMap.put("password", "secret");
configMap.put("client-id-prefix", "kete-v5-");
configMap.put("clean-session", "false");
- configMap.put("connection-timeout", "15");
- configMap.put("keep-alive-interval", "45");
- configMap.put("message-headers-enabled", "true");
+ configMap.put("connection-timeout-seconds", "15");
+ configMap.put("keep-alive-interval-seconds", "45");
configMap.put("tls.enabled", "true");
var config = new Mqtt5DestinationConfig();
@@ -1226,9 +1231,8 @@ public void shouldInitializeWithFullConfiguration() {
assertThat(config.getPassword()).isEqualTo("secret");
assertThat(config.getClientIdPrefix()).isEqualTo("kete-v5-");
assertThat(config.isCleanSession()).isFalse();
- assertThat(config.getConnectionTimeout()).isEqualTo(15);
- assertThat(config.getKeepAliveInterval()).isEqualTo(45);
- assertThat(config.isMessageHeadersEnabled()).isTrue();
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(15);
+ assertThat(config.getKeepAliveIntervalSeconds()).isEqualTo(45);
assertThat(config.getTls().isEnabled()).isTrue();
assertThat(config.getUrl()).isEqualTo("ssl://mqtt5.example.com:8885");
assertThat(config.getScheme()).isEqualTo("ssl://");
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/natsdestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/natsdestinationconfig/initializeTests.java
new file mode 100644
index 00000000..75d7e4e8
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/natsdestinationconfig/initializeTests.java
@@ -0,0 +1,492 @@
+package io.github.fortunen.kete.unittests.destinationconfigs.natsdestinationconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.destinations.nats.NatsDestinationConfig;
+
+public class initializeTests {
+
+ // =========================================================================
+ // Required Fields - Servers
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenServersIsMissing() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("servers is required");
+ }
+
+ @Test
+ public void shouldThrowWhenServersIsEmpty() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("servers is required");
+ }
+
+ @Test
+ public void shouldThrowWhenServersIsBlank() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", " ",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("servers is required");
+ }
+
+ // =========================================================================
+ // Required Fields - Subject
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenSubjectIsMissing() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("subject is required");
+ }
+
+ @Test
+ public void shouldThrowWhenSubjectIsEmpty() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", "",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("subject is required");
+ }
+
+ @Test
+ public void shouldThrowWhenSubjectIsBlank() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", " ",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("subject is required");
+ }
+
+ // =========================================================================
+ // Successful Initialization
+ // =========================================================================
+
+ @Test
+ public void shouldInitializeWithMinimalConfiguration() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://localhost:4222");
+ assertThat(config.getSubject()).isEqualTo("test-subject");
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(NatsDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ assertThat(config.getPingIntervalSeconds()).isEqualTo(NatsDestinationConfig.DEFAULT_PING_INTERVAL_SECONDS);
+ assertThat(config.getConnectionName()).isEqualTo(Constants.ID);
+ assertThat(config.getAuthMaterial()).isNotNull();
+ assertThat(config.getNatsOptions()).isNotNull();
+ }
+
+ @Test
+ public void shouldParseMultipleServers() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://server1:4222, nats://server2:4222, nats://server3:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://server1:4222", "nats://server2:4222", "nats://server3:4222");
+ }
+
+ @Test
+ public void shouldTrimServers() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", " nats://localhost:4222 ",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://localhost:4222");
+ }
+
+ @Test
+ public void shouldFilterEmptyServers() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://server1:4222,,nats://server2:4222, ,nats://server3:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://server1:4222", "nats://server2:4222", "nats://server3:4222");
+ }
+
+ // =========================================================================
+ // Connection Timeout
+ // =========================================================================
+
+ @Test
+ public void shouldUseDefaultConnectionTimeout() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(NatsDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ }
+
+ @Test
+ public void shouldUseCustomConnectionTimeout() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("authentication-method", "none");
+ map.put("connection-timeout-seconds", 30);
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(30);
+ }
+
+ @Test
+ public void shouldThrowWhenConnectionTimeoutIsNegative() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("authentication-method", "none");
+ map.put("connection-timeout-seconds", -1);
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("connection-timeout-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Ping Interval Seconds
+ // =========================================================================
+
+ @Test
+ public void shouldUseDefaultPingIntervalSeconds() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPingIntervalSeconds()).isEqualTo(60);
+ }
+
+ @Test
+ public void shouldUseCustomPingIntervalSeconds() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("authentication-method", "none");
+ map.put("ping-interval-seconds", 120);
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPingIntervalSeconds()).isEqualTo(120);
+ }
+
+ @Test
+ public void shouldThrowWhenPingIntervalSecondsIsNegative() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("authentication-method", "none");
+ map.put("ping-interval-seconds", -1);
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("ping-interval-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Connection Name
+ // =========================================================================
+
+ @Test
+ public void shouldUseDefaultConnectionName() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionName()).isEqualTo(Constants.ID);
+ }
+
+ @Test
+ public void shouldUseCustomConnectionName() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "authentication-method", "none",
+ "connection-name", "my-custom-connection"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionName()).isEqualTo("my-custom-connection");
+ }
+
+ // =========================================================================
+ // Message Headers Enabled
+ // =========================================================================
+
+ // =========================================================================
+ // NATS Options
+ // =========================================================================
+
+ @Test
+ public void shouldBuildNatsOptions() {
+
+ // arrange
+
+ var config = new NatsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getNatsOptions()).isNotNull();
+ assertThat(config.getNatsOptions().getConnectionName()).isEqualTo(Constants.ID);
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/natsjetstreamdestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/natsjetstreamdestinationconfig/initializeTests.java
new file mode 100644
index 00000000..6e323ad9
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/natsjetstreamdestinationconfig/initializeTests.java
@@ -0,0 +1,668 @@
+package io.github.fortunen.kete.unittests.destinationconfigs.natsjetstreamdestinationconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.Constants;
+import io.github.fortunen.kete.destinations.natsjetstream.NatsJetStreamDestinationConfig;
+
+public class initializeTests {
+
+ // =========================================================================
+ // Required Fields - Servers
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenServersIsMissing() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("servers is required");
+ }
+
+ @Test
+ public void shouldThrowWhenServersIsEmpty() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("servers is required");
+ }
+
+ @Test
+ public void shouldThrowWhenServersIsBlank() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", " ",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("servers is required");
+ }
+
+ // =========================================================================
+ // Required Fields - Subject
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenSubjectIsMissing() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("subject is required");
+ }
+
+ @Test
+ public void shouldThrowWhenSubjectIsEmpty() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("subject is required");
+ }
+
+ @Test
+ public void shouldThrowWhenSubjectIsBlank() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", " ",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("subject is required");
+ }
+
+ // =========================================================================
+ // Required Fields - Stream
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenStreamIsMissing() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("stream is required for JetStream destination");
+ }
+
+ @Test
+ public void shouldThrowWhenStreamIsEmpty() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("stream is required for JetStream destination");
+ }
+
+ @Test
+ public void shouldThrowWhenStreamIsBlank() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", " ",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("stream is required for JetStream destination");
+ }
+
+ // =========================================================================
+ // Successful Initialization
+ // =========================================================================
+
+ @Test
+ public void shouldInitializeWithMinimalConfiguration() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://localhost:4222");
+ assertThat(config.getSubject()).isEqualTo("test-subject");
+ assertThat(config.getStream()).isEqualTo("test-stream");
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(NatsJetStreamDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ assertThat(config.getPingIntervalSeconds()).isEqualTo(NatsJetStreamDestinationConfig.DEFAULT_PING_INTERVAL_SECONDS);
+ assertThat(config.getConnectionName()).isEqualTo(Constants.ID);
+ assertThat(config.getPublishTimeoutSeconds()).isEqualTo(NatsJetStreamDestinationConfig.DEFAULT_PUBLISH_TIMEOUT_SECONDS);
+ assertThat(config.getPublishTimeout()).isEqualTo(Duration.ofSeconds(10));
+ assertThat(config.getAuthMaterial()).isNotNull();
+ assertThat(config.getNatsOptions()).isNotNull();
+ }
+
+ @Test
+ public void shouldParseMultipleServers() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://server1:4222, nats://server2:4222, nats://server3:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://server1:4222", "nats://server2:4222", "nats://server3:4222");
+ }
+
+ @Test
+ public void shouldTrimServers() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", " nats://localhost:4222 ",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://localhost:4222");
+ }
+
+ @Test
+ public void shouldFilterEmptyServers() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://server1:4222,,nats://server2:4222, ,nats://server3:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServers()).containsExactly("nats://server1:4222", "nats://server2:4222", "nats://server3:4222");
+ }
+
+ // =========================================================================
+ // Connection Timeout
+ // =========================================================================
+
+ @Test
+ public void shouldUseDefaultConnectionTimeout() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(NatsJetStreamDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ }
+
+ @Test
+ public void shouldUseCustomConnectionTimeout() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats-jetstream");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("stream", "test-stream");
+ map.put("authentication-method", "none");
+ map.put("connection-timeout-seconds", 30);
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(30);
+ }
+
+ @Test
+ public void shouldThrowWhenConnectionTimeoutIsNegative() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats-jetstream");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("stream", "test-stream");
+ map.put("authentication-method", "none");
+ map.put("connection-timeout-seconds", -1);
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("connection-timeout-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Ping Interval Seconds
+ // =========================================================================
+
+ @Test
+ public void shouldUseDefaultPingIntervalSeconds() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPingIntervalSeconds()).isEqualTo(60);
+ }
+
+ @Test
+ public void shouldUseCustomPingIntervalSeconds() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats-jetstream");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("stream", "test-stream");
+ map.put("authentication-method", "none");
+ map.put("ping-interval-seconds", 120);
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPingIntervalSeconds()).isEqualTo(120);
+ }
+
+ @Test
+ public void shouldThrowWhenPingIntervalSecondsIsNegative() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats-jetstream");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("stream", "test-stream");
+ map.put("authentication-method", "none");
+ map.put("ping-interval-seconds", -1);
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("ping-interval-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Connection Name
+ // =========================================================================
+
+ @Test
+ public void shouldUseDefaultConnectionName() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionName()).isEqualTo(Constants.ID);
+ }
+
+ @Test
+ public void shouldUseCustomConnectionName() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none",
+ "connection-name", "my-jetstream-connection"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionName()).isEqualTo("my-jetstream-connection");
+ }
+
+ // =========================================================================
+ // Publish Timeout Seconds
+ // =========================================================================
+
+ @Test
+ public void shouldUseDefaultPublishTimeoutSeconds() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPublishTimeoutSeconds()).isEqualTo(10);
+ assertThat(config.getPublishTimeout()).isEqualTo(Duration.ofSeconds(10));
+ }
+
+ @Test
+ public void shouldUseCustomPublishTimeoutSeconds() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats-jetstream");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("stream", "test-stream");
+ map.put("authentication-method", "none");
+ map.put("publish-timeout-seconds", 30);
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPublishTimeoutSeconds()).isEqualTo(30);
+ assertThat(config.getPublishTimeout()).isEqualTo(Duration.ofSeconds(30));
+ }
+
+ @Test
+ public void shouldThrowWhenPublishTimeoutSecondsIsNegative() {
+
+ // arrange
+
+ var map = new HashMap();
+ map.put("kind", "nats-jetstream");
+ map.put("servers", "nats://localhost:4222");
+ map.put("subject", "test-subject");
+ map.put("stream", "test-stream");
+ map.put("authentication-method", "none");
+ map.put("publish-timeout-seconds", -1);
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(map));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("publish-timeout-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // NATS Options
+ // =========================================================================
+
+ @Test
+ public void shouldBuildNatsOptions() {
+
+ // arrange
+
+ var config = new NatsJetStreamDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "nats-jetstream",
+ "servers", "nats://localhost:4222",
+ "subject", "test-subject",
+ "stream", "test-stream",
+ "authentication-method", "none"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getNatsOptions()).isNotNull();
+ assertThat(config.getNatsOptions().getConnectionName()).isEqualTo(Constants.ID);
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/pulsardestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/pulsardestinationconfig/initializeTests.java
new file mode 100644
index 00000000..bde34db7
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/pulsardestinationconfig/initializeTests.java
@@ -0,0 +1,586 @@
+package io.github.fortunen.kete.unittests.destinationconfigs.pulsardestinationconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.util.Map;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.destinations.pulsar.PulsarDestinationConfig;
+
+public class initializeTests {
+
+ // =========================================================================
+ // Required Fields - Service URL
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenServiceUrlIsMissing() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "topic", "persistent://public/default/events"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("service-url is required");
+ }
+
+ @Test
+ public void shouldThrowWhenServiceUrlIsEmpty() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "",
+ "topic", "persistent://public/default/events"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("service-url is required");
+ }
+
+ @Test
+ public void shouldThrowWhenServiceUrlIsBlank() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", " ",
+ "topic", "persistent://public/default/events"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("service-url is required");
+ }
+
+ // =========================================================================
+ // Required Fields - Topic
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenTopicIsMissing() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("topic is required");
+ }
+
+ @Test
+ public void shouldThrowWhenTopicIsEmpty() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", ""
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("topic is required");
+ }
+
+ @Test
+ public void shouldThrowWhenTopicIsBlank() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", " "
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("topic is required");
+ }
+
+ // =========================================================================
+ // Success Cases
+ // =========================================================================
+
+ @Test
+ public void shouldInitializeWithRequiredFieldsOnly() throws Exception {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getServiceUrl()).isEqualTo("pulsar://localhost:6650");
+ assertThat(config.getTopic()).isEqualTo("persistent://public/default/events");
+ assertThat(config.getSendTimeoutSeconds()).isEqualTo(PulsarDestinationConfig.DEFAULT_SEND_TIMEOUT_SECONDS);
+ assertThat(config.getMaxPendingMessages()).isEqualTo(PulsarDestinationConfig.DEFAULT_MAX_PENDING_MESSAGES);
+ assertThat(config.getBatchingMaxMessages()).isEqualTo(PulsarDestinationConfig.DEFAULT_BATCHING_MAX_MESSAGES);
+ assertThat(config.isBlockIfQueueFull()).isEqualTo(PulsarDestinationConfig.DEFAULT_BLOCK_IF_QUEUE_FULL);
+ assertThat(config.getOperationTimeoutSeconds()).isEqualTo(PulsarDestinationConfig.DEFAULT_OPERATION_TIMEOUT_SECONDS);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(PulsarDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ assertThat(config.getKeepAliveIntervalSeconds()).isEqualTo(PulsarDestinationConfig.DEFAULT_KEEP_ALIVE_INTERVAL_SECONDS);
+ }
+
+ @Test
+ public void shouldInitializeWithCustomSendTimeout() throws Exception {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "send-timeout-seconds", 60
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getSendTimeoutSeconds()).isEqualTo(60);
+ }
+
+ @Test
+ public void shouldInitializeWithCustomMaxPendingMessages() throws Exception {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "max-pending-messages", 5000
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getMaxPendingMessages()).isEqualTo(5000);
+ }
+
+ @Test
+ public void shouldInitializeWithCustomBatchingMaxMessages() throws Exception {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "batching-max-messages", 2000
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getBatchingMaxMessages()).isEqualTo(2000);
+ }
+
+ @Test
+ public void shouldInitializeWithCustomBlockIfQueueFull() throws Exception {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "block-if-queue-full", false
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.isBlockIfQueueFull()).isFalse();
+ }
+
+ @Test
+ public void shouldInitializeWithCompressionType() throws Exception {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "compression-type", "ZSTD"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCompressionType().name()).isEqualTo("ZSTD");
+ }
+
+ @Test
+ public void shouldInitializeWithProducerName() throws Exception {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "producer-name", "keycloak-producer"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getProducerName()).isEqualTo("keycloak-producer");
+ }
+
+ // =========================================================================
+ // Validation - Invalid Compression Type
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenCompressionTypeIsInvalid() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "compression-type", "INVALID"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("compression-type must be one of: NONE, LZ4, ZLIB, ZSTD, SNAPPY");
+ }
+
+ // =========================================================================
+ // Validation - Batching Max Publish Delay
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenBatchingMaxPublishDelayIsZero() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "batching-max-publish-delay-seconds", 0
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("batching-max-publish-delay-seconds must be greater than 0");
+ }
+
+ @Test
+ public void shouldThrowWhenBatchingMaxPublishDelayIsNegative() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "batching-max-publish-delay-seconds", -1
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("batching-max-publish-delay-seconds must be greater than 0");
+ }
+
+ // =========================================================================
+ // Optional Fields - Username and Password
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToEmptyUsername() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getUsername()).isEmpty();
+ }
+
+ @Test
+ public void shouldDefaultToEmptyPassword() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPassword()).isEmpty();
+ }
+
+ @Test
+ public void shouldAcceptUsernameAndPassword() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "username", "pulsaruser",
+ "password", "pulsarpass"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getUsername()).isEqualTo("pulsaruser");
+ assertThat(config.getPassword()).isEqualTo("pulsarpass");
+ }
+
+ @Test
+ public void shouldTrimUsername() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "username", " pulsaruser "
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getUsername()).isEqualTo("pulsaruser");
+ }
+
+ @Test
+ public void shouldTrimPassword() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "password", " pulsarpass "
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPassword()).isEqualTo("pulsarpass");
+ }
+
+ // =========================================================================
+ // Token
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToEmptyToken() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getToken()).isEmpty();
+ }
+
+ @Test
+ public void shouldAcceptToken() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "token", "eyJhbGciOiJIUzI1NiJ9.test"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getToken()).isEqualTo("eyJhbGciOiJIUzI1NiJ9.test");
+ }
+
+ @Test
+ public void shouldTrimToken() {
+
+ // arrange
+
+ var config = new PulsarDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "pulsar",
+ "service-url", "pulsar://localhost:6650",
+ "topic", "persistent://public/default/events",
+ "token", " eyJhbGciOiJIUzI1NiJ9.test "
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getToken()).isEqualTo("eyJhbGciOiJIUzI1NiJ9.test");
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/redispubsubdestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/redispubsubdestinationconfig/initializeTests.java
new file mode 100644
index 00000000..85385c75
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/redispubsubdestinationconfig/initializeTests.java
@@ -0,0 +1,796 @@
+package io.github.fortunen.kete.unittests.destinationconfigs.redispubsubdestinationconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.destinations.redispubsub.RedisPubSubDestinationConfig;
+
+public class initializeTests {
+
+ // =========================================================================
+ // Required Fields - Host
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenHostIsMissing() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("host is required");
+ }
+
+ @Test
+ public void shouldThrowWhenHostIsEmpty() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("host is required");
+ }
+
+ @Test
+ public void shouldThrowWhenHostIsBlank() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", " ",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("host is required");
+ }
+
+ // =========================================================================
+ // Required Fields - Channel
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenChannelIsMissing() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("channel is required");
+ }
+
+ @Test
+ public void shouldThrowWhenChannelIsEmpty() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", ""
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("channel is required");
+ }
+
+ @Test
+ public void shouldThrowWhenChannelIsBlank() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", " "
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("channel is required");
+ }
+
+ // =========================================================================
+ // Port
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToPort6379WhenTlsDisabled() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPort()).isEqualTo(6379);
+ }
+
+ @Test
+ public void shouldDefaultToPort6380WhenTlsEnabled() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "tls.enabled", "true"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPort()).isEqualTo(6380);
+ }
+
+ @Test
+ public void shouldUseCustomPort() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "port", "16379"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPort()).isEqualTo(16379);
+ }
+
+ @Test
+ public void shouldThrowWhenPortIsNegative() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "port", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("port must be between 1 and 65535");
+ }
+
+ @Test
+ public void shouldThrowWhenPortExceedsMax() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "port", "65536"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("port must be between 1 and 65535");
+ }
+
+ // =========================================================================
+ // Database
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToDatabase0() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getDatabase()).isEqualTo(0);
+ }
+
+ @Test
+ public void shouldUseCustomDatabase() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "database", "5"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getDatabase()).isEqualTo(5);
+ }
+
+ @Test
+ public void shouldThrowWhenDatabaseIsNegative() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "database", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("database must be non-negative");
+ }
+
+ // =========================================================================
+ // Username
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToEmptyUsername() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getUsername()).isEmpty();
+ }
+
+ @Test
+ public void shouldUseCustomUsername() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "username", "redis-user"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getUsername()).isEqualTo("redis-user");
+ }
+
+ // =========================================================================
+ // Password
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToEmptyPassword() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPassword()).isEmpty();
+ }
+
+ @Test
+ public void shouldUseCustomPassword() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "password", "secret123"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPassword()).isEqualTo("secret123");
+ }
+
+ // =========================================================================
+ // Client Name
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToKeteClientName() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getClientName()).isEqualTo("kete");
+ }
+
+ @Test
+ public void shouldUseCustomClientName() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "client-name", "my-app"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getClientName()).isEqualTo("my-app");
+ }
+
+ // =========================================================================
+ // Connection Timeout
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToConnectionTimeout10() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(RedisPubSubDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ }
+
+ @Test
+ public void shouldUseCustomConnectionTimeout() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "connection-timeout-seconds", "30"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(30);
+ }
+
+ @Test
+ public void shouldThrowWhenConnectionTimeoutIsNegative() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "connection-timeout-seconds", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("connection-timeout-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Command Timeout
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToCommandTimeout60() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCommandTimeoutSeconds()).isEqualTo(RedisPubSubDestinationConfig.DEFAULT_COMMAND_TIMEOUT_SECONDS);
+ }
+
+ @Test
+ public void shouldUseCustomCommandTimeout() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "command-timeout-seconds", "120"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCommandTimeoutSeconds()).isEqualTo(120);
+ }
+
+ @Test
+ public void shouldThrowWhenCommandTimeoutIsNegative() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "command-timeout-seconds", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("command-timeout-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Redis URI
+ // =========================================================================
+
+ @Test
+ public void shouldBuildRedisUri() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().getHost()).isEqualTo("localhost");
+ assertThat(config.getRedisUri().getPort()).isEqualTo(6379);
+ assertThat(config.getRedisUri().getDatabase()).isEqualTo(0);
+ }
+
+ @Test
+ public void shouldBuildRedisUriWithAuthentication() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "username", "redis-user",
+ "password", "secret123"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().getUsername()).isEqualTo("redis-user");
+ assertThat(config.getRedisUri().getPassword()).isEqualTo("secret123".toCharArray());
+ }
+
+ @Test
+ public void shouldBuildRedisUriWithPasswordOnly() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "password", "secret123"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().getPassword()).isEqualTo("secret123".toCharArray());
+ }
+
+ @Test
+ public void shouldBuildRedisUriWithSslEnabled() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel",
+ "tls.enabled", "true"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().isSsl()).isTrue();
+ }
+
+ // =========================================================================
+ // Client Options
+ // =========================================================================
+
+ @Test
+ public void shouldBuildClientOptions() {
+
+ // arrange
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-pubsub",
+ "host", "localhost",
+ "channel", "test-channel"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getClientOptions()).isNotNull();
+ assertThat(config.getClientOptions().isAutoReconnect()).isTrue();
+ }
+
+ // =========================================================================
+ // Full Configuration
+ // =========================================================================
+
+ @Test
+ public void shouldInitializeWithAllOptions() {
+
+ // arrange
+
+ var configMap = new HashMap();
+ configMap.put("kind", "redis-pubsub");
+ configMap.put("host", "redis.example.com");
+ configMap.put("channel", "events");
+ configMap.put("port", "16379");
+ configMap.put("database", "3");
+ configMap.put("username", "user");
+ configMap.put("password", "pass");
+ configMap.put("client-name", "my-client");
+ configMap.put("connection-timeout-seconds", "15");
+ configMap.put("command-timeout-seconds", "90");
+
+ var config = new RedisPubSubDestinationConfig();
+ config.setConfiguration(new MapConfiguration(configMap));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getHost()).isEqualTo("redis.example.com");
+ assertThat(config.getChannel()).isEqualTo("events");
+ assertThat(config.getPort()).isEqualTo(16379);
+ assertThat(config.getDatabase()).isEqualTo(3);
+ assertThat(config.getUsername()).isEqualTo("user");
+ assertThat(config.getPassword()).isEqualTo("pass");
+ assertThat(config.getClientName()).isEqualTo("my-client");
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(15);
+ assertThat(config.getCommandTimeoutSeconds()).isEqualTo(90);
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/redisstreamsdestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/redisstreamsdestinationconfig/initializeTests.java
new file mode 100644
index 00000000..4a92fc81
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/redisstreamsdestinationconfig/initializeTests.java
@@ -0,0 +1,920 @@
+package io.github.fortunen.kete.unittests.destinationconfigs.redisstreamsdestinationconfig;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.util.Map;
+
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+import io.github.fortunen.kete.destinations.redisstreams.RedisStreamsDestinationConfig;
+
+public class initializeTests {
+
+ private Map createFullConfig() {
+ var map = new java.util.HashMap();
+ map.put("kind", "redis-streams");
+ map.put("host", "redis.example.com");
+ map.put("stream", "events");
+ map.put("port", "16379");
+ map.put("database", "3");
+ map.put("username", "user");
+ map.put("password", "pass");
+ map.put("client-name", "my-client");
+ map.put("connection-timeout-seconds", "15");
+ map.put("command-timeout-seconds", "90");
+ map.put("max-len", "5000");
+ map.put("approximate-trimming", "false");
+ return map;
+ }
+
+ // =========================================================================
+ // Required Fields - Host
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenHostIsMissing() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("host is required");
+ }
+
+ @Test
+ public void shouldThrowWhenHostIsEmpty() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("host is required");
+ }
+
+ @Test
+ public void shouldThrowWhenHostIsBlank() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", " ",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("host is required");
+ }
+
+ // =========================================================================
+ // Required Fields - Stream
+ // =========================================================================
+
+ @Test
+ public void shouldThrowWhenStreamIsMissing() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("stream is required");
+ }
+
+ @Test
+ public void shouldThrowWhenStreamIsEmpty() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", ""
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("stream is required");
+ }
+
+ @Test
+ public void shouldThrowWhenStreamIsBlank() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", " "
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("stream is required");
+ }
+
+ // =========================================================================
+ // Port
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToPort6379WhenTlsDisabled() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPort()).isEqualTo(6379);
+ }
+
+ @Test
+ public void shouldDefaultToPort6380WhenTlsEnabled() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "tls.enabled", "true"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPort()).isEqualTo(6380);
+ }
+
+ @Test
+ public void shouldUseCustomPort() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "port", "16379"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPort()).isEqualTo(16379);
+ }
+
+ @Test
+ public void shouldThrowWhenPortIsNegative() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "port", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("port must be between 1 and 65535");
+ }
+
+ @Test
+ public void shouldThrowWhenPortExceedsMax() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "port", "65536"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("port must be between 1 and 65535");
+ }
+
+ // =========================================================================
+ // Database
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToDatabase0() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getDatabase()).isEqualTo(0);
+ }
+
+ @Test
+ public void shouldUseCustomDatabase() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "database", "5"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getDatabase()).isEqualTo(5);
+ }
+
+ @Test
+ public void shouldThrowWhenDatabaseIsNegative() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "database", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("database must be non-negative");
+ }
+
+ // =========================================================================
+ // Username
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToEmptyUsername() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getUsername()).isEmpty();
+ }
+
+ @Test
+ public void shouldUseCustomUsername() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "username", "redis-user"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getUsername()).isEqualTo("redis-user");
+ }
+
+ // =========================================================================
+ // Password
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToEmptyPassword() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPassword()).isEmpty();
+ }
+
+ @Test
+ public void shouldUseCustomPassword() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "password", "secret123"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getPassword()).isEqualTo("secret123");
+ }
+
+ // =========================================================================
+ // Client Name
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToKeteClientName() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getClientName()).isEqualTo("kete");
+ }
+
+ @Test
+ public void shouldUseCustomClientName() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "client-name", "my-app"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getClientName()).isEqualTo("my-app");
+ }
+
+ // =========================================================================
+ // Connection Timeout
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToConnectionTimeout10() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(RedisStreamsDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
+ }
+
+ @Test
+ public void shouldUseCustomConnectionTimeout() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "connection-timeout-seconds", "30"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(30);
+ }
+
+ @Test
+ public void shouldThrowWhenConnectionTimeoutIsNegative() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "connection-timeout-seconds", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("connection-timeout-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Command Timeout
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToCommandTimeout60() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCommandTimeoutSeconds()).isEqualTo(RedisStreamsDestinationConfig.DEFAULT_COMMAND_TIMEOUT_SECONDS);
+ }
+
+ @Test
+ public void shouldUseCustomCommandTimeout() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "command-timeout-seconds", "120"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getCommandTimeoutSeconds()).isEqualTo(120);
+ }
+
+ @Test
+ public void shouldThrowWhenCommandTimeoutIsNegative() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "command-timeout-seconds", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("command-timeout-seconds must be non-negative");
+ }
+
+ // =========================================================================
+ // Max Len
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToMaxLen0() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getMaxLen()).isEqualTo(0);
+ }
+
+ @Test
+ public void shouldUseCustomMaxLen() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "max-len", "1000"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getMaxLen()).isEqualTo(1000);
+ }
+
+ @Test
+ public void shouldThrowWhenMaxLenIsNegative() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "max-len", "-1"
+ )));
+
+ // act
+
+ var thrown = catchThrowable(() -> config.initialize());
+
+ // assert
+
+ assertThat(thrown)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("max-len must be non-negative");
+ }
+
+ // =========================================================================
+ // Approximate Trimming
+ // =========================================================================
+
+ @Test
+ public void shouldDefaultToApproximateTrimmingTrue() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.isApproximateTrimming()).isTrue();
+ }
+
+ @Test
+ public void shouldUseCustomApproximateTrimming() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "approximate-trimming", "false"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.isApproximateTrimming()).isFalse();
+ }
+
+ // =========================================================================
+ // Redis URI
+ // =========================================================================
+
+ @Test
+ public void shouldBuildRedisUri() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().getHost()).isEqualTo("localhost");
+ assertThat(config.getRedisUri().getPort()).isEqualTo(6379);
+ assertThat(config.getRedisUri().getDatabase()).isEqualTo(0);
+ }
+
+ @Test
+ public void shouldBuildRedisUriWithAuthentication() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "username", "redis-user",
+ "password", "secret123"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().getUsername()).isEqualTo("redis-user");
+ assertThat(config.getRedisUri().getPassword()).isEqualTo("secret123".toCharArray());
+ }
+
+ @Test
+ public void shouldBuildRedisUriWithPasswordOnly() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "password", "secret123"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().getPassword()).isEqualTo("secret123".toCharArray());
+ }
+
+ @Test
+ public void shouldBuildRedisUriWithSslEnabled() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream",
+ "tls.enabled", "true"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getRedisUri()).isNotNull();
+ assertThat(config.getRedisUri().isSsl()).isTrue();
+ }
+
+ // =========================================================================
+ // Client Options
+ // =========================================================================
+
+ @Test
+ public void shouldBuildClientOptions() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "redis-streams",
+ "host", "localhost",
+ "stream", "test-stream"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getClientOptions()).isNotNull();
+ assertThat(config.getClientOptions().isAutoReconnect()).isTrue();
+ }
+
+ // =========================================================================
+ // Full Configuration
+ // =========================================================================
+
+ @Test
+ public void shouldInitializeWithAllOptions() {
+
+ // arrange
+
+ var config = new RedisStreamsDestinationConfig();
+ config.setConfiguration(new MapConfiguration(createFullConfig()));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
+ assertThat(config.getHost()).isEqualTo("redis.example.com");
+ assertThat(config.getStream()).isEqualTo("events");
+ assertThat(config.getPort()).isEqualTo(16379);
+ assertThat(config.getDatabase()).isEqualTo(3);
+ assertThat(config.getUsername()).isEqualTo("user");
+ assertThat(config.getPassword()).isEqualTo("pass");
+ assertThat(config.getClientName()).isEqualTo("my-client");
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(15);
+ assertThat(config.getCommandTimeoutSeconds()).isEqualTo(90);
+ assertThat(config.getMaxLen()).isEqualTo(5000);
+ assertThat(config.isApproximateTrimming()).isFalse();
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/stompdestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/stompdestinationconfig/initializeTests.java
similarity index 85%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/stompdestinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/stompdestinationconfig/initializeTests.java
index 4fd107cd..538c8ae7 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/stompdestinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/stompdestinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.stompdestinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.stompdestinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -23,6 +23,7 @@ public void shouldThrowWhenHostIsMissing() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"destination", "/queue/events"
)));
@@ -44,6 +45,7 @@ public void shouldThrowWhenHostIsEmpty() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "",
"destination", "/queue/events"
)));
@@ -66,6 +68,7 @@ public void shouldThrowWhenHostIsBlank() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", " ",
"destination", "/queue/events"
)));
@@ -92,6 +95,7 @@ public void shouldThrowWhenDestinationIsMissing() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost"
)));
@@ -113,6 +117,7 @@ public void shouldThrowWhenDestinationIsEmpty() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", ""
)));
@@ -135,6 +140,7 @@ public void shouldThrowWhenDestinationIsBlank() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", " "
)));
@@ -161,6 +167,7 @@ public void shouldInitializeWithMinimalConfiguration() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -186,6 +193,7 @@ public void shouldUseDefaultPort() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -206,6 +214,7 @@ public void shouldUseCustomPort() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"port", "61614",
"destination", "/queue/events"
@@ -227,6 +236,7 @@ public void shouldThrowWhenPortIsTooHigh() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"port", "65536",
"destination", "/queue/events"
@@ -250,6 +260,7 @@ public void shouldThrowWhenPortIsTooLow() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"port", "0",
"destination", "/queue/events"
@@ -277,6 +288,7 @@ public void shouldDefaultVirtualHostToHost() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -297,6 +309,7 @@ public void shouldUseCustomVirtualHost() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
"virtual-host", "/vhost1"
@@ -322,6 +335,7 @@ public void shouldHaveEmptyUsernameByDefault() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -342,6 +356,7 @@ public void shouldHaveEmptyPasswordByDefault() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -362,6 +377,7 @@ public void shouldParseUsername() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
"username", "admin"
@@ -383,6 +399,7 @@ public void shouldParsePassword() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
"password", "secret"
@@ -402,12 +419,13 @@ public void shouldParsePassword() {
// =========================================================================
@Test
- public void shouldDisableReceiptByDefault() {
+ public void shouldEnableReceiptByDefault() {
// arrange
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -418,6 +436,28 @@ public void shouldDisableReceiptByDefault() {
// assert
+ assertThat(config.isReceiptEnabled()).isTrue();
+ }
+
+ @Test
+ public void shouldDisableReceipt() {
+
+ // arrange
+
+ var config = new StompDestinationConfig();
+ config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
+ "host", "localhost",
+ "destination", "/queue/events",
+ "receipt-enabled", "false"
+ )));
+
+ // act
+
+ config.initialize();
+
+ // assert
+
assertThat(config.isReceiptEnabled()).isFalse();
}
@@ -428,6 +468,7 @@ public void shouldEnableReceipt() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
"receipt-enabled", "true"
@@ -453,6 +494,7 @@ public void shouldUseDefaultHeartBeatOutgoing() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -463,7 +505,7 @@ public void shouldUseDefaultHeartBeatOutgoing() {
// assert
- assertThat(config.getHeartBeatOutgoing()).isEqualTo(30000);
+ assertThat(config.getHeartBeatOutgoingSeconds()).isEqualTo(StompDestinationConfig.DEFAULT_HEART_BEAT_SECONDS);
}
@Test
@@ -473,6 +515,7 @@ public void shouldUseDefaultHeartBeatIncoming() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -483,7 +526,7 @@ public void shouldUseDefaultHeartBeatIncoming() {
// assert
- assertThat(config.getHeartBeatIncoming()).isEqualTo(30000);
+ assertThat(config.getHeartBeatIncomingSeconds()).isEqualTo(StompDestinationConfig.DEFAULT_HEART_BEAT_SECONDS);
}
@Test
@@ -493,10 +536,11 @@ public void shouldDisableHeartBeatWhenSetToZero() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "heart-beat-outgoing", "0",
- "heart-beat-incoming", "0"
+ "heart-beat-outgoing-seconds", "0",
+ "heart-beat-incoming-seconds", "0"
)));
// act
@@ -505,8 +549,8 @@ public void shouldDisableHeartBeatWhenSetToZero() {
// assert
- assertThat(config.getHeartBeatOutgoing()).isEqualTo(0);
- assertThat(config.getHeartBeatIncoming()).isEqualTo(0);
+ assertThat(config.getHeartBeatOutgoingSeconds()).isEqualTo(0);
+ assertThat(config.getHeartBeatIncomingSeconds()).isEqualTo(0);
}
@Test
@@ -516,9 +560,10 @@ public void shouldParseHeartBeatOutgoing() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "heart-beat-outgoing", "10000"
+ "heart-beat-outgoing-seconds", "10"
)));
// act
@@ -527,7 +572,7 @@ public void shouldParseHeartBeatOutgoing() {
// assert
- assertThat(config.getHeartBeatOutgoing()).isEqualTo(10000);
+ assertThat(config.getHeartBeatOutgoingSeconds()).isEqualTo(10);
}
@Test
@@ -537,9 +582,10 @@ public void shouldParseHeartBeatIncoming() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "heart-beat-incoming", "10000"
+ "heart-beat-incoming-seconds", "10"
)));
// act
@@ -548,7 +594,7 @@ public void shouldParseHeartBeatIncoming() {
// assert
- assertThat(config.getHeartBeatIncoming()).isEqualTo(10000);
+ assertThat(config.getHeartBeatIncomingSeconds()).isEqualTo(10);
}
@Test
@@ -558,9 +604,10 @@ public void shouldThrowWhenHeartBeatOutgoingIsNegative() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "heart-beat-outgoing", "-1"
+ "heart-beat-outgoing-seconds", "-1"
)));
// act
@@ -571,7 +618,7 @@ public void shouldThrowWhenHeartBeatOutgoingIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("heart-beat-outgoing must be non-negative");
+ .hasMessage("heart-beat-outgoing-seconds must be non-negative");
}
@Test
@@ -581,9 +628,10 @@ public void shouldThrowWhenHeartBeatIncomingIsNegative() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "heart-beat-incoming", "-1"
+ "heart-beat-incoming-seconds", "-1"
)));
// act
@@ -594,7 +642,7 @@ public void shouldThrowWhenHeartBeatIncomingIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("heart-beat-incoming must be non-negative");
+ .hasMessage("heart-beat-incoming-seconds must be non-negative");
}
// =========================================================================
@@ -608,6 +656,7 @@ public void shouldUseDefaultReadTimeout() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -618,7 +667,7 @@ public void shouldUseDefaultReadTimeout() {
// assert
- assertThat(config.getReadTimeoutMillis()).isEqualTo(30000);
+ assertThat(config.getReadTimeoutSeconds()).isEqualTo(30);
}
@Test
@@ -628,18 +677,19 @@ public void shouldParseReadTimeout() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "read-timeout-millis", "30000"
- )));
+ "read-timeout-seconds", "30"
+ )));
- // act
+ // act
- config.initialize();
+ config.initialize();
- // assert
+ // assert
- assertThat(config.getReadTimeoutMillis()).isEqualTo(30000);
+ assertThat(config.getReadTimeoutSeconds()).isEqualTo(30);
}
@Test
@@ -649,9 +699,10 @@ public void shouldThrowWhenReadTimeoutIsZero() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "read-timeout-millis", "0"
+ "read-timeout-seconds", "0"
)));
// act
@@ -662,7 +713,7 @@ public void shouldThrowWhenReadTimeoutIsZero() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("read-timeout-millis must be greater than 0");
+ .hasMessage("read-timeout-seconds must be greater than 0");
}
@Test
@@ -672,9 +723,10 @@ public void shouldThrowWhenReadTimeoutIsNegative() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
- "read-timeout-millis", "-1"
+ "read-timeout-seconds", "-1"
)));
// act
@@ -685,7 +737,7 @@ public void shouldThrowWhenReadTimeoutIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("read-timeout-millis must be greater than 0");
+ .hasMessage("read-timeout-seconds must be greater than 0");
}
// =========================================================================
@@ -699,6 +751,7 @@ public void shouldDisableTlsByDefault() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events"
)));
@@ -719,6 +772,7 @@ public void shouldEnableTls() {
var config = new StompDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "stomp",
"host", "localhost",
"destination", "/queue/events",
"tls.enabled", "true"
diff --git a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/websocketdestinationconfig/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/websocketdestinationconfig/initializeTests.java
similarity index 86%
rename from src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/websocketdestinationconfig/initializeTests.java
rename to src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/websocketdestinationconfig/initializeTests.java
index 7d6581ab..d21a21f6 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/destinantionconfigs/websocketdestinationconfig/initializeTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/destinationconfigs/websocketdestinationconfig/initializeTests.java
@@ -1,4 +1,4 @@
-package io.github.fortunen.kete.unittests.destinantionconfigs.websocketdestinationconfig;
+package io.github.fortunen.kete.unittests.destinationconfigs.websocketdestinationconfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
@@ -22,7 +22,7 @@ public void shouldThrowWhenHostIsMissing() {
// arrange
var config = new WebSocketDestinationConfig();
- config.setConfiguration(new MapConfiguration(Map.of()));
+ config.setConfiguration(new MapConfiguration(Map.of("kind", "websocket")));
// act
@@ -42,6 +42,7 @@ public void shouldThrowWhenHostIsEmpty() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", ""
)));
@@ -63,6 +64,7 @@ public void shouldThrowWhenHostIsBlank() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", " "
)));
@@ -88,6 +90,7 @@ public void shouldParseFullWsUrl() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"url", "ws://example.com:8080/events"
)));
@@ -109,6 +112,7 @@ public void shouldParseFullWssUrl() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"url", "wss://secure.example.com/events"
)));
@@ -130,6 +134,7 @@ public void shouldThrowWhenUrlSchemeIsInvalid() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"url", "http://example.com/events"
)));
@@ -155,6 +160,7 @@ public void shouldBuildUrlFromHostOnly() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com"
)));
@@ -177,6 +183,7 @@ public void shouldBuildUrlWithCustomPort() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"port", "8080"
)));
@@ -198,6 +205,7 @@ public void shouldBuildUrlWithCustomPath() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"path", "/events"
)));
@@ -219,6 +227,7 @@ public void shouldNormalizePathWithoutLeadingSlash() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"path", "events"
)));
@@ -244,6 +253,7 @@ public void shouldUseDefaultSecurePortWhenTlsEnabled() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"tls.enabled", "true"
)));
@@ -270,6 +280,7 @@ public void shouldDefaultToTextMode() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com"
)));
@@ -289,6 +300,7 @@ public void shouldEnableBinaryMode() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"binary-mode", "true"
)));
@@ -313,6 +325,7 @@ public void shouldUseDefaultConnectionTimeout() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com"
)));
@@ -322,7 +335,7 @@ public void shouldUseDefaultConnectionTimeout() {
// assert
- assertThat(config.getConnectionTimeout()).isEqualTo(10);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(WebSocketDestinationConfig.DEFAULT_CONNECTION_TIMEOUT_SECONDS);
}
@Test
@@ -332,8 +345,9 @@ public void shouldUseCustomConnectionTimeout() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
- "connection-timeout", "30"
+ "connection-timeout-seconds", "30"
)));
// act
@@ -342,7 +356,7 @@ public void shouldUseCustomConnectionTimeout() {
// assert
- assertThat(config.getConnectionTimeout()).isEqualTo(30);
+ assertThat(config.getConnectionTimeoutSeconds()).isEqualTo(30);
}
@Test
@@ -352,8 +366,9 @@ public void shouldThrowWhenConnectionTimeoutIsZero() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
- "connection-timeout", "0"
+ "connection-timeout-seconds", "0"
)));
// act
@@ -364,7 +379,7 @@ public void shouldThrowWhenConnectionTimeoutIsZero() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("connection-timeout must be greater than 0");
+ .hasMessage("connection-timeout-seconds must be greater than 0");
}
@Test
@@ -374,8 +389,9 @@ public void shouldThrowWhenConnectionTimeoutIsNegative() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
- "connection-timeout", "-1"
+ "connection-timeout-seconds", "-1"
)));
// act
@@ -386,7 +402,7 @@ public void shouldThrowWhenConnectionTimeoutIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("connection-timeout must be greater than 0");
+ .hasMessage("connection-timeout-seconds must be greater than 0");
}
// =========================================================================
@@ -400,6 +416,7 @@ public void shouldUseDefaultConnectionLostTimeout() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com"
)));
@@ -409,7 +426,7 @@ public void shouldUseDefaultConnectionLostTimeout() {
// assert
- assertThat(config.getConnectionLostTimeout()).isEqualTo(60);
+ assertThat(config.getConnectionLostTimeoutSeconds()).isEqualTo(WebSocketDestinationConfig.DEFAULT_CONNECTION_LOST_TIMEOUT_SECONDS);
}
@Test
@@ -419,8 +436,9 @@ public void shouldUseCustomConnectionLostTimeout() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
- "connection-lost-timeout", "30"
+ "connection-lost-timeout-seconds", "30"
)));
// act
@@ -429,7 +447,7 @@ public void shouldUseCustomConnectionLostTimeout() {
// assert
- assertThat(config.getConnectionLostTimeout()).isEqualTo(30);
+ assertThat(config.getConnectionLostTimeoutSeconds()).isEqualTo(30);
}
@Test
@@ -439,8 +457,9 @@ public void shouldDisableConnectionLostTimeoutWhenSetToZero() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
- "connection-lost-timeout", "0"
+ "connection-lost-timeout-seconds", "0"
)));
// act
@@ -449,7 +468,7 @@ public void shouldDisableConnectionLostTimeoutWhenSetToZero() {
// assert
- assertThat(config.getConnectionLostTimeout()).isEqualTo(0);
+ assertThat(config.getConnectionLostTimeoutSeconds()).isEqualTo(0);
}
@Test
@@ -459,8 +478,9 @@ public void shouldThrowWhenConnectionLostTimeoutIsNegative() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
- "connection-lost-timeout", "-1"
+ "connection-lost-timeout-seconds", "-1"
)));
// act
@@ -471,7 +491,7 @@ public void shouldThrowWhenConnectionLostTimeoutIsNegative() {
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
- .hasMessage("connection-lost-timeout must be non-negative");
+ .hasMessage("connection-lost-timeout-seconds must be non-negative");
}
// =========================================================================
@@ -485,6 +505,7 @@ public void shouldParseCustomHeaders() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"headers.Authorization", "Bearer token123",
"headers.X-Custom", "custom-value"
@@ -496,7 +517,7 @@ public void shouldParseCustomHeaders() {
// assert
- assertThat(config.getHeaders())
+ assertThat(config.getCustomHeaders())
.containsEntry("Authorization", "Bearer token123")
.containsEntry("X-Custom", "custom-value");
}
@@ -508,6 +529,7 @@ public void shouldHaveEmptyHeadersByDefault() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com"
)));
@@ -517,7 +539,7 @@ public void shouldHaveEmptyHeadersByDefault() {
// assert
- assertThat(config.getHeaders()).isEmpty();
+ assertThat(config.getCustomHeaders()).isEmpty();
}
// =========================================================================
@@ -531,6 +553,7 @@ public void shouldThrowWhenPortIsTooHigh() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"port", "65536"
)));
@@ -553,6 +576,7 @@ public void shouldThrowWhenPortIsTooLow() {
var config = new WebSocketDestinationConfig();
config.setConfiguration(new MapConfiguration(Map.of(
+ "kind", "websocket",
"host", "example.com",
"port", "0"
)));
diff --git a/src/test/java/io/github/fortunen/kete/unittests/natsauthmaterial/applyToTests.java b/src/test/java/io/github/fortunen/kete/unittests/natsauthmaterial/applyToTests.java
new file mode 100644
index 00000000..1719c886
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/natsauthmaterial/applyToTests.java
@@ -0,0 +1,185 @@
+package io.github.fortunen.kete.unittests.natsauthmaterial;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import io.github.fortunen.kete.NatsAuthMaterial;
+import io.nats.client.Options;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+class applyToTests {
+
+ @Test
+ void shouldThrowWhenBuilderIsNull() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "none");
+ var config = new MapConfiguration(map);
+ authMaterial.initialize(config);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.applyTo(null))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("builder is required");
+ }
+
+ @Test
+ void shouldApplyNoAuthForAuthNone() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "none");
+ var config = new MapConfiguration(map);
+ authMaterial.initialize(config);
+
+ var builder = new Options.Builder();
+
+ // act
+
+ authMaterial.applyTo(builder);
+
+ // assert
+
+ var options = builder.build();
+ assertThat(options).isNotNull();
+ }
+
+ @Test
+ void shouldApplyUsernameAndPassword() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "username-and-password");
+ map.put("username", "testuser");
+ map.put("password", "testpass");
+ var config = new MapConfiguration(map);
+ authMaterial.initialize(config);
+
+ var builder = new Options.Builder();
+
+ // act
+
+ authMaterial.applyTo(builder);
+
+ // assert
+
+ var options = builder.build();
+ assertThat(options).isNotNull();
+ }
+
+ @Test
+ void shouldApplyToken() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "token");
+ map.put("token", "my-secret-token");
+ var config = new MapConfiguration(map);
+ authMaterial.initialize(config);
+
+ var builder = new Options.Builder();
+
+ // act
+
+ authMaterial.applyTo(builder);
+
+ // assert
+
+ var options = builder.build();
+ assertThat(options).isNotNull();
+ }
+
+ @Test
+ void shouldApplyNKey() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "nkey");
+ map.put("nkey-seed", "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY");
+ var config = new MapConfiguration(map);
+ authMaterial.initialize(config);
+
+ var builder = new Options.Builder();
+
+ // act
+
+ authMaterial.applyTo(builder);
+
+ // assert
+
+ var options = builder.build();
+ assertThat(options).isNotNull();
+ assertThat(options.getAuthHandler()).isNotNull();
+ }
+
+ @Test
+ void shouldApplyCredentialsFileText() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var credsContent = "-----BEGIN NATS USER JWT-----\neyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJBQkNERUYiLCJpYXQiOjE3MDQwNjcyMDAsImlzcyI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsInN1YiI6IlVBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsIm5hdHMiOnt9fQ.SIGNATURE\n------END NATS USER JWT------\n-----BEGIN USER NKEY SEED-----\nSUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY\n------END USER NKEY SEED------";
+ var map = new HashMap();
+ map.put("authentication-method", "credentials-file-text");
+ map.put("credentials-file-text", credsContent);
+ var config = new MapConfiguration(map);
+ authMaterial.initialize(config);
+
+ var builder = new Options.Builder();
+
+ // act
+
+ authMaterial.applyTo(builder);
+
+ // assert
+
+ var options = builder.build();
+ assertThat(options).isNotNull();
+ assertThat(options.getAuthHandler()).isNotNull();
+ }
+
+ @Test
+ void shouldApplyCredentialsFileBase64() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var credsContent = "-----BEGIN NATS USER JWT-----\neyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJBQkNERUYiLCJpYXQiOjE3MDQwNjcyMDAsImlzcyI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsInN1YiI6IlVBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsIm5hdHMiOnt9fQ.SIGNATURE\n------END NATS USER JWT------\n-----BEGIN USER NKEY SEED-----\nSUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY\n------END USER NKEY SEED------";
+ var credsBase64 = Base64.getEncoder().encodeToString(credsContent.getBytes(StandardCharsets.UTF_8));
+ var map = new HashMap();
+ map.put("authentication-method", "credentials-file-base64");
+ map.put("credentials-file-base64", credsBase64);
+ var config = new MapConfiguration(map);
+ authMaterial.initialize(config);
+
+ var builder = new Options.Builder();
+
+ // act
+
+ authMaterial.applyTo(builder);
+
+ // assert
+
+ var options = builder.build();
+ assertThat(options).isNotNull();
+ assertThat(options.getAuthHandler()).isNotNull();
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/natsauthmaterial/initializeTests.java b/src/test/java/io/github/fortunen/kete/unittests/natsauthmaterial/initializeTests.java
new file mode 100644
index 00000000..a36911f9
--- /dev/null
+++ b/src/test/java/io/github/fortunen/kete/unittests/natsauthmaterial/initializeTests.java
@@ -0,0 +1,310 @@
+package io.github.fortunen.kete.unittests.natsauthmaterial;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.github.fortunen.kete.NatsAuthMaterial;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import org.apache.commons.configuration2.MapConfiguration;
+import org.junit.jupiter.api.Test;
+
+class initializeTests {
+
+ @Test
+ void shouldThrowWhenConfigurationIsNull() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(null))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("configuration is required");
+ }
+
+ @Test
+ void shouldThrowWhenAuthenticationMethodIsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var config = new MapConfiguration(new HashMap<>());
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("authentication-method is required (none, username-and-password, token, nkey, credentials-file-path, credentials-file-text, credentials-file-base64)");
+ }
+
+ @Test
+ void shouldThrowWhenAuthenticationMethodIsInvalid() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "invalid-method");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("invalid authentication-method: invalid-method (valid values: none, username-and-password, token, nkey, credentials-file-path, credentials-file-text, credentials-file-base64)");
+ }
+
+ @Test
+ void shouldInitializeWithAuthNone() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "none");
+ var config = new MapConfiguration(map);
+
+ // act
+
+ authMaterial.initialize(config);
+
+ // assert
+
+ assertThat(authMaterial.getAuthenticationMethod()).isEqualTo("none");
+ }
+
+ @Test
+ void shouldInitializeWithUsernameAndPassword() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "username-and-password");
+ map.put("username", "testuser");
+ map.put("password", "testpass");
+ var config = new MapConfiguration(map);
+
+ // act
+
+ authMaterial.initialize(config);
+
+ // assert
+
+ assertThat(authMaterial.getAuthenticationMethod()).isEqualTo("username-and-password");
+ assertThat(authMaterial.getUsername()).isEqualTo("testuser");
+ assertThat(authMaterial.getPassword()).isEqualTo("testpass");
+ }
+
+ @Test
+ void shouldThrowWhenUsernameIsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "username-and-password");
+ map.put("password", "testpass");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("username is required when authentication-method is username-and-password");
+ }
+
+ @Test
+ void shouldThrowWhenPasswordIsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "username-and-password");
+ map.put("username", "testuser");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("password is required when authentication-method is username-and-password");
+ }
+
+ @Test
+ void shouldInitializeWithToken() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "token");
+ map.put("token", "my-secret-token");
+ var config = new MapConfiguration(map);
+
+ // act
+
+ authMaterial.initialize(config);
+
+ // assert
+
+ assertThat(authMaterial.getAuthenticationMethod()).isEqualTo("token");
+ assertThat(authMaterial.getToken()).isEqualTo("my-secret-token");
+ }
+
+ @Test
+ void shouldThrowWhenTokenIsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "token");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("token is required when authentication-method is token");
+ }
+
+ @Test
+ void shouldInitializeWithNKey() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "nkey");
+ map.put("nkey-seed", "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY");
+ var config = new MapConfiguration(map);
+
+ // act
+
+ authMaterial.initialize(config);
+
+ // assert
+
+ assertThat(authMaterial.getAuthenticationMethod()).isEqualTo("nkey");
+ assertThat(authMaterial.getNkeySeed()).isEqualTo("SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY");
+ }
+
+ @Test
+ void shouldThrowWhenNkeySeedIsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "nkey");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("nkey-seed is required when authentication-method is nkey");
+ }
+
+ @Test
+ void shouldInitializeWithCredentialsFileText() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var credsContent = "-----BEGIN NATS USER JWT-----\neyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJBQkNERUYiLCJpYXQiOjE3MDQwNjcyMDAsImlzcyI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsInN1YiI6IlVBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsIm5hdHMiOnt9fQ.SIGNATURE\n------END NATS USER JWT------\n-----BEGIN USER NKEY SEED-----\nSUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY\n------END USER NKEY SEED------";
+ var map = new HashMap();
+ map.put("authentication-method", "credentials-file-text");
+ map.put("credentials-file-text", credsContent);
+ var config = new MapConfiguration(map);
+
+ // act
+
+ authMaterial.initialize(config);
+
+ // assert
+
+ assertThat(authMaterial.getAuthenticationMethod()).isEqualTo("credentials-file-text");
+ assertThat(authMaterial.getCredentialsFileContent()).isEqualTo(credsContent);
+ }
+
+ @Test
+ void shouldThrowWhenCredentialsFileTextIsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "credentials-file-text");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("credentials-file-text is required when authentication-method is credentials-file-text");
+ }
+
+ @Test
+ void shouldInitializeWithCredentialsFileBase64() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var credsContent = "-----BEGIN NATS USER JWT-----\neyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJBQkNERUYiLCJpYXQiOjE3MDQwNjcyMDAsImlzcyI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsInN1YiI6IlVBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsIm5hdHMiOnt9fQ.SIGNATURE\n------END NATS USER JWT------\n-----BEGIN USER NKEY SEED-----\nSUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY\n------END USER NKEY SEED------";
+ var credsBase64 = Base64.getEncoder().encodeToString(credsContent.getBytes(StandardCharsets.UTF_8));
+ var map = new HashMap();
+ map.put("authentication-method", "credentials-file-base64");
+ map.put("credentials-file-base64", credsBase64);
+ var config = new MapConfiguration(map);
+
+ // act
+
+ authMaterial.initialize(config);
+
+ // assert
+
+ assertThat(authMaterial.getAuthenticationMethod()).isEqualTo("credentials-file-base64");
+ assertThat(authMaterial.getCredentialsFileContent()).isEqualTo(credsContent);
+ }
+
+ @Test
+ void shouldThrowWhenCredentialsFileBase64IsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "credentials-file-base64");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("credentials-file-base64 is required when authentication-method is credentials-file-base64");
+ }
+
+ @Test
+ void shouldThrowWhenCredentialsFilePathIsMissing() {
+
+ // arrange
+
+ var authMaterial = new NatsAuthMaterial();
+ var map = new HashMap();
+ map.put("authentication-method", "credentials-file-path");
+ var config = new MapConfiguration(map);
+
+ // act & assert
+
+ assertThatThrownBy(() -> authMaterial.initialize(config))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("credentials-file-path is required when authentication-method is credentials-file-path");
+ }
+}
diff --git a/src/test/java/io/github/fortunen/kete/unittests/utils/certificateutils/parsePemPrivateKeyTests.java b/src/test/java/io/github/fortunen/kete/unittests/utils/certificateutils/parsePemPrivateKeyTests.java
index 16831e02..4de250e8 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/utils/certificateutils/parsePemPrivateKeyTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/utils/certificateutils/parsePemPrivateKeyTests.java
@@ -28,7 +28,7 @@ public void shouldParsePrivateKey() {
.isNotNull();
assertThat(result.getAlgorithm())
.as("Should have correct algorithm")
- .isIn("RSA", "EC");
+ .isIn("RSA", "EC", "ECDSA");
}
@Test
diff --git a/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/isDurationTests.java b/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/isDurationTests.java
index a980ad18..3dd85325 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/isDurationTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/isDurationTests.java
@@ -116,7 +116,7 @@ public void shouldReturnFalseForInvalidFormat() {
}
@Test
- public void shouldReturnTrueForPlainNumberAsMilliseconds() {
+ public void shouldReturnTrueForPlainNumberAsSeconds() {
// act
diff --git a/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/tryParseDurationTests.java b/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/tryParseDurationTests.java
index 1a9d752f..1e22541b 100644
--- a/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/tryParseDurationTests.java
+++ b/src/test/java/io/github/fortunen/kete/unittests/utils/validationutils/tryParseDurationTests.java
@@ -58,14 +58,14 @@ public void shouldParseIso8601Days() {
assertThat(ValidationUtils.tryParseDuration("P1D")).hasValue(Duration.ofDays(1));
}
- // Digits only (milliseconds)
+ // Digits only (seconds)
@Test
- public void shouldParseDigitsAsMilliseconds() {
+ public void shouldParseDigitsAsSeconds() {
// act & assert
- assertThat(ValidationUtils.tryParseDuration("1500")).hasValue(Duration.ofMillis(1500));
+ assertThat(ValidationUtils.tryParseDuration("1500")).hasValue(Duration.ofSeconds(1500));
}
@Test
diff --git a/stress-test/README.md b/stress-test/README.md
new file mode 100644
index 00000000..28c5548b
--- /dev/null
+++ b/stress-test/README.md
@@ -0,0 +1,290 @@
+# KETE Stress Test
+
+A comprehensive stress testing suite for KETE (Keycloak Events To Everywhere) that validates performance under sustained load.
+
+## Overview
+
+This stress test simulates real-world high-traffic scenarios by:
+- Running **5 concurrent login repeaters** that continuously authenticate against Keycloak
+- Streaming all authentication events through **KETE to Redis Streams**
+- Monitoring **throughput and metrics every minute** for 10 minutes
+- Calculating **messages per second** and tracking system health
+
+## Components
+
+### Infrastructure (`docker-compose.yml`)
+- **Keycloak**: Event source with KETE provider enabled (metrics enabled)
+- **Redis**: Destination system (Redis Streams)
+- **5x Login Repeaters**: Concurrent workers generating authentication events in tight loops
+
+### Login Repeater (`login-repeater.sh`)
+- Continuously authenticates against Keycloak using `admin-cli`
+- Logs progress every 100 requests
+- Runs without sleep for maximum stress (configurable)
+
+### Monitor (`monitor.ps1`)
+- Runs for 10 minutes (configurable)
+- Checks metrics every 60 seconds (configurable)
+- Tracks:
+ - Redis Stream message count
+ - Messages processed since last check
+ - Current throughput (msg/s)
+ - Average throughput (msg/s)
+ - KETE routes initialized
+ - KETE events sent total
+ - KETE events failed total
+- Generates CSV report with detailed measurements
+- Provides final summary with performance analysis
+
+## Requirements
+
+- Docker & Docker Compose
+- PowerShell 7+ (for monitoring script)
+- 4GB+ RAM recommended
+- CPU: 4+ cores recommended
+
+## Usage
+
+### 1. Start the Stress Test Infrastructure
+
+```bash
+cd stress-test
+docker compose up -d
+```
+
+This will start:
+- Redis (port 6379)
+- Keycloak with KETE (port 8080, metrics on 9000)
+- 5 login repeater workers
+
+### 2. Run the Monitor
+
+**Default (10 minutes, check every 60 seconds):**
+```powershell
+.\monitor.ps1
+```
+
+**Custom duration and interval:**
+```powershell
+# Run for 5 minutes, check every 30 seconds
+.\monitor.ps1 -DurationMinutes 5 -IntervalSeconds 30
+```
+
+**Custom parameters:**
+```powershell
+.\monitor.ps1 `
+ -DurationMinutes 10 `
+ -IntervalSeconds 60 `
+ -RedisContainer "stress-test-redis-1" `
+ -KeycloakUrl "http://localhost:8080" `
+ -StreamName "keycloak-events-stress"
+```
+
+### 3. Watch Worker Progress (Optional)
+
+Monitor individual worker output:
+```bash
+docker compose logs -f login-repeater-1
+docker compose logs -f login-repeater-2
+# ... etc
+```
+
+### 4. Stop the Test
+
+```bash
+docker compose down
+```
+
+## Output
+
+### Console Output
+
+The monitor displays real-time updates every minute:
+
+```
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ KETE Stress Test Monitor
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+Duration : 10 minutes
+Check Interval : 60 seconds
+Redis Stream : keycloak-events-stress
+Start Time : 2026-02-01 18:30:00
+
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+Check #1 - 18:31:00 - Elapsed: 1.0m / Remaining: 9.0m
+
+ Redis Stream Messages : 15234
+ Messages Since Last Check : 15234
+ Current Throughput : 254.23 msg/s
+ Average Throughput : 254.23 msg/s
+
+ KETE Routes Initialized : 1
+ KETE Events Sent Total : 15234
+ KETE Events Failed Total : 0
+```
+
+### Final Summary
+
+After completion, the monitor provides a comprehensive summary:
+
+```
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ STRESS TEST COMPLETE
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+Test Duration : 10.02 minutes
+Total Messages Processed : 152340
+
+Overall Throughput : 253.90 msg/s
+Peak Throughput : 312.45 msg/s
+Average Throughput : 254.67 msg/s
+
+KETE Routes Initialized : 1
+KETE Events Sent : 152340
+KETE Events Failed : 0
+
+Detailed results saved to: stress-test-results-20260201-183010.csv
+
+β SUCCESS: No failed events detected!
+β PERFORMANCE: System processed 253.90 messages per second
+```
+
+### CSV Report
+
+Detailed measurements are saved to a timestamped CSV file:
+
+```csv
+Timestamp,TotalMessages,MessagesSinceLastCheck,CurrentThroughput,AverageThroughput,RoutesInitialized,EventsSent,EventsFailed
+2026-02-01 18:31:00,15234,15234,254.23,254.23,1,15234,0
+2026-02-01 18:32:00,30567,15333,255.55,254.89,1,30567,0
+...
+```
+
+## Tuning
+
+### Increase Load
+
+To increase stress, edit `login-repeater.sh` and comment out the sleep:
+
+```bash
+# Tiny sleep to prevent overwhelming the system (adjust as needed)
+# Comment out for maximum stress
+# sleep 0.01 <-- Comment this line
+```
+
+### Scale Workers
+
+Add more repeaters in `docker-compose.yml`:
+
+```yaml
+ login-repeater-6:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "6"
+```
+
+### Different Destination
+
+To test with a different destination, modify `docker-compose.yml` Keycloak environment:
+
+**NATS Core:**
+```yaml
+kete.routes.stress-test.destination.kind: nats
+kete.routes.stress-test.destination.servers: nats://nats:4222
+kete.routes.stress-test.destination.subject: keycloak.events
+```
+
+**Kafka:**
+```yaml
+kete.routes.stress-test.destination.kind: kafka
+kete.routes.stress-test.destination.bootstrap.servers: kafka:9092
+kete.routes.stress-test.destination.topic: keycloak-events
+```
+
+## Metrics Endpoints
+
+- **Keycloak Health**: http://localhost:8080/health/ready
+- **Keycloak Metrics**: http://localhost:8080/metrics
+- **Keycloak Admin**: http://localhost:8080 (admin/admin)
+
+## Troubleshooting
+
+### Workers Not Starting
+
+Check if Keycloak is ready:
+```bash
+docker logs stress-test-keycloak-1
+```
+
+### No Messages in Redis
+
+Verify KETE route initialization:
+```bash
+docker logs stress-test-keycloak-1 | grep "kete Route"
+```
+
+Expected output:
+```
+kete Route 'stress-test' initialized: destination=RedisStreamsDestinationConfig
+```
+
+### Check Redis Stream Manually
+
+```bash
+docker exec stress-test-redis-1 redis-cli XLEN keycloak-events-stress
+docker exec stress-test-redis-1 redis-cli XREAD COUNT 1 STREAMS keycloak-events-stress 0
+```
+
+### High CPU/Memory Usage
+
+This is expected under stress. Monitor with:
+```bash
+docker stats
+```
+
+To reduce load, add sleep to `login-repeater.sh` or reduce worker count.
+
+## Performance Expectations
+
+Typical throughput (on modern hardware):
+
+| Workers | Expected Throughput |
+|---------|-------------------|
+| 1 | 50-100 msg/s |
+| 5 | 200-400 msg/s |
+| 10 | 400-800 msg/s |
+
+Actual performance depends on:
+- Hardware (CPU, RAM, disk I/O)
+- Network latency
+- Keycloak configuration
+- Destination system performance
+
+## Cleanup
+
+Remove all containers and networks:
+```bash
+docker compose down -v
+```
+
+Remove generated CSV files:
+```bash
+rm stress-test-results-*.csv
+```
+
+## Known Limitations
+
+- Workers may see connection errors during Keycloak startup (expected, they retry)
+- Very high loads (>1000 msg/s) may require Keycloak tuning
+- Redis Streams is used for easy verification; production systems may use Kafka/NATS
+- Test runs entirely on localhost; network latency not simulated
+
+## License
+
+This stress test is part of the KETE project and follows the same license.
diff --git a/stress-test/docker-compose.yml b/stress-test/docker-compose.yml
new file mode 100644
index 00000000..8abe7ce9
--- /dev/null
+++ b/stress-test/docker-compose.yml
@@ -0,0 +1,268 @@
+services:
+
+ postgres:
+ image: postgres:16-alpine
+ ports:
+ - 5432:5432
+ environment:
+ POSTGRES_DB: keycloak
+ POSTGRES_USER: keycloak
+ POSTGRES_PASSWORD: keycloak
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U keycloak"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 6379:6379
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 30
+
+ keycloak:
+ image: ghcr.io/fortunen/kete/quick-start-keycloak
+ command: start --http-enabled=true --hostname-strict=false
+ ports:
+ - 8080:8080
+ - 9000:9000
+ environment:
+ KC_DB: postgres
+ KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
+ KC_DB_USERNAME: keycloak
+ KC_DB_PASSWORD: keycloak
+ KC_METRICS_ENABLED: "true"
+ kete.routes.stress-test.destination.kind: redis-pubsub
+ kete.routes.stress-test.destination.host: redis
+ kete.routes.stress-test.destination.port: 6379
+ kete.routes.stress-test.destination.channel: keycloak-events-stress
+ kete.metrics.enabled: "true"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+ login-repeater-1:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "1"
+
+ login-repeater-2:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "2"
+
+ login-repeater-3:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "3"
+
+ login-repeater-4:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "4"
+
+ login-repeater-5:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "5"
+
+ login-repeater-6:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "6"
+
+ login-repeater-7:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "7"
+
+ login-repeater-8:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "8"
+
+ login-repeater-9:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "9"
+
+ login-repeater-10:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "10"
+
+ login-repeater-11:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "11"
+
+ login-repeater-12:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "12"
+
+ login-repeater-13:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "13"
+
+ login-repeater-14:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "14"
+
+ login-repeater-15:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "15"
+
+ login-repeater-16:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "16"
+
+ login-repeater-17:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "17"
+
+ login-repeater-18:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "18"
+
+ login-repeater-19:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "19"
+
+ login-repeater-20:
+ image: curlimages/curl:8.5.0
+ depends_on:
+ - keycloak
+ volumes:
+ - ./login-repeater.sh:/login-repeater.sh:ro
+ entrypoint: ["/bin/sh", "/login-repeater.sh"]
+ environment:
+ KEYCLOAK_URL: http://keycloak:8080
+ WORKER_ID: "20"
diff --git a/stress-test/login-repeater.sh b/stress-test/login-repeater.sh
new file mode 100644
index 00000000..3630e22b
--- /dev/null
+++ b/stress-test/login-repeater.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+
+# Wait for Keycloak to be ready
+echo "[Worker $WORKER_ID] Waiting for Keycloak to be ready..."
+until curl -sf "$KEYCLOAK_URL/realms/master" > /dev/null 2>&1; do
+ echo "[Worker $WORKER_ID] Keycloak not ready yet, waiting..."
+ sleep 5
+done
+
+echo "[Worker $WORKER_ID] Keycloak is ready! Getting initial token..."
+sleep 10
+
+# Get initial token and extract refresh_token
+RESPONSE=$(curl -sf \
+ -X POST \
+ "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
+ -d "client_id=admin-cli" \
+ -d "username=admin" \
+ -d "password=admin" \
+ -d "grant_type=password")
+
+REFRESH_TOKEN=$(echo "$RESPONSE" | grep -o '"refresh_token":"[^"]*' | cut -d'"' -f4)
+
+if [ -z "$REFRESH_TOKEN" ]; then
+ echo "[Worker $WORKER_ID] Failed to get initial token. Exiting."
+ exit 1
+fi
+
+echo "[Worker $WORKER_ID] Got token! Starting refresh loop (lighter load = more KETE events)..."
+
+# Counter for logging
+count=0
+start_time=$(date +%s)
+
+# Infinite loop - continuously refresh token (much lighter than login)
+while true; do
+ # Refresh token (generates REFRESH_TOKEN event - way lighter than LOGIN)
+ RESPONSE=$(curl -sf \
+ -X POST \
+ "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" \
+ -d "client_id=admin-cli" \
+ -d "grant_type=refresh_token" \
+ -d "refresh_token=$REFRESH_TOKEN")
+
+ # Update refresh token for next iteration
+ NEW_REFRESH_TOKEN=$(echo "$RESPONSE" | grep -o '"refresh_token":"[^"]*' | cut -d'"' -f4)
+ if [ -n "$NEW_REFRESH_TOKEN" ]; then
+ REFRESH_TOKEN="$NEW_REFRESH_TOKEN"
+ fi
+
+ count=$((count + 1))
+
+ # Log progress every 200 requests (more frequent due to higher throughput)
+ if [ $((count % 200)) -eq 0 ]; then
+ elapsed=$(($(date +%s) - start_time))
+ rate=$(awk "BEGIN {printf \"%.2f\", $count / $elapsed}")
+ echo "[Worker $WORKER_ID] Completed $count refreshes in ${elapsed}s (${rate} req/s)"
+ fi
+done
diff --git a/stress-test/monitor.ps1 b/stress-test/monitor.ps1
new file mode 100644
index 00000000..4c82f3fb
--- /dev/null
+++ b/stress-test/monitor.ps1
@@ -0,0 +1,109 @@
+#!/usr/bin/env pwsh
+# Stress Test Monitor Script for KETE
+# Monitors event generation rate and Redis connection pool statistics
+
+param(
+ [int]$StartupWaitSeconds = 45,
+ [int]$CheckIntervalSeconds = 60
+)
+
+Write-Host "=== KETE Stress Test Monitor ===" -ForegroundColor Cyan
+Write-Host "Waiting ${StartupWaitSeconds}s for Keycloak startup...`n" -ForegroundColor Yellow
+
+Start-Sleep -Seconds $StartupWaitSeconds
+
+# Verify route is active
+$route = try {
+ (Invoke-WebRequest -Uri http://localhost:9000/metrics -UseBasicParsing -TimeoutSec 5).Content |
+ Select-String 'kete_routes_active\s+([\d.]+)' |
+ ForEach-Object { $_.Matches.Groups[1].Value }
+} catch {
+ "N/A"
+}
+
+Write-Host "Route active: $route" -ForegroundColor $(if ($route -eq "1.0") { "Green" } else { "Red" })
+Write-Host ""
+
+if ($route -ne "1.0") {
+ Write-Host "WARNING: Route is not active. Check Keycloak logs." -ForegroundColor Red
+ Write-Host ""
+}
+
+# Monitoring loop
+$global:eventCount = 0
+$global:lastCount = 0
+
+# Start Redis subscriber in background to count messages
+$subscriberJob = Start-Job -ScriptBlock {
+ param($CheckInterval)
+ $count = 0
+ $lineNum = 0
+ docker exec stress-test-redis-1 redis-cli SUBSCRIBE keycloak-events-stress 2>$null | ForEach-Object {
+ $lineNum++
+ # Redis SUBSCRIBE output: line 1=type, line 2=channel, line 3=message (repeats)
+ # Every 3rd line is the actual message
+ if ($lineNum % 3 -eq 0) {
+ $count++
+ if ($count % 100 -eq 0) {
+ Write-Output $count
+ }
+ }
+ }
+} -ArgumentList $CheckIntervalSeconds
+
+while ($true) {
+ $start = $global:lastCount
+ Start-Sleep -Seconds $CheckIntervalSeconds
+
+ # Get count from background job
+ $jobOutput = Receive-Job -Job $subscriberJob -Keep | Select-Object -Last 1
+ if ($jobOutput) {
+ $global:eventCount = [int]$jobOutput
+ }
+ $end = $global:eventCount
+ $global:lastCount = $end
+
+ $ratePerSec = [math]::Round(($end - $start) / $CheckIntervalSeconds, 0)
+
+ # Fetch pool metrics and convert to string
+ $metrics = try {
+ $response = Invoke-WebRequest -Uri http://localhost:9000/metrics -UseBasicParsing -TimeoutSec 5
+ [System.Text.Encoding]::UTF8.GetString($response.Content)
+ } catch {
+ ""
+ }
+
+ $active = if ($metrics) {
+ if ($metrics -match 'kete_pool_active\{[^\}]*\}\s+([\d.]+)') { $Matches[1] } else { "?" }
+ } else {
+ "?"
+ }
+
+ $idle = if ($metrics) {
+ if ($metrics -match 'kete_pool_idle\{[^\}]*\}\s+([\d.]+)') { $Matches[1] } else { "?" }
+ } else {
+ "?"
+ }
+
+ $total = if ($metrics) {
+ if ($metrics -match 'kete_pool_total\{[^\}]*\}\s+([\d.]+)') { $Matches[1] } else { "?" }
+ } else {
+ "?"
+ }
+
+ $forwardTimeMax = if ($metrics) {
+ if ($metrics -match 'kete_forward_duration_seconds_max\{[^\}]*\}\s+([\d.]+)') {
+ [math]::Round([double]$Matches[1] * 1000, 2)
+ } else {
+ "?"
+ }
+ } else {
+ "?"
+ }
+
+ $timestamp = Get-Date -Format "HH:mm:ss"
+ $color = if ($ratePerSec -lt 1000) { "Red" } elseif ($ratePerSec -lt 2000) { "Yellow" } else { "Green" }
+
+ Write-Host ("[{0}] Events: {1:N0} | Rate: {2:N0}/sec | Pool: {3} active, {4} idle, {5} total | Forward: {6}ms" -f $timestamp, $end, $ratePerSec, $active, $idle, $total, $forwardTimeMax) -ForegroundColor $color
+}
+
diff --git a/stress-test/mosquitto.conf b/stress-test/mosquitto.conf
new file mode 100644
index 00000000..90357de6
--- /dev/null
+++ b/stress-test/mosquitto.conf
@@ -0,0 +1,4 @@
+listener 1883
+allow_anonymous true
+persistence false
+max_queued_messages 0