diff --git a/eventmesh-sdks/eventmesh-sdk-java/build.gradle b/eventmesh-sdks/eventmesh-sdk-java/build.gradle index be55c650a3..ea8a3a2770 100644 --- a/eventmesh-sdks/eventmesh-sdk-java/build.gradle +++ b/eventmesh-sdks/eventmesh-sdk-java/build.gradle @@ -66,3 +66,10 @@ dependencies { testImplementation "org.mockito:mockito-inline" testImplementation "org.mockito:mockito-junit-jupiter" } + +tasks.register('interopPeerClasspath') { + dependsOn testClasses + doLast { + println("INTEROP_PEER_CLASSPATH=${sourceSets.test.runtimeClasspath.asPath}") + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-java/src/test/java/org/apache/eventmesh/client/interop/GrpcInteropPeer.java b/eventmesh-sdks/eventmesh-sdk-java/src/test/java/org/apache/eventmesh/client/interop/GrpcInteropPeer.java new file mode 100644 index 0000000000..cae37980f8 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-java/src/test/java/org/apache/eventmesh/client/interop/GrpcInteropPeer.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.eventmesh.client.interop; + +import org.apache.eventmesh.client.grpc.config.EventMeshGrpcClientConfig; +import org.apache.eventmesh.client.grpc.consumer.EventMeshGrpcConsumer; +import org.apache.eventmesh.client.grpc.consumer.ReceiveMsgHook; +import org.apache.eventmesh.client.grpc.producer.EventMeshGrpcProducer; +import org.apache.eventmesh.common.EventMeshMessage; +import org.apache.eventmesh.common.enums.EventMeshProtocolType; +import org.apache.eventmesh.common.protocol.SubscriptionItem; +import org.apache.eventmesh.common.protocol.SubscriptionMode; +import org.apache.eventmesh.common.protocol.SubscriptionType; + +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** Small host-process peer used by the Rust SDK interop E2E suite. */ +public final class GrpcInteropPeer { + + private GrpcInteropPeer() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 3) { + throw new IllegalArgumentException("usage: publish|consume host topic [content]"); + } + String operation = args[0]; + String host = args[1]; + String topic = args[2]; + EventMeshGrpcClientConfig config = EventMeshGrpcClientConfig.builder() + .serverAddr(host).serverPort(10205).env("env").idc("idc").sys("java-interop") + .producerGroup("java-interop-producer").consumerGroup("java-interop-consumer") + .userName("eventmesh").password("eventmesh").build(); + + if ("publish".equals(operation)) { + try (EventMeshGrpcProducer producer = new EventMeshGrpcProducer(config)) { + producer.publish(EventMeshMessage.builder().topic(topic).content(args[3]).build()); + } + System.out.println("INTEROP_PUBLISHED"); + return; + } + if (!"consume".equals(operation)) { + throw new IllegalArgumentException("unknown operation: " + operation); + } + + CountDownLatch delivered = new CountDownLatch(1); + EventMeshGrpcConsumer consumer = new EventMeshGrpcConsumer(config); + consumer.registerListener(new ReceiveMsgHook() { + @Override + public Optional handle(EventMeshMessage message) { + System.out.println("INTEROP_RECEIVED=" + message.getTopic() + "\t" + message.getContent()); + delivered.countDown(); + return Optional.empty(); + } + + @Override + public EventMeshProtocolType getProtocolType() { + return EventMeshProtocolType.EVENT_MESH_MESSAGE; + } + }); + consumer.init(); + consumer.subscribe(Collections.singletonList(new SubscriptionItem(topic, SubscriptionMode.CLUSTERING, SubscriptionType.ASYNC))); + if (!delivered.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting for message"); + } + consumer.close(); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md b/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md new file mode 100644 index 0000000000..571e78277f --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md @@ -0,0 +1,152 @@ +# AGENTS.md — EventMesh Rust SDK + +Cargo crate `eventmesh` (`edition = "2021"`, **MSRV 1.86.0**). Speaks the +EventMesh **gRPC**, **HTTP**, and **TCP** protocols. gRPC wire format is +CloudEvents-protobuf; the simple `EventMeshMessage` model is converted at the +gRPC boundary by `codec.rs`. HTTP wire format is +`application/x-www-form-urlencoded` with JSON payloads in the `content` field, +mirroring the Java SDK. TCP uses the EventMesh binary wire protocol +(length-prefixed frames with `"EventMesh"` magic) and supports automatic +reconnect with exponential backoff plus CloudEvents JSON interop. + +This crate is **not** part of the Gradle build and has **no GitHub Actions +CI**. All verification is local. The parent repo `AGENTS.md` covers the +docker-compose profiles and runtime ports; this file covers the Rust workflow. + +## Build prerequisite: `protoc` + +`build.rs` invokes `tonic-build`, which shells out to the `protoc` compiler at +**build time**. `prost-build` finds `protoc` automatically when it is on +`PATH`; only set the `PROTOC` env var to point at a binary that is **not** on +`PATH`. (`brew install protobuf`, `apt-get install protobuf-compiler`, etc. all +put `protoc` on `PATH`, so no prefix is needed.) + +```bash +cargo build --features full +``` + +## Feature matrix + +| Feature | Notes | +|---|---| +| `grpc` (default) | gRPC transport — publish, batch, request-reply, stream/webhook subscribe. | +| `http` | HTTP transport — `HttpProducer`, `HttpConsumer`, webhook middleware + built-in `WebhookServer`. Uses reqwest + axum. | +| `tcp` | TCP transport — `TcpProducer`, `TcpConsumer`, native binary wire protocol. Auto-reconnect with exponential backoff. CloudEvents interop behind `cloud_events`. | +| `cloud_events` | Native `cloudevents::Event` interop (gRPC, HTTP, and TCP). | +| `tls` | TLS on the gRPC channel. | +| `full` | `grpc` + `http` + `tcp` + `cloud_events` + `tls`. Use this for clippy/test so every code path compiles. | +| `e2e` | Gates the live-server integration suite (`tests/e2e/`). A plain `cargo test` never touches Docker. | + +## Verification (the order the README mandates) + +```bash +cargo fmt +cargo clippy --features full --all-targets -- -D warnings +cargo test --features full +``` + +Clippy runs with **`-D warnings`** (warnings are errors here). There is no +`rustfmt.toml`/`clippy.toml` — defaults apply. To run a single test binary: +`cargo test --features full --test codec_test`. + +## Generated proto code (do not hand-edit) + +`build.rs` compiles `proto/eventmesh-{service,cloudevents}.proto` into `OUT_DIR` +at build time, generating **client stubs only** (`build_server(false)`), with +`--experimental_allow_proto3_optional`. The generated tree is pulled in via +`src/proto_gen.rs` → `tonic::include_proto!("...")`; it is **not** checked in. +Add convenience aliases in `proto_gen.rs`, not in the generated module. + +## Architecture notes + +- `src/lib.rs` is `#![deny(unsafe_code)]` — no `unsafe` anywhere. +- `src/transport/mod.rs` defines `Publisher` as an **async-fn-in-trait** + (Rust 1.86). It is therefore **not object-safe** — use the concrete + `GrpcProducer` / `TcpProducer` / `HttpProducer` directly, never `dyn`. + The subscribe side has **no trait** — each transport exposes its own + consumer type (`GrpcStreamConsumer`, `GrpcWebhookConsumer`, `TcpConsumer`, + `HttpConsumer`) with transport-specific `subscribe` / `unsubscribe` / + `subscribe_webhook` methods, a background receive loop (where applicable), + and `wait_for_shutdown()` for clean exit. +- `src/transport/grpc/codec.rs` is the `EventMeshMessage` ↔ CloudEvents-protobuf + bridge for the gRPC transport. +- `src/transport/http/codec.rs` is the `EventMeshMessage` ↔ form-urlencoded + + JSON bridge for the HTTP transport. The wire format is + `application/x-www-form-urlencoded` (not JSON bodies); payloads go in the + `content` form field as JSON strings. +- `src/config/grpc.rs` — `GrpcClientConfig` + fluent builder. +- `src/config/http.rs` — `HttpClientConfig` + fluent builder. Accepts + semicolon/comma-separated `host:port[:weight]` server lists; uses the shared + `LoadBalanceSelector` from `common/loadbalance.rs`. +- `src/transport/http/webhook.rs` — **internal** axum handler (`WebhookHandler` + + `WebhookState`) used only by `WebhookServer`. Not part of the public API. +- `src/transport/http/server.rs` — built-in `WebhookServer` (axum) implementing + `IntoFuture` for one-liner `.await` startup with optional graceful shutdown. +- `src/transport/tcp/connection.rs` — TCP engine: `establish()` does TCP + + HELLO handshake; `run()` is an outer reconnect loop that calls `io_loop()` + (the inner select! over read/write/heartbeat). When `ReconnectConfig::enabled` + (default `true`), the outer loop re-establishes the connection with + exponential backoff after I/O errors. `take_reconnect_rx()` delivers a + notification on each successful reconnect so consumers can replay + subscriptions. +- `src/transport/tcp/message.rs` — package builders for `EventMeshMessage` + (`build_message_package`) and CloudEvents (`build_cloud_event_package`, + behind `cloud_events`). CloudEvents bodies use `protocoltype=cloudevents` and + are serialized as `application/cloudevents+json` raw bytes (matching the Java + runtime's codec path). +- `src/config/tcp.rs` — `TcpClientConfig` + `ReconnectConfig` + fluent builders. +- `src/common/` — `ProtocolKey`, status codes, constants, `LoadBalanceSelector` + shared across transports. + +### HTTP transport specifics + +- The HTTP consumer is **client-only** (like the Java SDK): it registers a + webhook URL with the runtime and sends heartbeats. The runtime POSTs messages + to that URL. The SDK provides two ways to receive those pushes: + 1. **`WebhookServer`** — a built-in axum server (`IntoFuture` + graceful + shutdown) for users who don't want to manage their own HTTP server. + 2. **Custom endpoint** — the user hosts any HTTP server (axum, actix, hyper, + …) and decodes pushes with the **public codec helpers** in + `src/transport/http/codec.rs`: `parse_push_body`, + `PushMessageRequestBody::to_event_mesh_message`, and `WebhookReply` + (the `{"retCode": 0}` ack). See the `http_consumer_custom` example. +- There is intentionally **no public `WebhookHandler`/`WebhookLayer`/`WebhookState`** + type. The old "register the SDK's handler on your own Router" mode was + removed; users who embed the webhook in their own app write the handler + themselves on top of the codec utilities. +- Runtime routing: the EventMesh HTTP server has two routing mechanisms — + path-based (new-style handlers, checked first by URI prefix match) and + code-header-based (old-style, checked by the `code` header when no path + matches). Because this SDK sends `application/x-www-form-urlencoded` bodies, + **all** operations (publish, subscribe, unsubscribe, heartbeat) use + code-header-based routing via the root path `/` (`uri::ROOT`). Posting to a + path-based handler (e.g. `/eventmesh/subscribe/local`) with a form body breaks + body decoding: the `topic` form field is parsed as a string and cannot be + deserialized as `List`. +- Heartbeat interval: 30s (mirrors the Java SDK), spawned as a background + tokio task tied to a `CancellationToken`. + +## End-to-end tests (`tests/e2e/`) + +Run with: `cargo test --features e2e`. + +- The harness (`tests/e2e/runtime.rs`) **auto-starts the `rocketmq` docker-compose + profile** and tears it down at process exit. Set `EVENTMESH_E2E_EXTERNAL=1` to + point at a server you started yourself. +- If neither Docker nor a reachable server is found, **tests skip, not fail**. +- Each test generates a unique topic + consumer group (monotonic counter + nanos + timestamp), so the suite is parallel-safe on the shared broker. +- **Standalone (in-memory) broker limitations:** a topic must be created via the + admin API **and** a consumer subscribed *before* any publish, and it does not + implement request/reply. The harness warms topics automatically and the + request/reply test self-skips its assertion on standalone — use the `rocketmq` + profile (the e2e default) to exercise the full path. +- Topic creation hits the admin API at `POST /topic`, which expects + **`application/x-www-form-urlencoded`** (not JSON). + +## Conventions + +- **Apache license header is required on every `.rs` file** — copy the header + block from a neighbor (e.g. `build.rs`). Consistent with the rest of the repo. +- Builders use the `Option field + fluent setter + consuming build()` idiom + (see `GrpcClientConfigBuilder`); mirror it for new config types. diff --git a/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml b/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml index 42bf18682c..0875aae048 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml +++ b/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml @@ -1,86 +1,144 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at # -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# http://www.apache.org/licenses/LICENSE-2.0 # +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. [package] name = "eventmesh" -version = "1.9.0" +version = "2.0.0" edition = "2021" -authors = [ - "mxsm " -] -msrv = "1.75.0" -description = "Rust client for Apache EventMesh" +rust-version = "1.86.0" +authors = ["Apache EventMesh "] license = "Apache-2.0" -keywords = ["EventMesh", "SDK", "rust-client", "rust", "eventmesh-rust-sdk"] -readme = "./README.md" -homepage = "https://github.com/apache/eventmesh" +description = "Apache EventMesh Rust SDK" +homepage = "https://eventmesh.apache.org" repository = "https://github.com/apache/eventmesh" +keywords = ["eventmesh", "messaging", "cloud-events", "grpc"] +categories = ["api-bindings", "network-programming"] + +[lib] +name = "eventmesh" +path = "src/lib.rs" [features] -default = ["grpc", "eventmesh_message"] -full = ["grpc", "eventmesh_message","cloud_events"] -eventmesh_message = [] -cloud_events = [] -tls = [] -grpc = [] +# Transports are opt-in. This keeps the base crate lightweight and, in +# particular, lets consumers that only use the shared message model avoid +# compiling a protocol implementation. +default = [] +# Transport protocols (implemented incrementally). +grpc = ["dep:tonic", "dep:prost", "dep:prost-types"] +# HTTP transport (producer, consumer, webhook middleware + built-in server). +http = ["dep:reqwest", "dep:serde_urlencoded", "dep:axum", "dep:tower", "dep:tower-http"] +# TCP transport (producer, consumer, native TCP wire protocol). +# `tokio-util/codec` is required by the framed codec in +# `src/transport/tcp/{codec,connection}.rs`; without it the crate fails to +# compile when `tcp` is enabled without `grpc` (tonic pulls in `codec` only as +# a side effect of the `grpc` feature). +tcp = ["tokio-util/codec"] +# Message models. +cloud_events = ["dep:cloudevents", "dep:chrono"] +# TLS support. +tls = ["tonic/tls", "tonic/tls-native-roots"] +# Convenience aggregate. +full = ["grpc", "http", "tcp", "cloud_events", "tls"] +# End-to-end tests (tests/e2e/*). Gated off by default so plain `cargo test` +# never needs a live server. Run with `cargo test --features e2e`. Implies all +# transports + cloud_events so the full suite (gRPC + HTTP + TCP + CE) compiles +# from a single flag. +e2e = ["grpc", "http", "tcp", "cloud_events"] +# Cross-SDK E2E: starts the Java SDK peer on the host JVM. +interop_e2e = ["e2e"] -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -# common -anyhow = "1.0" +tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1", features = ["net"] } +tokio-util = "0.7" +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +bytes = "1" +rand = "0.8" +uuid = { version = "1", features = ["v4"] } +tracing = "0.1" +http = "1" +# gRPC transport. +tonic = { version = "0.12", optional = true } +prost = { version = "0.13", optional = true } +prost-types = { version = "0.13", optional = true } +# CloudEvents interop. +cloudevents = { package = "cloudevents-sdk", version = "0.7", optional = true } +chrono = { version = "0.4", optional = true, default-features = false, features = ["std", "clock"] } +# HTTP transport. +reqwest = { version = "0.12", optional = true, features = ["json"] } +serde_urlencoded = { version = "0.7", optional = true } +tower = { version = "0.5", optional = true } +tower-http = { version = "0.6", optional = true } +axum = { version = "0.7", optional = true } -#Rust grpc -tonic = "0.10" -prost = "0.12" -prost-types = "0.12" +[build-dependencies] +tonic-build = "0.12" -#tokio -tokio = { version = "1.32.0", features = ["full"] } +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } +tracing-subscriber = { version = "0.3", features = ["fmt"] } +# e2e test harness (only referenced under the `e2e` feature): +reqwest = { version = "0.12", default-features = false, features = ["json"] } +ctor = "0.2" -#serde -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +[[example]] +name = "grpc_producer" +path = "examples/grpc/producer.rs" +required-features = ["grpc"] -#log -tracing = "0.1" -tracing-subscriber = "0.3" +[[example]] +name = "grpc_consumer" +path = "examples/grpc/consumer.rs" +required-features = ["grpc"] -#cloudEvents -cloudevents-sdk = "0.7.0" +[[example]] +name = "grpc_producer_cloud_events" +path = "examples/grpc/producer_cloud_events.rs" +required-features = ["grpc", "cloud_events"] -# tools crate -thiserror = "1.0" -bytes = "1" -rand = "0.8" -uuid = { version = "1.4.1", features = ["v4"] } -local-ip-address = "0.5.6" -futures = "0.3" -log = "0.4.20" -chrono = "0.4" +[[example]] +name = "http_producer" +path = "examples/http/producer.rs" +required-features = ["http"] -[build-dependencies] -tonic-build = "0.10" +[[example]] +name = "http_consumer_server" +path = "examples/http/consumer_server.rs" +required-features = ["http"] + +[[example]] +name = "http_consumer_custom" +path = "examples/http/consumer_custom.rs" +required-features = ["http"] + +[[example]] +name = "tcp_producer" +path = "examples/tcp/producer.rs" +required-features = ["tcp"] [[example]] -name = "producer_example" -path = "examples/grpc/producer_example.rs" -required-features = ["grpc", "eventmesh_message","cloud_events"] +name = "tcp_consumer" +path = "examples/tcp/consumer.rs" +required-features = ["tcp"] [[example]] -name = "consumer_example" -path = "examples/grpc/consumer_example.rs" -required-features = ["grpc", "eventmesh_message"] \ No newline at end of file +name = "tcp_producer_cloud_events" +path = "examples/tcp/producer_cloud_events.rs" +required-features = ["tcp", "cloud_events"] diff --git a/eventmesh-sdks/eventmesh-sdk-rust/README.md b/eventmesh-sdks/eventmesh-sdk-rust/README.md index 04dc48088a..dbe98f4359 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/README.md +++ b/eventmesh-sdks/eventmesh-sdk-rust/README.md @@ -1,156 +1,175 @@ -## Eventmesh-rust-sdk +# eventmesh-rust-sdk -Eventmesh rust sdk +The Rust SDK for [Apache EventMesh](https://eventmesh.apache.org). Version 2 +uses explicit protocol clients, Rust-style configuration values, and one +non-generic `Message` enum that preserves EventMesh, OpenMessaging, and +optional CloudEvents messages. -## Quickstart +## Requirements -### Requirements +- Rust 1.86 or newer +- `protoc` 3.15 or newer when building with the `grpc` feature +- A compatible EventMesh runtime for network operations -1. rust toolchain, eventmesh's MSRV is 1.75. -2. protoc 3.15.0+ -3. setup eventmesh runtime +## Features -### Add Dependency +Transports are opt-in; the default feature set is empty. + +| Feature | Capability | +| --- | --- | +| `grpc` | `GrpcClient`, producer and stream consumer, Catalog and Workflow | +| `http` | `HttpClient`, webhook registration, and `WebhookServer` | +| `tcp` | `TcpClient`, producer, consumer, broadcast, and reconnect | +| `cloud_events` | `Message::CloudEvent(cloudevents::Event)` | +| `tls` | TLS support for gRPC | +| `full` | All supported transports, CloudEvents, and TLS | ```toml [dependencies] -eventmesh = { version = "1.9", features = ["default"] } +eventmesh = { version = "2", features = ["grpc"] } ``` -### Send message +## Messages + +`Message` is intentionally not generic and is not an SDK serialization +format. It only selects the public message dialect; the chosen transport owns +protobuf, HTTP-form, or TCP-frame serialization. ```rust -use std::time::{SystemTime, UNIX_EPOCH}; -use tracing::info; - -use eventmesh::config::EventMeshGrpcClientConfig; -use eventmesh::grpc::grpc_producer::EventMeshGrpcProducer; -use eventmesh::grpc::GrpcEventMeshMessageProducer; -use eventmesh::log; -use eventmesh::model::message::EventMeshMessage; - -#[eventmesh::main] -async fn main() -> Result<(), Box> { - log::init_logger(); - - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let mut producer = GrpcEventMeshMessageProducer::new(grpc_client_config); - - //Publish Message - info!("Publish Message to EventMesh........"); - let message = EventMeshMessage::default() - .with_biz_seq_no("1") - .with_content("123") - .with_create_time(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64) - .with_topic("123") - .with_unique_id("1111"); - let response = producer.publish(message.clone()).await?; - info!("Publish Message to EventMesh return result: {}", response); - - //Publish batch message - info!("Publish batch message to EventMesh........"); - let messages = vec![message.clone(), message.clone(), message.clone()]; - let response = producer.publish_batch(messages).await?; - info!( - "Publish batch message to EventMesh return result: {}", - response - ); - - //Publish batch message - info!("Publish request reply message to EventMesh........"); - let response = producer.request_reply(message.clone(), 1000).await?; - info!( - "Publish request reply message to EventMesh return result: {}", - response - ); - Ok(()) -} -``` +use eventmesh::message::{EventMeshMessage, Message, OpenMessage}; -### Subscribe message +let native = Message::from(EventMeshMessage::new("orders.created", "{\"id\": 42}")); +let open = Message::from(OpenMessage::new("orders.created", "{\"id\": 42}")); -```rust -use std::time::Duration; +// With `cloud_events` enabled: +// let cloud_event = Message::from(event); +``` -use tracing::info; +`Message::into_event_mesh()` and `Message::into_open()` make only the explicit +EventMesh/OpenMessaging conversions. CloudEvents are never silently flattened +into another model. -use eventmesh::common::ReceiveMessageListener; -use eventmesh::config::EventMeshGrpcClientConfig; -use eventmesh::grpc::grpc_consumer::EventMeshGrpcConsumer; -use eventmesh::log; -use eventmesh::model::message::EventMeshMessage; -use eventmesh::model::subscription::{SubscriptionItem, SubscriptionMode, SubscriptionType}; +## gRPC -struct EventMeshListener; +```rust +use eventmesh::{ + config::{Endpoint, GrpcConfig, ProducerOptions}, + message::{EventMeshMessage, Message}, + GrpcClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let endpoint = Endpoint::new("127.0.0.1", 10_205)?; + let client = GrpcClient::new(GrpcConfig::new(endpoint))?; + let producer = client.producer(ProducerOptions::new("orders-producer"))?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new("orders.created", "{\"id\": 42}"))) + .await?; + println!("accepted with code {}", receipt.code); + Ok(()) +} +``` -impl ReceiveMessageListener for EventMeshListener { - type Message = EventMeshMessage; +To receive messages, implement `MessageHandler`. `Ok(None)` acknowledges an +asynchronous message; `Ok(Some(reply))` sends a reply for a synchronous +delivery. A handler error is not acknowledged: HTTP asks for redelivery, while +gRPC and TCP close the current delivery stream/connection. - fn handle(&self, msg: Self::Message) -> eventmesh::Result> { - info!("Receive message from eventmesh================{:?}", msg); +```rust +use eventmesh::{ + config::{ConsumerOptions, Endpoint, GrpcConfig}, + message::Message, + subscription::Subscription, + GrpcClient, MessageHandler, +}; + +struct Log; + +impl MessageHandler for Log { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); Ok(None) } } -#[eventmesh::main] -async fn main() -> Result<(), Box> { - log::init_logger(); - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let listener = Box::new(EventMeshListener); - let mut consumer = EventMeshGrpcConsumer::new(grpc_client_config, listener); - //send - let item = SubscriptionItem::new( - "TEST-TOPIC-GRPC-ASYNC", - SubscriptionMode::CLUSTERING, - SubscriptionType::ASYNC, - ); - info!("==========Start consumer======================\n{}", item); - let _response = consumer.subscribe(vec![item.clone()]).await?; - tokio::time::sleep(Duration::from_secs(1000)).await; - info!("=========Unsubscribe start================"); - let response = consumer.unsubscribe(vec![item.clone()]).await?; - println!("unsubscribe result:{}", response); - Ok(()) +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = GrpcClient::new(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?))?; + let consumer = client + .stream_consumer( + ConsumerOptions::new("orders-consumer"), + [Subscription::new("orders.created")], + Log, + ) + .await?; + consumer.join().await } - ``` -## Development Guide +`GrpcConsumer::subscribe` and `unsubscribe` update a live stream. gRPC batch +publishing accepts EventMesh/OpenMessaging messages together, or a homogeneous +CloudEvents batch; mixed native/CloudEvents batches are rejected. -### Dependencies +## HTTP and TCP -In order to build `tonic` >= 0.8.0, you need the `protoc` Protocol Buffers compiler, along with Protocol Buffers resource files. +HTTP uses a long-lived webhook-registration consumer plus an optional built-in +webhook server. TCP has a connected producer/consumer and exposes broadcast as +a TCP-specific producer operation. See `examples/http` and `examples/tcp` for +runnable programs. -#### Ubuntu - -```bash -sudo apt update && sudo apt upgrade -y -sudo apt install -y protobuf-compiler libprotobuf-dev +```rust +use eventmesh::{ + config::{ConsumerOptions, Endpoint, EndpointSet, HttpConfig}, + subscription::Subscription, + webhook::WebhookServer, + HttpClient, +}; + +// Build WebhookServer with a MessageHandler, then register server.url(): +// let server = WebhookServer::new("0.0.0.0:8080".parse()?, handler); +let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", 10_105)?])?; +let client = HttpClient::new(HttpConfig::new(endpoints))?; +let consumer = client.webhook_consumer(ConsumerOptions::new("orders-http"))?; +// consumer.subscribe(Subscription::new("orders.created"), server.url()).await?; +# let _ = (consumer, Subscription::new("orders.created")); ``` -#### Alpine Linux +## Configuration and errors -```sh -sudo apk add protoc protobuf-dev -``` +Every protocol configuration starts with a validated `Endpoint` (HTTP takes a +non-empty `EndpointSet`). Use `Identity`, `Credentials`, `ClientOptions`, and +the transport-specific builder-style `with_*` methods to set optional values. +Secrets are redacted in `Debug` output. + +Operations return the public, pattern-matchable `eventmesh::Error`; relevant +variants include `Config`, `InvalidArgument`, `InvalidMessage`, `Timeout`, +`Server`, `Protocol`, `Unsupported`, and transport errors. There is no public +catch-all error variant. -#### macOS +## Catalog, Workflow, and discovery -Assuming [Homebrew](https://brew.sh/) is already installed. (If not, see instructions for installing Homebrew on [the Homebrew website](https://brew.sh/).) +`catalog`, `workflow`, and `discovery` remain public gRPC APIs. Catalog now +attaches to the public `GrpcConsumer` returned by `GrpcClient`: -```zsh -brew install protobuf +```rust,ignore +catalog.init(&consumer).await?; +// ... +catalog.destroy(&consumer).await?; ``` -#### Windows +## Development -- Download the latest version of `protoc-xx.y-win64.zip` from [HERE](https://github.com/protocolbuffers/protobuf/releases/latest) -- Extract the file `bin\protoc.exe` and put it somewhere in the `PATH` -- Verify installation by opening a command prompt and enter `protoc --version` +```bash +cargo fmt --check +cargo clippy --features full --all-targets -- -D warnings +cargo test --features full +cargo test --features e2e --no-run +``` -### Build +The `e2e` feature compiles the gRPC, HTTP, TCP, and CloudEvents integration +suite. Running it against a live runtime is documented in `tests/e2e/main.rs`. -```shell -cargo build -``` +## License +Apache License 2.0. diff --git a/eventmesh-sdks/eventmesh-sdk-rust/build.rs b/eventmesh-sdks/eventmesh-sdk-rust/build.rs index 55a010d4f7..b3b153a652 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/build.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/build.rs @@ -1,30 +1,55 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Compiles the EventMesh gRPC service protos (client stubs only). fn main() -> Result<(), Box> { + // Only generate when the grpc feature is enabled at build time. The protos + // are always present, but we skip codegen to avoid pulling tonic/prost when + // the consumer only wants the HTTP/TCP transports (added in later phases). + if !cfg!(feature = "grpc") { + return Ok(()); + } + tonic_build::configure() - .build_client(true) .build_server(false) - .compile( + .build_client(true) + .protoc_arg("--experimental_allow_proto3_optional") + .compile_protos( &[ "proto/eventmesh-service.proto", "proto/eventmesh-cloudevents.proto", ], &["proto"], )?; + + // Catalog and Workflow are SDK client APIs, but generating their server + // stubs as crate-private code lets the unit tests host protocol-faithful + // tonic fakes without making the EventMesh runtime service server API part + // of this crate's public surface. + tonic_build::configure() + .build_server(true) + .build_client(true) + .protoc_arg("--experimental_allow_proto3_optional") + .compile_protos(&["proto/catalog.proto", "proto/workflow.proto"], &["proto"])?; + + println!("cargo:rerun-if-changed=proto/eventmesh-service.proto"); + println!("cargo:rerun-if-changed=proto/eventmesh-cloudevents.proto"); + println!("cargo:rerun-if-changed=proto/catalog.proto"); + println!("cargo:rerun-if-changed=proto/workflow.proto"); Ok(()) } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/docker-compose.yml b/eventmesh-sdks/eventmesh-sdk-rust/docker-compose.yml new file mode 100644 index 0000000000..8c022800de --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/docker-compose.yml @@ -0,0 +1,160 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Docker Compose for EventMesh Runtime. +# +# Two profiles are provided. Pick ONE with the `--profile` flag: +# +# 1. Standalone (default, in-memory store, no external dependencies): +# docker compose --profile standalone up -d +# +# 2. RocketMQ (namesrv + broker + runtime, durable Event Store): +# docker compose --profile rocketmq up -d +# +# Notes: +# - EventMesh image: https://hub.docker.com/r/apache/eventmesh +# - Runtime exposes TCP 10000, HTTP 10105, gRPC 10205, Admin 10106. +# - To use a locally built image, set IMAGE env var, e.g.: +# IMAGE=yourname/eventmesh:tag docker compose --profile standalone up -d +# +# The default, eventmesh:jdk11-test, is a locally built JDK 11 image; set +# IMAGE=apache/eventmesh:latest to pull the upstream image instead. +# - `docker compose up` (no profile) starts nothing on purpose, to avoid +# accidentally booting the wrong storage backend. + +services: + + # ==================================================================== + # Profile: standalone + # ==================================================================== + eventmesh-standalone: + image: ${IMAGE:-eventmesh:jdk11-test} + container_name: eventmesh-standalone + profiles: ["standalone"] + ports: + - "10000:10000" + - "10105:10105" + - "10106:10106" + - "10205:10205" + # Allow the runtime container to reach webhook servers running on the host + # (needed by the HTTP transport consumer e2e tests). `host-gateway` is a + # special Docker keyword (Docker 20.10+) that resolves to the host IP. + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./docker/conf/eventmesh-standalone.properties:/data/app/eventmesh/conf/eventmesh.properties:ro,z + - eventmesh-standalone-logs:/data/app/eventmesh/logs + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "grep -q 'server state:RUNNING' /data/app/eventmesh/logs/eventmesh.out || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 40s + + # ==================================================================== + # Profile: rocketmq + # ==================================================================== + namesrv: + image: apache/rocketmq:4.9.4 + container_name: rocketmq-namesrv + profiles: ["rocketmq"] + command: sh mqnamesrv + ports: + - "9876:9876" + volumes: + - namesrv-logs:/home/rocketmq/logs + - namesrv-store:/home/rocketmq/store + environment: + JAVA_OPT_EXT: "-server -Xms256m -Xmx256m -Xmn128m" + restart: unless-stopped + + broker: + image: apache/rocketmq:4.9.4 + container_name: rocketmq-broker + profiles: ["rocketmq"] + depends_on: + - namesrv + command: sh mqbroker -c /etc/rocketmq/broker.conf + ports: + - "10909:10909" + - "10911:10911" + volumes: + - ./docker/conf/broker.conf:/etc/rocketmq/broker.conf:ro,z + # NOTE: broker-logs/broker-store named volumes are intentionally NOT used + # here. The rocketmq image doesn't ship /home/rocketmq/{logs,store}, so a + # named volume would be created root-owned and the broker (uid 3000) + # couldn't write to it, crashing on store init (exit 253). The broker + # creates these dirs itself (rocketmq-owned) inside the container layer. + environment: + NAMESRV_ADDR: "namesrv:9876" + # runbroker.sh otherwise auto-calculates heap from total host RAM and + # adds -XX:+AlwaysPreTouch, which commits the whole heap at boot. On a + # memory-constrained host that prevents the broker from starting (silent + # exit 253). Keep the footprint small and cap direct memory explicitly. + JAVA_OPT_EXT: "-server -Xms128m -Xmx256m -Xmn96m -XX:MaxDirectMemorySize=256m" + restart: unless-stopped + + eventmesh-rocketmq: + image: ${IMAGE:-eventmesh:jdk11-test} + container_name: eventmesh-rocketmq + profiles: ["rocketmq"] + depends_on: + - namesrv + - broker + # The published image ships rocketmq-*.jar in lib/ (system classpath). With + # them there, parent-first classloading always picks rocketmq-client's own + # ConsumeMessageConcurrentlyService over EventMesh's shadow copy that lives + # in the storage plugin, causing a ClassCastException that silently drops + # every consumed message (apache/eventmesh#5213). The plugin classloader + # (JarExtensionClassLoader) sorts its URLs alphabetically, so once the + # rocketmq jars sit next to eventmesh-storage-rocketmq.jar under + # plugin/storage/rocketmq/, the shadow class loads first and consumption + # works. Move them before starting the runtime. + command: + - bash + - -c + - | + mkdir -p plugin/storage/rocketmq + mv lib/rocketmq-*.jar plugin/storage/rocketmq/ 2>/dev/null || true + exec bash bin/start.sh + ports: + - "10000:10000" + - "10105:10105" + - "10106:10106" + - "10205:10205" + # Allow the runtime container to reach webhook servers running on the host + # (needed by the HTTP transport consumer e2e tests). + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./docker/conf/eventmesh-rocketmq.properties:/data/app/eventmesh/conf/eventmesh.properties:ro,z + - ./docker/conf/rocketmq-client.properties:/data/app/eventmesh/conf/rocketmq-client.properties:ro,z + - eventmesh-rocketmq-logs:/data/app/eventmesh/logs + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "grep -q 'server state:RUNNING' /data/app/eventmesh/logs/eventmesh.out || exit 1"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 60s + +volumes: + eventmesh-standalone-logs: + eventmesh-rocketmq-logs: + namesrv-logs: + namesrv-store: diff --git a/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/broker.conf b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/broker.conf new file mode 100644 index 0000000000..03743b74af --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/broker.conf @@ -0,0 +1,12 @@ +# RocketMQ broker config for docker-compose. +# brokerIP1 must be reachable from the EventMesh container. Using the broker +# service name works because EventMesh shares the same compose network. +brokerClusterName=DefaultCluster +brokerName=broker-a +brokerId=0 +namesrvAddr=namesrv:9876 +brokerIP1=broker +deleteWhen=04 +fileReservedTime=48 +brokerRole=ASYNC_MASTER +flushDiskType=ASYNC_FLUSH diff --git a/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/eventmesh-rocketmq.properties b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/eventmesh-rocketmq.properties new file mode 100644 index 0000000000..9b14e68a88 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/eventmesh-rocketmq.properties @@ -0,0 +1,106 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Full copy of the image's built-in eventmesh.properties with storage set to +# rocketmq. The namesrv address lives in rocketmq-client.properties. +# +########################## EventMesh Runtime Environment ########################## +eventMesh.server.idc=DEFAULT +eventMesh.server.env=PRD +eventMesh.server.provide.protocols=HTTP,TCP,GRPC +eventMesh.server.cluster=COMMON +eventMesh.server.name=EVENTMESH-runtime +eventMesh.sysid=0000 +eventMesh.server.tcp.port=10000 +eventMesh.server.http.port=10105 +eventMesh.server.grpc.port=10205 +eventMesh.server.admin.http.port=10106 + +########################## EventMesh Network Configuration ########################## +eventMesh.server.tcp.readerIdleSeconds=120 +eventMesh.server.tcp.writerIdleSeconds=120 +eventMesh.server.tcp.allIdleSeconds=120 +eventMesh.server.tcp.clientMaxNum=10000 +eventMesh.server.tcp.pushFailIsolateTimeInMills=30000 +eventMesh.server.tcp.RebalanceIntervalInMills=30000 +eventMesh.server.session.expiredInMills=60000 +eventMesh.server.tcp.msgReqnumPerSecond=15000 +eventMesh.server.http.msgReqnumPerSecond=15000 +eventMesh.server.session.upstreamBufferSize=20 +eventMesh.server.maxEventSize=1000 +eventMesh.server.maxEventBatchSize=10 +eventMesh.server.global.scheduler=5 +eventMesh.server.tcp.taskHandleExecutorPoolSize=8 +eventMesh.server.retry.async.pushRetryTimes=3 +eventMesh.server.retry.sync.pushRetryTimes=3 +eventMesh.server.retry.async.pushRetryDelayInMills=500 +eventMesh.server.retry.sync.pushRetryDelayInMills=500 +eventMesh.server.retry.pushRetryQueueSize=10000 +eventMesh.server.retry.plugin.type=default +eventMesh.server.gracefulShutdown.sleepIntervalInMills=1000 +eventMesh.server.rebalanceRedirect.sleepIntervalInMills=200 + +# TLS +eventMesh.server.useTls.enabled=false +eventMesh.server.ssl.protocol=TLSv1.1 +eventMesh.server.ssl.cer=sChat2.jks +eventMesh.server.ssl.pass=sNetty + +# ip address blacklist +eventMesh.server.blacklist.ipv4=0.0.0.0/8,127.0.0.0/8,169.254.0.0/16,255.255.255.255/32 +eventMesh.server.blacklist.ipv6=::/128,::1/128,ff00::/8 + +########################## EventMesh HTTP Admin Configuration ########################## +eventMesh.server.admin.threads.num=2 +eventMesh.server.admin.useTls.enabled=false +eventMesh.server.admin.ssl.protocol=TLSv1.3 +eventMesh.server.admin.ssl.cer=admin-server.jks +eventMesh.server.admin.ssl.pass=eventmesh-admin-server +eventMesh.server.admin.blacklist.ipv4=0.0.0.0/8,127.0.0.0/8,169.254.0.0/16,255.255.255.255/32 +eventMesh.server.admin.blacklist.ipv6=::/128,::1/128,ff00::/8 + +########################## EventMesh Plugin Configuration ########################## +# storage plugin: rocketmq +eventMesh.storage.plugin.type=rocketmq + +# security plugin +eventMesh.server.security.enabled=false +eventMesh.security.plugin.type=security +eventMesh.security.validation.type.token=false +eventMesh.security.publickey= + +# metaStorage plugin +eventMesh.metaStorage.plugin.enabled=false +eventMesh.metaStorage.plugin.type=nacos +eventMesh.metaStorage.plugin.server-addr=127.0.0.1:8848 +eventMesh.metaStorage.plugin.username=nacos +eventMesh.metaStorage.plugin.password=nacos + +# metrics plugin +eventMesh.metrics.plugin=prometheus + +# trace plugin +eventMesh.server.trace.enabled=false +eventMesh.trace.plugin=zipkin + +# webhook +eventMesh.webHook.admin.start=true +eventMesh.webHook.operationMode=file +eventMesh.webHook.fileMode.filePath= #{eventMeshHome}/webhook +eventMesh.webHook.nacosMode.serverAddr=127.0.0.1:8848 +# Webhook CloudEvent sending mode. MUST mirror eventMesh.storage.plugin.type. +eventMesh.webHook.producer.storage=rocketmq diff --git a/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/eventmesh-standalone.properties b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/eventmesh-standalone.properties new file mode 100644 index 0000000000..c2f0a03d6d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/eventmesh-standalone.properties @@ -0,0 +1,106 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This is a full copy of the image's built-in eventmesh.properties. +# Standalone mode: in-memory Event Store, no external broker required. +# +########################## EventMesh Runtime Environment ########################## +eventMesh.server.idc=DEFAULT +eventMesh.server.env=PRD +eventMesh.server.provide.protocols=HTTP,TCP,GRPC +eventMesh.server.cluster=COMMON +eventMesh.server.name=EVENTMESH-runtime +eventMesh.sysid=0000 +eventMesh.server.tcp.port=10000 +eventMesh.server.http.port=10105 +eventMesh.server.grpc.port=10205 +eventMesh.server.admin.http.port=10106 + +########################## EventMesh Network Configuration ########################## +eventMesh.server.tcp.readerIdleSeconds=120 +eventMesh.server.tcp.writerIdleSeconds=120 +eventMesh.server.tcp.allIdleSeconds=120 +eventMesh.server.tcp.clientMaxNum=10000 +eventMesh.server.tcp.pushFailIsolateTimeInMills=30000 +eventMesh.server.tcp.RebalanceIntervalInMills=30000 +eventMesh.server.session.expiredInMills=60000 +eventMesh.server.tcp.msgReqnumPerSecond=15000 +eventMesh.server.http.msgReqnumPerSecond=15000 +eventMesh.server.session.upstreamBufferSize=20 +eventMesh.server.maxEventSize=1000 +eventMesh.server.maxEventBatchSize=10 +eventMesh.server.global.scheduler=5 +eventMesh.server.tcp.taskHandleExecutorPoolSize=8 +eventMesh.server.retry.async.pushRetryTimes=3 +eventMesh.server.retry.sync.pushRetryTimes=3 +eventMesh.server.retry.async.pushRetryDelayInMills=500 +eventMesh.server.retry.sync.pushRetryDelayInMills=500 +eventMesh.server.retry.pushRetryQueueSize=10000 +eventMesh.server.retry.plugin.type=default +eventMesh.server.gracefulShutdown.sleepIntervalInMills=1000 +eventMesh.server.rebalanceRedirect.sleepIntervalInMills=200 + +# TLS +eventMesh.server.useTls.enabled=false +eventMesh.server.ssl.protocol=TLSv1.1 +eventMesh.server.ssl.cer=sChat2.jks +eventMesh.server.ssl.pass=sNetty + +# ip address blacklist +eventMesh.server.blacklist.ipv4=0.0.0.0/8,127.0.0.0/8,169.254.0.0/16,255.255.255.255/32 +eventMesh.server.blacklist.ipv6=::/128,::1/128,ff00::/8 + +########################## EventMesh HTTP Admin Configuration ########################## +eventMesh.server.admin.threads.num=2 +eventMesh.server.admin.useTls.enabled=false +eventMesh.server.admin.ssl.protocol=TLSv1.3 +eventMesh.server.admin.ssl.cer=admin-server.jks +eventMesh.server.admin.ssl.pass=eventmesh-admin-server +eventMesh.server.admin.blacklist.ipv4=0.0.0.0/8,127.0.0.0/8,169.254.0.0/16,255.255.255.255/32 +eventMesh.server.admin.blacklist.ipv6=::/128,::1/128,ff00::/8 + +########################## EventMesh Plugin Configuration ########################## +# storage plugin: standalone = in-memory, no external broker needed +eventMesh.storage.plugin.type=standalone + +# security plugin +eventMesh.server.security.enabled=false +eventMesh.security.plugin.type=security +eventMesh.security.validation.type.token=false +eventMesh.security.publickey= + +# metaStorage plugin +eventMesh.metaStorage.plugin.enabled=false +eventMesh.metaStorage.plugin.type=nacos +eventMesh.metaStorage.plugin.server-addr=127.0.0.1:8848 +eventMesh.metaStorage.plugin.username=nacos +eventMesh.metaStorage.plugin.password=nacos + +# metrics plugin +eventMesh.metrics.plugin=prometheus + +# trace plugin +eventMesh.server.trace.enabled=false +eventMesh.trace.plugin=zipkin + +# webhook +eventMesh.webHook.admin.start=true +eventMesh.webHook.operationMode=file +eventMesh.webHook.fileMode.filePath= #{eventMeshHome}/webhook +eventMesh.webHook.nacosMode.serverAddr=127.0.0.1:8848 +# Webhook CloudEvent sending mode. MUST mirror eventMesh.storage.plugin.type. +eventMesh.webHook.producer.storage=standalone diff --git a/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/rocketmq-client.properties b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/rocketmq-client.properties new file mode 100644 index 0000000000..17f1e3da9e --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/rocketmq-client.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +#######################rocketmq-client################## +# `namesrv` resolves to the RocketMQ namesrv service in this compose network. +eventMesh.server.rocketmq.namesrvAddr=namesrv:9876 +eventMesh.server.rocketmq.cluster=DefaultCluster +eventMesh.server.rocketmq.accessKey=******** +eventMesh.server.rocketmq.secretKey=******** \ No newline at end of file diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer.rs new file mode 100644 index 0000000000..3e39384bb4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer.rs @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, GrpcConfig}, + message::Message, + subscription::Subscription, + GrpcClient, MessageHandler, +}; + +struct PrintHandler; + +impl MessageHandler for PrintHandler { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = GrpcClient::new(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?))?; + let consumer = client + .stream_consumer( + ConsumerOptions::new("test-consumerGroup"), + [Subscription::new("test-topic-rust-sdk")], + PrintHandler, + ) + .await?; + consumer.join().await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer_example.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer_example.rs deleted file mode 100644 index 8f84875d69..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer_example.rs +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::time::Duration; - -use tracing::info; - -use eventmesh::common::ReceiveMessageListener; -use eventmesh::config::EventMeshGrpcClientConfig; -use eventmesh::grpc::grpc_consumer::EventMeshGrpcConsumer; -use eventmesh::log; -use eventmesh::model::message::EventMeshMessage; -use eventmesh::model::subscription::{SubscriptionItem, SubscriptionMode, SubscriptionType}; - -struct EventMeshListener; - -impl ReceiveMessageListener for EventMeshListener { - type Message = EventMeshMessage; - - fn handle(&self, msg: Self::Message) -> eventmesh::Result> { - info!("Receive message from eventmesh================{:?}", msg); - Ok(None) - } -} - -#[eventmesh::main] -async fn main() -> Result<(), Box> { - log::init_logger(); - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let listener = Box::new(EventMeshListener); - let mut consumer = EventMeshGrpcConsumer::new(grpc_client_config, listener); - //send - let item = SubscriptionItem::new( - "TEST-TOPIC-GRPC-ASYNC", - SubscriptionMode::CLUSTERING, - SubscriptionType::ASYNC, - ); - info!("==========Start consumer======================\n{}", item); - let _response = consumer.subscribe(vec![item.clone()]).await?; - tokio::time::sleep(Duration::from_secs(1000)).await; - info!("=========Unsubscribe start================"); - let response = consumer.unsubscribe(vec![item.clone()]).await?; - println!("unsubscribe result:{}", response); - Ok(()) -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer.rs new file mode 100644 index 0000000000..64dfceccd4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer.rs @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, GrpcConfig, ProducerOptions}, + message::{EventMeshMessage, Message}, + GrpcClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = GrpcClient::new(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?))?; + let producer = client.producer(ProducerOptions::new("test-producerGroup"))?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + "test-topic-rust-sdk", + "hello from rust", + ))) + .await?; + println!("published: {receipt:?}"); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_cloud_events.rs new file mode 100644 index 0000000000..3f409824b6 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_cloud_events.rs @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{ + config::{Endpoint, GrpcConfig, ProducerOptions}, + message::Message, + Error, GrpcClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let event = EventBuilderV10::new() + .id("rust-example-1") + .source("urn:eventmesh:rust-example") + .ty("com.example.created") + .subject("test-topic-rust-sdk") + .data("application/json", serde_json::json!({"message": "hello"})) + .build() + .map_err(|error| Error::InvalidArgument(format!("invalid CloudEvent: {error}")))?; + + let client = GrpcClient::new(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?))?; + let producer = client.producer(ProducerOptions::new("test-producerGroup"))?; + println!( + "published: {:?}", + producer.publish(Message::from(event)).await? + ); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_example.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_example.rs deleted file mode 100644 index 959c1a6a02..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_example.rs +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::time::{SystemTime, UNIX_EPOCH}; - -use chrono::Utc; -use cloudevents::{EventBuilder, EventBuilderV10}; -use tracing::info; - -use eventmesh::common::ProtocolKey; -use eventmesh::config::EventMeshGrpcClientConfig; -use eventmesh::grpc::grpc_producer::EventMeshGrpcProducer; -use eventmesh::grpc::GrpcEventMeshProducer; -use eventmesh::log; -use eventmesh::model::message::EventMeshMessage; - -#[eventmesh::main] -async fn main() -> Result<(), Box> { - log::init_logger(); - - //Publish Message - #[cfg(feature = "eventmesh_message")] - { - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let mut producer = GrpcEventMeshProducer::new(grpc_client_config); - info!("Publish Message to EventMesh........"); - let message = EventMeshMessage::default() - .with_biz_seq_no("1") - .with_content("123") - .with_create_time(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64) - .with_topic("123") - .with_unique_id("1111"); - let response = producer.publish(message.clone()).await?; - info!("Publish Message to EventMesh return result: {}", response); - - //Publish batch message - info!("Publish batch message to EventMesh........"); - let messages = vec![message.clone(), message.clone(), message.clone()]; - let response = producer.publish_batch(messages).await?; - info!( - "Publish batch message to EventMesh return result: {}", - response - ); - - //Publish batch message - info!("Publish request reply message to EventMesh........"); - let response = producer.request_reply(message.clone(), 1000).await?; - info!( - "Publish request reply message to EventMesh return result: {}", - response - ); - } - - #[cfg(feature = "cloud_events")] - { - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let mut producer = GrpcEventMeshProducer::new(grpc_client_config); - info!("Publish Message to EventMesh........"); - let message = EventBuilderV10::new() - .id("my_event.my_application") - .source("http://localhost:8080") - .subject("mxsm") - .ty("example.demo") - .time(Utc::now()) - .data(ProtocolKey::CLOUDEVENT_CONTENT_TYPE, "{\"aaa\":\"1111\"}") - .build()?; - let response = producer.publish(message.clone()).await?; - info!("Publish Message to EventMesh return result: {}", response); - - //Publish batch message - info!("Publish batch message to EventMesh........"); - let messages = vec![message.clone(), message.clone(), message.clone()]; - let response = producer.publish_batch(messages).await?; - info!( - "Publish batch message to EventMesh return result: {}", - response - ); - - //Publish batch message - info!("Publish request reply message to EventMesh........"); - let response = producer.request_reply(message.clone(), 1000).await?; - info!( - "Publish request reply message to EventMesh return result: {}", - response - ); - } - - Ok(()) -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_custom.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_custom.rs new file mode 100644 index 0000000000..8535df24f3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_custom.rs @@ -0,0 +1,51 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! The built-in server is the v2 customisation boundary: applications provide +//! a [`MessageHandler`] and keep their web framework private to the app. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, EndpointSet, HttpConfig}, + message::Message, + subscription::Subscription, + webhook::WebhookServer, + HttpClient, MessageHandler, +}; + +struct PrintHandler; + +impl MessageHandler for PrintHandler { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let server = WebhookServer::new("0.0.0.0:8081".parse().unwrap(), PrintHandler) + .with_advertise_url("http://127.0.0.1:8081/eventmesh/callback"); + let client = HttpClient::new(HttpConfig::new(EndpointSet::new([Endpoint::new( + "127.0.0.1", + 10_105, + )?])?))?; + let consumer = client.webhook_consumer(ConsumerOptions::new("test-consumerGroup"))?; + consumer + .subscribe(Subscription::new("test-topic-rust-sdk"), server.url()) + .await?; + server.await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_server.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_server.rs new file mode 100644 index 0000000000..ec7e40c562 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_server.rs @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, EndpointSet, HttpConfig}, + message::Message, + subscription::Subscription, + webhook::WebhookServer, + HttpClient, MessageHandler, +}; + +struct PrintHandler; + +impl MessageHandler for PrintHandler { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let server = WebhookServer::new("0.0.0.0:8080".parse().unwrap(), PrintHandler) + .with_advertise_url("http://127.0.0.1:8080/eventmesh/callback"); + let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", 10_105)?])?; + let client = HttpClient::new(HttpConfig::new(endpoints))?; + let consumer = client.webhook_consumer(ConsumerOptions::new("test-consumerGroup"))?; + consumer + .subscribe(Subscription::new("test-topic-rust-sdk"), server.url()) + .await?; + server.await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer.rs new file mode 100644 index 0000000000..1b7bdc8595 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer.rs @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, EndpointSet, HttpConfig, ProducerOptions}, + message::{EventMeshMessage, Message}, + HttpClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", 10_105)?])?; + let client = HttpClient::new(HttpConfig::new(endpoints))?; + let producer = client.producer(ProducerOptions::new("test-producerGroup"))?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + "test-topic-rust-sdk", + "hello from rust", + ))) + .await?; + println!("published: {receipt:?}"); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/interop.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/interop.rs new file mode 100644 index 0000000000..3e6b9b3c70 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/interop.rs @@ -0,0 +1,31 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Convert explicitly between EventMesh and OpenMessaging models before +//! sending them through the unified message API. + +use eventmesh::message::{EventMeshMessage, Message, OpenMessage}; + +fn main() -> eventmesh::Result<()> { + let native = EventMeshMessage::new("orders", "created").with_property("region", "cn"); + let open = Message::from(native).into_open()?; + let native = Message::from(open).into_event_mesh()?; + assert_eq!(native.topic.as_deref(), Some("orders")); + + let _open = OpenMessage::new("orders", "created"); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/consumer.rs new file mode 100644 index 0000000000..fcea5646f9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/consumer.rs @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, TcpConfig}, + message::Message, + subscription::Subscription, + MessageHandler, TcpClient, +}; + +struct PrintHandler; + +impl MessageHandler for PrintHandler { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?)); + let consumer = client + .consumer(ConsumerOptions::new("test-consumerGroup"), PrintHandler) + .await?; + consumer + .subscribe(Subscription::new("test-topic-rust-sdk")) + .await?; + consumer.join().await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer.rs new file mode 100644 index 0000000000..ed83bfb0bf --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer.rs @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, ProducerOptions, TcpConfig}, + message::{EventMeshMessage, Message}, + TcpClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?)); + let producer = client + .producer(ProducerOptions::new("test-producerGroup")) + .await?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + "test-topic-rust-sdk", + "hello from rust", + ))) + .await?; + println!("published: {receipt:?}"); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer_cloud_events.rs new file mode 100644 index 0000000000..d2128e4b0c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer_cloud_events.rs @@ -0,0 +1,48 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{ + config::{Endpoint, ProducerOptions, TcpConfig}, + message::Message, + Error, TcpClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let event = EventBuilderV10::new() + .id("rust-example-1") + .source("urn:eventmesh:rust-example") + .ty("com.example.created") + .subject("test-topic-rust-sdk") + .data( + "application/cloudevents+json", + serde_json::json!({"message": "hello"}), + ) + .build() + .map_err(|error| Error::InvalidArgument(format!("invalid CloudEvent: {error}")))?; + + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?)); + let producer = client + .producer(ProducerOptions::new("test-producerGroup")) + .await?; + println!( + "published: {:?}", + producer.publish(Message::from(event)).await? + ); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/proto/catalog.proto b/eventmesh-sdks/eventmesh-sdk-rust/proto/catalog.proto new file mode 100644 index 0000000000..1cdca48772 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/proto/catalog.proto @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +syntax = "proto3"; + +package eventmesh.catalog.api.protocol; + +option java_package = "org.apache.eventmesh.common.protocol.catalog.protos"; +option java_multiple_files = true; +option java_outer_classname = "EventmeshCatalogGrpc"; +option go_package = "github.com/apache/eventmesh/eventmesh-catalog-go/api/proto"; + +service Catalog { + rpc Registry(RegistryRequest) returns (RegistryResponse); + rpc QueryOperations(QueryOperationsRequest) returns (QueryOperationsResponse); +} + +message RegistryRequest { + string file_name = 1; + string definition = 2; +} + +message RegistryResponse {} + +message QueryOperationsRequest { + string service_name = 1; + string operation_id = 2; +} + +message Operation { + string channel_name = 1; + string schema = 2; + string type = 3; +} + +message QueryOperationsResponse { + repeated Operation operations = 1; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-cloudevents.proto b/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-cloudevents.proto index 37e07a0b40..c4e1d6f9d7 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-cloudevents.proto +++ b/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-cloudevents.proto @@ -1,19 +1,20 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + diff --git a/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-service.proto b/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-service.proto index 99d57bc514..41cc3aa16a 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-service.proto +++ b/eventmesh-sdks/eventmesh-sdk-rust/proto/eventmesh-service.proto @@ -1,19 +1,20 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + syntax = "proto3"; package org.apache.eventmesh.cloudevents.v1; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/proto/workflow.proto b/eventmesh-sdks/eventmesh-sdk-rust/proto/workflow.proto new file mode 100644 index 0000000000..c138d8a0c9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/proto/workflow.proto @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +syntax = "proto3"; + +package eventmesh.workflow.api.protocol; + +option java_package = "org.apache.eventmesh.common.protocol.workflow.protos"; +option java_multiple_files = true; +option java_outer_classname = "EventmeshWorkflowGrpc"; +option go_package = "github.com/apache/eventmesh/eventmesh-workflow-go/api/proto"; + +service Workflow { + rpc Execute(ExecuteRequest) returns (ExecuteResponse); +} + +message ExecuteRequest { + string id = 1; + string instance_id = 2; + string task_instance_id = 3; + string input = 4; +} + +message ExecuteResponse { + string instance_id = 1; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/catalog.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/catalog.rs new file mode 100644 index 0000000000..cde01aeb10 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/catalog.rs @@ -0,0 +1,311 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Catalog-driven subscriptions for the gRPC stream consumer. + +use tokio::sync::Mutex; + +use crate::config::CatalogClientConfig; +use crate::discovery::ServiceDiscovery; +use crate::error::{EventMeshError, Result}; +use crate::grpc::GrpcConsumer; +use crate::model::SubscriptionItem; +use crate::proto_gen::catalog::catalog_client::CatalogClient as CatalogGrpcClient; +use crate::proto_gen::catalog::{Operation, QueryOperationsRequest}; +use crate::service::resolved_grpc_config; +use crate::transport::grpc::GrpcClient; +use crate::MessageHandler; + +#[derive(Default)] +struct CatalogState { + initialized: bool, + subscriptions: Vec, +} + +/// Synchronizes Catalog `subscribe` operations to an existing gRPC stream +/// consumer. +/// +/// This mirrors the Java SDK's `EventMeshCatalogClient`: initialization queries +/// the application's operations and subscribes only to operations whose type +/// is exactly `"subscribe"`. The client owns only the subscriptions it created; +/// [`destroy`](Self::destroy) never removes caller-managed subscriptions. +pub struct CatalogClient { + config: CatalogClientConfig, + discovery: D, + state: Mutex, + lifecycle: Mutex<()>, +} + +impl CatalogClient { + /// Create a Catalog client using `discovery` to resolve + /// [`CatalogClientConfig::server_name`]. + pub fn new(config: CatalogClientConfig, discovery: D) -> Self { + Self { + config, + discovery, + state: Mutex::new(CatalogState::default()), + lifecycle: Mutex::new(()), + } + } + + /// Query Catalog and subscribe the provided stream consumer to the + /// returned `subscribe` channels. A successful call is idempotent until + /// [`destroy`](Self::destroy) succeeds. + pub async fn init(&self, consumer: &GrpcConsumer) -> Result<()> + where + H: MessageHandler, + { + let _lifecycle = self.lifecycle.lock().await; + if self.state.lock().await.initialized { + return Ok(()); + } + + let items = self.query_subscription_items().await?; + let mut subscriptions = Vec::new(); + for item in items { + if !consumer.has_stream_subscription(&item.topic).await { + subscriptions.push(item); + } + } + if !subscriptions.is_empty() { + consumer.subscribe_catalog(subscriptions.clone()).await?; + } + + let mut state = self.state.lock().await; + state.subscriptions = subscriptions; + state.initialized = true; + Ok(()) + } + + /// Unsubscribe only the channels previously created by [`init`](Self::init). + /// + /// If the consumer rejects the unsubscribe, local state is retained so a + /// subsequent `destroy` can retry it. + pub async fn destroy(&self, consumer: &GrpcConsumer) -> Result<()> + where + H: MessageHandler, + { + let _lifecycle = self.lifecycle.lock().await; + let subscriptions = { + let state = self.state.lock().await; + if !state.initialized { + return Ok(()); + } + state.subscriptions.clone() + }; + + if !subscriptions.is_empty() { + consumer.unsubscribe_catalog(subscriptions).await?; + } + + *self.state.lock().await = CatalogState::default(); + Ok(()) + } + + async fn query_subscription_items(&self) -> Result> { + let instance = self + .discovery + .select_one(self.config.server_name.clone()) + .await? + .ok_or_else(|| EventMeshError::ServiceUnavailable(self.config.server_name.clone()))?; + let config = resolved_grpc_config( + &self.config.server_name, + instance, + self.config.timeout, + self.config.use_tls, + self.config.tls_config.clone(), + )?; + let channel = GrpcClient::channel(&config)?; + let mut client = CatalogGrpcClient::new(channel); + let response = tokio::time::timeout( + self.config.timeout, + client.query_operations(QueryOperationsRequest { + service_name: self.config.app_server_name.clone(), + operation_id: String::new(), + }), + ) + .await + .map_err(|_| EventMeshError::Timeout(self.config.timeout))??; + Ok(subscription_items( + &response.into_inner().operations, + &self.config, + )) + } +} + +fn subscription_items( + operations: &[Operation], + config: &CatalogClientConfig, +) -> Vec { + let mut items = Vec::new(); + for operation in operations { + if operation.r#type == "subscribe" { + let item = SubscriptionItem::new( + operation.channel_name.clone(), + config.subscription_mode, + config.subscription_type, + ); + if !items.contains(&item) { + items.push(item); + } + } + } + items +} + +#[cfg(test)] +mod tests { + use std::future::Future; + + use super::*; + use crate::discovery::ServiceInstance; + use crate::proto_gen::catalog::catalog_server::{Catalog, CatalogServer}; + use crate::proto_gen::catalog::{QueryOperationsResponse, RegistryRequest, RegistryResponse}; + use tokio::sync::oneshot; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::{Request, Response, Status}; + + struct StaticDiscovery(ServiceInstance); + + impl ServiceDiscovery for StaticDiscovery { + #[allow(clippy::manual_async_fn)] + fn select_one( + &self, + service_name: String, + ) -> impl Future>> + Send { + assert_eq!(service_name, "eventmesh-catalog"); + let instance = self.0.clone(); + async move { Ok(Some(instance)) } + } + } + + struct TestCatalog; + + #[tonic::async_trait] + impl Catalog for TestCatalog { + async fn registry( + &self, + _: Request, + ) -> std::result::Result, Status> { + Ok(Response::new(RegistryResponse {})) + } + + async fn query_operations( + &self, + request: Request, + ) -> std::result::Result, Status> { + let request = request.into_inner(); + assert_eq!(request.service_name, "payment"); + assert!(request.operation_id.is_empty()); + Ok(Response::new(QueryOperationsResponse { + operations: vec![ + Operation { + channel_name: "payments.received".into(), + schema: String::new(), + r#type: "subscribe".into(), + }, + Operation { + channel_name: "payments.done".into(), + schema: String::new(), + r#type: "publish".into(), + }, + ], + })) + } + } + + fn config() -> CatalogClientConfig { + CatalogClientConfig::builder() + .app_server_name("payment") + .build() + .unwrap() + } + + #[test] + fn maps_only_subscribe_operations_and_deduplicates_topics() { + let operations = vec![ + Operation { + channel_name: "orders.created".into(), + schema: "schema".into(), + r#type: "subscribe".into(), + }, + Operation { + channel_name: "orders.created".into(), + schema: "schema".into(), + r#type: "subscribe".into(), + }, + Operation { + channel_name: "orders.completed".into(), + schema: "schema".into(), + r#type: "publish".into(), + }, + ]; + + let items = subscription_items(&operations, &config()); + assert_eq!(items.len(), 1); + assert_eq!(items[0].topic, "orders.created"); + } + + #[test] + fn applies_configured_subscription_mode_and_type() { + let config = CatalogClientConfig::builder() + .app_server_name("payment") + .subscription_mode(crate::model::SubscriptionMode::BROADCASTING) + .subscription_type(crate::model::SubscriptionType::SYNC) + .build() + .unwrap(); + let items = subscription_items( + &[Operation { + channel_name: "events".into(), + schema: String::new(), + r#type: "subscribe".into(), + }], + &config, + ); + assert_eq!(items[0].mode, crate::model::SubscriptionMode::BROADCASTING); + assert_eq!(items[0].r#type, crate::model::SubscriptionType::SYNC); + } + + #[tokio::test] + async fn query_uses_discovered_endpoint_and_catalog_wire_contract() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(CatalogServer::new(TestCatalog)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + let client = CatalogClient::new( + CatalogClientConfig::builder() + .app_server_name("payment") + .timeout(std::time::Duration::from_secs(1)) + .build() + .unwrap(), + StaticDiscovery(ServiceInstance::new("127.0.0.1", port)), + ); + let items = client.query_subscription_items().await.unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].topic, "payments.received"); + let _ = shutdown_tx.send(()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common.rs deleted file mode 100644 index 7b3ea0d018..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common.rs +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -//! Common utilities for eventmesh. - -/// Constants. -pub mod constants; - -/// Eventmesh message utilities. -pub mod grpc_eventmesh_message_utils; - -/// Local IP helper. -pub(crate) mod local_ip; - -/// Protocol keys. -mod protocol_key; - -/// Random string generator. -mod random_string_util; - -/// Re-export protocol keys. -pub use crate::common::protocol_key::ProtocolKey; - -/// Re-export random string generator. -pub use crate::common::random_string_util::RandomStringUtils; - -/// Trait for message listener. -pub trait ReceiveMessageListener: Sync + Send { - /// Message type. - type Message; - - /// Handle received message. - /// - /// # Arguments - /// - /// * `msg` - The received message. - /// - /// # Returns - /// - /// The processed message or error. - fn handle(&self, msg: Self::Message) -> crate::Result>; -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/constants.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/constants.rs index 5c0ef06907..bc9788a8f2 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common/constants.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/constants.rs @@ -1,56 +1,57 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -pub const DEFAULT_EVENTMESH_MESSAGE_TTL: i32 = 4000; -pub const SDK_STREAM_URL: &str = "grpc_stream"; +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. -pub struct DataContentType; +//! Shared protocol/SDK constants. -impl DataContentType { - pub const TEXT_PLAIN: &'static str = "text/plain"; - pub const JSON: &'static str = "application/json"; -} +/// Default message TTL in milliseconds (mirrors the Java SDK). +pub const DEFAULT_MESSAGE_TTL: i32 = 4_000; -pub(crate) struct SpecVersion; +/// Placeholder URL recorded for stream-mode subscriptions (server treats the +/// stream itself as the delivery channel). +pub const SDK_STREAM_URL: &str = "grpc_stream"; -#[allow(dead_code)] +/// CloudEvents spec versions understood by this SDK. +pub struct SpecVersion; impl SpecVersion { - pub(crate) const V1: &'static str = "1.0"; - pub(crate) const V03: &'static str = "0.3"; + /// CloudEvents 1.0 (the version EventMesh normalizes to). + pub const V1: &str = "1.0"; + /// CloudEvents 0.3 (legacy). + pub const V03: &str = "0.3"; } -#[derive(Debug, PartialEq, Eq)] +/// Common `datacontenttype` values. +pub struct DataContentType; +impl DataContentType { + pub const TEXT_PLAIN: &str = "text/plain"; + pub const JSON: &str = "application/json"; + pub const XML: &str = "application/xml"; + pub const PROTOBUF: &str = "application/protobuf"; + pub const CLOUDEVENTS_JSON: &str = "application/cloudevents+json"; +} + +/// Client role reported to the server (gRPC `clienttype` attribute / TCP purpose). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClientType { - PUB, - SUB, + Pub = 1, + Sub = 2, } impl ClientType { - pub fn get(type_: i32) -> Option { - match type_ { - 1 => Some(ClientType::PUB), - 2 => Some(ClientType::SUB), - _ => None, - } - } - - pub fn contains(client_type: i32) -> bool { - match client_type { - 1 | 2 => true, - _ => false, - } + pub fn as_i32(self) -> i32 { + self as i32 } } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/grpc_eventmesh_message_utils.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/grpc_eventmesh_message_utils.rs deleted file mode 100644 index c1ff9a3221..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common/grpc_eventmesh_message_utils.rs +++ /dev/null @@ -1,671 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::any::Any; -use std::collections::{HashMap, HashSet}; -use std::fmt::Debug; -use std::time::{SystemTime, UNIX_EPOCH}; - -use cloudevents::Data::String as EventString; -use cloudevents::{AttributesReader, Data, Event, EventBuilder, EventBuilderV10}; -use tonic::transport::Uri; - -use crate::common::constants::{DataContentType, SpecVersion, DEFAULT_EVENTMESH_MESSAGE_TTL}; -use crate::common::{ProtocolKey, RandomStringUtils}; -use crate::config::EventMeshGrpcClientConfig; -use crate::model::message::EventMeshMessage; -use crate::model::response::EventMeshResponse; -use crate::model::subscription::SubscriptionItem; -use crate::model::EventMeshProtocolType; -use crate::proto_cloud_event::{PbAttr, PbCloudEvent, PbCloudEventAttributeValue, PbData}; - -pub struct ProtoSupport; - -impl ProtoSupport { - pub fn is_text_content(content_type: &str) -> bool { - if content_type.is_empty() { - return false; - } - - content_type.starts_with("text/") - || content_type == "application/json" - || content_type == "application/xml" - || content_type.ends_with("+json") - || content_type.ends_with("+xml") - } - - pub fn is_proto_content(content_type: &str) -> bool { - content_type == "application/protobuf" - } -} - -pub struct EventMeshCloudEventUtils; - -impl EventMeshCloudEventUtils { - const CLOUD_EVENT_TYPE: &'static str = "org.apache.eventmesh"; - - pub fn build_common_cloud_event_attributes( - client_config: &EventMeshGrpcClientConfig, - protocol_type: EventMeshProtocolType, - ) -> HashMap { - let mut attribute_value_map = HashMap::with_capacity(64); - attribute_value_map.insert( - ProtocolKey::ENV.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(client_config.env.clone())), - }, - ); - attribute_value_map.insert( - ProtocolKey::IDC.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(client_config.idc.clone())), - }, - ); - attribute_value_map.insert( - ProtocolKey::IP.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(crate::common::local_ip::get_local_ip_v4())), - }, - ); - attribute_value_map.insert( - ProtocolKey::PID.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(std::process::id().to_string())), - }, - ); - attribute_value_map.insert( - ProtocolKey::SYS.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(client_config.sys.clone())), - }, - ); - attribute_value_map.insert( - ProtocolKey::LANGUAGE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(client_config.language.clone())), - }, - ); - attribute_value_map.insert( - ProtocolKey::USERNAME.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(client_config.user_name.clone())), - }, - ); - attribute_value_map.insert( - ProtocolKey::PASSWD.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(client_config.password.clone())), - }, - ); - attribute_value_map.insert( - ProtocolKey::PROTOCOL_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - protocol_type.protocol_type_name().to_string(), - )), - }, - ); - attribute_value_map.insert( - ProtocolKey::PROTOCOL_VERSION.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(SpecVersion::V1.to_string())), - }, - ); - attribute_value_map - } - - pub fn build_event_subscription( - client_config: &EventMeshGrpcClientConfig, - protocol_type: EventMeshProtocolType, - url: &str, - subscription_items: &[SubscriptionItem], - ) -> Option { - if subscription_items.is_empty() { - return None; - } - let subscription_item_set: HashSet = - subscription_items.iter().cloned().collect(); - let mut attribute_value_map = - Self::build_common_cloud_event_attributes(client_config, protocol_type); - attribute_value_map.insert( - ProtocolKey::CONSUMERGROUP.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(client_config.consumer_group.clone()?)), - }, - ); - attribute_value_map.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::JSON.to_string())), - }, - ); - if !url.trim().is_empty() { - attribute_value_map.insert( - ProtocolKey::URL.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(url.to_string())), - }, - ); - } - let subscription_item_json = serde_json::to_string(&subscription_item_set); - if subscription_item_json.is_err() { - return None; - } - Some(PbCloudEvent { - id: RandomStringUtils::generate_uuid(), - source: Uri::builder() - .path_and_query("/") - .build() - .unwrap() - .to_string(), - spec_version: SpecVersion::V1.to_string(), - r#type: Self::CLOUD_EVENT_TYPE.to_string(), - attributes: attribute_value_map, - data: Some(PbData::TextData(subscription_item_json.unwrap())), - }) - } - - pub fn build_event_mesh_cloud_event( - message: T, - client_config: &EventMeshGrpcClientConfig, - ) -> Option - where - T: Any, - { - let message_any = &message as &dyn Any; - - if let Some(em_message) = message_any.downcast_ref::() { - return Some(Self::switch_event_mesh_message_2_event_mesh_cloud_event( - em_message, - client_config, - EventMeshProtocolType::EventMeshMessage, - )); - } - if let Some(cloud_event) = message_any.downcast_ref::() { - return Some(Self::switch_cloud_event_2_event_mesh_cloud_event( - cloud_event, - client_config, - EventMeshProtocolType::CloudEvents, - )); - } - - None - } - - pub fn switch_event_mesh_message_2_event_mesh_cloud_event( - message: &EventMeshMessage, - client_config: &EventMeshGrpcClientConfig, - protocol_type: EventMeshProtocolType, - ) -> PbCloudEvent { - let mut attribute_value_map = - Self::build_common_cloud_event_attributes(client_config, protocol_type); - let ttl = message - .get_prop(ProtocolKey::TTL) - .cloned() - .unwrap_or_else(|| DEFAULT_EVENTMESH_MESSAGE_TTL.to_string()); - let seq_num = message - .biz_seq_no - .clone() - .unwrap_or_else(|| RandomStringUtils::generate_num(30)); - let unique_id = message - .unique_id - .clone() - .unwrap_or_else(|| RandomStringUtils::generate_num(30)); - attribute_value_map - .entry(ProtocolKey::DATA_CONTENT_TYPE.to_string()) - .or_insert_with(|| PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::TEXT_PLAIN.to_string())), - }); - - attribute_value_map.insert( - ProtocolKey::TTL.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(ttl.to_string())), - }, - ); - attribute_value_map.insert( - ProtocolKey::SEQ_NUM.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(seq_num.to_string())), - }, - ); - - attribute_value_map.insert( - ProtocolKey::SEQ_NUM.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(seq_num.to_string())), - }, - ); - - attribute_value_map.insert( - ProtocolKey::PROTOCOL_DESC.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - ProtocolKey::PROTOCOL_DESC_GRPC_CLOUD_EVENT.to_string(), - )), - }, - ); - - attribute_value_map.insert( - ProtocolKey::UNIQUE_ID.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(unique_id.to_string())), - }, - ); - attribute_value_map.insert( - ProtocolKey::PRODUCERGROUP.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - client_config.producer_group.clone().unwrap(), - )), - }, - ); - if let Some(topic) = &message.topic { - attribute_value_map.insert( - ProtocolKey::SUBJECT.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(topic.to_string())), - }, - ); - } - attribute_value_map.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::TEXT_PLAIN.to_string())), - }, - ); - let props = &message.prop; - let text_plain = DataContentType::TEXT_PLAIN.to_string(); - let data_content_type = props - .get(ProtocolKey::DATA_CONTENT_TYPE) - .unwrap_or(&text_plain); - props.iter().for_each(|(key, value)| { - attribute_value_map.insert( - key.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(value.to_string())), - }, - ); - }); - - let data = { - if let Some(content) = &message.content { - if ProtoSupport::is_text_content(data_content_type) { - Some(PbData::TextData(content.to_string())) - } else if ProtoSupport::is_proto_content(data_content_type) { - Some(PbData::ProtoData(prost_types::Any { - type_url: String::from(""), - value: content.clone().into_bytes(), - })) - } else { - Some(PbData::BinaryData(content.clone().into_bytes())) - } - } else { - None - } - }; - PbCloudEvent { - id: RandomStringUtils::generate_uuid(), - source: Uri::builder() - .path_and_query("/") - .build() - .unwrap() - .to_string(), - spec_version: SpecVersion::V1.to_string(), - r#type: Self::CLOUD_EVENT_TYPE.to_string(), - attributes: attribute_value_map, - data, - } - } - - pub fn switch_cloud_event_2_event_mesh_cloud_event( - message: &Event, - client_config: &EventMeshGrpcClientConfig, - protocol_type: EventMeshProtocolType, - ) -> PbCloudEvent { - let mut attribute_value_map = - Self::build_common_cloud_event_attributes(client_config, protocol_type); - let ttl = message - .extension(ProtocolKey::TTL) - .map_or(DEFAULT_EVENTMESH_MESSAGE_TTL.to_string(), |value| { - value.to_string() - }); - let seq_num = message - .extension(ProtocolKey::SEQ_NUM) - .map_or(RandomStringUtils::generate_num(30), |value| { - value.to_string() - }); - let unique_id = message.id().to_string(); - attribute_value_map - .entry(ProtocolKey::DATA_CONTENT_TYPE.to_string()) - .or_insert_with(|| PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::TEXT_PLAIN.to_string())), - }); - - attribute_value_map.insert( - ProtocolKey::TTL.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(ttl.to_string())), - }, - ); - attribute_value_map.insert( - ProtocolKey::SEQ_NUM.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(seq_num.to_string())), - }, - ); - - attribute_value_map.insert( - ProtocolKey::SEQ_NUM.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(seq_num.to_string())), - }, - ); - - attribute_value_map.insert( - ProtocolKey::PROTOCOL_DESC.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - ProtocolKey::PROTOCOL_DESC_GRPC_CLOUD_EVENT.to_string(), - )), - }, - ); - - attribute_value_map.insert( - ProtocolKey::UNIQUE_ID.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(unique_id.to_string())), - }, - ); - attribute_value_map.insert( - ProtocolKey::PRODUCERGROUP.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - client_config.producer_group.clone().unwrap(), - )), - }, - ); - - attribute_value_map.insert( - ProtocolKey::SUBJECT.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(message.subject().unwrap().to_string())), - }, - ); - - attribute_value_map.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::TEXT_PLAIN.to_string())), - }, - ); - message.iter_extensions().for_each(|(key, value)| { - attribute_value_map.insert( - key.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(value.to_string())), - }, - ); - }); - - let data = { - if let Some(content) = message.data() { - match content { - Data::Binary(bytes) => Some(PbData::ProtoData(prost_types::Any { - type_url: String::from(""), - value: bytes.clone(), - })), - EventString(string) => Some(PbData::TextData(string.clone())), - Data::Json(_json) => None, - } - } else { - None - } - }; - PbCloudEvent { - id: RandomStringUtils::generate_uuid(), - source: Uri::builder() - .path_and_query("/") - .build() - .unwrap() - .to_string(), - spec_version: SpecVersion::V1.to_string(), - r#type: Self::CLOUD_EVENT_TYPE.to_string(), - attributes: attribute_value_map, - data, - } - } - - pub fn build_message_from_event_mesh_cloud_event(cloud_event: &PbCloudEvent) -> Option - where - T: Any + Debug + From, - { - let seq = EventMeshCloudEventUtils::get_seq_num(cloud_event); - let unique_id = EventMeshCloudEventUtils::get_unique_id(cloud_event); - - if seq.is_empty() || unique_id.is_empty() { - return None; - } - Some(T::from(cloud_event.clone())) - } - - pub(crate) fn switch_event_mesh_cloud_event_2_event_mesh_message( - cloud_event: &PbCloudEvent, - ) -> EventMeshMessage { - let mut prop = HashMap::new(); - cloud_event.attributes.iter().for_each(|(key, value)| { - prop.insert( - key.to_string(), - (&(value.attr)).clone().unwrap().to_string(), - ); - }); - let topic = EventMeshCloudEventUtils::get_subject(cloud_event); - let biz_seq_no = EventMeshCloudEventUtils::get_seq_num(cloud_event); - let unique_id = EventMeshCloudEventUtils::get_unique_id(cloud_event); - let content = EventMeshCloudEventUtils::get_text_data(cloud_event); - EventMeshMessage { - biz_seq_no: Some(biz_seq_no), - unique_id: Some(unique_id), - topic: Some(topic), - content: Some(content), - prop, - create_time: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or_else(|_err| 0u64, |time| time.as_millis() as u64), - } - } - - pub(crate) fn switch_event_mesh_cloud_event_2_cloud_event(cloud_event: PbCloudEvent) -> Event { - let topic = EventMeshCloudEventUtils::get_subject(&cloud_event); - let unique_id = EventMeshCloudEventUtils::get_unique_id(&cloud_event); - let content = EventMeshCloudEventUtils::get_text_data(&cloud_event); - let source = EventMeshCloudEventUtils::get_source(&cloud_event); - - let mut builder = EventBuilderV10::new() - .id(unique_id) - .subject(topic) - .source(source) - .ty(ProtocolKey::CLOUD_EVENTS_PROTOCOL_NAME) - .data(DataContentType::JSON, content); - - for (key, value) in cloud_event.attributes { - builder = builder.extension(key.as_str(), value.attr.clone().unwrap().to_string()); - } - - builder.build().unwrap() - } - - #[allow(dead_code)] - pub(crate) fn switch_cloud_event_2_event_mesh_message(cloud_event: Event) -> EventMeshMessage { - let mut prop = HashMap::new(); - cloud_event.iter_attributes().for_each(|(key, value)| { - prop.insert(key.to_string(), value.to_string()); - }); - let topic = cloud_event.subject().unwrap().to_string(); - let biz_seq_no = cloud_event - .extension(ProtocolKey::SEQ_NUM) - .unwrap() - .to_string(); - let unique_id = cloud_event.id().to_string(); - let content = cloud_event.data().unwrap().to_string(); - EventMeshMessage { - biz_seq_no: Some(biz_seq_no), - unique_id: Some(unique_id), - topic: Some(topic), - content: Some(content), - prop, - create_time: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or_else(|_err| 0u64, |time| time.as_millis() as u64), - } - } - - pub fn get_seq_num(cloud_event: &PbCloudEvent) -> String { - cloud_event - .attributes - .get(ProtocolKey::SEQ_NUM) - .map_or_else( - || String::new(), - |ce| { - ce.attr - .clone() - .unwrap_or(PbAttr::CeString(String::new())) - .to_string() - }, - ) - } - - pub fn get_unique_id(cloud_event: &PbCloudEvent) -> String { - cloud_event - .attributes - .get(ProtocolKey::UNIQUE_ID) - .map_or_else( - || String::new(), - |ce| { - ce.attr - .clone() - .unwrap_or(PbAttr::CeString(String::new())) - .to_string() - }, - ) - } - - pub fn get_data_content(cloud_event: &PbCloudEvent) -> String { - cloud_event - .attributes - .get(ProtocolKey::DATA_CONTENT_TYPE) - .map_or_else( - || String::new(), - |ce| { - ce.attr - .clone() - .unwrap_or(PbAttr::CeString(String::new())) - .to_string() - }, - ) - } - - pub fn get_subject(cloud_event: &PbCloudEvent) -> String { - cloud_event - .attributes - .get(ProtocolKey::SUBJECT) - .map_or_else( - || String::new(), - |ce| { - ce.attr - .clone() - .unwrap_or(PbAttr::CeString(String::new())) - .to_string() - }, - ) - } - - pub fn get_text_data(cloud_event: &PbCloudEvent) -> String { - cloud_event - .data - .clone() - .unwrap_or(PbData::TextData(String::new())) - .to_string() - } - - pub fn get_source(cloud_event: &PbCloudEvent) -> String { - cloud_event.attributes.get(ProtocolKey::SOURCE).map_or_else( - || String::new(), - |ce| { - ce.attr - .clone() - .unwrap_or(PbAttr::CeString(String::new())) - .to_string() - }, - ) - } - - pub fn get_response(cloud_event: &PbCloudEvent) -> EventMeshResponse { - let code = cloud_event - .attributes - .get(ProtocolKey::GRPC_RESPONSE_CODE) - .map_or_else( - || None, - |val| { - if let Some(ref value) = val.attr { - Some(value.to_string()) - } else { - None - } - }, - ); - let msg = cloud_event - .attributes - .get(ProtocolKey::GRPC_RESPONSE_MESSAGE) - .map_or_else( - || None, - |val| { - if let Some(ref value) = val.attr { - Some(value.to_string()) - } else { - None - } - }, - ); - let time = cloud_event - .attributes - .get(ProtocolKey::GRPC_RESPONSE_TIME) - .map_or_else( - || None, - |val| { - if let Some(ref value) = val.attr { - Some(value.to_string().parse::().unwrap_or(0)) - } else { - None - } - }, - ); - EventMeshResponse::new(code, msg, time) - } - - pub fn get_ttl(cloud_event: &PbCloudEvent) -> String { - cloud_event.attributes.get(ProtocolKey::TTL).map_or_else( - || String::new(), - |ce| { - ce.attr - .clone() - .unwrap_or(PbAttr::CeString(String::new())) - .to_string() - }, - ) - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/loadbalance.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/loadbalance.rs new file mode 100644 index 0000000000..323df5d25b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/loadbalance.rs @@ -0,0 +1,265 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Load-balancing across multiple EventMesh nodes (used by the HTTP +//! transport and multi-endpoint gRPC clients). +//! +//! Ported from `org.apache.eventmesh.common.loadbalance`. + +use std::sync::Mutex; + +use rand::Rng; + +use crate::error::{EventMeshError, Result}; + +/// Configured load-balance strategy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LoadBalance { + #[default] + Random, + WeightRandom, + WeightRoundRobin, +} + +/// A server endpoint, optionally weighted. Address format: `host:port` or +/// `host:port:weight` (weight defaults to 1 when omitted). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerNode { + pub host: String, + pub port: u16, + pub weight: i32, +} + +impl ServerNode { + /// Parse `host:port` or `host:port:weight`. + pub fn parse(addr: &str) -> Result { + let parts: Vec<&str> = addr.split(':').collect(); + match parts.len() { + 2 => { + let port = parts[1] + .parse::() + .map_err(|e| EventMeshError::Config(format!("bad port in {addr:?}: {e}")))?; + Ok(Self { + host: parts[0].to_string(), + port, + weight: 1, + }) + } + 3 => { + let port = parts[1] + .parse::() + .map_err(|e| EventMeshError::Config(format!("bad port in {addr:?}: {e}")))?; + let weight = parts[2] + .parse::() + .map_err(|e| EventMeshError::Config(format!("bad weight in {addr:?}: {e}")))?; + if weight <= 0 { + return Err(EventMeshError::Config(format!( + "weight must be > 0: {addr:?}" + ))); + } + Ok(Self { + host: parts[0].to_string(), + port, + weight, + }) + } + _ => Err(EventMeshError::Config(format!( + "expected host:port[:weight], got {addr:?}" + ))), + } + } + + pub fn addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + +/// Stateful selector over a set of nodes. +pub enum LoadBalanceSelector { + Random { + nodes: Vec, + }, + WeightRandom { + nodes: Vec, + /// Precomputed sum of all node weights (each clamped to ≥ 1). + /// Stored as `i64` to avoid overflow on large weights. + total_weight: i64, + }, + WeightRoundRobin { + nodes: Vec, + /// Precomputed sum of all node weights (each clamped to ≥ 1). + total_weight: i64, + /// Current weighted round-robin counters (smooth WRR, nginx-style). + /// Stored as `i64` to avoid overflow on large / long-running sums. + counters: Mutex>, + }, +} + +impl LoadBalanceSelector { + /// Build a selector for the given nodes using the chosen strategy. + pub fn new(nodes: Vec, strategy: LoadBalance) -> Result { + if nodes.is_empty() { + return Err(EventMeshError::Config( + "load-balance requires at least one node".into(), + )); + } + Ok(match strategy { + LoadBalance::Random => Self::Random { nodes }, + LoadBalance::WeightRandom => { + let total_weight: i64 = nodes.iter().map(|n| n.weight.max(1) as i64).sum(); + Self::WeightRandom { + nodes, + total_weight, + } + } + LoadBalance::WeightRoundRobin => { + let total_weight: i64 = nodes.iter().map(|n| n.weight.max(1) as i64).sum(); + let counters = Mutex::new(vec![0; nodes.len()]); + Self::WeightRoundRobin { + nodes, + total_weight, + counters, + } + } + }) + } + + /// Pick the next node. + pub fn select(&self) -> &ServerNode { + match self { + Self::Random { nodes } => { + let idx = rand::thread_rng().gen_range(0..nodes.len()); + &nodes[idx] + } + Self::WeightRandom { + nodes, + total_weight, + } => { + // O(n) walk — does NOT expand nodes by weight, so large + // weights cannot cause OOM. Mirrors the Java SDK's + // WeightRandomLoadBalanceSelector. + let mut target = rand::thread_rng().gen_range(0..*total_weight); + for n in nodes.iter() { + target -= n.weight.max(1) as i64; + if target < 0 { + return n; + } + } + // Fallback (defensive — unreachable when total_weight is exact). + &nodes[nodes.len() - 1] + } + Self::WeightRoundRobin { + nodes, + total_weight, + counters, + } => { + // Smooth weighted round-robin (nginx-style). Counters are i64 + // so large weights or long uptimes cannot overflow. + let mut guard = counters.lock().expect("counter lock poisoned"); + let mut best = 0usize; + for (i, n) in nodes.iter().enumerate() { + guard[i] += n.weight.max(1) as i64; + if guard[i] > guard[best] { + best = i; + } + } + guard[best] -= total_weight; + &nodes[best] + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_host_port() { + let n = ServerNode::parse("1.2.3.4:10105").unwrap(); + assert_eq!(n.host, "1.2.3.4"); + assert_eq!(n.port, 10105); + assert_eq!(n.weight, 1); + } + + #[test] + fn parse_weighted() { + let n = ServerNode::parse("1.2.3.4:10105:5").unwrap(); + assert_eq!(n.weight, 5); + assert_eq!(n.addr(), "1.2.3.4:10105"); + } + + #[test] + fn random_selects_within_set() { + let nodes = vec![ + ServerNode::parse("a:1").unwrap(), + ServerNode::parse("b:2").unwrap(), + ]; + let sel = LoadBalanceSelector::new(nodes.clone(), LoadBalance::Random).unwrap(); + for _ in 0..20 { + let n = sel.select(); + assert!(nodes.contains(n)); + } + } + + #[test] + fn weight_round_robin_distributes_proportionally() { + let nodes = vec![ + ServerNode::parse("a:1:5").unwrap(), + ServerNode::parse("b:1:1").unwrap(), + ]; + let sel = LoadBalanceSelector::new(nodes, LoadBalance::WeightRoundRobin).unwrap(); + let mut a = 0; + for _ in 0..60 { + if sel.select().host == "a" { + a += 1; + } + } + // ~5/6 should be 'a'. + assert!(a > 35 && a < 65, "a={a}"); + } + + #[test] + fn weight_random_distributes_proportionally() { + let nodes = vec![ + ServerNode::parse("a:1:9").unwrap(), + ServerNode::parse("b:1:1").unwrap(), + ]; + let sel = LoadBalanceSelector::new(nodes, LoadBalance::WeightRandom).unwrap(); + let mut a = 0; + for _ in 0..1000 { + if sel.select().host == "a" { + a += 1; + } + } + // ~9/10 should be 'a'. + assert!(a > 850 && a < 950, "a={a}"); + } + + #[test] + fn weight_random_handles_large_weight_without_oom() { + // Previously this would expand to a Vec of 1 billion entries. + let nodes = vec![ + ServerNode::parse("a:1:1000000").unwrap(), + ServerNode::parse("b:1:1").unwrap(), + ]; + let sel = LoadBalanceSelector::new(nodes, LoadBalance::WeightRandom).unwrap(); + for _ in 0..10 { + sel.select(); + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/local_ip.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/local_ip.rs deleted file mode 100644 index 4c46a2931d..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common/local_ip.rs +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use local_ip_address::local_ip; -use std::net::{IpAddr, Ipv4Addr}; - -/// Get local IPv4 address. -/// -/// Get local IPv4 address, fallback to 127.0.0.1 if failed. -/// -/// # Returns -/// -/// The local IPv4 address as string. -pub(crate) fn get_local_ip_v4() -> String { - let ip_addr = local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - ip_addr.to_string() -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/mod.rs new file mode 100644 index 0000000000..4b569348ef --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/mod.rs @@ -0,0 +1,29 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Cross-protocol constants, protocol keys, helpers and load-balancing. + +pub mod constants; +pub mod loadbalance; +pub mod protocol_key; +pub mod status_code; +pub mod util; + +pub use constants::{DataContentType, SpecVersion, DEFAULT_MESSAGE_TTL}; +pub use loadbalance::{LoadBalance, LoadBalanceSelector}; +pub use protocol_key::ProtocolKey; +pub use util::{local_ip_v4, RandomStringUtils}; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/protocol_key.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/protocol_key.rs index c4f4b27778..4195c6f58b 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common/protocol_key.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/protocol_key.rs @@ -1,66 +1,75 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -pub struct ProtocolKey; +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! CloudEvent attribute keys used across protocols. +//! +//! These mirror `org.apache.eventmesh.common.protocol.grpc.common.ProtocolKey` +//! on the server. Attribute keys are lowercase strings stored in the +//! CloudEvent `attributes` map (gRPC) or sent as HTTP/TCP headers. +/// Container for all well-known attribute key constants. +pub struct ProtocolKey; #[allow(dead_code)] impl ProtocolKey { - // EventMesh extensions - pub const ENV: &'static str = "env"; - pub const IDC: &'static str = "idc"; - pub const SYS: &'static str = "sys"; - pub const PID: &'static str = "pid"; - pub const IP: &'static str = "ip"; - pub const USERNAME: &'static str = "username"; - pub const PASSWD: &'static str = "passwd"; - pub const LANGUAGE: &'static str = "language"; - pub const PROTOCOL_TYPE: &'static str = "protocoltype"; - pub const PROTOCOL_VERSION: &'static str = "protocolversion"; - pub const PROTOCOL_DESC: &'static str = "protocoldesc"; - pub const SEQ_NUM: &'static str = "seqnum"; - pub const UNIQUE_ID: &'static str = "uniqueid"; - pub const TTL: &'static str = "ttl"; - pub const PRODUCERGROUP: &'static str = "producergroup"; - pub const CONSUMERGROUP: &'static str = "consumergroup"; - pub const TAG: &'static str = "tag"; - pub const CONTENT_TYPE: &'static str = "contenttype"; - pub const PROPERTY_MESSAGE_CLUSTER: &'static str = "cluster"; - pub const URL: &'static str = "url"; - pub const CLIENT_TYPE: &'static str = "clienttype"; - pub const GRPC_RESPONSE_CODE: &'static str = "status_code"; - pub const GRPC_RESPONSE_MESSAGE: &'static str = "response_message"; - pub const GRPC_RESPONSE_TIME: &'static str = "time"; + // ---- client identity (carried in every request) ---- + pub const ENV: &str = "env"; + pub const IDC: &str = "idc"; + pub const SYS: &str = "sys"; + pub const PID: &str = "pid"; + pub const IP: &str = "ip"; + pub const USERNAME: &str = "username"; + pub const PASSWD: &str = "passwd"; + pub const LANGUAGE: &str = "language"; - pub const SUB_MESSAGE_TYPE: &'static str = "submessagetype"; + // ---- protocol descriptors ---- + pub const PROTOCOL_TYPE: &str = "protocoltype"; + pub const PROTOCOL_VERSION: &str = "protocolversion"; + pub const PROTOCOL_DESC: &str = "protocoldesc"; + pub const PROTOCOL_DESC_GRPC_CLOUD_EVENT: &str = "grpc-cloud-event"; + pub const CLOUD_EVENTS_PROTOCOL_NAME: &str = "cloudevents"; - // CloudEvents spec - pub const ID: &'static str = "id"; - pub const SOURCE: &'static str = "source"; - pub const SPECVERSION: &'static str = "specversion"; - pub const TYPE: &'static str = "type"; - pub const DATA_CONTENT_TYPE: &'static str = "datacontenttype"; - pub const DATA_SCHEMA: &'static str = "dataschema"; - pub const SUBJECT: &'static str = "subject"; - pub const TIME: &'static str = "time"; - pub const EVENT_DATA: &'static str = "eventdata"; + // ---- message routing ---- + pub const SEQ_NUM: &str = "seqnum"; + pub const UNIQUE_ID: &str = "uniqueid"; + pub const TTL: &str = "ttl"; + pub const PRODUCERGROUP: &str = "producergroup"; + pub const CONSUMERGROUP: &str = "consumergroup"; + pub const TAG: &str = "tag"; + pub const URL: &str = "url"; + pub const CLIENT_TYPE: &str = "clienttype"; + pub const SUB_MESSAGE_TYPE: &str = "submessagetype"; + pub const PROPERTY_MESSAGE_CLUSTER: &str = "cluster"; - //protocol desc - pub const PROTOCOL_DESC_GRPC_CLOUD_EVENT: &'static str = "grpc-cloud-event"; + // ---- CloudEvents spec attributes (lowercased for the attributes map) ---- + pub const ID: &str = "id"; + pub const SOURCE: &str = "source"; + pub const SPECVERSION: &str = "specversion"; + pub const TYPE: &str = "type"; + pub const DATA_CONTENT_TYPE: &str = "datacontenttype"; + pub const DATA_SCHEMA: &str = "dataschema"; + pub const SUBJECT: &str = "subject"; + pub const TIME: &str = "time"; + pub const EVENT_DATA: &str = "eventdata"; - pub const CLOUD_EVENTS_PROTOCOL_NAME: &'static str = "cloudevents"; + // ---- server -> client response (gRPC) ---- + pub const GRPC_RESPONSE_CODE: &str = "statuscode"; + pub const GRPC_RESPONSE_MESSAGE: &str = "responsemessage"; + pub const GRPC_RESPONSE_TIME: &str = "time"; - pub const CLOUDEVENT_CONTENT_TYPE: &'static str = "application/cloudevents+json"; + // ---- subscription reply marker ---- + pub const SUBSCRIPTION_REPLY: &str = "subscription_reply"; } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/random_string_util.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/random_string_util.rs deleted file mode 100644 index 71c48a7248..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common/random_string_util.rs +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use rand::distributions::Alphanumeric; -use rand::Rng; -use std::iter; -use uuid; - -pub struct RandomStringUtils; - -impl RandomStringUtils { - /// Generate a random alphanumeric string. - /// - /// Generate a random string with given length, containing alphanumeric characters. - /// - /// # Arguments - /// - /// * `length` - The length of generated string. - /// - /// # Returns - /// - /// The randomly generated string. - pub fn generate_num(length: usize) -> String { - let random_string = iter::repeat(()) - .map(|()| rand::thread_rng().sample(Alphanumeric) as char) - .take(length) - .collect(); - random_string - } - - /// Generate a random UUID string. - /// - /// # Returns - /// - /// The randomly generated UUID string. - pub fn generate_uuid() -> String { - let uuid = uuid::Uuid::new_v4(); - uuid.to_string() - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/status_code.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/status_code.rs new file mode 100644 index 0000000000..35209a9ae5 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/status_code.rs @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Status / return codes returned by the EventMesh server. + +/// gRPC status codes the EventMesh server returns in the `statuscode` +/// CloudEvent attribute (mirrors `org.apache.eventmesh.common.protocol.grpc.common.StatusCode`). +/// +/// `SUCCESS` (0) means OK; everything else is an error. +pub struct StatusCode; +#[allow(dead_code)] +impl StatusCode { + pub const SUCCESS: i32 = 0; + pub const OVERLOAD: i32 = 1; + pub const EVENTMESH_REQUESTCODE_INVALID: i32 = 2; + pub const EVENTMESH_SEND_SYNC_MSG_ERR: i32 = 3; + pub const EVENTMESH_WAITING_RR_MSG_ERR: i32 = 4; + pub const EVENTMESH_PROTOCOL_HEADER_ERR: i32 = 6; + pub const EVENTMESH_PROTOCOL_BODY_ERR: i32 = 7; + pub const EVENTMESH_STOP: i32 = 8; + pub const EVENTMESH_REJECT_BY_PROCESSOR_ERROR: i32 = 9; + pub const EVENTMESH_BATCH_PUBLISH_ERR: i32 = 10; + pub const EVENTMESH_BATCH_SPEED_OVER_LIMIT_ERR: i32 = 11; + pub const EVENTMESH_PACKAGE_MSG_ERR: i32 = 12; + pub const EVENTMESH_GROUP_PRODUCER_STOPPED_ERR: i32 = 13; + pub const EVENTMESH_SEND_ASYNC_MSG_ERR: i32 = 14; + pub const EVENTMESH_REPLY_MSG_ERR: i32 = 15; + pub const EVENTMESH_RUNTIME_ERR: i32 = 16; + pub const EVENTMESH_SEND_BATCHLOG_MSG_ERR: i32 = 17; + pub const EVENTMESH_SUBSCRIBE_ERR: i32 = 17; + pub const EVENTMESH_UNSUBSCRIBE_ERR: i32 = 18; + pub const EVENTMESH_HEARTBEAT_ERR: i32 = 19; + pub const EVENTMESH_ACL_ERR: i32 = 20; + pub const EVENTMESH_SEND_MESSAGE_SPEED_OVER_LIMIT_ERR: i32 = 21; + pub const EVENTMESH_REQUEST_REPLY_MSG_ERR: i32 = 22; + pub const CLIENT_RESUBSCRIBE: i32 = 30; +} + +/// HTTP consumer return codes (returned in the webhook response body as +/// `{"retCode": n}`). Mirrors `ClientRetCode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientRetCode { + /// Remote consumer accepted and handled the message. + RemoteOk = 0, + /// Healthy consumption. + Ok = 1, + /// Transient failure; broker should retry. + Retry = 2, + /// Permanent failure. + Fail = 3, + /// No active listener; broker should stop pushing. + NoListen = 5, +} + +impl ClientRetCode { + pub fn as_i32(self) -> i32 { + self as i32 + } +} + +/// Legacy request-code integer for the old HTTP `code` header (rarely needed +/// with the path-based API, kept for completeness). +pub struct RequestCode; +#[allow(dead_code)] +impl RequestCode { + pub const MSG_SEND_SYNC: i32 = 101; + pub const MSG_BATCH_SEND: i32 = 102; + pub const MSG_SEND_ASYNC: i32 = 104; + pub const HTTP_PUSH_CLIENT_ASYNC: i32 = 105; + pub const HTTP_PUSH_CLIENT_SYNC: i32 = 106; + pub const REPLY_MESSAGE: i32 = 301; + pub const HEARTBEAT: i32 = 203; + pub const SUBSCRIBE: i32 = 206; + pub const UNSUBSCRIBE: i32 = 207; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/util.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/util.rs new file mode 100644 index 0000000000..0514ec9bac --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/util.rs @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Small utility helpers: local IP discovery and random string generation. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use rand::Rng; +use uuid::Uuid; + +/// Best-effort local IPv4 (used to populate the `ip` attribute / header). +/// Falls back to `127.0.0.1` when nothing suitable is found. +pub fn local_ip_v4() -> String { + // Resolve the OS-assigned outbound IP by opening a UDP socket to a public + // address (no packets are actually sent for UDP connect). + std::net::UdpSocket::bind("0.0.0.0:0") + .and_then(|s| { + s.connect("8.8.8.8:80")?; + s.local_addr().map(|a| a.ip().to_string()) + }) + .unwrap_or_else(|_| "127.0.0.1".to_string()) +} + +/// Random string generators used for `bizSeqNo` / `uniqueId` / CloudEvent id. +pub struct RandomStringUtils; +impl RandomStringUtils { + /// A random UUID v4 (lowercase, hyphenated). + pub fn generate_uuid() -> String { + Uuid::new_v4().to_string() + } + + /// A numeric string of the given length. + pub fn generate_num(len: usize) -> String { + let mut rng = rand::thread_rng(); + (0..len) + .map(|_| char::from_digit(rng.gen_range(0..10), 10).unwrap()) + .collect() + } + + /// An alphanumeric string of the given length. + pub fn generate_alphanumeric(len: usize) -> String { + rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(len) + .map(char::from) + .collect::() + } +} + +/// Current time as milliseconds since the Unix epoch. +pub fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uuid_is_unique() { + assert_ne!( + RandomStringUtils::generate_uuid(), + RandomStringUtils::generate_uuid() + ); + } + + #[test] + fn num_length() { + let s = RandomStringUtils::generate_num(30); + assert_eq!(s.len(), 30); + assert!(s.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn local_ip_returns_something() { + let ip = local_ip_v4(); + assert!(!ip.is_empty()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config.rs deleted file mode 100644 index 3deb30beb7..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/config.rs +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -//! Configurations. - -/// gRPC client configuration. -mod grpc_config; - -#[cfg(feature = "grpc")] - -/// Re-export gRPC client configuration when "grpc" feature is enabled. -pub use crate::config::grpc_config::EventMeshGrpcClientConfig; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/catalog.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/catalog.rs new file mode 100644 index 0000000000..420e6553b2 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/catalog.rs @@ -0,0 +1,144 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Catalog client configuration. + +use std::time::Duration; + +use crate::config::TlsConfig; +use crate::error::{EventMeshError, Result}; +use crate::model::{SubscriptionMode, SubscriptionType}; + +/// Default logical Catalog service name, matching the Java SDK. +pub const DEFAULT_CATALOG_SERVER_NAME: &str = "eventmesh-catalog"; +/// Default Catalog RPC timeout. +pub const DEFAULT_CATALOG_TIMEOUT: Duration = Duration::from_secs(5); + +/// Configuration for [`crate::catalog::CatalogClient`]. +#[derive(Debug, Clone)] +pub struct CatalogClientConfig { + /// Logical name resolved through [`crate::discovery::ServiceDiscovery`]. + pub server_name: String, + /// Name of the application whose operations are queried from Catalog. + pub app_server_name: String, + /// Delivery mode applied to Catalog-provided subscribe operations. + pub subscription_mode: SubscriptionMode, + /// Delivery type applied to Catalog-provided subscribe operations. + pub subscription_type: SubscriptionType, + /// Timeout for short Catalog RPCs. + pub timeout: Duration, + /// Use TLS for the resolved gRPC endpoint. + pub use_tls: bool, + /// Optional TLS details for the resolved gRPC endpoint. + pub tls_config: Option, +} + +impl CatalogClientConfig { + /// Start a fluent builder. + pub fn builder() -> CatalogClientConfigBuilder { + CatalogClientConfigBuilder::default() + } +} + +/// Fluent builder for [`CatalogClientConfig`]. +#[derive(Debug, Clone, Default)] +pub struct CatalogClientConfigBuilder { + server_name: Option, + app_server_name: Option, + subscription_mode: Option, + subscription_type: Option, + timeout: Option, + use_tls: Option, + tls_config: Option, +} + +impl CatalogClientConfigBuilder { + pub fn server_name(mut self, v: impl Into) -> Self { + self.server_name = Some(v.into()); + self + } + pub fn app_server_name(mut self, v: impl Into) -> Self { + self.app_server_name = Some(v.into()); + self + } + pub fn subscription_mode(mut self, v: SubscriptionMode) -> Self { + self.subscription_mode = Some(v); + self + } + pub fn subscription_type(mut self, v: SubscriptionType) -> Self { + self.subscription_type = Some(v); + self + } + pub fn timeout(mut self, v: Duration) -> Self { + self.timeout = Some(v); + self + } + pub fn use_tls(mut self, v: bool) -> Self { + self.use_tls = Some(v); + self + } + pub fn tls_config(mut self, v: TlsConfig) -> Self { + self.tls_config = Some(v); + self + } + + /// Build a Catalog configuration. `app_server_name` is required by + /// Catalog's `QueryOperations` protocol. + pub fn build(self) -> Result { + let app_server_name = self + .app_server_name + .filter(|name| !name.trim().is_empty()) + .ok_or_else(|| EventMeshError::Config("catalog app_server_name is required".into()))?; + Ok(CatalogClientConfig { + server_name: self + .server_name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_CATALOG_SERVER_NAME.into()), + app_server_name, + subscription_mode: self + .subscription_mode + .unwrap_or(SubscriptionMode::CLUSTERING), + subscription_type: self.subscription_type.unwrap_or(SubscriptionType::ASYNC), + timeout: self.timeout.unwrap_or(DEFAULT_CATALOG_TIMEOUT), + use_tls: self.use_tls.unwrap_or(false), + tls_config: self.tls_config, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_java_sdk() { + let config = CatalogClientConfig::builder() + .app_server_name("payment") + .build() + .unwrap(); + assert_eq!(config.server_name, DEFAULT_CATALOG_SERVER_NAME); + assert_eq!(config.subscription_mode, SubscriptionMode::CLUSTERING); + assert_eq!(config.subscription_type, SubscriptionType::ASYNC); + assert_eq!(config.timeout, DEFAULT_CATALOG_TIMEOUT); + assert!(!config.use_tls); + } + + #[test] + fn app_server_name_is_required() { + assert!(CatalogClientConfig::builder().build().is_err()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/client.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/client.rs new file mode 100644 index 0000000000..47852a2454 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/client.rs @@ -0,0 +1,768 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Transport configuration types. + +use std::time::Duration; + +use crate::error::{EventMeshError, Result}; + +use super::{ClientIdentity, ReconnectConfig, TlsConfig}; + +/// A validated EventMesh host and port. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Endpoint { + host: String, + port: u16, + weight: u32, +} + +impl Endpoint { + /// Construct an endpoint. Host names and IP literals are accepted; port + /// zero and blank hosts are rejected. + pub fn new(host: impl Into, port: u16) -> Result { + let host = host.into(); + if host.trim().is_empty() { + return Err(EventMeshError::Config( + "endpoint host must not be empty".into(), + )); + } + if host.chars().any(char::is_whitespace) { + return Err(EventMeshError::Config( + "endpoint host must not contain whitespace".into(), + )); + } + if port == 0 { + return Err(EventMeshError::Config( + "endpoint port must not be zero".into(), + )); + } + Ok(Self { + host, + port, + weight: 1, + }) + } + + /// Return the endpoint host. + pub fn host(&self) -> &str { + &self.host + } + + /// Return the endpoint port. + pub const fn port(&self) -> u16 { + self.port + } + + /// Return a copy with a non-zero HTTP load-balancing weight. + pub fn with_weight(mut self, weight: u32) -> Result { + if weight == 0 { + return Err(EventMeshError::Config( + "endpoint weight must be greater than zero".into(), + )); + } + self.weight = weight; + Ok(self) + } + + /// The HTTP load-balancing weight. + pub const fn weight(&self) -> u32 { + self.weight + } + + /// Render an authority, including brackets for IPv6 literals. + pub fn authority(&self) -> String { + format!("{}:{}", self.authority_host(), self.port) + } + + /// Render the host component for an authority, including brackets around + /// bare IPv6 literals. + fn authority_host(&self) -> String { + if self.host.contains(':') && !self.host.starts_with('[') { + format!("[{}]", self.host) + } else { + self.host.clone() + } + } +} + +/// A non-empty collection of HTTP endpoints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointSet(Vec); + +impl EndpointSet { + /// Construct a non-empty endpoint set. + pub fn new(endpoints: impl IntoIterator) -> Result { + let endpoints: Vec<_> = endpoints.into_iter().collect(); + if endpoints.is_empty() { + return Err(EventMeshError::Config( + "at least one endpoint is required".into(), + )); + } + Ok(Self(endpoints)) + } + + /// Borrow the endpoints in this set. + pub fn endpoints(&self) -> &[Endpoint] { + &self.0 + } +} + +/// Authentication material supplied to EventMesh. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Credentials { + username: Option, + password: Option, + token: Option, +} + +impl Credentials { + /// Start with no credentials. + pub const fn new() -> Self { + Self { + username: None, + password: None, + token: None, + } + } + + /// Configure username/password authentication. + pub fn with_basic(mut self, username: impl Into, password: impl Into) -> Self { + self.username = Some(username.into()); + self.password = Some(password.into()); + self + } + + /// Configure bearer/token authentication. + pub fn with_token(mut self, token: impl Into) -> Self { + self.token = Some(token.into()); + self + } +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "***")) + .field("token", &self.token.as_ref().map(|_| "***")) + .finish() + } +} + +/// Runtime identity attached to EventMesh requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + env: String, + idc: String, + system: String, + process_id: String, + ip: String, + language: String, +} + +impl Default for Identity { + fn default() -> Self { + let legacy = ClientIdentity::detect(); + Self { + env: legacy.env, + idc: legacy.idc, + system: legacy.sys, + process_id: legacy.pid, + ip: legacy.ip, + language: legacy.language, + } + } +} + +impl Identity { + /// Set the EventMesh environment label. + pub fn with_env(mut self, env: impl Into) -> Self { + self.env = env.into(); + self + } + + /// Set the data-centre label. + pub fn with_idc(mut self, idc: impl Into) -> Self { + self.idc = idc.into(); + self + } + + /// Set the calling system/application name. + pub fn with_system(mut self, system: impl Into) -> Self { + self.system = system.into(); + self + } +} + +/// Options shared by a protocol client. +#[derive(Debug, Clone)] +pub struct ClientOptions { + request_timeout: Duration, +} + +impl Default for ClientOptions { + fn default() -> Self { + Self { + request_timeout: Duration::from_secs(5), + } + } +} + +impl ClientOptions { + /// Override the timeout for unary client operations. + pub fn with_request_timeout(mut self, request_timeout: Duration) -> Self { + self.request_timeout = request_timeout; + self + } + + /// Return the configured unary-operation timeout. + pub const fn request_timeout(&self) -> Duration { + self.request_timeout + } +} + +/// Options for a producer role. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProducerOptions { + group: String, +} + +impl ProducerOptions { + /// Create options for `group`. + pub fn new(group: impl Into) -> Self { + Self { + group: group.into(), + } + } +} + +/// Options for a consumer role. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsumerOptions { + group: String, + concurrency: usize, +} + +impl ConsumerOptions { + /// Create options for `group` with serial delivery by default. + pub fn new(group: impl Into) -> Self { + Self { + group: group.into(), + concurrency: 1, + } + } + + /// Allow up to `concurrency` handlers to run concurrently. + pub fn with_concurrency(mut self, concurrency: usize) -> Self { + self.concurrency = concurrency.max(1); + self + } +} + +/// HTTP endpoint selection policy. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum LoadBalance { + /// Choose an endpoint at random. + #[default] + Random, + /// Choose according to configured endpoint weights. + WeightedRandom, + /// Smooth weighted round-robin selection. + WeightedRoundRobin, +} + +/// gRPC client configuration. +#[derive(Debug, Clone)] +pub struct GrpcConfig { + endpoint: Endpoint, + options: ClientOptions, + identity: Identity, + credentials: Credentials, + use_tls: bool, + tls_config: Option, +} + +impl GrpcConfig { + /// Build a gRPC configuration for `endpoint`. + pub fn new(endpoint: Endpoint) -> Self { + Self { + endpoint, + options: ClientOptions::default(), + identity: Identity::default(), + credentials: Credentials::default(), + use_tls: false, + tls_config: None, + } + } + + /// Override common client options. + pub fn with_options(mut self, options: ClientOptions) -> Self { + self.options = options; + self + } + + /// Override request identity. + pub fn with_identity(mut self, identity: Identity) -> Self { + self.identity = identity; + self + } + + /// Set credentials. + pub fn with_credentials(mut self, credentials: Credentials) -> Self { + self.credentials = credentials; + self + } + + /// Enable TLS, optionally with explicit TLS material. + pub fn with_tls(mut self, tls_config: Option) -> Self { + self.use_tls = true; + self.tls_config = tls_config; + self + } + + /// Return the configured server endpoint. + pub const fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + /// Return shared client options. + pub const fn options(&self) -> &ClientOptions { + &self.options + } + + /// Return the request identity. + pub const fn identity(&self) -> &Identity { + &self.identity + } + + /// Return the configured credentials. + pub const fn credentials(&self) -> &Credentials { + &self.credentials + } + + /// Whether TLS is enabled. + pub const fn tls_enabled(&self) -> bool { + self.use_tls + } + + /// Return explicitly configured TLS material, if any. + pub const fn tls_config(&self) -> Option<&TlsConfig> { + self.tls_config.as_ref() + } + + #[cfg(feature = "grpc")] + pub(crate) const fn request_timeout(&self) -> Duration { + self.options.request_timeout + } + + #[cfg(feature = "grpc")] + pub(crate) fn legacy( + &self, + producer: Option<&ProducerOptions>, + consumer: Option<&ConsumerOptions>, + ) -> super::GrpcClientConfig { + let mut identity = legacy_identity(&self.identity, &self.credentials); + if let Some(producer) = producer { + identity.producer_group = producer.group.clone(); + } + if let Some(consumer) = consumer { + identity.consumer_group = consumer.group.clone(); + } + super::GrpcClientConfig { + server_addr: self.endpoint.authority_host(), + server_port: self.endpoint.port, + use_tls: self.use_tls, + tls_config: self.tls_config.clone(), + timeout: self.options.request_timeout, + identity, + max_concurrent_handlers: consumer.map_or(1, |options| options.concurrency), + } + } +} + +/// HTTP client configuration. +#[derive(Debug, Clone)] +pub struct HttpConfig { + endpoints: EndpointSet, + load_balance: LoadBalance, + options: ClientOptions, + identity: Identity, + credentials: Credentials, + use_tls: bool, +} + +impl HttpConfig { + /// Build an HTTP configuration for a non-empty endpoint set. + pub fn new(endpoints: EndpointSet) -> Self { + Self { + endpoints, + load_balance: LoadBalance::Random, + options: ClientOptions::default(), + identity: Identity::default(), + credentials: Credentials::default(), + use_tls: false, + } + } + + /// Set endpoint selection policy. + pub fn with_load_balance(mut self, load_balance: LoadBalance) -> Self { + self.load_balance = load_balance; + self + } + + /// Override common client options. + pub fn with_options(mut self, options: ClientOptions) -> Self { + self.options = options; + self + } + + /// Override request identity. + pub fn with_identity(mut self, identity: Identity) -> Self { + self.identity = identity; + self + } + + /// Set credentials. + pub fn with_credentials(mut self, credentials: Credentials) -> Self { + self.credentials = credentials; + self + } + + /// Enable HTTPS. + pub fn with_tls(mut self) -> Self { + self.use_tls = true; + self + } + + /// Return the configured endpoints. + pub const fn endpoints(&self) -> &EndpointSet { + &self.endpoints + } + + /// Return the endpoint selection policy. + pub const fn load_balance(&self) -> LoadBalance { + self.load_balance + } + + /// Return shared client options. + pub const fn options(&self) -> &ClientOptions { + &self.options + } + + /// Return the request identity. + pub const fn identity(&self) -> &Identity { + &self.identity + } + + /// Return the configured credentials. + pub const fn credentials(&self) -> &Credentials { + &self.credentials + } + + /// Whether HTTPS is enabled. + pub const fn tls_enabled(&self) -> bool { + self.use_tls + } + + #[cfg(feature = "http")] + pub(crate) const fn request_timeout(&self) -> Duration { + self.options.request_timeout + } + + #[cfg(feature = "http")] + pub(crate) fn legacy( + &self, + producer: Option<&ProducerOptions>, + consumer: Option<&ConsumerOptions>, + ) -> super::HttpClientConfig { + let mut identity = legacy_identity(&self.identity, &self.credentials); + if let Some(producer) = producer { + identity.producer_group = producer.group.clone(); + } + if let Some(consumer) = consumer { + identity.consumer_group = consumer.group.clone(); + } + let nodes = self + .endpoints + .0 + .iter() + .map(|endpoint| crate::common::loadbalance::ServerNode { + host: endpoint.authority_host(), + port: endpoint.port, + weight: endpoint.weight as i32, + }) + .collect(); + super::HttpClientConfig { + nodes, + load_balance: match self.load_balance { + LoadBalance::Random => crate::common::loadbalance::LoadBalance::Random, + LoadBalance::WeightedRandom => { + crate::common::loadbalance::LoadBalance::WeightRandom + } + LoadBalance::WeightedRoundRobin => { + crate::common::loadbalance::LoadBalance::WeightRoundRobin + } + }, + use_tls: self.use_tls, + pool_size: super::http::DEFAULT_POOL_SIZE, + pool_idle_timeout: Duration::from_secs(super::http::DEFAULT_IDLE_TIMEOUT_SECS), + timeout: self.options.request_timeout, + identity, + } + } +} + +/// TCP reconnect settings. +#[derive(Debug, Clone)] +pub struct ReconnectPolicy { + enabled: bool, + max_retries: usize, + initial_backoff: Duration, + max_backoff: Duration, +} + +impl Default for ReconnectPolicy { + fn default() -> Self { + let legacy = ReconnectConfig::default(); + Self { + enabled: legacy.enabled, + max_retries: legacy.max_retries, + initial_backoff: legacy.initial_backoff, + max_backoff: legacy.max_backoff, + } + } +} + +impl ReconnectPolicy { + /// Enable or disable automatic reconnect. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Limit reconnect attempts (`usize::MAX` means indefinitely). + pub fn with_max_retries(mut self, max_retries: usize) -> Self { + self.max_retries = max_retries; + self + } + + /// Set the delay before the first reconnect attempt. + pub fn with_initial_backoff(mut self, initial_backoff: Duration) -> Self { + self.initial_backoff = initial_backoff; + self + } + + /// Cap exponential reconnect backoff at this duration. + pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self { + self.max_backoff = max_backoff; + self + } + + /// Whether reconnection is enabled. + pub const fn enabled(&self) -> bool { + self.enabled + } + + /// The maximum number of reconnect attempts. + pub const fn max_retries(&self) -> usize { + self.max_retries + } + + /// The initial reconnect delay. + pub const fn initial_backoff(&self) -> Duration { + self.initial_backoff + } + + /// The maximum reconnect delay. + pub const fn max_backoff(&self) -> Duration { + self.max_backoff + } +} + +/// TCP client configuration. +#[derive(Debug, Clone)] +pub struct TcpConfig { + endpoint: Endpoint, + options: ClientOptions, + identity: Identity, + credentials: Credentials, + reconnect: ReconnectPolicy, + heartbeat_interval: Duration, +} + +impl TcpConfig { + /// Build a TCP configuration for `endpoint`. + pub fn new(endpoint: Endpoint) -> Self { + Self { + endpoint, + options: ClientOptions::default(), + identity: Identity::default(), + credentials: Credentials::default(), + reconnect: ReconnectPolicy::default(), + heartbeat_interval: Duration::from_secs(30), + } + } + + /// Override common client options. + pub fn with_options(mut self, options: ClientOptions) -> Self { + self.options = options; + self + } + + /// Override request identity. + pub fn with_identity(mut self, identity: Identity) -> Self { + self.identity = identity; + self + } + + /// Set credentials. + pub fn with_credentials(mut self, credentials: Credentials) -> Self { + self.credentials = credentials; + self + } + + /// Configure reconnection. + pub fn with_reconnect(mut self, reconnect: ReconnectPolicy) -> Self { + self.reconnect = reconnect; + self + } + + /// Override the heartbeat interval. + pub fn with_heartbeat_interval(mut self, heartbeat_interval: Duration) -> Self { + self.heartbeat_interval = heartbeat_interval; + self + } + + /// Return the configured server endpoint. + pub const fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + /// Return shared client options. + pub const fn options(&self) -> &ClientOptions { + &self.options + } + + /// Return the request identity. + pub const fn identity(&self) -> &Identity { + &self.identity + } + + /// Return the configured credentials. + pub const fn credentials(&self) -> &Credentials { + &self.credentials + } + + /// Return the reconnect policy. + pub const fn reconnect(&self) -> &ReconnectPolicy { + &self.reconnect + } + + /// Return the heartbeat interval. + pub const fn heartbeat_interval(&self) -> Duration { + self.heartbeat_interval + } + + #[cfg(feature = "tcp")] + pub(crate) const fn request_timeout(&self) -> Duration { + self.options.request_timeout + } + + #[cfg(feature = "tcp")] + pub(crate) fn legacy( + &self, + producer: Option<&ProducerOptions>, + consumer: Option<&ConsumerOptions>, + ) -> super::TcpClientConfig { + let mut identity = legacy_identity(&self.identity, &self.credentials); + if let Some(producer) = producer { + identity.producer_group = producer.group.clone(); + } + if let Some(consumer) = consumer { + identity.consumer_group = consumer.group.clone(); + } + super::TcpClientConfig { + server_addr: self.endpoint.authority_host(), + server_port: self.endpoint.port, + timeout: self.options.request_timeout, + heartbeat_interval: self.heartbeat_interval, + reconnect: ReconnectConfig { + enabled: self.reconnect.enabled, + max_retries: self.reconnect.max_retries, + initial_backoff: self.reconnect.initial_backoff, + max_backoff: self.reconnect.max_backoff, + }, + identity, + } + } +} + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +fn legacy_identity(identity: &Identity, credentials: &Credentials) -> ClientIdentity { + ClientIdentity { + env: identity.env.clone(), + idc: identity.idc.clone(), + sys: identity.system.clone(), + pid: identity.process_id.clone(), + ip: identity.ip.clone(), + language: identity.language.clone(), + username: credentials.username.clone().unwrap_or_default(), + password: credentials.password.clone().unwrap_or_default(), + token: credentials.token.clone(), + producer_group: "DefaultProducerGroup".into(), + consumer_group: "DefaultConsumerGroup".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_brackets_ipv6_authorities() { + let endpoint = Endpoint::new("::1", 10_205).unwrap(); + assert_eq!(endpoint.authority(), "[::1]:10205"); + } + + #[cfg(feature = "grpc")] + #[test] + fn grpc_legacy_config_brackets_ipv6_host() { + let legacy = GrpcConfig::new(Endpoint::new("::1", 10_205).unwrap()).legacy(None, None); + assert_eq!(legacy.authority(), "[::1]:10205"); + } + + #[cfg(feature = "http")] + #[test] + fn http_legacy_config_brackets_ipv6_host() { + let endpoints = EndpointSet::new([Endpoint::new("::1", 10_105).unwrap()]).unwrap(); + let legacy = HttpConfig::new(endpoints).legacy(None, None); + assert_eq!(legacy.nodes[0].addr(), "[::1]:10105"); + } + + #[cfg(feature = "tcp")] + #[test] + fn tcp_legacy_config_brackets_ipv6_host() { + let legacy = TcpConfig::new(Endpoint::new("::1", 10_000).unwrap()).legacy(None, None); + assert_eq!(legacy.authority(), "[::1]:10000"); + } + + #[test] + fn endpoint_set_requires_an_endpoint() { + assert!(EndpointSet::new(Vec::new()).is_err()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc.rs new file mode 100644 index 0000000000..cbaff14bb3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc.rs @@ -0,0 +1,244 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! gRPC client configuration. + +use std::time::Duration; + +use crate::config::{ClientIdentity, TlsConfig}; + +/// Default gRPC port of an EventMesh runtime. +pub const DEFAULT_GRPC_PORT: u16 = 10_205; +/// Default request timeout. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); +/// Default max concurrent `listener.handle()` calls in the stream receive loop. +pub const DEFAULT_MAX_CONCURRENT_HANDLERS: usize = 64; + +/// Configuration for the gRPC transport. +#[derive(Debug, Clone)] +pub struct GrpcClientConfig { + /// Server host (no scheme, no port), e.g. `"127.0.0.1"`. + pub server_addr: String, + /// Server gRPC port (default `10205`). + pub server_port: u16, + /// Whether to use TLS (`https`). + pub use_tls: bool, + /// TLS details (CA cert, client identity, SNI, ...). Applied only when + /// `use_tls` is `true` and the `tls` cargo feature is enabled. If `None`, + /// tonic uses its built-in defaults. + pub tls_config: Option, + /// Default RPC timeout applied when none is given to a call. + pub timeout: Duration, + /// Client identity sent with every request. + pub identity: ClientIdentity, + /// Max number of `listener.handle()` calls running concurrently in the + /// gRPC stream receive loop. Provides backpressure: when at capacity the + /// loop stops pulling from the gRPC stream (relying on gRPC flow control + /// to pause the server). Set to `1` to restore strict serial / + /// in-order-reply semantics (matching the Java SDK). + pub max_concurrent_handlers: usize, +} + +impl Default for GrpcClientConfig { + fn default() -> Self { + Self { + server_addr: "localhost".into(), + server_port: DEFAULT_GRPC_PORT, + use_tls: false, + tls_config: None, + timeout: DEFAULT_TIMEOUT, + identity: ClientIdentity::detect(), + max_concurrent_handlers: DEFAULT_MAX_CONCURRENT_HANDLERS, + } + } +} + +impl GrpcClientConfig { + /// Start a fluent builder. + pub fn builder() -> GrpcClientConfigBuilder { + GrpcClientConfigBuilder::default() + } + + /// `host:port` authority string. + pub fn authority(&self) -> String { + format!("{}:{}", self.server_addr, self.server_port) + } +} + +/// Fluent builder for [`GrpcClientConfig`]. +#[derive(Clone, Default)] +pub struct GrpcClientConfigBuilder { + server_addr: Option, + server_port: Option, + use_tls: Option, + tls_config: Option, + timeout: Option, + identity: Option, + max_concurrent_handlers: Option, + // identity convenience setters: + env: Option, + idc: Option, + sys: Option, + producer_group: Option, + consumer_group: Option, + username: Option, + password: Option, + token: Option, +} + +impl GrpcClientConfigBuilder { + pub fn server_addr(mut self, v: impl Into) -> Self { + self.server_addr = Some(v.into()); + self + } + pub fn server_port(mut self, v: u16) -> Self { + self.server_port = Some(v); + self + } + pub fn use_tls(mut self, v: bool) -> Self { + self.use_tls = Some(v); + self + } + pub fn tls_config(mut self, v: TlsConfig) -> Self { + self.tls_config = Some(v); + self + } + pub fn timeout(mut self, v: Duration) -> Self { + self.timeout = Some(v); + self + } + pub fn max_concurrent_handlers(mut self, v: usize) -> Self { + self.max_concurrent_handlers = Some(v); + self + } + pub fn identity(mut self, v: ClientIdentity) -> Self { + self.identity = Some(v); + self + } + pub fn env(mut self, v: impl Into) -> Self { + self.env = Some(v.into()); + self + } + pub fn idc(mut self, v: impl Into) -> Self { + self.idc = Some(v.into()); + self + } + pub fn sys(mut self, v: impl Into) -> Self { + self.sys = Some(v.into()); + self + } + pub fn producer_group(mut self, v: impl Into) -> Self { + self.producer_group = Some(v.into()); + self + } + pub fn consumer_group(mut self, v: impl Into) -> Self { + self.consumer_group = Some(v.into()); + self + } + pub fn username(mut self, v: impl Into) -> Self { + self.username = Some(v.into()); + self + } + pub fn password(mut self, v: impl Into) -> Self { + self.password = Some(v.into()); + self + } + pub fn token(mut self, v: impl Into) -> Self { + self.token = Some(v.into()); + self + } + + pub fn build(self) -> GrpcClientConfig { + let GrpcClientConfigBuilder { + server_addr, + server_port, + use_tls, + tls_config, + timeout, + identity, + max_concurrent_handlers, + env, + idc, + sys, + producer_group, + consumer_group, + username, + password, + token, + } = self; + + let mut identity = identity.unwrap_or_default(); + if let Some(v) = env { + identity.env = v; + } + if let Some(v) = idc { + identity.idc = v; + } + if let Some(v) = sys { + identity.sys = v; + } + if let Some(v) = producer_group { + identity.producer_group = v; + } + if let Some(v) = consumer_group { + identity.consumer_group = v; + } + if let Some(v) = username { + identity.username = v; + } + if let Some(v) = password { + identity.password = v; + } + if let Some(v) = token { + identity.token = Some(v); + } + + GrpcClientConfig { + server_addr: server_addr.unwrap_or_else(|| "localhost".into()), + server_port: server_port.unwrap_or(DEFAULT_GRPC_PORT), + use_tls: use_tls.unwrap_or(false), + tls_config, + timeout: timeout.unwrap_or(DEFAULT_TIMEOUT), + identity, + max_concurrent_handlers: max_concurrent_handlers + .unwrap_or(DEFAULT_MAX_CONCURRENT_HANDLERS) + .max(1), + } + } +} + +impl std::fmt::Debug for GrpcClientConfigBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GrpcClientConfigBuilder") + .field("server_addr", &self.server_addr) + .field("server_port", &self.server_port) + .field("use_tls", &self.use_tls) + .field("tls_config", &self.tls_config) + .field("timeout", &self.timeout) + .field("identity", &self.identity) + .field("max_concurrent_handlers", &self.max_concurrent_handlers) + .field("env", &self.env) + .field("idc", &self.idc) + .field("sys", &self.sys) + .field("producer_group", &self.producer_group) + .field("consumer_group", &self.consumer_group) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "***")) + .field("token", &self.token.as_ref().map(|_| "***")) + .finish() + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc_config.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc_config.rs deleted file mode 100644 index 25855a0d7f..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc_config.rs +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -#[derive(Debug, Clone)] -pub struct EventMeshGrpcClientConfig { - pub(crate) server_addr: String, - pub(crate) server_port: u32, - pub(crate) env: String, - pub(crate) consumer_group: Option, - pub(crate) producer_group: Option, - pub(crate) idc: String, - pub(crate) sys: String, - pub(crate) user_name: String, - pub(crate) password: String, - pub(crate) language: String, - pub(crate) use_tls: Option, - pub(crate) time_out: Option, -} - -impl Default for EventMeshGrpcClientConfig { - fn default() -> Self { - Self { - server_addr: "localhost".to_string(), - server_port: 10205, - env: "dev".to_string(), - consumer_group: Some("DefaultConsumerGroup".to_string()), - producer_group: Some("DefaultProducerGroup".to_string()), - idc: "default".to_string(), - sys: "evetmesh".to_string(), - user_name: "username".to_string(), - password: "password".to_string(), - language: "Rust".to_string(), - use_tls: Some(false), - time_out: Some(5000), - } - } -} - -impl ToString for EventMeshGrpcClientConfig { - fn to_string(&self) -> String { - format!( - "ClientConfig={{ServerAddr={},ServerPort={},env={:?},\ - idc={:?},producerGroup={:?},consumerGroup={:?},\ - sys={:?},userName={:?},password=***,\ - useTls={:?},timeOut={:?}}}", - self.server_addr, - self.server_port, - self.env, - self.idc, - self.producer_group, - self.consumer_group, - self.sys, - self.user_name, - self.use_tls, - self.time_out - ) - } -} - -#[allow(dead_code)] -impl EventMeshGrpcClientConfig { - pub fn new() -> Self { - Default::default() - } - - pub fn set_server_addr(mut self, server_addr: String) -> Self { - self.server_addr = server_addr; - self - } - pub fn set_server_port(mut self, server_port: u32) -> Self { - self.server_port = server_port; - self - } - pub fn set_env(mut self, env: String) -> Self { - self.env = env; - self - } - pub fn set_consumer_group(mut self, consumer_group: String) -> Self { - self.consumer_group = Some(consumer_group); - self - } - pub fn set_producer_group(mut self, producer_group: String) -> Self { - self.producer_group = Some(producer_group); - self - } - pub fn set_idc(mut self, idc: String) -> Self { - self.idc = idc; - self - } - pub fn set_sys(mut self, sys: String) -> Self { - self.sys = sys; - self - } - pub fn set_user_name(mut self, user_name: String) -> Self { - self.user_name = user_name; - self - } - pub fn set_password(mut self, password: String) -> Self { - self.password = password; - self - } - pub fn set_language(mut self, language: String) -> Self { - self.language = language; - self - } - pub fn set_use_tls(mut self, use_tls: bool) -> Self { - self.use_tls = Some(use_tls); - self - } - pub fn set_time_out(mut self, time_out: u64) -> Self { - self.time_out = Some(time_out); - self - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/http.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/http.rs new file mode 100644 index 0000000000..4939c4bfa3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/http.rs @@ -0,0 +1,273 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! HTTP client configuration. + +use std::time::Duration; + +use crate::common::loadbalance::LoadBalance; +use crate::config::ClientIdentity; + +/// Default HTTP port of an EventMesh runtime. +pub const DEFAULT_HTTP_PORT: u16 = 10_105; +/// Default request timeout (mirrors the Java SDK's `Constants.DEFAULT_HTTP_TIME_OUT`). +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15); +/// Default connection pool size (mirrors the Java SDK). +pub const DEFAULT_POOL_SIZE: usize = 30; +/// Default idle connection eviction (seconds). +pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 10; + +/// Configuration for the HTTP transport. +/// +/// `servers` is a list of `host:port` (or `host:port:weight`) strings, +/// semicolon- or comma-separated in the builder, mirroring the Java SDK's +/// `liteEventMeshAddr` field. +#[derive(Debug, Clone)] +pub struct HttpClientConfig { + /// Parsed server nodes for load balancing. + pub nodes: Vec, + /// Load-balance strategy. + pub load_balance: LoadBalance, + /// Use TLS (`https`). + pub use_tls: bool, + /// Connection pool max size. + pub pool_size: usize, + /// Idle connection eviction timeout. + pub pool_idle_timeout: Duration, + /// Default request timeout. + pub timeout: Duration, + /// Client identity sent with every request. + pub identity: ClientIdentity, +} + +impl Default for HttpClientConfig { + fn default() -> Self { + Self { + nodes: vec![ + crate::common::loadbalance::ServerNode::parse("localhost:10105") + .expect("default node"), + ], + load_balance: LoadBalance::Random, + use_tls: false, + pool_size: DEFAULT_POOL_SIZE, + pool_idle_timeout: Duration::from_secs(DEFAULT_IDLE_TIMEOUT_SECS), + timeout: DEFAULT_TIMEOUT, + identity: ClientIdentity::detect(), + } + } +} + +impl HttpClientConfig { + /// Start a fluent builder. + pub fn builder() -> HttpClientConfigBuilder { + HttpClientConfigBuilder::default() + } +} + +/// Fluent builder for [`HttpClientConfig`]. +#[derive(Clone, Default)] +pub struct HttpClientConfigBuilder { + servers: Option, + load_balance: Option, + use_tls: Option, + pool_size: Option, + pool_idle_timeout: Option, + timeout: Option, + identity: Option, + // identity convenience setters: + env: Option, + idc: Option, + sys: Option, + producer_group: Option, + consumer_group: Option, + username: Option, + password: Option, + token: Option, +} + +impl HttpClientConfigBuilder { + /// Set server addresses. Accepts `;` or `,` separated `host:port[:weight]` strings. + pub fn servers(mut self, v: impl Into) -> Self { + self.servers = Some(v.into()); + self + } + + pub fn load_balance(mut self, v: LoadBalance) -> Self { + self.load_balance = Some(v); + self + } + + pub fn use_tls(mut self, v: bool) -> Self { + self.use_tls = Some(v); + self + } + + pub fn pool_size(mut self, v: usize) -> Self { + self.pool_size = Some(v); + self + } + + pub fn pool_idle_timeout(mut self, v: Duration) -> Self { + self.pool_idle_timeout = Some(v); + self + } + + pub fn timeout(mut self, v: Duration) -> Self { + self.timeout = Some(v); + self + } + + pub fn identity(mut self, v: ClientIdentity) -> Self { + self.identity = Some(v); + self + } + + pub fn env(mut self, v: impl Into) -> Self { + self.env = Some(v.into()); + self + } + + pub fn idc(mut self, v: impl Into) -> Self { + self.idc = Some(v.into()); + self + } + + pub fn sys(mut self, v: impl Into) -> Self { + self.sys = Some(v.into()); + self + } + + pub fn producer_group(mut self, v: impl Into) -> Self { + self.producer_group = Some(v.into()); + self + } + + pub fn consumer_group(mut self, v: impl Into) -> Self { + self.consumer_group = Some(v.into()); + self + } + + pub fn username(mut self, v: impl Into) -> Self { + self.username = Some(v.into()); + self + } + + pub fn password(mut self, v: impl Into) -> Self { + self.password = Some(v.into()); + self + } + + pub fn token(mut self, v: impl Into) -> Self { + self.token = Some(v.into()); + self + } + + /// Build the config, parsing the server address list. + pub fn build(self) -> crate::error::Result { + let HttpClientConfigBuilder { + servers, + load_balance, + use_tls, + pool_size, + pool_idle_timeout, + timeout, + identity, + env, + idc, + sys, + producer_group, + consumer_group, + username, + password, + token, + } = self; + + let mut identity = identity.unwrap_or_default(); + if let Some(v) = env { + identity.env = v; + } + if let Some(v) = idc { + identity.idc = v; + } + if let Some(v) = sys { + identity.sys = v; + } + if let Some(v) = producer_group { + identity.producer_group = v; + } + if let Some(v) = consumer_group { + identity.consumer_group = v; + } + if let Some(v) = username { + identity.username = v; + } + if let Some(v) = password { + identity.password = v; + } + if let Some(v) = token { + identity.token = Some(v); + } + + // Parse server addresses — accept `;` or `,` separators. + let raw = servers.unwrap_or_else(|| "localhost:10105".into()); + let nodes: Vec = raw + .split([';', ',']) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(crate::common::loadbalance::ServerNode::parse) + .collect::>>()?; + + if nodes.is_empty() { + return Err(crate::error::EventMeshError::Config( + "at least one server address is required".into(), + )); + } + + Ok(HttpClientConfig { + nodes, + load_balance: load_balance.unwrap_or_default(), + use_tls: use_tls.unwrap_or(false), + pool_size: pool_size.unwrap_or(DEFAULT_POOL_SIZE), + pool_idle_timeout: pool_idle_timeout + .unwrap_or(Duration::from_secs(DEFAULT_IDLE_TIMEOUT_SECS)), + timeout: timeout.unwrap_or(DEFAULT_TIMEOUT), + identity, + }) + } +} + +impl std::fmt::Debug for HttpClientConfigBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpClientConfigBuilder") + .field("servers", &self.servers) + .field("load_balance", &self.load_balance) + .field("use_tls", &self.use_tls) + .field("pool_size", &self.pool_size) + .field("pool_idle_timeout", &self.pool_idle_timeout) + .field("timeout", &self.timeout) + .field("identity", &self.identity) + .field("env", &self.env) + .field("idc", &self.idc) + .field("sys", &self.sys) + .field("producer_group", &self.producer_group) + .field("consumer_group", &self.consumer_group) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "***")) + .field("token", &self.token.as_ref().map(|_| "***")) + .finish() + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/identity.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/identity.rs new file mode 100644 index 0000000000..b90145299d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/identity.rs @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Client identity fields shared by every transport. + +/// Who the client is. Every protocol carries these to the server as either +/// CloudEvent attributes (gRPC), HTTP headers, or `UserAgent` fields (TCP). +#[derive(Clone)] +pub struct ClientIdentity { + /// Environment tag (e.g. `"prod"`). + pub env: String, + /// Data-center id (e.g. `"default"`). + pub idc: String, + /// Subsystem / application name. + pub sys: String, + /// OS process id, as a string. + pub pid: String, + /// Local IP (auto-detected if you use [`ClientIdentity::detect`]). + pub ip: String, + /// Language tag (defaults to `"RUST"`). + pub language: String, + /// Optional ACL username. + pub username: String, + /// Optional ACL password. + pub password: String, + /// Optional auth token (JWT etc.). + pub token: Option, + /// Producer group name. + pub producer_group: String, + /// Consumer group name. + pub consumer_group: String, +} + +impl ClientIdentity { + /// Detect local IP + pid and fill in sensible defaults. + pub fn detect() -> Self { + Self { + env: "env".into(), + idc: "default".into(), + sys: "sys".into(), + pid: std::process::id().to_string(), + ip: crate::common::local_ip_v4(), + language: "RUST".into(), + username: String::new(), + password: String::new(), + token: None, + producer_group: "DefaultProducerGroup".into(), + consumer_group: "DefaultConsumerGroup".into(), + } + } +} + +impl Default for ClientIdentity { + fn default() -> Self { + Self::detect() + } +} + +impl std::fmt::Debug for ClientIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClientIdentity") + .field("env", &self.env) + .field("idc", &self.idc) + .field("sys", &self.sys) + .field("pid", &self.pid) + .field("ip", &self.ip) + .field("language", &self.language) + .field("username", &self.username) + .field("password", &"***") + .field("token", &self.token.as_ref().map(|_| "***")) + .field("producer_group", &self.producer_group) + .field("consumer_group", &self.consumer_group) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_redacts_secrets() { + let id = ClientIdentity { + password: "hunter2".into(), + token: Some("jwt-token".into()), + ..ClientIdentity::detect() + }; + let s = format!("{id:?}"); + assert!(!s.contains("hunter2"), "password leaked: {s}"); + assert!(!s.contains("jwt-token"), "token leaked: {s}"); + assert!(s.contains("***"), "redaction marker missing: {s}"); + } + + #[test] + fn debug_redacts_empty_secrets() { + let id = ClientIdentity::detect(); + let s = format!("{id:?}"); + assert!( + !s.contains("password") || s.contains("\"***\""), + "password field should be redacted: {s}" + ); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/mod.rs new file mode 100644 index 0000000000..dba7fa51be --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/mod.rs @@ -0,0 +1,62 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Public client configuration. +//! +//! All transport configurations require an explicit endpoint. The old +//! transport configuration types remain crate-private adapters while Catalog +//! and Workflow keep their established public configuration contracts. + +mod client; +#[allow(dead_code)] +mod grpc; +#[allow(dead_code)] +mod http; +#[allow(dead_code)] +mod identity; +#[allow(dead_code)] +mod tcp; +pub mod tls; + +#[cfg(feature = "grpc")] +pub mod catalog; + +#[cfg(feature = "grpc")] +pub mod workflow; + +pub use client::{ + ClientOptions, ConsumerOptions, Credentials, Endpoint, EndpointSet, GrpcConfig, HttpConfig, + Identity, LoadBalance, ProducerOptions, ReconnectPolicy, TcpConfig, +}; +pub use tls::{TlsClientIdentity, TlsConfig, TlsConfigBuilder}; + +#[cfg(feature = "grpc")] +pub use catalog::{CatalogClientConfig, CatalogClientConfigBuilder}; + +#[cfg(feature = "grpc")] +pub use workflow::{WorkflowClientConfig, WorkflowClientConfigBuilder}; + +// Legacy adapters used by the private protocol implementations and retained +// Catalog/Workflow clients. Do not make these public again. +#[cfg(feature = "grpc")] +pub(crate) use grpc::GrpcClientConfig; +#[cfg(feature = "http")] +pub(crate) use http::HttpClientConfig; +pub(crate) use identity::ClientIdentity; +pub(crate) use tcp::ReconnectConfig; +#[cfg(feature = "tcp")] +pub(crate) use tcp::TcpClientConfig; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/tcp.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/tcp.rs new file mode 100644 index 0000000000..173d684167 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/tcp.rs @@ -0,0 +1,326 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! TCP client configuration. + +use std::time::Duration; + +use crate::config::ClientIdentity; + +/// Default TCP port of an EventMesh runtime. +pub const DEFAULT_TCP_PORT: u16 = 10_000; + +/// Default request timeout (mirrors the Java SDK `DEFAULT_TIME_OUT_MILLS`). +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20); + +/// Heartbeat interval (mirrors the Java SDK `HEARTBEAT = 30_000 ms`). +pub const DEFAULT_HEARTBEAT: Duration = Duration::from_secs(30); + +/// Default initial backoff before the first reconnect attempt. +pub const DEFAULT_RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_secs(1); + +/// Default maximum backoff between reconnect attempts. +pub const DEFAULT_RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(30); + +/// Reconnect policy for the TCP transport. +/// +/// When enabled (the default), the background I/O task automatically +/// re-establishes the TCP connection + HELLO handshake after an I/O error or +/// server-side close. For consumers, subscriptions are replayed automatically +/// after a successful reconnect. +/// +/// This mirrors the Java SDK's heartbeat-driven reconnect (`TcpClient.heartbeat` +/// checks `channel.isActive()` every 30 s and calls `reconnect()` when false), +/// but with exponential backoff and a configurable retry cap instead of a flat +/// 30 s cadence. +#[derive(Debug, Clone)] +pub struct ReconnectConfig { + /// Whether automatic reconnect is enabled (default `true`). + pub enabled: bool, + /// Maximum reconnect attempts before giving up. `usize::MAX` = infinite + /// (default, matching the Java SDK). + pub max_retries: usize, + /// Initial backoff duration before the first retry (default `1 s`). + pub initial_backoff: Duration, + /// Cap for the exponential backoff (default `30 s`). + pub max_backoff: Duration, +} + +impl Default for ReconnectConfig { + fn default() -> Self { + Self { + enabled: true, + max_retries: usize::MAX, + initial_backoff: DEFAULT_RECONNECT_INITIAL_BACKOFF, + max_backoff: DEFAULT_RECONNECT_MAX_BACKOFF, + } + } +} + +impl ReconnectConfig { + /// Start a fluent builder. + pub fn builder() -> ReconnectConfigBuilder { + ReconnectConfigBuilder::default() + } +} + +/// Fluent builder for [`ReconnectConfig`]. +#[derive(Debug, Clone, Default)] +pub struct ReconnectConfigBuilder { + enabled: Option, + max_retries: Option, + initial_backoff: Option, + max_backoff: Option, +} + +impl ReconnectConfigBuilder { + pub fn enabled(mut self, v: bool) -> Self { + self.enabled = Some(v); + self + } + pub fn max_retries(mut self, v: usize) -> Self { + self.max_retries = Some(v); + self + } + pub fn initial_backoff(mut self, v: Duration) -> Self { + self.initial_backoff = Some(v); + self + } + pub fn max_backoff(mut self, v: Duration) -> Self { + self.max_backoff = Some(v); + self + } + pub fn build(self) -> ReconnectConfig { + let ReconnectConfigBuilder { + enabled, + max_retries, + initial_backoff, + max_backoff, + } = self; + let mut cfg = ReconnectConfig::default(); + if let Some(v) = enabled { + cfg.enabled = v; + } + if let Some(v) = max_retries { + cfg.max_retries = v; + } + if let Some(v) = initial_backoff { + cfg.initial_backoff = v; + } + if let Some(v) = max_backoff { + cfg.max_backoff = v; + } + cfg + } +} + +/// Configuration for the TCP transport. +#[derive(Debug, Clone)] +pub struct TcpClientConfig { + /// Server host (no scheme, no port), e.g. `"127.0.0.1"`. + pub server_addr: String, + /// Server TCP port (default `10000`). + pub server_port: u16, + /// Default request timeout applied when none is given to a call. + pub timeout: Duration, + /// Heartbeat interval. + pub heartbeat_interval: Duration, + /// Automatic reconnect policy (default: enabled, infinite retries, 1–30 s + /// exponential backoff). + pub reconnect: ReconnectConfig, + /// Client identity sent with every request. + pub identity: ClientIdentity, +} + +impl Default for TcpClientConfig { + fn default() -> Self { + Self { + server_addr: "localhost".into(), + server_port: DEFAULT_TCP_PORT, + timeout: DEFAULT_TIMEOUT, + heartbeat_interval: DEFAULT_HEARTBEAT, + reconnect: ReconnectConfig::default(), + identity: ClientIdentity::detect(), + } + } +} + +impl TcpClientConfig { + /// Start a fluent builder. + pub fn builder() -> TcpClientConfigBuilder { + TcpClientConfigBuilder::default() + } + + /// `host:port` string. + pub fn authority(&self) -> String { + format!("{}:{}", self.server_addr, self.server_port) + } +} + +/// Fluent builder for [`TcpClientConfig`]. +#[derive(Clone, Default)] +pub struct TcpClientConfigBuilder { + server_addr: Option, + server_port: Option, + timeout: Option, + heartbeat_interval: Option, + reconnect: Option, + identity: Option, + // identity convenience setters: + env: Option, + idc: Option, + sys: Option, + producer_group: Option, + consumer_group: Option, + username: Option, + password: Option, + token: Option, +} + +impl TcpClientConfigBuilder { + pub fn server_addr(mut self, v: impl Into) -> Self { + self.server_addr = Some(v.into()); + self + } + pub fn server_port(mut self, v: u16) -> Self { + self.server_port = Some(v); + self + } + pub fn timeout(mut self, v: Duration) -> Self { + self.timeout = Some(v); + self + } + pub fn heartbeat_interval(mut self, v: Duration) -> Self { + self.heartbeat_interval = Some(v); + self + } + pub fn reconnect(mut self, v: ReconnectConfig) -> Self { + self.reconnect = Some(v); + self + } + pub fn identity(mut self, v: ClientIdentity) -> Self { + self.identity = Some(v); + self + } + pub fn env(mut self, v: impl Into) -> Self { + self.env = Some(v.into()); + self + } + pub fn idc(mut self, v: impl Into) -> Self { + self.idc = Some(v.into()); + self + } + pub fn sys(mut self, v: impl Into) -> Self { + self.sys = Some(v.into()); + self + } + pub fn producer_group(mut self, v: impl Into) -> Self { + self.producer_group = Some(v.into()); + self + } + pub fn consumer_group(mut self, v: impl Into) -> Self { + self.consumer_group = Some(v.into()); + self + } + pub fn username(mut self, v: impl Into) -> Self { + self.username = Some(v.into()); + self + } + pub fn password(mut self, v: impl Into) -> Self { + self.password = Some(v.into()); + self + } + pub fn token(mut self, v: impl Into) -> Self { + self.token = Some(v.into()); + self + } + + pub fn build(self) -> TcpClientConfig { + let TcpClientConfigBuilder { + server_addr, + server_port, + timeout, + heartbeat_interval, + reconnect, + identity, + env, + idc, + sys, + producer_group, + consumer_group, + username, + password, + token, + } = self; + + let mut identity = identity.unwrap_or_default(); + if let Some(v) = env { + identity.env = v; + } + if let Some(v) = idc { + identity.idc = v; + } + if let Some(v) = sys { + identity.sys = v; + } + if let Some(v) = producer_group { + identity.producer_group = v; + } + if let Some(v) = consumer_group { + identity.consumer_group = v; + } + if let Some(v) = username { + identity.username = v; + } + if let Some(v) = password { + identity.password = v; + } + if let Some(v) = token { + identity.token = Some(v); + } + + TcpClientConfig { + server_addr: server_addr.unwrap_or_else(|| "localhost".into()), + server_port: server_port.unwrap_or(DEFAULT_TCP_PORT), + timeout: timeout.unwrap_or(DEFAULT_TIMEOUT), + heartbeat_interval: heartbeat_interval.unwrap_or(DEFAULT_HEARTBEAT), + reconnect: reconnect.unwrap_or_default(), + identity, + } + } +} + +impl std::fmt::Debug for TcpClientConfigBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TcpClientConfigBuilder") + .field("server_addr", &self.server_addr) + .field("server_port", &self.server_port) + .field("timeout", &self.timeout) + .field("heartbeat_interval", &self.heartbeat_interval) + .field("reconnect", &self.reconnect) + .field("identity", &self.identity) + .field("env", &self.env) + .field("idc", &self.idc) + .field("sys", &self.sys) + .field("producer_group", &self.producer_group) + .field("consumer_group", &self.consumer_group) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "***")) + .field("token", &self.token.as_ref().map(|_| "***")) + .finish() + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/tls.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/tls.rs new file mode 100644 index 0000000000..0037cac151 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/tls.rs @@ -0,0 +1,278 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! TLS configuration for the gRPC channel. +//! +//! This module is plain data only (no tonic dependency) so that +//! [`TlsConfig`] is always available regardless of the `tls` cargo feature. +//! The actual application of TLS settings to the tonic [`Endpoint`] is gated +//! behind `#[cfg(feature = "tls")]` in [`crate::transport::grpc::client`]. + +use std::path::PathBuf; + +/// TLS configuration for the gRPC channel. +/// +/// Set on [`GrpcClientConfig`](super::GrpcClientConfig) and used only when +/// `use_tls` is `true`. If `use_tls` is `true` but `tls_config` is `None`, +/// the OS-native trust roots are loaded automatically and the endpoint +/// authority is used as the SNI domain. +/// +/// # Examples +/// +/// ```ignore +/// use eventmesh::config::{GrpcClientConfig, TlsConfig, TlsClientIdentity}; +/// +/// // Self-signed CA +/// let config = GrpcClientConfig::builder() +/// .server_addr("eventmesh.internal") +/// .use_tls(true) +/// .tls_config( +/// TlsConfig::builder() +/// .ca_cert_path("/etc/ssl/certs/internal-ca.pem") +/// .use_native_roots(true) +/// .build(), +/// ) +/// .build(); +/// +/// // Mutual TLS +/// let config = GrpcClientConfig::builder() +/// .server_addr("eventmesh.internal") +/// .use_tls(true) +/// .tls_config( +/// TlsConfig::builder() +/// .ca_cert_path("/etc/ssl/certs/ca.pem") +/// .client_identity(TlsClientIdentity { +/// cert_pem: std::fs::read("client.pem").unwrap(), +/// key_pem: std::fs::read("client.key").unwrap(), +/// }) +/// .build(), +/// ) +/// .build(); +/// ``` +#[derive(Clone, Default)] +pub struct TlsConfig { + /// Expected SNI / certificate hostname. Defaults to `server_addr` when + /// unset — set this when connecting via IP but the cert is for a domain. + pub domain: Option, + /// Path to a PEM-encoded CA certificate file. Used only when + /// `ca_cert_pem` is `None`. + pub ca_cert_path: Option, + /// Inline PEM-encoded CA certificate bytes. Takes precedence over + /// `ca_cert_path`. + pub ca_cert_pem: Option>, + /// Load the OS-native trust roots in addition to any explicit CA cert. + pub use_native_roots: bool, + /// Client certificate + key for mutual TLS (mTLS). + pub client_identity: Option, +} + +impl TlsConfig { + /// Start a fluent builder. + pub fn builder() -> TlsConfigBuilder { + TlsConfigBuilder::default() + } + + /// Resolve CA cert bytes from inline PEM or file path. + /// + /// Returns `None` when neither source is configured. Returns `Some(Err)` + /// when the file cannot be read. + pub fn ca_cert_pem_bytes(&self) -> Option>> { + self.ca_cert_pem + .as_ref() + .map(|pem| Ok(pem.clone())) + .or_else(|| self.ca_cert_path.as_ref().map(std::fs::read)) + } +} + +/// PEM-encoded client certificate + private key for mutual TLS. +#[derive(Clone)] +pub struct TlsClientIdentity { + /// PEM-encoded certificate chain. + pub cert_pem: Vec, + /// PEM-encoded private key. + pub key_pem: Vec, +} + +/// Fluent builder for [`TlsConfig`]. +#[derive(Clone, Default)] +pub struct TlsConfigBuilder { + domain: Option, + ca_cert_path: Option, + ca_cert_pem: Option>, + use_native_roots: Option, + client_identity: Option, +} + +impl TlsConfigBuilder { + /// Set the SNI / certificate hostname. + pub fn domain(mut self, v: impl Into) -> Self { + self.domain = Some(v.into()); + self + } + + /// Path to a PEM-encoded CA certificate file. + pub fn ca_cert_path(mut self, v: impl Into) -> Self { + self.ca_cert_path = Some(v.into()); + self + } + + /// Inline PEM-encoded CA certificate bytes. + pub fn ca_cert_pem(mut self, v: impl Into>) -> Self { + self.ca_cert_pem = Some(v.into()); + self + } + + /// Whether to load the OS-native trust roots alongside any explicit CA. + pub fn use_native_roots(mut self, v: bool) -> Self { + self.use_native_roots = Some(v); + self + } + + /// Client certificate + key for mTLS. + pub fn client_identity(mut self, v: TlsClientIdentity) -> Self { + self.client_identity = Some(v); + self + } + + /// Finalize the config. + pub fn build(self) -> TlsConfig { + TlsConfig { + domain: self.domain, + ca_cert_path: self.ca_cert_path, + ca_cert_pem: self.ca_cert_pem, + use_native_roots: self.use_native_roots.unwrap_or(false), + client_identity: self.client_identity, + } + } +} + +// ----------------------------------------------------------------------- +// Redacting Debug impls +// ----------------------------------------------------------------------- + +impl std::fmt::Debug for TlsClientIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsClientIdentity") + .field("cert_pem", &format!("<{} bytes>", self.cert_pem.len())) + .field("key_pem", &"***") + .finish() + } +} + +impl std::fmt::Debug for TlsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsConfig") + .field("domain", &self.domain) + .field("ca_cert_path", &self.ca_cert_path) + .field( + "ca_cert_pem", + &self + .ca_cert_pem + .as_ref() + .map(|v| format!("<{} bytes>", v.len())), + ) + .field("use_native_roots", &self.use_native_roots) + .field("client_identity", &self.client_identity) + .finish() + } +} + +impl std::fmt::Debug for TlsConfigBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsConfigBuilder") + .field("domain", &self.domain) + .field("ca_cert_path", &self.ca_cert_path) + .field( + "ca_cert_pem", + &self + .ca_cert_pem + .as_ref() + .map(|v| format!("<{} bytes>", v.len())), + ) + .field("use_native_roots", &self.use_native_roots) + .field("client_identity", &self.client_identity) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builder_defaults() { + let tls = TlsConfig::builder().build(); + assert!(tls.domain.is_none()); + assert!(tls.ca_cert_path.is_none()); + assert!(tls.ca_cert_pem.is_none()); + assert!(!tls.use_native_roots); + assert!(tls.client_identity.is_none()); + } + + #[test] + fn builder_full() { + let tls = TlsConfig::builder() + .domain("example.com") + .ca_cert_pem(b"fake-pem".to_vec()) + .use_native_roots(true) + .client_identity(TlsClientIdentity { + cert_pem: b"cert".to_vec(), + key_pem: b"key".to_vec(), + }) + .build(); + assert_eq!(tls.domain.as_deref(), Some("example.com")); + assert_eq!(tls.ca_cert_pem.as_deref(), Some(b"fake-pem" as &[u8])); + assert!(tls.use_native_roots); + let id = tls.client_identity.unwrap(); + assert_eq!(id.cert_pem, b"cert"); + assert_eq!(id.key_pem, b"key"); + } + + #[test] + fn ca_cert_pem_bytes_prefers_inline() { + let tls = TlsConfig::builder() + .ca_cert_pem(b"inline".to_vec()) + .ca_cert_path("/nonexistent") + .build(); + assert_eq!( + tls.ca_cert_pem_bytes().unwrap().unwrap(), + b"inline".to_vec() + ); + } + + #[test] + fn ca_cert_pem_bytes_returns_none_when_unset() { + let tls = TlsConfig::builder().build(); + assert!(tls.ca_cert_pem_bytes().is_none()); + } + + #[test] + fn debug_redacts_private_key() { + let tls = TlsConfig::builder() + .ca_cert_pem(b"fake-ca".to_vec()) + .client_identity(TlsClientIdentity { + cert_pem: b"my-cert".to_vec(), + key_pem: b"PRIVATE KEY MATERIAL".to_vec(), + }) + .build(); + let s = format!("{tls:?}"); + assert!(!s.contains("PRIVATE KEY MATERIAL"), "key leaked: {s}"); + assert!(!s.contains("my-cert"), "cert content leaked: {s}"); + assert!(s.contains("***"), "redaction marker missing: {s}"); + assert!(s.contains("<7 bytes>"), "ca cert length missing: {s}"); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/config/workflow.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/workflow.rs new file mode 100644 index 0000000000..600719dca4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/workflow.rs @@ -0,0 +1,110 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Workflow client configuration. + +use std::time::Duration; + +use crate::config::TlsConfig; + +/// Default logical Workflow service name, matching the Java SDK. +pub const DEFAULT_WORKFLOW_SERVER_NAME: &str = "eventmesh-workflow"; +/// Default Workflow RPC timeout. +pub const DEFAULT_WORKFLOW_TIMEOUT: Duration = Duration::from_secs(5); + +/// Configuration for [`crate::workflow::WorkflowClient`]. +#[derive(Debug, Clone)] +pub struct WorkflowClientConfig { + /// Logical name resolved through [`crate::discovery::ServiceDiscovery`]. + pub server_name: String, + /// Timeout for the `Execute` RPC. + pub timeout: Duration, + /// Use TLS for the resolved gRPC endpoint. + pub use_tls: bool, + /// Optional TLS details for the resolved gRPC endpoint. + pub tls_config: Option, +} + +impl Default for WorkflowClientConfig { + fn default() -> Self { + Self { + server_name: DEFAULT_WORKFLOW_SERVER_NAME.into(), + timeout: DEFAULT_WORKFLOW_TIMEOUT, + use_tls: false, + tls_config: None, + } + } +} + +impl WorkflowClientConfig { + /// Start a fluent builder. + pub fn builder() -> WorkflowClientConfigBuilder { + WorkflowClientConfigBuilder::default() + } +} + +/// Fluent builder for [`WorkflowClientConfig`]. +#[derive(Debug, Clone, Default)] +pub struct WorkflowClientConfigBuilder { + server_name: Option, + timeout: Option, + use_tls: Option, + tls_config: Option, +} + +impl WorkflowClientConfigBuilder { + pub fn server_name(mut self, v: impl Into) -> Self { + self.server_name = Some(v.into()); + self + } + pub fn timeout(mut self, v: Duration) -> Self { + self.timeout = Some(v); + self + } + pub fn use_tls(mut self, v: bool) -> Self { + self.use_tls = Some(v); + self + } + pub fn tls_config(mut self, v: TlsConfig) -> Self { + self.tls_config = Some(v); + self + } + pub fn build(self) -> WorkflowClientConfig { + WorkflowClientConfig { + server_name: self + .server_name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_WORKFLOW_SERVER_NAME.into()), + timeout: self.timeout.unwrap_or(DEFAULT_WORKFLOW_TIMEOUT), + use_tls: self.use_tls.unwrap_or(false), + tls_config: self.tls_config, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_java_sdk() { + let config = WorkflowClientConfig::builder().build(); + assert_eq!(config.server_name, DEFAULT_WORKFLOW_SERVER_NAME); + assert_eq!(config.timeout, DEFAULT_WORKFLOW_TIMEOUT); + assert!(!config.use_tls); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/discovery.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/discovery.rs new file mode 100644 index 0000000000..5b4456204d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/discovery.rs @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Pluggable service discovery for Catalog and Workflow clients. + +use std::collections::HashMap; +use std::future::Future; + +use crate::Result; + +/// A service endpoint returned by a [`ServiceDiscovery`] implementation. +/// +/// Discovery implementations should return `None` when no healthy instance is +/// available. Clients defensively reject an instance whose [`healthy`](Self::healthy) +/// flag is `false`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceInstance { + /// Host or IP address of the selected service instance. + pub host: String, + /// gRPC port of the selected service instance. + pub port: u16, + /// Whether the registry considers the instance healthy. + pub healthy: bool, + /// Registry-specific instance metadata. + pub metadata: HashMap, +} + +impl ServiceInstance { + /// Construct a healthy service instance with no metadata. + pub fn new(host: impl Into, port: u16) -> Self { + Self { + host: host.into(), + port, + healthy: true, + metadata: HashMap::new(), + } + } +} + +/// Selects one service instance by logical service name. +/// +/// This intentionally mirrors the Java SDK's `Selector#selectOne`, while +/// using constructor injection instead of a process-global selector factory. +/// Implement this trait for Nacos, Consul, Kubernetes, or an application's +/// existing service-registry client. +pub trait ServiceDiscovery: Send + Sync { + /// Resolve one instance for `service_name`, or `None` when none is + /// available. The returned future must not borrow `service_name`. + fn select_one( + &self, + service_name: String, + ) -> impl Future>> + Send; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_instance_defaults_to_healthy_without_metadata() { + let instance = ServiceInstance::new("catalog.internal", 9000); + assert!(instance.healthy); + assert!(instance.metadata.is_empty()); + assert_eq!(instance.host, "catalog.internal"); + assert_eq!(instance.port, 9000); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs index 3315a0ae4f..3b704da7fe 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs @@ -1,58 +1,129 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::fmt::{Display, Formatter}; -use thiserror::Error; - -#[allow(dead_code)] -#[derive(Debug, Error)] -pub enum EventMeshError { - /// Invalid arguments - InvalidArgs(String), - - /// gRpc status +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Public, pattern-matchable error type for the SDK. + +use std::time::Duration; + +/// All errors produced by the EventMesh SDK. +/// +/// The type is intentionally public and pattern-matchable. Protocol adapters +/// translate their implementation-specific failures into these variants so a +/// caller never needs to depend on tonic, reqwest, or the TCP frame format. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A client configuration problem (missing field, bad URL, ...). + #[error("config error: {0}")] + Config(String), + + /// The caller supplied an invalid message or argument. + #[error("invalid argument: {0}")] + InvalidArgument(String), + + /// A gRPC transport / status error. #[cfg(feature = "grpc")] - GRpcStatus(#[from] tonic::Status), + #[error("grpc error ({code}): {message}")] + Grpc { + /// gRPC status code rendered by the transport. + code: String, + /// Status description returned by the peer. + message: String, + }, + + /// A gRPC transport layer (channel/connect) error. + #[cfg(feature = "grpc")] + #[error("grpc transport error: {0}")] + GrpcTransport(String), + + /// An HTTP transport error (Phase 2). + #[error("http error: status {status}: {message}")] + Http { status: u16, message: String }, + + /// A TCP transport error (Phase 3). + #[error("tcp error: {0}")] + Tcp(String), + + /// Serialization / deserialization failure. + #[error("codec error: {0}")] + Codec(#[from] serde_json::Error), + + /// A message failed validation before it was sent on the wire. + #[error("invalid message: {0}")] + InvalidMessage(String), - EventMeshLocal(String), + /// An operation did not complete within its timeout. + #[error("operation timed out after {0:?}")] + Timeout(Duration), - EventMeshRemote(String), + /// A protocol adapter rejected or could not encode a wire-level value. + #[error("{transport} protocol error: {message}")] + Protocol { + /// The protocol that produced the error (for example `grpc` or `tcp`). + transport: &'static str, + /// A stable, human-readable explanation. + message: String, + }, - EventMeshFromStrError(String), + /// The EventMesh server returned a non-success response code. + #[error("server error: code={code} message={message}")] + Server { code: i32, message: String }, + + /// Low-level I/O error. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + /// A channel (mpsc/oneshot) was closed, e.g. the connection task exited. + #[error("channel closed: {0}")] + ChannelClosed(String), + + /// The operation is not supported by the active transport. + #[error("unsupported operation: {0}")] + Unsupported(String), + + /// A service-discovery implementation failed while resolving an endpoint. + #[error("service discovery error: {0}")] + ServiceDiscovery(String), + + /// No healthy instance was available for a logical service name. + #[error("service unavailable: {0}")] + ServiceUnavailable(String), } -impl Display for EventMeshError { - #[inline] - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - #[cfg(feature = "grpc")] - EventMeshError::GRpcStatus(e) => write!(f, "grpc request error: {}", e), - EventMeshError::EventMeshLocal(ref err_msg) => { - write!(f, "EventMesh client error: {}", err_msg) - } - EventMeshError::EventMeshRemote(ref err_msg) => { - write!(f, "EventMesh remote error: {}", err_msg) - } - EventMeshError::EventMeshFromStrError(ref err_msg) => { - write!(f, "EventMesh Parse from String error: {}", err_msg) - } - EventMeshError::InvalidArgs(ref err_msg) => { - write!(f, "Invalid args: {}", err_msg) - } +/// Convenience `Result` alias used throughout the SDK. +pub type Result = std::result::Result; + +// The protocol implementation is migrated in stages. Keep this alias crate +// private so the old spelling remains available to internal modules without +// becoming part of the 2.0 public API. +pub(crate) use Error as EventMeshError; + +#[cfg(feature = "grpc")] +impl From for Error { + fn from(status: tonic::Status) -> Self { + Self::Grpc { + code: status.code().to_string(), + message: status.message().to_owned(), } } } + +#[cfg(feature = "grpc")] +impl From for Error { + fn from(err: tonic::transport::Error) -> Self { + Self::GrpcTransport(err.to_string()) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs index 30046f8c5a..6358258f08 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs @@ -1,34 +1,240 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -//! gRPC client implementations. - -/// EventMesh message types. -pub(crate) mod r#impl; - -/// gRPC consumer client. -pub mod grpc_consumer; - -/// gRPC producer client. -pub mod grpc_producer; - -/// Protobuf generated definitions. -pub(crate) mod pb; - -#[cfg(feature = "grpc")] -/// Re-export gRPC eventmesh message producer when features enabled. -pub use crate::grpc::r#impl::grpc_producer_impl::GrpcEventMeshProducer; +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! gRPC client API. + +use crate::config::{ConsumerOptions, GrpcConfig, ProducerOptions}; +use crate::error::{EventMeshError, Result}; +use crate::handler::PublicHandler; +use crate::message::{Message, PublishReceipt}; +use crate::subscription::Subscription; +use crate::transport::grpc::{ + GrpcClient as ChannelClient, GrpcProducer as LegacyProducer, + GrpcStreamConsumer as LegacyConsumer, +}; +use crate::transport::Publisher as LegacyPublisher; +use crate::MessageHandler; + +/// A configured EventMesh gRPC client. +#[derive(Clone)] +pub struct GrpcClient { + config: GrpcConfig, +} + +impl GrpcClient { + /// Validate and create a gRPC client handle. + /// + /// Channel creation remains lazy; network I/O begins with the first + /// operation or stream consumer. + pub fn new(config: GrpcConfig) -> Result { + ChannelClient::new(&config.legacy(None, None))?; + Ok(Self { config }) + } + + /// Create a producer role using `options`. + pub fn producer(&self, options: ProducerOptions) -> Result { + Ok(GrpcProducer { + inner: LegacyProducer::connect(self.config.legacy(Some(&options), None))?, + timeout: self.config.request_timeout(), + }) + } + + /// Open a long-lived gRPC stream consumer. + /// + /// gRPC requires at least one initial subscription to open its stream; + /// additional subscriptions can be added on the returned consumer. + pub async fn stream_consumer( + &self, + options: ConsumerOptions, + subscriptions: impl IntoIterator, + handler: H, + ) -> Result> + where + H: MessageHandler, + { + let subscriptions: Vec<_> = subscriptions + .into_iter() + .map(|subscription| subscription.as_legacy()) + .collect(); + let inner = LegacyConsumer::subscribe_stream( + self.config.legacy(None, Some(&options)), + PublicHandler::new(handler), + subscriptions, + None::>, + ) + .await?; + Ok(GrpcConsumer { inner }) + } +} + +/// gRPC publishing capability. +pub struct GrpcProducer { + inner: LegacyProducer, + timeout: std::time::Duration, +} + +/// A long-lived gRPC stream consumer. +pub struct GrpcConsumer { + inner: LegacyConsumer>, +} + +impl GrpcConsumer { + /// Add a subscription to the active stream. + pub async fn subscribe(&self, subscription: Subscription) -> Result<()> { + self.inner.subscribe(vec![subscription.as_legacy()]).await + } + + /// Remove a stream subscription. + pub async fn unsubscribe(&self, subscription: Subscription) -> Result<()> { + self.inner + .unsubscribe_stream(vec![subscription.as_legacy()]) + .await + .map(|_| ()) + } + + /// Request graceful stream shutdown. + pub async fn shutdown(&self) { + self.inner.shutdown().await; + } + + /// Wait for stream shutdown. + pub async fn join(&self) -> Result<()> { + self.inner.wait_for_shutdown().await + } + + pub(crate) async fn has_stream_subscription(&self, topic: &str) -> bool { + self.inner.has_stream_subscription(topic).await + } + + pub(crate) async fn subscribe_catalog( + &self, + subscriptions: Vec, + ) -> Result<()> { + self.inner.subscribe(subscriptions).await + } + + pub(crate) async fn unsubscribe_catalog( + &self, + subscriptions: Vec, + ) -> Result<()> { + self.inner + .unsubscribe_stream(subscriptions) + .await + .map(|_| ()) + } +} + +impl GrpcProducer { + /// Publish one event and wait for the EventMesh acknowledgement. + pub async fn publish(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .publish(message) + .await + .map(PublishReceipt::from_legacy), + Message::Open(message) => self + .inner + .publish_open_message(message) + .await + .map(PublishReceipt::from_legacy), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .publish_cloud_event(event) + .await + .map(PublishReceipt::from_legacy), + } + } + + /// Publish a homogeneous batch of events through gRPC's batch RPC. + pub async fn publish_batch(&self, messages: Vec) -> Result { + if messages.is_empty() { + return Err(EventMeshError::InvalidArgument( + "batch publish requires at least one message".into(), + )); + } + + #[cfg(feature = "cloud_events")] + if messages + .iter() + .all(|message| matches!(message, Message::CloudEvent(_))) + { + let events = messages + .into_iter() + .map(|message| match message { + Message::CloudEvent(event) => event, + _ => unreachable!("all messages were checked above"), + }) + .collect(); + return self + .inner + .publish_cloud_event_batch(events) + .await + .map(PublishReceipt::from_legacy); + } + + #[cfg(feature = "cloud_events")] + if messages + .iter() + .any(|message| matches!(message, Message::CloudEvent(_))) + { + return Err(EventMeshError::Unsupported( + "mixed EventMesh/OpenMessaging and CloudEvents gRPC batches".into(), + )); + } + self.inner + .publish_message_batch(messages) + .await + .map(PublishReceipt::from_legacy) + } + + /// Send an event and await its reply. + pub async fn request_reply(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .request_reply(message, self.timeout) + .await + .map(Message::EventMesh), + Message::Open(message) => self + .inner + .request_reply_open_message(message, self.timeout) + .await + .map(Message::Open), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .request_reply_cloud_event(event, self.timeout) + .await + .map(Message::CloudEvent), + } + } + + /// Send an EventMesh or OpenMessaging message without waiting for a + /// broker acknowledgement. + pub async fn publish_one_way(&self, message: Message) -> Result<()> { + match message { + Message::EventMesh(message) => self.inner.publish_one_way(message).await, + Message::Open(message) => self.inner.publish_one_way_open_message(message).await, + #[cfg(feature = "cloud_events")] + Message::CloudEvent(_) => Err(EventMeshError::Unsupported( + "gRPC one-way CloudEvents publishing".into(), + )), + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_consumer.rs deleted file mode 100644 index e31c97a530..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_consumer.rs +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use futures::lock::Mutex; -use tonic::codegen::tokio_stream::StreamExt; -use tracing::error; - -use crate::common::constants::{DataContentType, SDK_STREAM_URL}; -use crate::common::grpc_eventmesh_message_utils::EventMeshCloudEventUtils; -use crate::common::{ProtocolKey, ReceiveMessageListener}; -use crate::config::EventMeshGrpcClientConfig; -use crate::error::EventMeshError::{EventMeshLocal, InvalidArgs}; -use crate::model::message::EventMeshMessage; -use crate::model::response::EventMeshResponse; -use crate::model::subscription::{ - HeartbeatItem, SubscriptionItem, SubscriptionItemWrapper, SubscriptionReply, -}; -use crate::model::EventMeshProtocolType; -use crate::net::GrpcClient; -use crate::proto_cloud_event::{PbAttr, PbCloudEvent, PbCloudEventAttributeValue, PbData}; - -pub struct EventMeshGrpcConsumer { - inner: GrpcClient, - grpc_config: EventMeshGrpcClientConfig, - subscription_map: Arc>>, - listener: Arc>>, -} - -impl EventMeshGrpcConsumer { - pub fn new( - grpc_config: EventMeshGrpcClientConfig, - listener: Box>, - ) -> Self { - let client = GrpcClient::new(&grpc_config).unwrap(); - let subscription_map = Arc::new(Mutex::new(HashMap::with_capacity(16))); - let listener = Arc::new(listener); - let _ = EventMeshGrpcConsumer::heartbeat( - client.clone(), - grpc_config.clone(), - Arc::clone(&subscription_map), - ); - Self { - inner: client, - grpc_config, - subscription_map: Arc::clone(&subscription_map), - listener: Arc::clone(&listener), - } - } - - pub async fn subscribe_webhook( - &mut self, - subscription_items: Vec, - url: impl Into, - ) -> crate::Result { - if subscription_items.is_empty() { - return Err(InvalidArgs("subscription_items is empty".to_string()).into()); - } - let cloud_event = EventMeshCloudEventUtils::build_event_subscription( - &self.grpc_config, - EventMeshProtocolType::EventMeshMessage, - url.into().as_str(), - &subscription_items, - ); - if cloud_event.is_none() { - return Err( - EventMeshLocal("SubscriptionItem switch to CloudEvent error".to_string()).into(), - ); - } - let result = self - .inner - .subscribe_webhook_inner(cloud_event.unwrap()) - .await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - pub async fn subscribe( - &mut self, - subscription_items: Vec, - ) -> crate::Result<()> { - if subscription_items.is_empty() { - return Err(InvalidArgs("subscription_items is empty".to_string()).into()); - } - - let map = self.subscription_map.clone(); - let mut guard = map.lock().await; - subscription_items.iter().for_each(|item| { - guard.insert( - item.topic.clone(), - SubscriptionItemWrapper { - subscription_item: item.clone(), - url: SDK_STREAM_URL.to_string(), - }, - ); - }); - let cloud_event = EventMeshCloudEventUtils::build_event_subscription( - &self.grpc_config, - EventMeshProtocolType::EventMeshMessage, - String::new().as_str(), - &subscription_items, - ); - if cloud_event.is_none() { - return Err( - EventMeshLocal("SubscriptionItem switch to CloudEvent error".to_string()).into(), - ); - } - let (keeper, mut resp_stream) = self.inner.subscribe_bi_inner(cloud_event.unwrap()).await?; - let listener_inner = Arc::clone(&self.listener); - tokio::spawn(async move { - while let Some(received) = resp_stream.next().await { - if let Err(status) = received { - error!("Subscribe receive error, status {}", status); - continue; - } - let mut received = received.unwrap(); - let eventmesh_message = - EventMeshCloudEventUtils::build_message_from_event_mesh_cloud_event::< - EventMeshMessage, - >(&received); - if eventmesh_message.is_none() { - continue; - } - received.attributes.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::JSON.to_string())), - }, - ); - - let handled_msg = listener_inner.handle(eventmesh_message.unwrap()); - if let Ok(msg_option) = handled_msg { - if let Some(_msg) = msg_option { - received.attributes.insert( - ProtocolKey::SUB_MESSAGE_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - SubscriptionReply::SUB_TYPE.to_string(), - )), - }, - ); - received.data = None; - let _ = keeper.sender.send(received).await; - } - } else { - error!("Handle Receive error:{}", handled_msg.unwrap_err()) - } - } - }); - Ok(()) - } - - pub async fn unsubscribe( - &mut self, - unsubscription_items: Vec, - ) -> crate::Result { - if unsubscription_items.is_empty() { - return Err(InvalidArgs("unsubscription_items is empty".to_string()).into()); - } - let map = self.subscription_map.clone(); - let mut guard = map.lock().await; - unsubscription_items.iter().for_each(|item| { - guard.remove(item.topic.as_str()); - }); - let cloud_event = EventMeshCloudEventUtils::build_event_subscription( - &self.grpc_config, - EventMeshProtocolType::EventMeshMessage, - String::new().as_str(), - &unsubscription_items, - ); - if cloud_event.is_none() { - return Err( - EventMeshLocal("SubscriptionItem switch to CloudEvent error".to_string()).into(), - ); - } - let result = self.inner.unsubscribe_inner(cloud_event.unwrap()).await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - fn heartbeat( - mut client: GrpcClient, - grpc_config: EventMeshGrpcClientConfig, - subscription_map: Arc>>, - ) -> crate::Result<()> { - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(20)).await; - let mut attributes = EventMeshCloudEventUtils::build_common_cloud_event_attributes( - &grpc_config, - EventMeshProtocolType::EventMeshMessage, - ); - attributes.insert( - ProtocolKey::CONSUMERGROUP.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - grpc_config.consumer_group.clone().unwrap(), - )), - }, - ); - attributes.insert( - ProtocolKey::CLIENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeInteger(2)), - }, - ); - attributes.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::JSON.to_string())), - }, - ); - - let map = subscription_map.lock().await; - let heartbeat_items = map - .iter() - .filter_map(|(key, value)| { - Some(HeartbeatItem { - topic: key.to_string(), - url: value.url.clone(), - }) - }) - .collect::>(); - let mut cloud_event = PbCloudEvent::default(); - cloud_event.attributes = attributes; - cloud_event.data = Some(PbData::TextData( - serde_json::to_string(&heartbeat_items).unwrap(), - )); - let _result = client.heartbeat_inner(cloud_event).await; - } - }); - Ok(()) - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_producer.rs deleted file mode 100644 index 867588fb88..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_producer.rs +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -//! Trait for gRPC eventmesh producer. - -use crate::model::response::EventMeshResponse; -use std::future::Future; - -/// Trait for gRPC eventmesh producer. -pub trait EventMeshGrpcProducer { - /// Publish a message. - fn publish(&mut self, message: M) -> impl Future>; - - /// Publish a batch of messages. - fn publish_batch( - &mut self, - messages: Vec, - ) -> impl Future>; - - /// Request reply for a message. - fn request_reply( - &mut self, - message: M, - time_out: u64, - ) -> impl Future>; -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl.rs deleted file mode 100644 index edb47941c5..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#[cfg(feature = "grpc")] -pub mod grpc_producer_impl; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl/grpc_producer_impl.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl/grpc_producer_impl.rs deleted file mode 100644 index f81c3f65e7..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl/grpc_producer_impl.rs +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::any::Any; -use std::fmt::Debug; -use std::marker::PhantomData; - -use tonic::transport::Uri; - -use crate::common::constants::{DataContentType, DEFAULT_EVENTMESH_MESSAGE_TTL}; -use crate::common::grpc_eventmesh_message_utils::EventMeshCloudEventUtils; -use crate::config::EventMeshGrpcClientConfig; -use crate::error::EventMeshError; -use crate::grpc::grpc_producer::EventMeshGrpcProducer; -use crate::model::message::EventMeshMessage; -use crate::model::response::EventMeshResponse; -use crate::model::EventMeshProtocolType; -use crate::net::GrpcClient; -use crate::proto_cloud_event::{ - EventMeshCloudEventBuilder, PbCloudEvent, PbCloudEventBatch, PbData, -}; - -/// gRPC EventMesh message producer. -pub struct GrpcEventMeshProducer { - /// gRPC client. - inner: GrpcClient, - - /// gRPC configuration. - grpc_config: EventMeshGrpcClientConfig, - - _mark: PhantomData, -} - -impl GrpcEventMeshProducer -where - M: Any, -{ - pub fn new(grpc_config: EventMeshGrpcClientConfig) -> Self { - let client = GrpcClient::new(&grpc_config).unwrap(); - Self { - inner: client, - grpc_config, - _mark: PhantomData::, - } - } - - #[allow(dead_code)] - fn build_event_mesh_cloud_event(&mut self, message: EventMeshMessage) -> PbCloudEvent { - let mut event = EventMeshCloudEventBuilder::default() - .with_env(self.grpc_config.env.clone()) - .with_idc(self.grpc_config.idc.clone()) - .with_ip(crate::common::local_ip::get_local_ip_v4()) - .with_pid(std::process::id().to_string()) - .with_sys(self.grpc_config.sys.clone()) - .with_user_name(self.grpc_config.user_name.clone()) - .with_password(self.grpc_config.password.clone()) - .with_language("Rust") - .with_protocol_type(EventMeshProtocolType::CloudEvents.protocol_type_name()) - .with_ttl(DEFAULT_EVENTMESH_MESSAGE_TTL.to_string()) - .with_subject(message.biz_seq_no.clone().unwrap()) - .with_producergroup( - self.grpc_config - .producer_group - .clone() - .map_or_else(|| String::from("Default_Producer_Group"), |val| val) - .clone(), - ) - .with_uniqueid(message.unique_id.unwrap()) - .with_data_content_type(DataContentType::TEXT_PLAIN) - .build(); - event.id = message.biz_seq_no.clone().unwrap(); - event.source = Uri::builder() - .path_and_query("/") - .build() - .unwrap() - .to_string(); - event.spec_version = "1.0".to_string(); - event.r#type = "Rust".to_string(); - event.data = Some(PbData::TextData(message.content.unwrap().into())); - event - } - - fn build_event_mesh_cloud_event_batch( - &mut self, - messages: Vec, - ) -> Option { - if messages.is_empty() { - return None; - } - - let events = messages - .into_iter() - .map(|msg| { - EventMeshCloudEventUtils::build_event_mesh_cloud_event(msg, &self.grpc_config) - .unwrap() - }) - .collect(); - - let mut cloud_event_batch = PbCloudEventBatch::default(); - cloud_event_batch.events = events; - - Some(cloud_event_batch) - } -} - -/// gRPC EventMesh message producer implementation. -#[allow(unused_variables)] -impl EventMeshGrpcProducer for GrpcEventMeshProducer -where - M: Any + Debug + From, -{ - /// Publish a message. - async fn publish(&mut self, message: M) -> crate::Result { - let event = - EventMeshCloudEventUtils::build_event_mesh_cloud_event(message, &self.grpc_config); - if event.is_none() { - return Err(EventMeshError::EventMeshLocal( - "Create Event Mesh cloud event Error".to_string(), - ) - .into()); - } - let result = self.inner.publish_inner(event.unwrap()).await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - /// Publish a batch of messages. - async fn publish_batch(&mut self, messages: Vec) -> crate::Result { - let events = self.build_event_mesh_cloud_event_batch(messages); - if events.is_none() { - return Err(EventMeshError::EventMeshLocal("Vec is empty".to_string()).into()); - } - let result = self.inner.batch_publish_inner(events.unwrap()).await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - /// Request reply for a message. - async fn request_reply(&mut self, message: M, time_out: u64) -> crate::Result { - let event = - EventMeshCloudEventUtils::build_event_mesh_cloud_event(message, &self.grpc_config); - let result = self - .inner - .request_reply_inner(event.unwrap(), time_out) - .await?; - Ok(EventMeshCloudEventUtils::build_message_from_event_mesh_cloud_event(&result).unwrap()) - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/pb.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/pb.rs deleted file mode 100644 index 26e69e91a9..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/pb.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -pub mod cloud_events { - tonic::include_proto!("org.apache.eventmesh.cloudevents.v1"); -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/handler.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/handler.rs new file mode 100644 index 0000000000..a61da564d7 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/handler.rs @@ -0,0 +1,68 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Public handler contract and private adapters for the previous transport +//! engines. + +use std::future::Future; + +use crate::error::Result; +use crate::message::Message; + +/// Handles a delivered EventMesh message. +/// +/// Return `Ok(None)` to acknowledge an asynchronous message, or +/// `Ok(Some(reply))` to reply to a synchronous delivery. Returning an error +/// reports application failure to the transport adapter rather than treating +/// it as a successful business acknowledgement. +pub trait MessageHandler: Send + Sync + 'static { + /// Handle one delivery. + fn handle(&self, message: Message) -> impl Future>> + Send; +} + +impl MessageHandler for F +where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future>> + Send + 'static, +{ + fn handle(&self, message: Message) -> impl Future>> + Send { + (self)(message) + } +} + +/// Adapter used by the public protocol clients. It preserves the message +/// dialect selected by the transport decoder. +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +pub(crate) struct PublicHandler { + handler: H, +} + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +impl PublicHandler { + pub(crate) fn new(handler: H) -> Self { + Self { handler } + } +} + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +impl crate::MessageListener for PublicHandler { + type Message = Message; + + async fn handle(&self, message: Message) -> Result> { + self.handler.handle(message).await + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/http.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/http.rs new file mode 100644 index 0000000000..00313c7870 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/http.rs @@ -0,0 +1,160 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! HTTP client API. + +/// Framework-independent helpers for custom webhook endpoints. +pub mod codec { + pub use crate::transport::http::codec::{ + parse_push_body, PushMessageRequestBody, WebhookReply, + }; +} + +use crate::config::{ConsumerOptions, HttpConfig, ProducerOptions}; +use crate::error::Result; +use crate::message::{Message, PublishReceipt}; +use crate::subscription::{DeliveryType, Subscription}; +use crate::transport::http::{HttpConsumer as LegacyConsumer, HttpProducer as LegacyProducer}; +use crate::transport::Publisher as LegacyPublisher; + +/// A configured EventMesh HTTP client. +#[derive(Clone)] +pub struct HttpClient { + config: HttpConfig, +} + +impl HttpClient { + /// Validate and create an HTTP client handle. + pub fn new(config: HttpConfig) -> Result { + // Constructing the private HTTP client validates the endpoint set and + // request client without issuing network I/O. + LegacyProducer::new(config.legacy(None, None))?; + Ok(Self { config }) + } + + /// Create a publishing role. + pub fn producer(&self, options: ProducerOptions) -> Result { + Ok(HttpProducer { + inner: LegacyProducer::new(self.config.legacy(Some(&options), None))?, + timeout: self.config.request_timeout(), + }) + } + + /// Create a long-lived webhook-registration consumer. + pub fn webhook_consumer(&self, options: ConsumerOptions) -> Result { + Ok(HttpConsumer { + inner: LegacyConsumer::new( + self.config.legacy(None, Some(&options)), + None::>, + )?, + }) + } +} + +/// HTTP publishing capability. +pub struct HttpProducer { + inner: LegacyProducer, + timeout: std::time::Duration, +} + +impl HttpProducer { + /// Publish one event. + pub async fn publish(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .publish(message) + .await + .map(PublishReceipt::from_legacy), + Message::Open(message) => self + .inner + .publish_open_message(message) + .await + .map(PublishReceipt::from_legacy), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .publish_cloud_event(event) + .await + .map(PublishReceipt::from_legacy), + } + } + + /// Send an event and await its reply. + pub async fn request_reply(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .request_reply(message, self.timeout) + .await + .map(Message::EventMesh), + Message::Open(message) => self + .inner + .request_reply_open_message(message, self.timeout) + .await + .map(Message::Open), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .request_reply_cloud_event(event, self.timeout) + .await + .map(Message::CloudEvent), + } + } +} + +/// A long-lived HTTP webhook registration and heartbeat manager. +pub struct HttpConsumer { + inner: LegacyConsumer, +} + +impl HttpConsumer { + /// Register `subscription` to send deliveries to `webhook_url`. + pub async fn subscribe( + &self, + subscription: Subscription, + webhook_url: impl Into, + ) -> Result<()> { + if subscription.delivery_type == DeliveryType::Sync { + return Err(crate::error::EventMeshError::Unsupported( + "HTTP request/reply subscriptions".into(), + )); + } + self.inner + .subscribe_webhook(vec![subscription.as_legacy()], webhook_url) + .await + .map(|_| ()) + } + + /// Remove a previously registered webhook subscription. + pub async fn unsubscribe(&self, subscription: Subscription) -> Result<()> { + self.inner + .unsubscribe(vec![subscription.as_legacy()]) + .await + .map(|_| ()) + } + + /// Stop the heartbeat task. + pub async fn shutdown(&self) { + self.inner.shutdown().await; + } + + /// Wait for the heartbeat task to stop. + pub async fn join(&self) { + self.inner.wait_for_shutdown().await; + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs index a03f8cfa2d..282cc1769a 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs @@ -1,323 +1,104 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Apache EventMesh Rust SDK. //! -//! - -/// Re-export eventmesh main. -pub use eventmesh::main; -/// Re-export eventmesh as tokio. -pub use tokio as eventmesh; - -/// Shorthand for `anyhow::Result`. -pub type Result = anyhow::Result; - -// Modules - -/// Configurations. +//! Version 2 exposes protocol-specific clients and a shared [`message::Message`] +//! enum. The enum preserves native EventMesh, OpenMessaging, and (when the +//! feature is enabled) CloudEvents models; gRPC protobuf, HTTP form, and TCP +//! frame details are private implementation details. + +#![deny(unsafe_code)] + +// These modules are wire adapters retained behind the v2 public façade. Some +// protocol-specific compatibility paths are intentionally feature-dependent, +// so not every helper is referenced in every feature combination. +#[allow(dead_code, unused_imports)] +mod common; pub mod config; - -/// Errors. -pub(crate) mod error; - -/// Network utils. -mod net; - -/// Common utilities. -pub mod common; - -/// gRPC client implementations. +pub mod discovery; +mod error; +mod handler; +pub mod message; +#[allow(dead_code, unused_imports)] +mod model; +pub mod subscription; +pub mod webhook; + +#[cfg(feature = "grpc")] +pub mod proto_gen; + +/// Catalog service client, available with the `grpc` feature. +#[cfg(feature = "grpc")] +pub mod catalog; + +/// Workflow service client, available with the `grpc` feature. +#[cfg(feature = "grpc")] +pub mod workflow; + +#[cfg(feature = "grpc")] +mod service; + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +#[allow(dead_code, unused_imports)] +mod transport; + +/// gRPC client API. +#[cfg(feature = "grpc")] pub mod grpc; -/// Logging. -pub mod log; - -/// Data models. -pub mod model; - -/// Module contains Protobuf CloudEvent related types and builder. -pub mod proto_cloud_event { - use cloudevents::Event; - - use crate::common::ProtocolKey; - /// Protobuf CloudEvent attribute value enum. - pub use crate::grpc::pb::cloud_events::cloud_event::cloud_event_attribute_value::Attr as PbAttr; - use crate::grpc::pb::cloud_events::cloud_event::cloud_event_attribute_value::Attr; - pub use crate::grpc::pb::cloud_events::cloud_event::CloudEventAttributeValue as PbCloudEventAttributeValue; - pub use crate::grpc::pb::cloud_events::cloud_event::Data as PbData; - use crate::grpc::pb::cloud_events::cloud_event::{CloudEventAttributeValue, Data}; - /// Protobuf CloudEvent message. - pub use crate::grpc::pb::cloud_events::{ - CloudEvent as PbCloudEvent, CloudEventBatch as PbCloudEventBatch, - }; - use crate::model::message::EventMeshMessage; - - impl ToString for PbAttr { - /// Convert Protobuf attribute to String. - fn to_string(&self) -> String { - match self { - Attr::CeBoolean(value) => value.to_string(), - Attr::CeInteger(value) => value.to_string(), - Attr::CeString(value) => value.clone(), - Attr::CeBytes(value) => unsafe { String::from_utf8_unchecked(value.clone()) }, - Attr::CeUri(value) => value.clone(), - Attr::CeUriRef(value) => value.clone(), - Attr::CeTimestamp(value) => value.to_string(), - } - } - } - - impl From for PbCloudEvent { - fn from(_value: EventMeshMessage) -> Self { - todo!() - } - } - - impl From for PbCloudEvent { - fn from(_value: Event) -> Self { - todo!() - } - } - - impl ToString for PbData { - /// Convert Protobuf data to String. - fn to_string(&self) -> String { - match self { - Data::BinaryData(value) => unsafe { String::from_utf8_unchecked(value.clone()) }, - Data::TextData(value) => value.clone(), - Data::ProtoData(value) => unsafe { - String::from_utf8_unchecked(value.value.clone()) - }, - } - } - } - - /// Builder for constructing Protobuf CloudEvent. - #[derive(Debug, Default)] - pub struct EventMeshCloudEventBuilder { - /// Environment attribute. - pub(crate) env: String, - - /// IDC attribute. - pub(crate) idc: String, - - /// IP address attribute. - pub(crate) ip: String, - - /// Optional process ID attribute. - pub(crate) pid: Option, - - /// System attribute. - pub(crate) sys: String, - - /// Username attribute. - pub(crate) user_name: String, - - /// Password attribute. - pub(crate) password: String, - - /// Language attribute. - pub(crate) language: String, - - /// Protocol type attribute. - pub(crate) protocol_type: String, - - /// Protocol version attribute. - pub(crate) protocol_version: String, - - /// TTL attribute. - pub(crate) ttl: String, - - /// Subject attribute. - pub(crate) subject: String, - - /// Producer group attribute. - pub(crate) producergroup: String, - - /// Unique ID attribute. - pub(crate) uniqueid: String, - - /// Data content type attribute. - pub(crate) data_content_type: String, - } - - impl EventMeshCloudEventBuilder { - /// Set process ID attribute. - pub fn with_pid(mut self, pid: impl Into) -> Self { - self.pid = Some(pid.into()); - self - } - - /// Set environment attribute. - pub fn with_env(mut self, env: impl Into) -> Self { - self.env = env.into(); - self - } - - /// Set IDC attribute. - pub fn with_idc(mut self, idc: impl Into) -> Self { - self.idc = idc.into(); - self - } - - /// Set IP address attribute. - pub fn with_ip(mut self, ip: impl Into) -> Self { - self.ip = ip.into(); - self - } - - /// Set system attribute. - pub fn with_sys(mut self, sys: impl Into) -> Self { - self.sys = sys.into(); - self - } - - /// Set username attribute. - pub fn with_user_name(mut self, user_name: impl Into) -> Self { - self.user_name = user_name.into(); - self - } - - /// Set password attribute. - pub fn with_password(mut self, password: impl Into) -> Self { - self.password = password.into(); - self - } - - /// Set language attribute. - pub fn with_language(mut self, language: impl Into) -> Self { - self.language = language.into(); - self - } - - /// Set protocol type attribute. - pub fn with_protocol_type(mut self, protocol_type: impl Into) -> Self { - self.protocol_type = protocol_type.into(); - self - } - - /// Set protocol version attribute. - pub fn with_protocol_version(mut self, protocol_version: impl Into) -> Self { - self.protocol_version = protocol_version.into(); - self - } - - /// Set TTL attribute. - pub fn with_ttl(mut self, ttl: impl Into) -> Self { - self.ttl = ttl.into(); - self - } - - /// Set subject attribute. - pub fn with_subject(mut self, subject: impl Into) -> Self { - self.subject = subject.into(); - self - } - - /// Set producer group attribute. - pub fn with_producergroup(mut self, producergroup: impl Into) -> Self { - self.producergroup = producergroup.into(); - self - } - - /// Set unique ID attribute. - pub fn with_uniqueid(mut self, uniqueid: impl Into) -> Self { - self.uniqueid = uniqueid.into(); - self - } - - /// Set data content type attribute. - pub fn with_data_content_type(mut self, data_content_type: impl Into) -> Self { - self.data_content_type = data_content_type.into(); - self - } - - /// Build the Protobuf CloudEvent - pub fn build(&self) -> PbCloudEvent { - let mut cloud_event = PbCloudEvent::default(); - cloud_event.attributes.insert( - ProtocolKey::ENV.to_string(), - Self::build_cloud_event_attr(&self.env), - ); - cloud_event.attributes.insert( - ProtocolKey::IDC.to_string(), - Self::build_cloud_event_attr(&self.idc), - ); - cloud_event.attributes.insert( - ProtocolKey::IP.to_string(), - Self::build_cloud_event_attr(&self.ip), - ); - if let Some(ref pid) = self.pid { - cloud_event.attributes.insert( - ProtocolKey::PID.to_string(), - Self::build_cloud_event_attr(pid), - ); - } - cloud_event.attributes.insert( - ProtocolKey::SYS.to_string(), - Self::build_cloud_event_attr(&self.sys), - ); - cloud_event.attributes.insert( - ProtocolKey::LANGUAGE.to_string(), - Self::build_cloud_event_attr(&self.language), - ); - cloud_event.attributes.insert( - ProtocolKey::USERNAME.to_string(), - Self::build_cloud_event_attr(&self.user_name), - ); - cloud_event.attributes.insert( - ProtocolKey::PASSWD.to_string(), - Self::build_cloud_event_attr(&self.password), - ); - cloud_event.attributes.insert( - ProtocolKey::PROTOCOL_TYPE.to_string(), - Self::build_cloud_event_attr(&self.protocol_type), - ); - cloud_event.attributes.insert( - ProtocolKey::PROTOCOL_VERSION.to_string(), - Self::build_cloud_event_attr(&self.protocol_version), - ); - cloud_event.attributes.insert( - ProtocolKey::TTL.to_string(), - Self::build_cloud_event_attr(&self.ttl), - ); - cloud_event.attributes.insert( - ProtocolKey::SUBJECT.to_string(), - Self::build_cloud_event_attr(&self.subject), - ); - cloud_event.attributes.insert( - ProtocolKey::PRODUCERGROUP.to_string(), - Self::build_cloud_event_attr(&self.producergroup), - ); - cloud_event.attributes.insert( - ProtocolKey::UNIQUE_ID.to_string(), - Self::build_cloud_event_attr(&self.uniqueid), - ); - cloud_event.attributes.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - Self::build_cloud_event_attr(&self.data_content_type), - ); - cloud_event - } - - /// Helper method to build a Protobuf CloudEvent attribute value. - fn build_cloud_event_attr(value: impl Into) -> CloudEventAttributeValue { - let mut attr_value = PbCloudEventAttributeValue::default(); - attr_value.attr = Some(PbAttr::CeString(value.into())); - attr_value - } - } +/// HTTP client API. +#[cfg(feature = "http")] +pub mod http; + +/// TCP client API. +#[cfg(feature = "tcp")] +pub mod tcp; + +pub use error::{Error, Result}; +pub use handler::MessageHandler; +pub use message::{EventMeshMessage, Message, MessageKind, OpenMessage, PublishReceipt}; +pub use subscription::{DeliveryMode, DeliveryType, Subscription}; + +#[cfg(feature = "grpc")] +pub use grpc::GrpcClient; + +#[cfg(feature = "http")] +pub use http::HttpClient; + +#[cfg(feature = "tcp")] +pub use tcp::TcpClient; + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +use std::future::Future; + +/// Convenience trait alias for an async listener of delivered messages. +/// +/// A listener returns `Some(message)` to send a reply back to the broker +/// (request-reply semantics), `None` for plain async consumption, or an error +/// to tell the adapter that the delivery was not handled successfully. +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +pub(crate) trait MessageListener: Send + Sync + 'static { + /// The message type this listener accepts. + type Message: Send; + + /// Handle a delivered message. Return `Some` to reply, `None` to ack only. + fn handle( + &self, + message: Self::Message, + ) -> impl Future>> + Send; } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/log.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/log.rs deleted file mode 100644 index 87f39217bc..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/log.rs +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -pub fn init_logger() { - tracing_subscriber::fmt() - .with_thread_names(true) - .with_level(true) - .with_line_number(true) - .with_thread_ids(true) - .with_max_level(tracing::Level::INFO) - .init(); -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/message.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/message.rs new file mode 100644 index 0000000000..18ee2aea3f --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/message.rs @@ -0,0 +1,174 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Public EventMesh message models. +//! +//! [`Message`] is the protocol-independent envelope accepted by every v2 +//! producer and delivered to stream consumers. It deliberately preserves the +//! three message dialects EventMesh supports instead of flattening them into a +//! lossy set of string fields. Transport-specific wire encoding remains an +//! implementation detail. + +#[cfg(feature = "cloud_events")] +use crate::error::EventMeshError; +use crate::error::Result; + +pub use crate::model::{EventMeshMessage, OpenMessage}; + +/// Which public event dialect a [`Message`] contains. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MessageKind { + /// EventMesh's native message model. + EventMesh, + /// The OpenMessaging-compatible message model. + Open, + /// A CNCF CloudEvent. + #[cfg(feature = "cloud_events")] + CloudEvent, +} + +/// A public EventMesh event. +/// +/// This enum is intentionally not `serde::Serialize`: serializing an enum +/// would produce an SDK-specific tagged representation, which is not any of +/// the EventMesh protocol wire formats. The selected transport performs the +/// corresponding protobuf, form, or TCP-frame encoding internally. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + /// EventMesh's native envelope. + EventMesh(EventMeshMessage), + /// An OpenMessaging-compatible envelope. + Open(OpenMessage), + /// A native CloudEvent. + #[cfg(feature = "cloud_events")] + CloudEvent(cloudevents::Event), +} + +/// Confirmation returned by a successful EventMesh publish operation. +/// +/// A broker-side rejection is returned as [`crate::Error::Server`], so a +/// receipt always represents an accepted operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublishReceipt { + /// The server's acknowledgement code (normally zero). + pub code: i64, + /// Optional acknowledgement text. + pub message: Option, + /// Optional server processing time in milliseconds. + pub server_time_millis: Option, +} + +impl PublishReceipt { + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn from_legacy(response: crate::model::PublishResponse) -> Self { + Self { + code: response.code.unwrap_or(0), + message: response.message, + server_time_millis: response.time, + } + } +} + +impl Message { + /// Return the dialect stored in this message. + pub const fn kind(&self) -> MessageKind { + match self { + Self::EventMesh(_) => MessageKind::EventMesh, + Self::Open(_) => MessageKind::Open, + #[cfg(feature = "cloud_events")] + Self::CloudEvent(_) => MessageKind::CloudEvent, + } + } + + /// Borrow the native EventMesh message, if this is that dialect. + pub fn as_event_mesh(&self) -> Option<&EventMeshMessage> { + match self { + Self::EventMesh(message) => Some(message), + _ => None, + } + } + + /// Borrow the OpenMessaging message, if this is that dialect. + pub fn as_open(&self) -> Option<&OpenMessage> { + match self { + Self::Open(message) => Some(message), + _ => None, + } + } + + /// Convert this message to the EventMesh native model. + /// + /// OpenMessaging conversion is lossless. CloudEvents are not silently + /// collapsed here: callers must select a transport that supports the + /// CloudEvents variant directly. + pub fn into_event_mesh(self) -> Result { + match self { + Self::EventMesh(message) => Ok(message), + Self::Open(message) => Ok(message.to_event_mesh_message()), + #[cfg(feature = "cloud_events")] + Self::CloudEvent(_) => Err(EventMeshError::Unsupported( + "converting CloudEvent to EventMeshMessage loses CloudEvents semantics".into(), + )), + } + } + + /// Convert this message to the OpenMessaging model. + pub fn into_open(self) -> Result { + match self { + Self::EventMesh(message) => Ok(OpenMessage::from_event_mesh_message(message)), + Self::Open(message) => Ok(message), + #[cfg(feature = "cloud_events")] + Self::CloudEvent(_) => Err(EventMeshError::Unsupported( + "converting CloudEvent to OpenMessage loses CloudEvents semantics".into(), + )), + } + } +} + +impl From for Message { + fn from(message: EventMeshMessage) -> Self { + Self::EventMesh(message) + } +} + +impl From for Message { + fn from(message: OpenMessage) -> Self { + Self::Open(message) + } +} + +#[cfg(feature = "cloud_events")] +impl From for Message { + fn from(event: cloudevents::Event) -> Self { + Self::CloudEvent(event) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_message_round_trips_through_native_conversion() { + let open = OpenMessage::builder() + .topic("orders") + .body("created") + .build(); + let native = Message::from(open.clone()).into_event_mesh().unwrap(); + assert_eq!(Message::from(native).into_open().unwrap(), open); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model.rs deleted file mode 100644 index 299c11147a..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model.rs +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::fmt::{Display, Formatter}; - -#[derive(Debug)] -pub enum EventMeshProtocolType { - CloudEvents, - EventMeshMessage, - OpenMessage, -} - -impl Display for EventMeshProtocolType { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - EventMeshProtocolType::CloudEvents => { - writeln!(f, "cloudevents") - } - EventMeshProtocolType::EventMeshMessage => { - writeln!(f, "eventmeshmessage") - } - EventMeshProtocolType::OpenMessage => { - writeln!(f, "openmessage") - } - } - } -} - -impl EventMeshProtocolType { - pub fn protocol_type_name(&self) -> &'static str { - match self { - EventMeshProtocolType::CloudEvents => "cloudevents", - EventMeshProtocolType::EventMeshMessage => "eventmeshmessage", - EventMeshProtocolType::OpenMessage => "openmessage", - } - } -} - -pub mod message; - -pub(crate) mod convert; -pub mod event_clouds; -pub(crate) mod response; -pub mod subscription; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/convert.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/convert.rs deleted file mode 100644 index 8a4901f6a4..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/convert.rs +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use crate::proto_cloud_event::PbCloudEvent; - -/// Trait for converting from Protobuf CloudEvent to a type `T`. -pub trait FromPbCloudEvent { - /// Convert Protobuf CloudEvent to type `T`. - /// - /// # Arguments - /// - /// * `event` - The Protobuf CloudEvent to convert from. - /// - /// # Returns - /// - /// Optional converted value of type `T`. - fn from_pb_cloud_event(event: &PbCloudEvent) -> Option; -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/event_clouds.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/event_clouds.rs deleted file mode 100644 index 324ea00a94..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/event_clouds.rs +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - use crate::common::grpc_eventmesh_message_utils::EventMeshCloudEventUtils; - use cloudevents::Event; - - use crate::proto_cloud_event::PbCloudEvent; - - impl From for Event { - fn from(value: PbCloudEvent) -> Self { - EventMeshCloudEventUtils::switch_event_mesh_cloud_event_2_cloud_event(value) - } - } - \ No newline at end of file diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/message.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/message.rs index a3c42e6f06..ffc4b9a1fa 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/message.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/message.rs @@ -1,70 +1,60 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#![cfg(feature = "eventmesh_message")] - -#[allow(unused_imports)] -use cloudevents::Event; -use serde::{Deserialize, Serialize}; +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! The core user-facing message type. + use std::collections::HashMap; use std::fmt; -use std::time::{SystemTime, UNIX_EPOCH}; -use crate::common::grpc_eventmesh_message_utils::EventMeshCloudEventUtils; -use crate::model::convert::FromPbCloudEvent; -use crate::proto_cloud_event::PbCloudEvent; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize, Serialize, Clone)] +use crate::common::util::now_millis; + +/// A simple, idiomatic EventMesh message: a topic + string content + arbitrary +/// string properties. +/// +/// This maps directly to `org.apache.eventmesh.common.EventMeshMessage` on the +/// Java side. It is the primary message type of the SDK; CloudEvents interop is +/// available behind the `cloud_events` feature (see the conversion impls in +/// `transport::grpc::codec`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EventMeshMessage { - #[serde(rename = "bizSeqNo")] - pub(crate) biz_seq_no: Option, - #[serde(rename = "uniqueId")] - pub(crate) unique_id: Option, - pub(crate) topic: Option, - pub(crate) content: Option, - pub(crate) prop: HashMap, - #[serde(rename = "createTime")] - pub(crate) create_time: u64, + /// Optional business sequence number (correlates request/reply). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub biz_seq_no: Option, + /// Optional application-level unique id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unique_id: Option, + /// The destination topic. Required for publish. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic: Option, + /// The message payload as a string. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option, + /// Free-form string properties (become CloudEvent attributes on the wire). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub props: HashMap, + /// Creation time, epoch milliseconds. + pub create_time: u64, + /// Optional TTL in milliseconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, } -impl fmt::Display for EventMeshMessage { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "EventMeshMessage {{")?; - if let Some(biz_seq_no) = &self.biz_seq_no { - write!(f, " biz_seq_no: {},", biz_seq_no)?; - } - if let Some(unique_id) = &self.unique_id { - write!(f, " unique_id: {},", unique_id)?; - } - if let Some(topic) = &self.topic { - write!(f, " topic: {},", topic)?; - } - if let Some(content) = &self.content { - write!(f, " content: {},", content)?; - } - write!(f, " prop: {{")?; - for (key, value) in &self.prop { - write!(f, " {}: {},", key, value)?; - } - write!(f, " }},")?; - write!(f, " create_time: {},", self.create_time)?; - write!(f, " }}") - } -} impl Default for EventMeshMessage { fn default() -> Self { Self { @@ -72,90 +62,112 @@ impl Default for EventMeshMessage { unique_id: None, topic: None, content: None, - prop: HashMap::with_capacity(0), - create_time: SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or_else(|_err| 0u64, |time| time.as_millis() as u64), + props: HashMap::new(), + create_time: now_millis(), + ttl: None, } } } -#[allow(dead_code)] +impl fmt::Display for EventMeshMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EventMeshMessage") + .field("topic", &self.topic) + .field("biz_seq_no", &self.biz_seq_no) + .field("unique_id", &self.unique_id) + .field("content_len", &self.content.as_ref().map(|c| c.len())) + .field("props", &self.props) + .field("create_time", &self.create_time) + .finish() + } +} + impl EventMeshMessage { - pub fn new( - biz_seq_no: impl Into, - unique_id: impl Into, - topic: impl Into, - content: impl Into, - prop: HashMap, - create_time: u64, - ) -> Self { + /// Construct a native EventMesh message with a required topic and text + /// payload. Validation still occurs at the sending boundary because a + /// caller may subsequently mutate this compatibility model internally. + pub fn new(topic: impl Into, content: impl Into) -> Self { Self { - biz_seq_no: Some(biz_seq_no.into()), - unique_id: Some(unique_id.into()), topic: Some(topic.into()), content: Some(content.into()), - prop, - create_time, + ..Self::default() } } - pub fn add_prop(mut self, key: String, val: String) -> Self { - self.prop.insert(key, val); + /// Start a builder. Equivalent to [`EventMeshMessageBuilder::default`]. + pub(crate) fn builder() -> EventMeshMessageBuilder { + EventMeshMessageBuilder::default() + } + + /// Insert/overwrite a property. + pub fn set_prop(&mut self, key: impl Into, value: impl Into) -> &mut Self { + self.props.insert(key.into(), value.into()); self } - pub fn get_prop(&self, key: &str) -> Option<&String> { - self.prop.get(key) + /// Get a property by key. + pub fn get_prop(&self, key: &str) -> Option<&str> { + self.props.get(key).map(|s| s.as_str()) } - pub fn remove_prop_if_present(mut self, key: &str) -> Self { - self.prop.remove(key); + /// Return a copy with an additional extension property. + pub fn with_property(mut self, key: impl Into, value: impl Into) -> Self { + self.props.insert(key.into(), value.into()); self } +} - pub fn with_biz_seq_no(mut self, biz_seq_no: impl Into) -> Self { - self.biz_seq_no = Some(biz_seq_no.into()); +/// Fluent builder for [`EventMeshMessage`]. +#[derive(Debug, Clone, Default)] +pub struct EventMeshMessageBuilder { + biz_seq_no: Option, + unique_id: Option, + topic: Option, + content: Option, + props: HashMap, + ttl: Option, +} + +impl EventMeshMessageBuilder { + pub fn biz_seq_no(mut self, v: impl Into) -> Self { + self.biz_seq_no = Some(v.into()); self } - - pub fn with_unique_id(mut self, unique_id: impl Into) -> Self { - self.unique_id = Some(unique_id.into()); + pub fn unique_id(mut self, v: impl Into) -> Self { + self.unique_id = Some(v.into()); self } - - pub fn with_topic(mut self, topic: impl Into) -> Self { - self.topic = Some(topic.into()); + pub fn topic(mut self, v: impl Into) -> Self { + self.topic = Some(v.into()); self } - - pub fn with_content(mut self, content: impl Into) -> Self { - self.content = Some(content.into()); + pub fn content(mut self, v: impl Into) -> Self { + self.content = Some(v.into()); self } - - pub fn with_create_time(mut self, create_time: u64) -> Self { - self.create_time = create_time; + pub fn ttl_millis(mut self, v: i64) -> Self { + self.ttl = Some(v); self } -} - -impl FromPbCloudEvent for EventMeshMessage { - fn from_pb_cloud_event(event: &PbCloudEvent) -> Option { - Some(EventMeshCloudEventUtils::switch_event_mesh_cloud_event_2_event_mesh_message(event)) + pub fn prop(mut self, key: impl Into, value: impl Into) -> Self { + self.props.insert(key.into(), value.into()); + self } -} - -impl From for EventMeshMessage { - fn from(value: PbCloudEvent) -> Self { - EventMeshCloudEventUtils::switch_event_mesh_cloud_event_2_event_mesh_message(&value) + pub fn props(mut self, props: HashMap) -> Self { + self.props = props; + self } -} -#[cfg(feature = "cloud_events")] -impl From for EventMeshMessage { - fn from(value: Event) -> Self { - EventMeshCloudEventUtils::switch_cloud_event_2_event_mesh_message(value) + pub fn build(self) -> EventMeshMessage { + EventMeshMessage { + biz_seq_no: self.biz_seq_no, + unique_id: self.unique_id, + topic: self.topic, + content: self.content, + props: self.props, + create_time: now_millis(), + ttl: self.ttl, + } } } @@ -164,43 +176,27 @@ mod tests { use super::*; #[test] - fn test_default() { - let default_msg = EventMeshMessage::default(); - - assert_eq!(default_msg.biz_seq_no, None); - assert_eq!(default_msg.unique_id, None); - assert_eq!(default_msg.topic, None); - assert_eq!(default_msg.content, None); - assert!(default_msg.prop.is_empty()); + fn builder_round_trip() { + let m = EventMeshMessage::builder() + .topic("t") + .content("c") + .biz_seq_no("b") + .unique_id("u") + .prop("k", "v") + .ttl_millis(1000) + .build(); + assert_eq!(m.topic.as_deref(), Some("t")); + assert_eq!(m.content.as_deref(), Some("c")); + assert_eq!(m.get_prop("k"), Some("v")); + assert_eq!(m.ttl, Some(1000)); + assert!(m.create_time > 0); } #[test] - fn test_new() { - let msg = EventMeshMessage::new( - "biz_seq_123", - "unique_456", - "test_topic", - "message_content", - HashMap::new(), - 1234567890, - ); - - assert_eq!(msg.biz_seq_no, Some("biz_seq_123".to_string())); - assert_eq!(msg.unique_id, Some("unique_456".to_string())); - assert_eq!(msg.topic, Some("test_topic".to_string())); - assert_eq!(msg.content, Some("message_content".to_string())); - assert!(msg.prop.is_empty()); - assert_eq!(msg.create_time, 1234567890); - } - - #[test] - fn test_add_prop() { - let mut msg = EventMeshMessage::default(); - - msg = msg.add_prop("key1".to_string(), "value1".to_string()); - msg = msg.add_prop("key2".to_string(), "value2".to_string()); - - assert_eq!(msg.get_prop("key1"), Some(&"value1".to_string())); - assert_eq!(msg.get_prop("key2"), Some(&"value2".to_string())); + fn serde_round_trip() { + let m = EventMeshMessage::builder().topic("t").content("c").build(); + let json = serde_json::to_string(&m).unwrap(); + let back: EventMeshMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back); } } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/mod.rs new file mode 100644 index 0000000000..6e8a3f6e17 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/mod.rs @@ -0,0 +1,49 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Message, subscription and response types. + +pub mod message; +pub mod open_message; +pub mod response; +pub mod subscription; + +pub use message::{EventMeshMessage, EventMeshMessageBuilder}; +pub use open_message::{OpenMessage, OpenMessageBuilder}; +pub use response::PublishResponse; +pub use subscription::{HeartbeatItem, SubscriptionItem, SubscriptionMode, SubscriptionType}; + +/// Wire protocol the SDK advertises to the server (`protocoltype` attribute). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventMeshProtocolType { + /// Native CloudEvents (`io.cloudevents`). + CloudEvents, + /// The SDK's lightweight `EventMeshMessage`. + EventMeshMessage, + /// OpenMessaging. + OpenMessage, +} + +impl EventMeshProtocolType { + pub fn as_str(self) -> &'static str { + match self { + Self::CloudEvents => "cloudevents", + Self::EventMeshMessage => "eventmeshmessage", + Self::OpenMessage => "openmessage", + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/open_message.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/open_message.rs new file mode 100644 index 0000000000..90aaee2684 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/open_message.rs @@ -0,0 +1,194 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Lightweight OpenMessaging-compatible message model. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::EventMeshMessage; + +/// An OpenMessaging-style message. +/// +/// `topic` corresponds to OpenMessaging's destination header. The Rust SDK +/// keeps it explicit so it can be validated and routed by every EventMesh +/// transport without requiring an OpenMessaging provider implementation. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpenMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub topic: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub properties: HashMap, +} + +impl OpenMessage { + /// Construct an OpenMessaging-compatible message. + pub fn new(topic: impl Into, body: impl Into) -> Self { + Self { + topic: Some(topic.into()), + body: Some(body.into()), + headers: HashMap::new(), + properties: HashMap::new(), + } + } + + /// Start building an [`OpenMessage`]. + pub(crate) fn builder() -> OpenMessageBuilder { + OpenMessageBuilder::default() + } + + /// Convert to the SDK's common transport message representation. + pub fn to_event_mesh_message(&self) -> EventMeshMessage { + // Keep properties and headers in separate wire namespaces. OpenMessage + // permits arbitrary property keys, including `header.*`, so storing a + // header directly as `header.{key}` while leaving properties unescaped + // makes the conversion lossy. + let mut props: HashMap = self + .properties + .iter() + .map(|(key, value)| (format!("property.{key}"), value.clone())) + .collect(); + for (key, value) in &self.headers { + props.insert(format!("header.{key}"), value.clone()); + } + let mut builder = EventMeshMessage::builder().props(props); + if let Some(topic) = &self.topic { + builder = builder.topic(topic); + } + if let Some(body) = &self.body { + builder = builder.content(body); + } + builder.build() + } + + /// Reconstruct an OpenMessaging-style message from a common transport message. + pub fn from_event_mesh_message(message: EventMeshMessage) -> Self { + let mut headers = HashMap::new(); + let mut properties = HashMap::new(); + for (key, value) in message.props { + if let Some(key) = key.strip_prefix("header.") { + headers.insert(key.to_string(), value); + } else if let Some(key) = key.strip_prefix("property.") { + properties.insert(key.to_string(), value); + } else { + // Preserve messages produced by SDK versions before the + // property namespace was introduced. + properties.insert(key, value); + } + } + Self { + topic: message.topic, + body: message.content, + headers, + properties, + } + } + + /// Return a copy with an OpenMessaging header. + pub fn with_header(mut self, key: impl Into, value: impl Into) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + /// Return a copy with an OpenMessaging property. + pub fn with_property(mut self, key: impl Into, value: impl Into) -> Self { + self.properties.insert(key.into(), value.into()); + self + } +} + +/// Fluent builder for [`OpenMessage`]. +#[derive(Debug, Clone, Default)] +pub struct OpenMessageBuilder { + topic: Option, + body: Option, + headers: HashMap, + properties: HashMap, +} + +impl OpenMessageBuilder { + pub fn topic(mut self, value: impl Into) -> Self { + self.topic = Some(value.into()); + self + } + + pub fn body(mut self, value: impl Into) -> Self { + self.body = Some(value.into()); + self + } + + pub fn header(mut self, key: impl Into, value: impl Into) -> Self { + self.headers.insert(key.into(), value.into()); + self + } + + pub fn property(mut self, key: impl Into, value: impl Into) -> Self { + self.properties.insert(key.into(), value.into()); + self + } + + pub fn build(self) -> OpenMessage { + OpenMessage { + topic: self.topic, + body: self.body, + headers: self.headers, + properties: self.properties, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conversion_preserves_body_and_metadata() { + let message = OpenMessage::builder() + .topic("orders") + .body("created") + .header("traceparent", "00-abc") + .property("region", "cn") + .build(); + let common = message.to_event_mesh_message(); + assert_eq!(common.topic.as_deref(), Some("orders")); + assert_eq!(common.content.as_deref(), Some("created")); + assert_eq!(common.get_prop("header.traceparent"), Some("00-abc")); + assert_eq!(common.get_prop("property.region"), Some("cn")); + assert_eq!(OpenMessage::from_event_mesh_message(common), message); + } + + #[test] + fn conversion_preserves_colliding_header_prefixed_property() { + let message = OpenMessage::builder() + .header("traceparent", "header-value") + .property("header.traceparent", "property-value") + .build(); + + let common = message.to_event_mesh_message(); + assert_eq!(common.get_prop("header.traceparent"), Some("header-value")); + assert_eq!( + common.get_prop("property.header.traceparent"), + Some("property-value") + ); + assert_eq!(OpenMessage::from_event_mesh_message(common), message); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs index 6e269623cb..f732b080da 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs @@ -1,63 +1,80 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::fmt::{Display, Formatter}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize, Default)] -pub struct EventMeshResponse { - #[serde(rename = "respCode")] - resp_code: Option, - - #[serde(rename = "respMsg")] - resp_msg: Option, - - #[serde(rename = "respTime")] - resp_time: Option, +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Server response type. + +use serde::{Deserialize, Serialize}; + +/// The response returned by the broker for fire-and-forget publish / batch +/// publish / subscribe / unsubscribe / heartbeat operations. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PublishResponse { + /// Numeric response code (`status_code` attribute). `0` means success. + #[serde(default, rename = "respCode")] + pub code: Option, + /// Human-readable response message. + #[serde(default, rename = "respMsg")] + pub message: Option, + /// Server-side processing time, milliseconds. + #[serde(default, rename = "respTime")] + pub time: Option, } -impl EventMeshResponse { - pub fn new( - resp_code: Option, - resp_msg: Option, - resp_time: Option, - ) -> Self { +impl PublishResponse { + pub fn new(code: Option, message: Option, time: Option) -> Self { Self { - resp_code, - resp_msg, - resp_time, + code, + message, + time, } } + + /// Whether the server explicitly reported success (`code == Some(0)`). + /// + /// A missing or unparseable code is treated as **failure**, not success, + /// so that garbled / incomplete responses can never masquerade as `Ok`. + pub fn is_success(&self) -> bool { + self.code == Some(0) + } } -impl Display for EventMeshResponse { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "EventMeshResponse[")?; - if let Some(ref code) = self.resp_code { - write!(f, "code={code},")?; - } - if let Some(ref msg) = self.resp_msg { - write!(f, "message={msg},")?; - } - if let Some(time) = self.resp_time { - write!(f, "response time={time},")?; - } - write!(f, "]")?; - Ok(()) +impl std::fmt::Display for PublishResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "PublishResponse(code={:?}, msg={:?}, time={:?})", + self.code, self.message, self.time + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_only_when_explicit_zero() { + assert!(PublishResponse::new(Some(0), None, None).is_success()); + assert!(!PublishResponse::new(Some(1), None, None).is_success()); + } + + #[test] + fn missing_code_is_not_success() { + assert!(!PublishResponse::new(None, None, None).is_success()); + assert!(!PublishResponse::default().is_success()); } } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs index a31cd3b90d..26adb397bb 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs @@ -1,277 +1,159 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::collections::HashMap; +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Subscription model: topics, modes, types, heartbeat items and reply. + use std::fmt; -use std::fmt::{Display, Formatter}; use std::str::FromStr; use serde::{Deserialize, Serialize}; -use crate::error::EventMeshError; +use crate::error::{EventMeshError, Result}; -#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Hash, Clone)] +/// One topic the consumer wants delivered, with its delivery mode/type. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct SubscriptionItem { pub topic: String, pub mode: SubscriptionMode, #[serde(rename = "type")] - pub type_: SubscriptionType, + pub r#type: SubscriptionType, } impl SubscriptionItem { - pub fn new(topic: impl Into, mode: SubscriptionMode, type_: SubscriptionType) -> Self { - SubscriptionItem { + pub fn new(topic: impl Into, mode: SubscriptionMode, r#type: SubscriptionType) -> Self { + Self { topic: topic.into(), mode, - type_, + r#type, } } } impl fmt::Display for SubscriptionItem { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "SubscriptionItem {{ topic: {}, mode: {}, type: {} }}", - self.topic, self.mode, self.type_ + "SubscriptionItem(topic={}, mode={}, type={})", + self.topic, self.mode, self.r#type ) } } -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Hash)] +/// Delivery distribution: cluster (competing consumers) vs broadcast (all). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[allow(clippy::upper_case_acronyms)] // Mirrors EventMesh's wire enum names. pub enum SubscriptionMode { BROADCASTING, CLUSTERING, - UNRECOGNIZED, } -impl Display for SubscriptionMode { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - writeln!(f, "{}", self.to_string()) +impl fmt::Display for SubscriptionMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) } } impl SubscriptionMode { - pub fn to_string(&self) -> &'static str { + pub fn as_str(self) -> &'static str { match self { - SubscriptionMode::BROADCASTING => "BROADCASTING", - SubscriptionMode::CLUSTERING => "CLUSTERING", - SubscriptionMode::UNRECOGNIZED => "UNRECOGNIZED", - } - } - - fn from_str_inner(input: &str) -> Result { - match input { - "BROADCASTING" => Ok(SubscriptionMode::BROADCASTING), - "CLUSTERING" => Ok(SubscriptionMode::CLUSTERING), - "UNRECOGNIZED" => Ok(SubscriptionMode::UNRECOGNIZED), - _ => Err(EventMeshError::EventMeshFromStrError(format!( - "{} can not parse to SubscriptionMode", - input - ))), + Self::BROADCASTING => "BROADCASTING", + Self::CLUSTERING => "CLUSTERING", } } } impl FromStr for SubscriptionMode { type Err = EventMeshError; - - fn from_str(s: &str) -> Result { - Self::from_str_inner(s) - } -} - -impl TryFrom for SubscriptionMode { - type Error = EventMeshError; - - fn try_from(value: String) -> Result { - Self::from_str_inner(value.as_str()) - } -} - -impl TryFrom<&'static str> for SubscriptionMode { - type Error = EventMeshError; - - fn try_from(value: &'static str) -> Result { - Self::from_str_inner(value) + fn from_str(s: &str) -> Result { + match s { + "BROADCASTING" => Ok(Self::BROADCASTING), + "CLUSTERING" => Ok(Self::CLUSTERING), + other => Err(EventMeshError::InvalidArgument(format!( + "unknown SubscriptionMode: {other}" + ))), + } } } -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Hash)] +/// Delivery style. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[allow(clippy::upper_case_acronyms)] // Mirrors EventMesh's wire enum names. pub enum SubscriptionType { - SYNC, + /// Asynchronous push. ASYNC, - UNRECOGNIZED, + /// Synchronous request/reply. + SYNC, } -impl Display for SubscriptionType { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - writeln!(f, "{}", self.to_string()) +impl fmt::Display for SubscriptionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) } } impl SubscriptionType { - pub fn to_string(&self) -> &'static str { + pub fn as_str(self) -> &'static str { match self { - SubscriptionType::SYNC => "SYNC", - SubscriptionType::ASYNC => "ASYNC", - SubscriptionType::UNRECOGNIZED => "UNRECOGNIZED", - } - } - - fn from_str_inner(s: &str) -> Result { - match s { - "SYNC" => Ok(SubscriptionType::SYNC), - "ASYNC" => Ok(SubscriptionType::ASYNC), - "UNRECOGNIZED" => Ok(SubscriptionType::UNRECOGNIZED), - _ => Err(EventMeshError::EventMeshFromStrError(format!( - "{} can not parse to SubscriptionMode", - s - ))), + Self::ASYNC => "ASYNC", + Self::SYNC => "SYNC", } } } impl FromStr for SubscriptionType { type Err = EventMeshError; - - fn from_str(s: &str) -> Result { - Self::from_str_inner(s) - } -} - -impl TryFrom for SubscriptionType { - type Error = EventMeshError; - - fn try_from(value: String) -> Result { - Self::from_str_inner(value.as_str()) - } -} - -impl TryFrom<&'static str> for SubscriptionType { - type Error = EventMeshError; - - fn try_from(value: &'static str) -> Result { - Self::from_str_inner(value) - } -} - -#[derive(Debug)] -pub(crate) struct SubscriptionItemWrapper { - pub(crate) subscription_item: SubscriptionItem, - pub(crate) url: String, -} - -impl Display for SubscriptionItemWrapper { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - writeln!( - f, - "SubscriptionItem={},url={}", - self.subscription_item, self.url - ) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SubscriptionReply { - #[serde(rename = "producerGroup")] - pub(crate) producer_group: String, - pub(crate) topic: String, - pub(crate) content: String, - pub(crate) ttl: String, - #[serde(rename = "uniqueId")] - pub(crate) unique_id: String, - #[serde(rename = "seqNum")] - pub(crate) seq_num: String, - pub(crate) tag: Option, - pub(crate) properties: HashMap, -} - -impl SubscriptionReply { - pub const SUB_TYPE: &'static str = "subscription_reply"; - - pub fn new( - producer_group: String, - topic: String, - content: String, - ttl: String, - unique_id: String, - seq_num: String, - tag: Option, - properties: HashMap, - ) -> Self { - Self { - producer_group, - topic, - content, - ttl, - unique_id, - seq_num, - tag, - properties, + fn from_str(s: &str) -> Result { + match s { + "ASYNC" => Ok(Self::ASYNC), + "SYNC" => Ok(Self::SYNC), + other => Err(EventMeshError::InvalidArgument(format!( + "unknown SubscriptionType: {other}" + ))), } } } -impl ToString for SubscriptionReply { - fn to_string(&self) -> String { - format!( - "SubscriptionReply {{ - producer_group: {:?}, - topic: {:?}, - content: {:?}, - ttl: {:?}, - unique_id: {:?}, - seq_num: {:?}, - tag: {:?}, - properties: {:?} - }}", - self.producer_group, - self.topic, - self.content, - self.ttl, - self.unique_id, - self.seq_num, - self.tag, - self.properties - ) - } -} - +/// One entry of the heartbeat payload (`text_data` JSON array). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HeartbeatItem { - pub(crate) topic: String, - pub(crate) url: String, + pub topic: String, + pub url: String, } impl HeartbeatItem { - pub fn new(topic: String, url: String) -> Self { - Self { topic, url } + pub fn new(topic: impl Into, url: impl Into) -> Self { + Self { + topic: topic.into(), + url: url.into(), + } } } -impl Display for HeartbeatItem { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - writeln!( - f, - "HeartbeatItem {{ - topic: {}, - url: {} - }}", - self.url, self.topic - ) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subscription_item_serializes_type_field() { + let item = + SubscriptionItem::new("t", SubscriptionMode::CLUSTERING, SubscriptionType::ASYNC); + let json = serde_json::to_string(&item).unwrap(); + assert!(json.contains(r#""type":"ASYNC""#)); + let back: SubscriptionItem = serde_json::from_str(&json).unwrap(); + assert_eq!(item, back); } } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/net.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/net.rs deleted file mode 100644 index 1a1b09f320..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/net.rs +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -#![allow(unused_imports)] -pub use crate::net::grpc::grpc_client::GrpcClient; -pub use crate::net::grpc::grpc_client::SubscribeStreamKeeper; -mod grpc; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/net/grpc.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/net/grpc.rs deleted file mode 100644 index 54ddb9a238..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/net/grpc.rs +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -pub(crate) mod grpc_client; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/net/grpc/grpc_client.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/net/grpc/grpc_client.rs deleted file mode 100644 index 8d4a376f0b..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/net/grpc/grpc_client.rs +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::time::Duration; - -use tokio::sync::mpsc; -use tokio::sync::mpsc::Sender; -use tonic::codegen::tokio_stream::wrappers::ReceiverStream; -use tonic::transport::{Channel, Endpoint, Uri}; -use tonic::{Request, Streaming}; - -use crate::common::ProtocolKey; -use crate::config::EventMeshGrpcClientConfig; -use crate::error::EventMeshError; -use crate::error::EventMeshError::EventMeshRemote; -use crate::grpc::pb::cloud_events::cloud_event::cloud_event_attribute_value::Attr; -use crate::grpc::pb::cloud_events::consumer_service_client::ConsumerServiceClient; -use crate::grpc::pb::cloud_events::heartbeat_service_client::HeartbeatServiceClient; -use crate::grpc::pb::cloud_events::publisher_service_client::PublisherServiceClient; -use crate::proto_cloud_event::{PbCloudEvent, PbCloudEventBatch}; - -pub struct SubscribeStreamKeeper { - pub(crate) sender: Sender, -} - -impl SubscribeStreamKeeper { - pub(crate) fn new(sender: Sender) -> Self { - Self { sender } - } -} - -#[derive(Clone)] -pub struct GrpcClient { - publisher_inner: PublisherServiceClient, - consumer_inner: ConsumerServiceClient, - heartbeat_inner: HeartbeatServiceClient, -} - -impl GrpcClient { - pub fn new(grpc_config: &EventMeshGrpcClientConfig) -> crate::Result { - #[cfg(feature = "tls")] - let scheme = { "https" }; - - #[cfg(not(feature = "tls"))] - let scheme = { - if let Some(tls) = grpc_config.use_tls { - if tls { - "https" - } else { - "http" - } - } else { - "http" - } - }; - let url = format!("{}:{}", grpc_config.server_addr, grpc_config.server_port); - let endpoint_uri = Uri::builder() - .scheme(scheme) - .authority(url) - .path_and_query("/") - .build()?; - let endpoint = Endpoint::from(endpoint_uri) - .connect_timeout(Duration::from_millis(10000)) - .keep_alive_while_idle(true) - .tcp_nodelay(true) - .tcp_keepalive(Some(Duration::from_secs(100))); - - let channel = endpoint.connect_lazy(); - let publisher_service_client = PublisherServiceClient::new(channel.clone()); - let consumer_service_client = ConsumerServiceClient::new(channel.clone()); - let heartbeat_inner_client = HeartbeatServiceClient::new(channel); - Ok(Self { - publisher_inner: publisher_service_client, - consumer_inner: consumer_service_client, - heartbeat_inner: heartbeat_inner_client, - }) - } - - pub(crate) async fn publish_inner( - &mut self, - cloud_event: PbCloudEvent, - ) -> crate::Result { - let result = self - .publisher_inner - .publish(cloud_event) - .await - .map_err(|e| EventMeshError::GRpcStatus(e))? - .into_inner(); - Ok(result) - } - - pub(crate) async fn batch_publish_inner( - &mut self, - cloud_events: PbCloudEventBatch, - ) -> crate::Result { - let result = self - .publisher_inner - .batch_publish(cloud_events) - .await - .map_err(|e| EventMeshError::GRpcStatus(e))? - .into_inner(); - Ok(result) - } - - pub(crate) async fn request_reply_inner( - &mut self, - cloud_event: PbCloudEvent, - time_out: u64, - ) -> crate::Result { - let future_task = self.publisher_inner.request_reply(cloud_event); - let result = tokio::time::timeout(Duration::from_millis(time_out), future_task).await; - match result { - Ok(Ok(value)) => { - let event = value.into_inner(); - if let Some(code) = event.attributes.get(ProtocolKey::GRPC_RESPONSE_CODE) { - if let Some(code_num) = &code.attr { - match code_num { - Attr::CeString(cd) if cd != "0" => { - if let Some(msg) = - event.attributes.get(ProtocolKey::GRPC_RESPONSE_MESSAGE) - { - if let Some(msg_inner) = &msg.attr { - match msg_inner { - Attr::CeString(msg) => { - return Err(EventMeshRemote(msg.to_string()).into()); - } - _ => {} - } - } - } - return Err( - EventMeshRemote("EventMesh remote error".to_string()).into() - ); - } - _ => {} - } - } - } - Ok(event) - } - Ok(Err(err)) => Err(EventMeshError::GRpcStatus(err).into()), - Err(_) => Err(EventMeshError::EventMeshLocal("Request reply error".to_string()).into()), - } - } - - pub(crate) async fn subscribe_webhook_inner( - &mut self, - cloud_event: PbCloudEvent, - ) -> crate::Result { - let result = self - .consumer_inner - .subscribe(cloud_event) - .await - .map_err(|e| EventMeshError::GRpcStatus(e))? - .into_inner(); - Ok(result) - } - - pub(crate) async fn subscribe_bi_inner( - &mut self, - cloud_event: PbCloudEvent, - ) -> crate::Result<(SubscribeStreamKeeper, Streaming)> { - let (sender, receiver) = mpsc::channel::(16); - sender.send(cloud_event).await?; - let streaming = self - .consumer_inner - .subscribe_stream(Request::new(ReceiverStream::new(receiver))) - .await - .map_err(|e| EventMeshError::GRpcStatus(e))? - .into_inner(); - Ok((SubscribeStreamKeeper::new(sender), streaming)) - } - - pub(crate) async fn unsubscribe_inner( - &mut self, - cloud_event: PbCloudEvent, - ) -> crate::Result { - let result = self - .consumer_inner - .unsubscribe(cloud_event) - .await - .map_err(|e| EventMeshError::GRpcStatus(e))? - .into_inner(); - Ok(result) - } - - pub(crate) async fn heartbeat_inner( - &mut self, - cloud_event: PbCloudEvent, - ) -> crate::Result { - let result = self - .heartbeat_inner - .heartbeat(Request::new(cloud_event)) - .await - .map_err(|e| EventMeshError::GRpcStatus(e))? - .into_inner(); - Ok(result) - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/proto_gen.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/proto_gen.rs new file mode 100644 index 0000000000..73425a7b14 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/proto_gen.rs @@ -0,0 +1,75 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Generated gRPC stubs plus convenience type aliases and attribute helpers. + +/// The raw generated module tree. +pub mod pb { + tonic::include_proto!("org.apache.eventmesh.cloudevents.v1"); +} + +/// Catalog generated types. Kept crate-private: the public Catalog API is the +/// Java-SDK-compatible operation-to-subscription lifecycle, not a schema +/// registry wrapper. +pub(crate) mod catalog { + tonic::include_proto!("eventmesh.catalog.api.protocol"); +} + +/// Workflow generated types. The public Workflow module re-exports the +/// request/response messages while keeping generated client/server machinery +/// internal. +pub(crate) mod workflow { + tonic::include_proto!("eventmesh.workflow.api.protocol"); +} + +// ---- convenience aliases used throughout the gRPC transport ---- +pub use pb::cloud_event::cloud_event_attribute_value::Attr as PbAttr; +pub use pb::cloud_event::CloudEventAttributeValue as PbCloudEventAttributeValue; +pub use pb::cloud_event::Data as PbData; +pub use pb::consumer_service_client::ConsumerServiceClient; +pub use pb::heartbeat_service_client::HeartbeatServiceClient; +pub use pb::publisher_service_client::PublisherServiceClient; +pub use pb::CloudEvent as PbCloudEvent; +pub use pb::CloudEventBatch as PbCloudEventBatch; + +/// Build a string-valued CloudEvent attribute. +pub fn attr_str(value: impl Into) -> PbCloudEventAttributeValue { + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeString(value.into())), + } +} + +/// Build an int32-valued CloudEvent attribute. +pub fn attr_int(value: i32) -> PbCloudEventAttributeValue { + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeInteger(value)), + } +} + +/// Read an attribute's value as a string. EventMesh only ever uses the +/// string/uri/uri-ref variants for its protocol attributes, but we handle the +/// others defensively (no `unsafe`). +pub fn attr_as_str(value: &PbCloudEventAttributeValue) -> String { + match &value.attr { + Some(PbAttr::CeString(s)) | Some(PbAttr::CeUri(s)) | Some(PbAttr::CeUriRef(s)) => s.clone(), + Some(PbAttr::CeBoolean(b)) => b.to_string(), + Some(PbAttr::CeInteger(i)) => i.to_string(), + Some(PbAttr::CeBytes(b)) => String::from_utf8_lossy(b).into_owned(), + Some(PbAttr::CeTimestamp(ts)) => format!("{}.{}", ts.seconds, ts.nanos), + None => String::new(), + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/service.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/service.rs new file mode 100644 index 0000000000..19f42fa9d9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/service.rs @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Internal helpers shared by discovered gRPC service clients. + +use std::time::Duration; + +use crate::config::{GrpcClientConfig, TlsConfig}; +use crate::discovery::ServiceInstance; +use crate::error::{EventMeshError, Result}; + +pub(crate) fn resolved_grpc_config( + service_name: &str, + instance: ServiceInstance, + timeout: Duration, + use_tls: bool, + tls_config: Option, +) -> Result { + if !instance.healthy { + return Err(EventMeshError::ServiceUnavailable(service_name.into())); + } + if instance.host.trim().is_empty() || instance.port == 0 { + return Err(EventMeshError::ServiceDiscovery(format!( + "service {service_name:?} returned invalid endpoint {:?}:{}", + instance.host, instance.port + ))); + } + + let mut builder = GrpcClientConfig::builder() + .server_addr(instance.host) + .server_port(instance.port) + .timeout(timeout) + .use_tls(use_tls); + if let Some(tls_config) = tls_config { + builder = builder.tls_config(tls_config); + } + Ok(builder.build()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_unhealthy_or_invalid_instances() { + let mut unhealthy = ServiceInstance::new("workflow", 9000); + unhealthy.healthy = false; + assert!(matches!( + resolved_grpc_config("workflow", unhealthy, Duration::from_secs(1), false, None), + Err(EventMeshError::ServiceUnavailable(_)) + )); + + assert!(matches!( + resolved_grpc_config( + "workflow", + ServiceInstance::new("", 0), + Duration::from_secs(1), + false, + None, + ), + Err(EventMeshError::ServiceDiscovery(_)) + )); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/subscription.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/subscription.rs new file mode 100644 index 0000000000..1f0805010d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/subscription.rs @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Subscription declarations shared by all transports. + +/// A requested subscription. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Subscription { + /// The topic to receive. + pub topic: String, + /// How messages are distributed among consumers. + pub delivery_mode: DeliveryMode, + /// Whether delivery is asynchronous or request/reply. + pub delivery_type: DeliveryType, +} + +impl Subscription { + /// Create an asynchronous clustered subscription for `topic`. + pub fn new(topic: impl Into) -> Self { + Self { + topic: topic.into(), + delivery_mode: DeliveryMode::Cluster, + delivery_type: DeliveryType::Async, + } + } + + /// Set the delivery mode. + pub fn with_delivery_mode(mut self, delivery_mode: DeliveryMode) -> Self { + self.delivery_mode = delivery_mode; + self + } + + /// Set the delivery type. + pub fn with_delivery_type(mut self, delivery_type: DeliveryType) -> Self { + self.delivery_type = delivery_type; + self + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn as_legacy(&self) -> crate::model::SubscriptionItem { + crate::model::SubscriptionItem::new( + self.topic.clone(), + self.delivery_mode.as_legacy(), + self.delivery_type.as_legacy(), + ) + } +} + +/// Consumer distribution mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DeliveryMode { + /// Every subscriber receives the event. + Broadcast, + /// One consumer in the group receives the event. + Cluster, +} + +impl DeliveryMode { + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) const fn as_legacy(self) -> crate::model::SubscriptionMode { + match self { + Self::Broadcast => crate::model::SubscriptionMode::BROADCASTING, + Self::Cluster => crate::model::SubscriptionMode::CLUSTERING, + } + } +} + +/// Consumer delivery semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DeliveryType { + /// Acknowledged asynchronous delivery. + Async, + /// Request/reply delivery. + Sync, +} + +impl DeliveryType { + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) const fn as_legacy(self) -> crate::model::SubscriptionType { + match self { + Self::Async => crate::model::SubscriptionType::ASYNC, + Self::Sync => crate::model::SubscriptionType::SYNC, + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/tcp.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/tcp.rs new file mode 100644 index 0000000000..600421f21f --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/tcp.rs @@ -0,0 +1,202 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Native TCP client API. + +use crate::config::{ConsumerOptions, ProducerOptions, TcpConfig}; +use crate::error::Result; +use crate::handler::PublicHandler; +use crate::message::{Message, PublishReceipt}; +use crate::subscription::Subscription; +use crate::transport::tcp::{TcpConsumer as LegacyConsumer, TcpProducer as LegacyProducer}; +use crate::transport::Publisher as LegacyPublisher; +use crate::MessageHandler; + +/// A configured EventMesh TCP client. +#[derive(Clone)] +pub struct TcpClient { + config: TcpConfig, +} + +impl TcpClient { + /// Create a TCP client handle. Connections are opened by role factories. + pub fn new(config: TcpConfig) -> Self { + Self { config } + } + + /// Connect a producer role to the TCP endpoint. + pub async fn producer(&self, options: ProducerOptions) -> Result { + Ok(TcpProducer { + inner: LegacyProducer::connect(self.config.legacy(Some(&options), None)).await?, + timeout: self.config.request_timeout(), + }) + } + + /// Connect a long-lived TCP consumer role. + pub async fn consumer(&self, options: ConsumerOptions, handler: H) -> Result> + where + H: MessageHandler, + { + Ok(TcpConsumer { + inner: LegacyConsumer::connect( + self.config.legacy(None, Some(&options)), + PublicHandler::new(handler), + None::>, + ) + .await?, + }) + } +} + +/// TCP publishing capability. +pub struct TcpProducer { + inner: LegacyProducer, + timeout: std::time::Duration, +} + +/// A long-lived TCP consumer. +pub struct TcpConsumer { + inner: LegacyConsumer>, +} + +impl TcpConsumer { + /// Add a TCP subscription. + pub async fn subscribe(&self, subscription: Subscription) -> Result<()> { + self.inner.subscribe(&[subscription.as_legacy()]).await + } + + /// Remove a TCP subscription. + pub async fn unsubscribe(&self, subscription: Subscription) -> Result<()> { + self.inner + .unsubscribe(vec![subscription.as_legacy()]) + .await + .map(|_| ()) + } + + /// Request consumer shutdown. + pub async fn shutdown(&self) { + self.inner.shutdown().await; + } + + /// Wait for TCP consumer shutdown. + pub async fn join(&self) -> Result<()> { + shutdown_result(self.inner.wait_for_shutdown().await) + } +} + +fn shutdown_result(reason: crate::transport::tcp::ShutdownReason) -> Result<()> { + match reason { + crate::transport::tcp::ShutdownReason::Cancelled => Ok(()), + crate::transport::tcp::ShutdownReason::Redirect(info) => { + Err(crate::error::EventMeshError::Tcp(format!( + "server redirected consumer to {}:{}", + info.ip, info.port + ))) + } + crate::transport::tcp::ShutdownReason::ChannelClosed => Err( + crate::error::EventMeshError::ChannelClosed("TCP consumer connection closed".into()), + ), + crate::transport::tcp::ShutdownReason::Error(message) => { + Err(crate::error::EventMeshError::Tcp(message)) + } + } +} + +impl TcpProducer { + /// Publish one event and wait for EventMesh acknowledgement. + pub async fn publish(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .publish(message) + .await + .map(PublishReceipt::from_legacy), + Message::Open(message) => self + .inner + .publish_open_message(message) + .await + .map(PublishReceipt::from_legacy), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .publish_cloud_event(event) + .await + .map(PublishReceipt::from_legacy), + } + } + + /// Broadcast an event without waiting for a broker acknowledgement. + pub async fn broadcast(&self, message: Message) -> Result<()> { + match message { + Message::EventMesh(message) => self.inner.broadcast(message).await, + Message::Open(message) => self.inner.broadcast_open_message(message).await, + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self.inner.broadcast_cloud_event(event).await, + } + } + + /// Send an event and await its reply. + pub async fn request_reply(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .request_reply(message, self.timeout) + .await + .map(Message::EventMesh), + Message::Open(message) => self + .inner + .request_reply_open_message(message, self.timeout) + .await + .map(Message::Open), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .request_reply_cloud_event(event, self.timeout) + .await + .map(Message::CloudEvent), + } + } + + /// Shut down the TCP connection. + pub async fn shutdown(&self) { + self.inner.shutdown().await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::tcp::frame::RedirectInfo; + use crate::transport::tcp::ShutdownReason; + + #[test] + fn abnormal_consumer_shutdown_is_an_error() { + assert!(shutdown_result(ShutdownReason::ChannelClosed).is_err()); + assert!(shutdown_result(ShutdownReason::Error("driver failed".into())).is_err()); + let error = shutdown_result(ShutdownReason::Redirect(RedirectInfo { + ip: "127.0.0.2".into(), + port: 10_000, + })) + .unwrap_err(); + assert!(error.to_string().contains("127.0.0.2:10000")); + } + + #[test] + fn cancelled_consumer_shutdown_is_clean() { + assert!(shutdown_result(ShutdownReason::Cancelled).is_ok()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/client.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/client.rs new file mode 100644 index 0000000000..f06aa4dccd --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/client.rs @@ -0,0 +1,290 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Low-level gRPC client: one tonic [`Channel`] shared by the three service +//! stubs. + +use std::time::Duration; + +use tonic::codegen::tokio_stream::wrappers::ReceiverStream; +use tonic::transport::{Channel, Endpoint}; +use tonic::{Request, Streaming}; + +use crate::config::GrpcClientConfig; +use crate::error::{EventMeshError, Result}; +use crate::proto_gen::{ + ConsumerServiceClient, HeartbeatServiceClient, PbCloudEvent, PbCloudEventBatch, + PublisherServiceClient, +}; + +/// A connection to the EventMesh gRPC server. +/// +/// Cheaply cloneable (wraps a multiplexed tonic channel). +#[derive(Clone)] +pub struct GrpcClient { + publisher: PublisherServiceClient, + consumer: ConsumerServiceClient, + heartbeat: HeartbeatServiceClient, +} + +impl GrpcClient { + /// Build a lazy channel from a config. Does **not** block on connection. + pub fn new(config: &GrpcClientConfig) -> Result { + Ok(Self::from_channel(Self::channel(config)?)) + } + + /// Build a lazy gRPC channel from a config. + /// + /// This is shared by the Catalog and Workflow service clients after they + /// resolve a logical service name. It deliberately has crate visibility so + /// those clients share the Runtime transport's TLS and socket settings + /// without exposing a generic channel factory as public SDK API. + pub(crate) fn channel(config: &GrpcClientConfig) -> Result { + let scheme = if config.use_tls { "https" } else { "http" }; + let uri = format!("{}://{}", scheme, config.authority()); + let endpoint = Endpoint::from_shared(uri.clone()) + .map_err(|e| EventMeshError::Config(format!("bad endpoint {uri:?}: {e}")))? + .connect_timeout(Duration::from_secs(10)) + // No channel-wide request timeout: it would wrongly cap the + // long-lived subscribe_stream and caller-controlled request_reply + // RPCs. Per-call timeouts are applied by the producer/consumer + // wrappers instead. + .keep_alive_while_idle(true) + .tcp_nodelay(true) + .tcp_keepalive(Some(Duration::from_secs(100))); + + // Apply TLS settings (CA cert, client identity, SNI) when enabled. + // When the `tls` cargo feature is OFF, `use_tls=true` is rejected + // explicitly instead of silently producing an `https://` URI that + // tonic cannot actually encrypt. + #[cfg(not(feature = "tls"))] + if config.use_tls { + return Err(EventMeshError::Config( + "use_tls=true but the 'tls' cargo feature is not enabled. \ + Add the 'tls' (or 'full') feature to your dependency on \ + eventmesh to enable TLS support." + .into(), + )); + } + + #[cfg(feature = "tls")] + let endpoint = if config.use_tls { + Self::apply_tls(endpoint, config)? + } else { + endpoint + }; + + Ok(endpoint.connect_lazy()) + } + + /// Configure the tonic [`Endpoint`] with [`tonic::transport::ClientTlsConfig`] + /// derived from [`GrpcClientConfig`]. + /// + /// When `tls_config` is `None`, the SNI domain is set to `server_addr` and + /// the OS-native trust roots are loaded so TLS verification succeeds against + /// publicly-trusted certificates. When present, the CA certificate, + /// native roots flag, and mTLS client identity are applied. + #[cfg(feature = "tls")] + fn apply_tls(endpoint: Endpoint, config: &GrpcClientConfig) -> Result { + use tonic::transport::{Certificate, ClientTlsConfig, Identity}; + + let tls = config.tls_config.as_ref(); + let domain = tls + .and_then(|t| t.domain.clone()) + .unwrap_or_else(|| config.server_addr.clone()); + + let mut tls_config = ClientTlsConfig::new().domain_name(domain); + + if let Some(tls) = tls { + // CA certificate — inline PEM takes precedence over file path. + match tls.ca_cert_pem_bytes() { + Some(Ok(pem)) => { + tls_config = tls_config.ca_certificate(Certificate::from_pem(pem)); + } + Some(Err(e)) => { + return Err(EventMeshError::Config(format!( + "failed to read CA certificate: {e}" + ))); + } + None => {} + } + + // OS-native trust roots. + if tls.use_native_roots { + tls_config = tls_config.with_native_roots(); + } + + // mTLS client identity. + if let Some(id) = &tls.client_identity { + tls_config = tls_config + .identity(Identity::from_pem(id.cert_pem.clone(), id.key_pem.clone())); + } + } else { + // No explicit TLS config — load the OS-native trust roots so + // the system trust store is used for certificate verification, + // matching the documented default. Without this, tonic has no + // trust anchor and every TLS handshake fails. + tls_config = tls_config.with_native_roots(); + } + + endpoint + .tls_config(tls_config) + .map_err(|e| EventMeshError::Config(format!("TLS config error: {e}"))) + } + + fn from_channel(channel: Channel) -> Self { + Self { + publisher: PublisherServiceClient::new(channel.clone()), + consumer: ConsumerServiceClient::new(channel.clone()), + heartbeat: HeartbeatServiceClient::new(channel), + } + } + + pub async fn publish(&self, event: PbCloudEvent) -> Result { + Ok(self.publisher.clone().publish(event).await?.into_inner()) + } + + pub async fn batch_publish(&self, events: PbCloudEventBatch) -> Result { + Ok(self + .publisher + .clone() + .batch_publish(events) + .await? + .into_inner()) + } + + /// Fire-and-forget publish via the `publishOneWay` RPC. The server returns + /// an empty response (no per-message ack), so callers cannot inspect the + /// broker's status code — this is intentional fire-and-forget semantics. + pub async fn publish_one_way(&self, event: PbCloudEvent) -> Result<()> { + self.publisher.clone().publish_one_way(event).await?; + Ok(()) + } + + pub async fn request_reply(&self, event: PbCloudEvent) -> Result { + Ok(self + .publisher + .clone() + .request_reply(event) + .await? + .into_inner()) + } + + /// Subscribe via webhook (server POSTs events to the URL). Returns the + /// broker's ack CloudEvent. + pub async fn subscribe_webhook(&self, event: PbCloudEvent) -> Result { + Ok(self.consumer.clone().subscribe(event).await?.into_inner()) + } + + /// Open a bidirectional stream subscription. The first message on the + /// request stream should be the subscription CloudEvent. + /// + /// The stream-open future is wrapped in a timeout to surface a helpful + /// diagnostic when the caller is running on a **current-thread** tokio + /// runtime. On a current-thread runtime tonic's background connection + /// tasks cannot make progress while the caller awaits the server's + /// response headers, producing an indefinite hang; the timeout converts + /// that hang into a clear error message instead. + pub async fn subscribe_stream( + &self, + first: PbCloudEvent, + ) -> Result<( + tokio::sync::mpsc::Sender, + Streaming, + )> { + let (tx, rx) = tokio::sync::mpsc::channel::(32); + tx.send(first) + .await + .map_err(|e| EventMeshError::ChannelClosed(format!("stream open send: {e}")))?; + let mut stream_client = self.consumer.clone(); + + // The stream-open `.await` resolves once the server sends response + // headers. On a single-threaded runtime this never completes because + // tonic's internal connection driver task is starved. Wrap it in a + // timeout so the caller gets an actionable error instead of a hang. + const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(15); + let response = tokio::time::timeout( + STREAM_OPEN_TIMEOUT, + stream_client.subscribe_stream(Request::new(ReceiverStream::new(rx))), + ) + .await + .map_err(|_| EventMeshError::Protocol { + transport: "grpc", + message: format!( + "subscribe_stream did not receive server headers within \ + {STREAM_OPEN_TIMEOUT:?}. This is almost always caused by a \ + current-thread tokio runtime (the default for \ + #[tokio::test]); tonic's background connection tasks \ + cannot progress. Fix: use #[tokio::test(flavor = \ + \"multi_thread\")] or \ + tokio::runtime::Builder::new_multi_thread()" + ), + })??; + Ok((tx, response.into_inner())) + } + + pub async fn unsubscribe(&self, event: PbCloudEvent) -> Result { + Ok(self.consumer.clone().unsubscribe(event).await?.into_inner()) + } + + pub async fn heartbeat(&self, event: PbCloudEvent) -> Result { + Ok(self + .heartbeat + .clone() + .heartbeat(Request::new(event)) + .await? + .into_inner()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::GrpcClientConfig; + + /// When the `tls` cargo feature is OFF, `use_tls=true` must return an + /// explicit error instead of silently creating an `https://` endpoint + /// that tonic cannot encrypt. + #[cfg(not(feature = "tls"))] + #[test] + fn use_tls_without_feature_returns_error() { + let config = GrpcClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10205) + .use_tls(true) + .build(); + match GrpcClient::new(&config) { + Err(e) => assert!( + e.to_string().contains("tls"), + "error should mention tls: {e}" + ), + Ok(_) => panic!("expected error when use_tls=true without tls feature"), + } + } + + /// Smoke test: `use_tls=false` (or default) always succeeds regardless + /// of the `tls` feature — the endpoint is plain HTTP. + #[tokio::test] + async fn plain_http_always_builds() { + let config = GrpcClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10205) + .build(); + // connect_lazy does not actually connect, so this should never fail. + let _ = GrpcClient::new(&config).unwrap(); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/codec.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/codec.rs new file mode 100644 index 0000000000..b2bd876035 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/codec.rs @@ -0,0 +1,781 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Conversions between user-facing message types and the CloudEvents protobuf +//! wire format. +//! +//! All helpers here are free functions (mirroring the style of +//! [`crate::transport::http::codec`]). Encoding goes user message → +//! `PbCloudEvent`; decoding goes `PbCloudEvent` → user type. + +use std::collections::HashMap; + +use prost::Message as _; +use prost_types::Any as PbAny; + +use crate::common::constants::{DataContentType, DEFAULT_MESSAGE_TTL, SDK_STREAM_URL}; +use crate::common::{ProtocolKey, RandomStringUtils}; +use crate::config::GrpcClientConfig; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, EventMeshProtocolType, PublishResponse, SubscriptionItem}; +use crate::proto_gen::{ + attr_as_str, attr_int, attr_str, PbAttr, PbCloudEvent, PbCloudEventAttributeValue, + PbCloudEventBatch, PbData, +}; + +/// The CloudEvent `type` for EventMesh-internal events. +const CLOUD_EVENT_TYPE: &str = "org.apache.eventmesh"; +/// Default CloudEvent `source` (URI-reference `/`). +const DEFAULT_SOURCE: &str = "/"; + +/// Does this content-type imply text data on the wire? +pub fn is_text_content(content_type: &str) -> bool { + content_type.starts_with("text/") + || content_type == DataContentType::JSON + || content_type == DataContentType::XML + || content_type.ends_with("+json") + || content_type.ends_with("+xml") +} + +/// Does this content-type imply protobuf data on the wire? +pub fn is_proto_content(content_type: &str) -> bool { + content_type == DataContentType::PROTOBUF +} + +/// Build the common identity attributes (`env/idc/ip/pid/sys/language/...`) +/// that every request must carry. +pub fn common_attributes( + config: &GrpcClientConfig, + protocol_type: EventMeshProtocolType, +) -> HashMap { + let id = &config.identity; + let mut m = HashMap::with_capacity(16); + m.insert(ProtocolKey::ENV.into(), attr_str(&id.env)); + m.insert(ProtocolKey::IDC.into(), attr_str(&id.idc)); + m.insert(ProtocolKey::IP.into(), attr_str(&id.ip)); + m.insert(ProtocolKey::PID.into(), attr_str(&id.pid)); + m.insert(ProtocolKey::SYS.into(), attr_str(&id.sys)); + m.insert(ProtocolKey::LANGUAGE.into(), attr_str(&id.language)); + m.insert(ProtocolKey::USERNAME.into(), attr_str(&id.username)); + m.insert(ProtocolKey::PASSWD.into(), attr_str(&id.password)); + m.insert( + ProtocolKey::PROTOCOL_TYPE.into(), + attr_str(protocol_type.as_str()), + ); + m.insert(ProtocolKey::PROTOCOL_VERSION.into(), attr_str("1.0")); + if let Some(token) = &id.token { + if !token.is_empty() { + m.insert("token".into(), attr_str(token)); + } + } + m +} + +/// Build the subscription CloudEvent (carries the `SubscriptionItem` JSON +/// list in `text_data`, plus the optional webhook `url`). +pub fn build_subscription_event( + config: &GrpcClientConfig, + protocol_type: EventMeshProtocolType, + url: Option<&str>, + items: &[SubscriptionItem], +) -> Result { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "subscription items must not be empty".into(), + )); + } + let mut attrs = common_attributes(config, protocol_type); + attrs.insert( + ProtocolKey::CONSUMERGROUP.into(), + attr_str(&config.identity.consumer_group), + ); + attrs.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(DataContentType::JSON), + ); + if let Some(u) = url { + let trimmed = u.trim(); + if !trimmed.is_empty() { + attrs.insert(ProtocolKey::URL.into(), attr_str(trimmed)); + } + } + let text = serde_json::to_string(items)?; + Ok(base_event(attrs, Some(PbData::TextData(text)))) +} + +/// Convert an [`EventMeshMessage`] into the wire CloudEvent for publishing. +pub fn from_event_mesh_message( + message: &EventMeshMessage, + config: &GrpcClientConfig, +) -> Result { + let protocol_type = EventMeshProtocolType::EventMeshMessage; + let mut attrs = common_attributes(config, protocol_type); + + let ttl = message + .ttl + .map(|t| t.to_string()) + .or_else(|| message.get_prop(ProtocolKey::TTL).map(str::to_string)) + .unwrap_or_else(|| DEFAULT_MESSAGE_TTL.to_string()); + let seq_num = message + .biz_seq_no + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + let unique_id = message + .unique_id + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + + attrs.insert(ProtocolKey::TTL.into(), attr_str(ttl)); + attrs.insert(ProtocolKey::SEQ_NUM.into(), attr_str(&seq_num)); + attrs.insert(ProtocolKey::UNIQUE_ID.into(), attr_str(&unique_id)); + attrs.insert( + ProtocolKey::PRODUCERGROUP.into(), + attr_str(&config.identity.producer_group), + ); + attrs.insert( + ProtocolKey::PROTOCOL_DESC.into(), + attr_str(ProtocolKey::PROTOCOL_DESC_GRPC_CLOUD_EVENT), + ); + + if let Some(topic) = &message.topic { + if !topic.is_empty() { + attrs.insert(ProtocolKey::SUBJECT.into(), attr_str(topic)); + } + } + + // Resolve the content type from props (default text/plain). + let data_content_type = message + .get_prop(ProtocolKey::DATA_CONTENT_TYPE) + .unwrap_or(DataContentType::TEXT_PLAIN) + .to_string(); + attrs.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(data_content_type.as_str()), + ); + + // Fold remaining user props into attributes (excluding ones we already set). + for (k, v) in &message.props { + attrs.entry(k.clone()).or_insert_with(|| attr_str(v)); + } + + let data = match &message.content { + Some(content) if is_text_content(&data_content_type) => { + Some(PbData::TextData(content.clone())) + } + Some(content) if is_proto_content(&data_content_type) => { + // Match the Java SDK: content bytes are a serialized + // `google.protobuf.Any` message (produced by `Any.pack(...)` or + // manual construction, then serialized). Java calls + // `Any.parseFrom(content.getBytes(UTF_8))` on the producer side + // and `any.toByteArray()` on the consumer side. We mirror both + // directions so Rust↔Java `application/protobuf` messages are + // wire-compatible. + let any = PbAny::decode(content.as_bytes()).map_err(|e| EventMeshError::Protocol { + transport: "grpc", + message: format!( + "failed to decode application/protobuf content as google.protobuf.Any: {e}" + ), + })?; + Some(PbData::ProtoData(any)) + } + Some(content) => Some(PbData::BinaryData(content.as_bytes().to_vec())), + None => None, + }; + + Ok(base_event(attrs, data)) +} + +/// Convert an OpenMessaging value to the shared protobuf wire shape while +/// retaining the `openmessage` protocol discriminator. +pub fn from_open_message( + message: &crate::model::OpenMessage, + config: &GrpcClientConfig, +) -> Result { + let mut event = from_event_mesh_message(&message.to_event_mesh_message(), config)?; + event.attributes.insert( + ProtocolKey::PROTOCOL_TYPE.into(), + attr_str(EventMeshProtocolType::OpenMessage.as_str()), + ); + Ok(event) +} + +/// Build a `CloudEventBatch` from many messages (one RPC, many events). +pub fn from_event_mesh_messages( + messages: &[EventMeshMessage], + config: &GrpcClientConfig, +) -> Result { + let mut events = Vec::with_capacity(messages.len()); + for m in messages { + events.push(from_event_mesh_message(m, config)?); + } + Ok(PbCloudEventBatch { events }) +} + +/// Decode a delivered CloudEvent back into an [`EventMeshMessage`]. +pub fn to_event_mesh_message(cloud_event: &PbCloudEvent) -> EventMeshMessage { + let mut props = HashMap::with_capacity(cloud_event.attributes.len()); + for (key, value) in &cloud_event.attributes { + props.insert(key.clone(), attr_as_str(value)); + } + let topic = get_subject(cloud_event); + let biz_seq_no = get_seq_num(cloud_event); + let unique_id = get_unique_id(cloud_event); + let content = get_text_data(cloud_event); + let ttl = get_ttl(cloud_event).parse::().ok(); + + EventMeshMessage { + biz_seq_no: (!biz_seq_no.is_empty()).then_some(biz_seq_no), + unique_id: (!unique_id.is_empty()).then_some(unique_id), + topic: (!topic.is_empty()).then_some(topic), + content: (!content.is_empty()).then_some(content), + props, + create_time: crate::common::util::now_millis(), + ttl, + } +} + +/// Extract the broker [`PublishResponse`] (status_code / message / time). +pub fn to_response(cloud_event: &PbCloudEvent) -> PublishResponse { + let code = cloud_event + .attributes + .get(ProtocolKey::GRPC_RESPONSE_CODE) + .and_then(|v| v.attr.as_ref()) + .and_then(|a| match a { + PbAttr::CeString(s) => s.parse::().ok(), + PbAttr::CeInteger(i) => Some(*i as i64), + _ => None, + }); + let message = cloud_event + .attributes + .get(ProtocolKey::GRPC_RESPONSE_MESSAGE) + .map(attr_as_str) + .filter(|s| !s.is_empty()); + let time = cloud_event + .attributes + .get(ProtocolKey::GRPC_RESPONSE_TIME) + .and_then(|v| attr_as_str(v).parse::().ok()); + PublishResponse::new(code, message, time) +} + +pub fn get_seq_num(cloud_event: &PbCloudEvent) -> String { + cloud_event + .attributes + .get(ProtocolKey::SEQ_NUM) + .map(attr_as_str) + .unwrap_or_default() +} + +pub fn get_unique_id(cloud_event: &PbCloudEvent) -> String { + cloud_event + .attributes + .get(ProtocolKey::UNIQUE_ID) + .map(attr_as_str) + .unwrap_or_default() +} + +pub fn get_subject(cloud_event: &PbCloudEvent) -> String { + // Only read the `subject` attribute — do NOT fall back to `source`. + // Internally-built events set `source` to the default "/", so a fallback + // would yield a topic of "/" instead of an empty topic. This mirrors + // EventMeshCloudEventUtils.getSubject in the Java SDK. + cloud_event + .attributes + .get(ProtocolKey::SUBJECT) + .map(attr_as_str) + .unwrap_or_default() +} + +pub fn get_ttl(cloud_event: &PbCloudEvent) -> String { + cloud_event + .attributes + .get(ProtocolKey::TTL) + .map(attr_as_str) + .unwrap_or_default() +} + +pub fn get_text_data(cloud_event: &PbCloudEvent) -> String { + match &cloud_event.data { + Some(PbData::TextData(s)) => s.clone(), + Some(PbData::BinaryData(b)) => String::from_utf8_lossy(b).into_owned(), + Some(PbData::ProtoData(any)) => { + // Match Java: `new String(protoData.toByteArray(), UTF_8)`. + // Re-serializes the `Any` to bytes then decodes as UTF-8 string. + let mut buf = Vec::with_capacity(any.encoded_len()); + let _ = any.encode(&mut buf); + String::from_utf8_lossy(&buf).into_owned() + } + None => String::new(), + } +} + +/// Assemble a base CloudEvent with a fresh id and the common envelope +/// fields. +fn base_event( + attributes: HashMap, + data: Option, +) -> PbCloudEvent { + PbCloudEvent { + id: RandomStringUtils::generate_uuid(), + source: DEFAULT_SOURCE.into(), + spec_version: "1.0".into(), + r#type: CLOUD_EVENT_TYPE.into(), + attributes, + data, + } +} + +/// Mark a CloudEvent as a subscription reply (sent back over the stream). +/// +/// Mirrors the Java SDK's `SubStreamHandler.buildReplyMessage`: +/// - Tags the message with `SUB_MESSAGE_TYPE = SUBSCRIPTION_REPLY`. +/// - Forces `datacontenttype` to `application/json` so cross-SDK consumers +/// that dispatch on content type decode the reply consistently. +/// +/// The reply's data is left intact: EventMesh's `ReplyMessageProcessor` +/// runs `ServiceUtils.validateCloudEventData`, which for text content +/// requires a non-empty `textData` — clearing the data here would make the +/// reply fail validation and never reach `producer.reply()`, breaking +/// request/reply. +pub fn mark_as_reply(cloud_event: &mut PbCloudEvent) { + cloud_event.attributes.insert( + ProtocolKey::SUB_MESSAGE_TYPE.into(), + attr_str(ProtocolKey::SUBSCRIPTION_REPLY), + ); + cloud_event.attributes.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(DataContentType::JSON), + ); +} + +/// (Optional) CloudEvents interop: convert a native [`cloudevents::Event`] +/// to the wire CloudEvent. +#[cfg(feature = "cloud_events")] +pub fn from_cloudevent( + event: &cloudevents::Event, + config: &GrpcClientConfig, +) -> Result { + use cloudevents::AttributesReader; + + let protocol_type = EventMeshProtocolType::CloudEvents; + let mut attrs = common_attributes(config, protocol_type); + + let ttl = event + .extension(ProtocolKey::TTL) + .map(|v| v.to_string()) + .unwrap_or_else(|| DEFAULT_MESSAGE_TTL.to_string()); + let seq_num = event + .extension(ProtocolKey::SEQ_NUM) + .map(|v| v.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + // UNIQUE_ID: preserve an existing extension, otherwise generate one. + // Do NOT clobber it with the CE id — the CE id travels in the top-level + // `id` field (see below) and cross-language consumers dedup on that. + let unique_id = event + .extension(ProtocolKey::UNIQUE_ID) + .map(|v| v.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + + attrs.insert(ProtocolKey::TTL.into(), attr_str(ttl)); + attrs.insert(ProtocolKey::SEQ_NUM.into(), attr_str(&seq_num)); + attrs.insert(ProtocolKey::UNIQUE_ID.into(), attr_str(&unique_id)); + attrs.insert( + ProtocolKey::PRODUCERGROUP.into(), + attr_str(&config.identity.producer_group), + ); + attrs.insert( + ProtocolKey::PROTOCOL_DESC.into(), + attr_str(ProtocolKey::PROTOCOL_DESC_GRPC_CLOUD_EVENT), + ); + if let Some(subject) = event.subject() { + attrs.insert(ProtocolKey::SUBJECT.into(), attr_str(subject)); + } + if let Some(dct) = event.datacontenttype() { + attrs.insert(ProtocolKey::DATA_CONTENT_TYPE.into(), attr_str(dct)); + } else { + attrs.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(DataContentType::TEXT_PLAIN), + ); + } + // Preserve standard CE attributes the previous code dropped. + if let Some(t) = event.time() { + attrs.insert( + ProtocolKey::TIME.into(), + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeTimestamp(prost_types::Timestamp { + seconds: t.timestamp(), + nanos: t.timestamp_subsec_nanos() as i32, + })), + }, + ); + } + if let Some(ds) = event.dataschema() { + attrs.insert( + ProtocolKey::DATA_SCHEMA.into(), + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeUri(ds.to_string())), + }, + ); + } + // Preserve typed extension values (Boolean / Integer) instead of + // stringifying everything. + for (k, v) in event.iter_extensions() { + if attrs.contains_key(k) { + continue; + } + let attr = match v { + cloudevents::event::ExtensionValue::String(s) => attr_str(s), + cloudevents::event::ExtensionValue::Boolean(b) => PbCloudEventAttributeValue { + attr: Some(PbAttr::CeBoolean(*b)), + }, + cloudevents::event::ExtensionValue::Integer(i) => PbCloudEventAttributeValue { + attr: Some(PbAttr::CeInteger(*i as i32)), + }, + }; + attrs.insert(k.to_string(), attr); + } + + let data = match event.data() { + Some(cloudevents::Data::String(s)) => Some(PbData::TextData(s.clone())), + Some(cloudevents::Data::Binary(b)) => Some(PbData::BinaryData(b.clone())), + Some(cloudevents::Data::Json(j)) => Some(PbData::TextData(j.to_string())), + None => None, + }; + + Ok(PbCloudEvent { + id: event.id().to_string(), + source: event.source().to_string(), + spec_version: event.specversion().to_string(), + r#type: event.ty().to_string(), + attributes: attrs, + data, + }) +} + +/// (Optional) CloudEvents interop: convert the wire CloudEvent back into a +/// native [`cloudevents::Event`]. +#[cfg(feature = "cloud_events")] +pub fn to_cloudevent(cloud_event: PbCloudEvent) -> Result { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let topic = get_subject(&cloud_event); + // Use the protobuf `id` field (the standard CE id) rather than the + // EventMesh-specific UNIQUE_ID extension, so cross-language consumers + // can dedup and correlate correctly. + let ce_id = if cloud_event.id.is_empty() { + get_unique_id(&cloud_event) + } else { + cloud_event.id.clone() + }; + let source = if cloud_event.source.is_empty() { + DEFAULT_SOURCE.to_string() + } else { + cloud_event.source.clone() + }; + let ty = if cloud_event.r#type.is_empty() { + ProtocolKey::CLOUD_EVENTS_PROTOCOL_NAME.to_string() + } else { + cloud_event.r#type.clone() + }; + let content_type = cloud_event + .attributes + .get(ProtocolKey::DATA_CONTENT_TYPE) + .map(attr_as_str) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DataContentType::JSON.to_string()); + + let dataschema = cloud_event + .attributes + .get(ProtocolKey::DATA_SCHEMA) + .map(attr_as_str) + .filter(|s| !s.is_empty()); + + let mut builder = EventBuilderV10::new().id(ce_id).source(source).ty(ty); + + // Preserve binary vs text data instead of lossy-converting everything + // to a string. When dataschema is present use data_with_schema so the + // schema URL round-trips as a standard CE attribute. + match (&cloud_event.data, &dataschema) { + (Some(PbData::TextData(s)), Some(ds)) => { + builder = builder.data_with_schema(content_type.as_str(), ds.as_str(), s.clone()); + } + (Some(PbData::BinaryData(b)), Some(ds)) => { + builder = builder.data_with_schema(content_type.as_str(), ds.as_str(), b.clone()); + } + (Some(PbData::ProtoData(any)), Some(ds)) => { + builder = builder.data_with_schema( + content_type.as_str(), + ds.as_str(), + String::from_utf8_lossy(&any.value).into_owned(), + ); + } + (Some(PbData::TextData(s)), None) => { + builder = builder.data(content_type.as_str(), s.clone()); + } + (Some(PbData::BinaryData(b)), None) => { + builder = builder.data(content_type.as_str(), b.clone()); + } + (Some(PbData::ProtoData(any)), None) => { + builder = builder.data( + content_type.as_str(), + String::from_utf8_lossy(&any.value).into_owned(), + ); + } + (None, _) => { + builder = builder.data(content_type.as_str(), String::new()); + } + } + + if !topic.is_empty() { + builder = builder.subject(topic); + } + + // Extract the standard CE `time` attribute instead of skipping it. + if let Some(v) = cloud_event.attributes.get(ProtocolKey::TIME) { + match &v.attr { + Some(PbAttr::CeTimestamp(ts)) => { + if let Some(dt) = chrono::DateTime::from_timestamp(ts.seconds, ts.nanos as u32) { + builder = builder.time(dt); + } + } + Some(PbAttr::CeString(s)) => { + builder = builder.time(s.clone()); + } + _ => {} + } + } + + for (k, v) in cloud_event.attributes { + if matches!( + k.as_str(), + ProtocolKey::GRPC_RESPONSE_CODE + | ProtocolKey::GRPC_RESPONSE_MESSAGE + | ProtocolKey::TIME + | ProtocolKey::DATA_SCHEMA + | ProtocolKey::DATA_CONTENT_TYPE + ) { + continue; + } + builder = builder.extension(k.as_str(), attr_as_str(&v)); + } + builder.build().map_err(|e| EventMeshError::Protocol { + transport: "grpc", + message: format!("cloudevents build error: {e}"), + }) +} + +/// Build a heartbeat CloudEvent. +pub(crate) fn build_heartbeat( + config: &GrpcClientConfig, + items: &[(String, String)], +) -> Result { + let mut attrs = common_attributes(config, EventMeshProtocolType::EventMeshMessage); + attrs.insert( + ProtocolKey::CONSUMERGROUP.into(), + attr_str(&config.identity.consumer_group), + ); + attrs.insert(ProtocolKey::CLIENT_TYPE.into(), attr_int(2)); // SUB + attrs.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(DataContentType::JSON), + ); + + let heartbeat_items: Vec = items + .iter() + .map(|(topic, url)| crate::model::HeartbeatItem { + topic: topic.clone(), + url: if url.is_empty() { + SDK_STREAM_URL.to_string() + } else { + url.clone() + }, + }) + .collect(); + let text = serde_json::to_string(&heartbeat_items)?; + Ok(base_event(attrs, Some(PbData::TextData(text)))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::GrpcClientConfig; + + fn cfg() -> GrpcClientConfig { + GrpcClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10205) + .env("env") + .idc("idc") + .producer_group("pg") + .consumer_group("cg") + .build() + } + + #[test] + fn round_trips_message_to_cloud_event() { + let cfg = cfg(); + let msg = EventMeshMessage::builder() + .topic("test-topic") + .content("hello") + .biz_seq_no("seq-1") + .unique_id("uid-1") + .prop("custom", "val") + .build(); + let ce = from_event_mesh_message(&msg, &cfg).unwrap(); + assert_eq!(get_subject(&ce), "test-topic"); + assert_eq!(get_seq_num(&ce), "seq-1"); + assert_eq!(get_text_data(&ce), "hello"); + assert_eq!(ce.attributes.get("custom").map(attr_as_str).unwrap(), "val"); + + let back = to_event_mesh_message(&ce); + assert_eq!(back.topic.as_deref(), Some("test-topic")); + assert_eq!(back.content.as_deref(), Some("hello")); + } + + #[test] + fn builds_subscription_event_with_url() { + let cfg = cfg(); + let items = vec![SubscriptionItem::new( + "t", + crate::model::SubscriptionMode::CLUSTERING, + crate::model::SubscriptionType::ASYNC, + )]; + let ce = build_subscription_event( + &cfg, + EventMeshProtocolType::EventMeshMessage, + Some("http://x/y"), + &items, + ) + .unwrap(); + assert_eq!( + ce.attributes.get("url").map(attr_as_str).unwrap(), + "http://x/y" + ); + assert_eq!( + ce.attributes.get("consumergroup").map(attr_as_str).unwrap(), + "cg" + ); + // The topic list is carried as JSON in text_data. + assert!(get_text_data(&ce).contains("\"topic\":\"t\"")); + } + + #[test] + fn parses_response_code() { + let mut ce = PbCloudEvent::default(); + ce.attributes + .insert(ProtocolKey::GRPC_RESPONSE_CODE.into(), attr_str("0")); + ce.attributes + .insert(ProtocolKey::GRPC_RESPONSE_MESSAGE.into(), attr_str("ok")); + let resp = to_response(&ce); + assert!(resp.is_success()); + assert_eq!(resp.message.as_deref(), Some("ok")); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloudevent_roundtrip_preserves_fields() { + use cloudevents::{AttributesReader, EventBuilder, EventBuilderV10}; + + let original = EventBuilderV10::new() + .id("my-ce-id") + .source("http://example.com/src") + .ty("com.example.event") + .time("2023-07-13T12:00:00Z") + .data_with_schema( + "application/json", + "http://example.com/schema", + r#"{"k":"v"}"#.to_string(), + ) + .extension("bool-ext", true) + .extension("int-ext", 42i64) + .extension("str-ext", "hello") + .build() + .unwrap(); + + let cfg = cfg(); + let pb = from_cloudevent(&original, &cfg).unwrap(); + + // id is preserved, not replaced with a random UUID. + assert_eq!(pb.id, "my-ce-id"); + + // time is preserved as CeTimestamp. + match pb + .attributes + .get(ProtocolKey::TIME) + .and_then(|v| v.attr.as_ref()) + { + Some(PbAttr::CeTimestamp(_)) => {} + other => panic!("expected CeTimestamp for time, got {other:?}"), + } + + // dataschema is preserved as CeUri. + match pb + .attributes + .get(ProtocolKey::DATA_SCHEMA) + .and_then(|v| v.attr.as_ref()) + { + Some(PbAttr::CeUri(s)) => assert_eq!(s, "http://example.com/schema"), + other => panic!("expected CeUri for dataschema, got {other:?}"), + } + + // Typed extensions preserve their wire types. + match pb.attributes.get("bool-ext").and_then(|v| v.attr.as_ref()) { + Some(PbAttr::CeBoolean(true)) => {} + other => panic!("expected CeBoolean(true) for bool-ext, got {other:?}"), + } + match pb.attributes.get("int-ext").and_then(|v| v.attr.as_ref()) { + Some(PbAttr::CeInteger(42)) => {} + other => panic!("expected CeInteger(42) for int-ext, got {other:?}"), + } + + // Round-trip back to a native CE. + let back = to_cloudevent(pb).unwrap(); + assert_eq!(back.id(), "my-ce-id"); + assert_eq!(back.ty(), "com.example.event"); + assert!(back.time().is_some()); + assert!(back.dataschema().is_some()); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn to_cloudevent_preserves_binary_data() { + use cloudevents::AttributesReader; + let mut pb = PbCloudEvent { + id: "bin-id".into(), + source: "/".into(), + spec_version: "1.0".into(), + r#type: "com.example.binary".into(), + ..Default::default() + }; + pb.attributes.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str("application/octet-stream"), + ); + pb.data = Some(PbData::BinaryData(vec![0, 1, 2, 3])); + + let ce = to_cloudevent(pb).unwrap(); + assert_eq!(ce.ty(), "com.example.binary"); + match ce.data() { + Some(cloudevents::Data::Binary(b)) => assert_eq!(b, &[0, 1, 2, 3]), + other => panic!("expected binary data, got {other:?}"), + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/consumer.rs new file mode 100644 index 0000000000..d5cbc77eab --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/consumer.rs @@ -0,0 +1,1034 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! gRPC consumer — stream and webhook modes. +//! +//! Two consumer types are provided: +//! +//! - [`GrpcStreamConsumer`] — opens a bidirectional gRPC stream and +//! dispatches delivered messages to a user-supplied [`MessageListener`]. +//! The stream, receive loop, and heartbeat all run as background tasks. +//! - [`GrpcWebhookConsumer`] — a lightweight RPC-only client that registers +//! webhook URLs with the runtime (the runtime POSTs delivered messages to +//! the URL over HTTP). No listener, no receive loop. +//! +//! Both types support [`subscribe_webhook`], [`unsubscribe_stream`] / +//! [`unsubscribe_webhook`], and [`wait_for_shutdown`]. +//! +//! [`subscribe_webhook`]: GrpcStreamConsumer::subscribe_webhook +//! [`unsubscribe_stream`]: GrpcStreamConsumer::unsubscribe_stream +//! [`unsubscribe_webhook`]: GrpcStreamConsumer::unsubscribe_webhook + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::task::{JoinHandle, JoinSet}; +use tokio_util::sync::CancellationToken; +use tonic::codegen::tokio_stream::StreamExt; +use tracing::{debug, warn}; + +use crate::common::constants::SDK_STREAM_URL; +use crate::common::protocol_key::ProtocolKey; +use crate::error::{EventMeshError, Result}; +use crate::message::Message; +use crate::model::{EventMeshMessage, EventMeshProtocolType, PublishResponse, SubscriptionItem}; +use crate::transport::grpc::client::GrpcClient; +use crate::transport::grpc::codec; +use crate::transport::grpc::heartbeat::{self, StreamTx}; +use crate::MessageListener; + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +/// A locally-recorded subscription entry, used by the heartbeat loop. +#[derive(Debug, Clone)] +pub(crate) struct SubscriptionEntry { + #[allow(dead_code)] + pub(crate) item: SubscriptionItem, + pub(crate) url: String, +} + +/// Message representation supported by the gRPC stream decoder. +pub trait GrpcMessage: Send + 'static { + fn decode_grpc(event: &crate::proto_gen::PbCloudEvent) -> Result + where + Self: Sized; + + fn encode_grpc( + &self, + config: &crate::config::GrpcClientConfig, + ) -> Result; +} + +impl GrpcMessage for EventMeshMessage { + fn decode_grpc(event: &crate::proto_gen::PbCloudEvent) -> Result { + Ok(codec::to_event_mesh_message(event)) + } + + fn encode_grpc( + &self, + config: &crate::config::GrpcClientConfig, + ) -> Result { + codec::from_event_mesh_message(self, config) + } +} + +impl GrpcMessage for Message { + fn decode_grpc(event: &crate::proto_gen::PbCloudEvent) -> Result { + let protocol_type = event + .attributes + .get(ProtocolKey::PROTOCOL_TYPE) + .map(crate::proto_gen::attr_as_str) + .unwrap_or_default(); + + if protocol_type == EventMeshProtocolType::CloudEvents.as_str() { + #[cfg(feature = "cloud_events")] + return codec::to_cloudevent(event.clone()).map(Self::CloudEvent); + } + + let native = codec::to_event_mesh_message(event); + if protocol_type == EventMeshProtocolType::OpenMessage.as_str() { + Ok(Self::Open( + crate::model::OpenMessage::from_event_mesh_message(native), + )) + } else { + Ok(Self::EventMesh(native)) + } + } + + fn encode_grpc( + &self, + config: &crate::config::GrpcClientConfig, + ) -> Result { + match self { + Self::EventMesh(message) => codec::from_event_mesh_message(message, config), + Self::Open(message) => codec::from_open_message(message, config), + #[cfg(feature = "cloud_events")] + Self::CloudEvent(event) => codec::from_cloudevent(event, config), + } + } +} + +// --------------------------------------------------------------------------- +// Shutdown-signal helper +// --------------------------------------------------------------------------- + +/// Spawn a watcher that cancels `token` when `signal` resolves. +/// +/// If `signal` is `None`, nothing is spawned — the token can only be +/// cancelled by `shutdown()` / drop. +fn spawn_signal_watcher( + signal: Option + Send + 'static>, + token: CancellationToken, +) { + if let Some(signal) = signal { + tokio::spawn(async move { + tokio::select! { + _ = signal => token.cancel(), + _ = token.cancelled() => {} + } + }); + } +} + +/// Wait for a background task to finish, returning its result. +async fn await_task(handle: &Mutex>>) -> Option { + match handle.lock().await.take() { + Some(h) => match h.await { + Ok(result) => Some(result), + Err(e) => { + warn!("background task panicked: {e}"); + None + } + }, + None => None, + } +} + +// --------------------------------------------------------------------------- +// GrpcStreamConsumer +// --------------------------------------------------------------------------- + +/// gRPC stream consumer. +/// +/// Opens a bidirectional gRPC stream, dispatches delivered messages to the +/// listener, and maintains a background heartbeat. The stream, receive loop, +/// and heartbeat all run as background tokio tasks that are stopped when the +/// consumer is dropped or explicitly via [`shutdown`](Self::shutdown) / +/// [`wait_for_shutdown`](Self::wait_for_shutdown). +/// +/// Delivered messages are dispatched to the listener **concurrently** — up to +/// [`GrpcClientConfig::max_concurrent_handlers`] (default 64) may be in flight +/// at once. This means replies can arrive at the broker out of message-arrival +/// order (each reply is self-correlating via request attributes, so protocol +/// correctness is unaffected). Set `max_concurrent_handlers = 1` to restore +/// strict serial / in-order-reply semantics matching the Java SDK. +/// +/// Subscribe and unsubscribe RPCs can be called at any time after construction +/// — they are sent over the already-open stream (subscribe) or as independent +/// unary RPCs (unsubscribe). +/// +/// # Example +/// +/// ```ignore +/// # use eventmesh::{ +/// # config::GrpcClientConfig, grpc::GrpcStreamConsumer, +/// # model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, +/// # MessageListener, +/// # }; +/// # struct MyListener; +/// # impl MessageListener for MyListener { +/// # type Message = EventMeshMessage; +/// # async fn handle(&self, _: Self::Message) -> Option { None } +/// # } +/// # #[tokio::main] +/// # async fn main() -> eventmesh::Result<()> { +/// let consumer = GrpcStreamConsumer::subscribe_stream( +/// GrpcClientConfig::builder().build(), +/// MyListener, +/// vec![SubscriptionItem::new("t", SubscriptionMode::CLUSTERING, SubscriptionType::ASYNC)], +/// Some(async { tokio::signal::ctrl_c().await.ok(); }), +/// ).await?; +/// consumer.wait_for_shutdown().await; +/// # Ok(()) +/// # } +/// ``` +pub struct GrpcStreamConsumer +where + L::Message: GrpcMessage, +{ + client: GrpcClient, + config: crate::config::GrpcClientConfig, + subscriptions: Arc>>, + _listener: std::marker::PhantomData>, + shutdown: CancellationToken, + heartbeat_handle: Mutex>>, + stream_tx: StreamTx, + driver_handle: Mutex>>>, +} + +impl GrpcStreamConsumer +where + L::Message: GrpcMessage, +{ + /// Open a bidirectional stream subscription and spawn the receive loop + + /// heartbeat as background tasks. + /// + /// `items` are sent as the first message on the stream (the subscription + /// request). `shutdown_signal` is an optional future whose resolution + /// triggers graceful shutdown of the stream and heartbeat. When omitted, + /// shutdown can only be initiated by [`shutdown`](Self::shutdown) or drop. + /// + /// # Runtime requirement + /// + /// This method **requires a multi-threaded tokio runtime**. On a + /// current-thread runtime (the default for `#[tokio::test]`), + /// tonic's background connection tasks cannot progress and the call + /// will time out after 15 seconds with a diagnostic error. Use: + /// + /// ```text + /// #[tokio::test(flavor = "multi_thread")] + /// ``` + /// + /// (`#[tokio::main]` is already multi-threaded by default.) + pub async fn subscribe_stream( + config: crate::config::GrpcClientConfig, + listener: L, + items: Vec, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "subscription items must not be empty".into(), + )); + } + + let client = GrpcClient::new(&config)?; + let subscriptions = Arc::new(Mutex::new(HashMap::new())); + let shutdown = CancellationToken::new(); + let stream_tx: StreamTx = Arc::new(Mutex::new(None)); + let listener = Arc::new(listener); + + // Signal watcher. + spawn_signal_watcher(shutdown_signal, shutdown.clone()); + + // Build the subscription event (first stream message). + let event = codec::build_subscription_event( + &config, + EventMeshProtocolType::EventMeshMessage, + None, + &items, + )?; + + // Eagerly open the stream. + let (reply_tx, stream) = client.subscribe_stream(event).await?; + let reply_tx = Arc::new(reply_tx); + + // Register the stream sender so heartbeat resubscribe can re-use it. + { + *stream_tx.lock().await = Some((*reply_tx).clone()); + } + + // Record the initial subscription. + { + let mut guard = subscriptions.lock().await; + for item in &items { + guard.insert( + (item.topic.clone(), SDK_STREAM_URL.to_string()), + SubscriptionEntry { + item: item.clone(), + url: SDK_STREAM_URL.to_string(), + }, + ); + } + } + + // Spawn heartbeat. + let heartbeat_handle = heartbeat::spawn( + client.clone(), + config.clone(), + Arc::clone(&subscriptions), + Arc::clone(&stream_tx), + shutdown.clone(), + ); + + // Spawn the receive-loop driver. + let driver_handle = spawn_stream_driver( + stream, + reply_tx, + Arc::clone(&listener), + config.clone(), + stream_tx.clone(), + shutdown.clone(), + ); + + Ok(Self { + client, + config, + subscriptions, + _listener: std::marker::PhantomData, + shutdown, + heartbeat_handle: Mutex::new(Some(heartbeat_handle)), + stream_tx, + driver_handle: Mutex::new(Some(driver_handle)), + }) + } + + /// Subscribe to additional topics over the already-open stream. + /// + /// The subscription CloudEvent is sent through the stream's request + /// channel. Returns an error if the stream is no longer active, is shutting + /// down, or remains backpressured beyond the configured timeout. + pub async fn subscribe(&self, items: Vec) -> Result<()> { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "subscription items must not be empty".into(), + )); + } + let event = codec::build_subscription_event( + &self.config, + EventMeshProtocolType::EventMeshMessage, + None, + &items, + )?; + match heartbeat::stream_sender(&self.stream_tx).await { + Some(tx) => { + // The state lock is released before awaiting bounded-channel + // capacity. This keeps stream teardown and heartbeat replay + // from being blocked by a backpressured caller subscription. + match heartbeat::await_with_timeout_or_shutdown( + &self.shutdown, + self.config.timeout, + tx.reserve(), + ) + .await + { + heartbeat::OperationOutcome::Completed(Ok(permit)) => { + permit.send(event); + let mut sub_guard = self.subscriptions.lock().await; + for item in &items { + sub_guard.insert( + (item.topic.clone(), SDK_STREAM_URL.to_string()), + SubscriptionEntry { + item: item.clone(), + url: SDK_STREAM_URL.to_string(), + }, + ); + } + Ok(()) + } + heartbeat::OperationOutcome::Completed(Err(e)) => { + Err(EventMeshError::ChannelClosed(format!("subscribe: {e}"))) + } + heartbeat::OperationOutcome::TimedOut => { + Err(EventMeshError::Timeout(self.config.timeout)) + } + heartbeat::OperationOutcome::Cancelled => Err(EventMeshError::ChannelClosed( + "stream is shutting down".into(), + )), + } + } + None => Err(EventMeshError::ChannelClosed("stream is not active".into())), + } + } + + /// Subscribe via webhook: the server POSTs delivered events to `url`. + /// + /// This is a unary gRPC RPC — it does not use the stream. It can be + /// called on a stream consumer to mix stream and webhook subscriptions. + pub async fn subscribe_webhook( + &self, + items: Vec, + url: impl Into, + ) -> Result { + subscribe_webhook_rpc(&self.client, &self.config, &self.subscriptions, items, url).await + } + + /// Unsubscribe stream-mode topics (registered via `subscribe_stream` or + /// `subscribe`). + /// + /// This is an independent unary RPC — it is **not** sent over the open + /// stream. The server matches stream clients by IP + PID, so no URL is + /// needed. + pub async fn unsubscribe_stream( + &self, + items: Vec, + ) -> Result { + unsubscribe_stream_rpc(&self.client, &self.config, &self.subscriptions, items).await + } + + /// Whether this consumer already has a stream subscription for `topic`. + /// + /// Subscription ownership is not represented by the EventMesh stream + /// protocol, so internal collaborators use this to avoid taking ownership + /// of a caller-created subscription. + pub(crate) async fn has_stream_subscription(&self, topic: &str) -> bool { + self.subscriptions + .lock() + .await + .contains_key(&(topic.to_owned(), SDK_STREAM_URL.to_string())) + } + + /// Unsubscribe webhook-mode topics (registered via `subscribe_webhook`). + /// + /// `url` must be the same webhook URL passed to `subscribe_webhook`. + /// The server matches webhook clients by URL — omitting or mismatching + /// it leaves a ghost subscription that continues to receive pushes. + pub async fn unsubscribe_webhook( + &self, + items: Vec, + url: impl Into, + ) -> Result { + unsubscribe_webhook_rpc(&self.client, &self.config, &self.subscriptions, items, url).await + } + + /// Current consumer group. + pub fn consumer_group(&self) -> &str { + &self.config.identity.consumer_group + } + + /// Explicitly shut down: cancel the shared token and await the driver and + /// heartbeat tasks' exit. + pub async fn shutdown(&self) { + self.shutdown.cancel(); + await_task(&self.driver_handle).await; + await_task(&self.heartbeat_handle).await; + } + + /// Block until the shutdown signal fires or the stream / heartbeat tasks + /// exit on their own, then await their clean exit. + /// + /// If no shutdown signal was provided at construction time, this blocks + /// until the tasks exit naturally (e.g. the server closes the stream). + pub async fn wait_for_shutdown(&self) -> Result<()> { + self.shutdown.cancelled().await; + let driver_result = match self.driver_handle.lock().await.take() { + Some(handle) => match handle.await { + Ok(result) => result, + Err(error) => Err(EventMeshError::ChannelClosed(format!( + "gRPC consumer driver task panicked: {error}" + ))), + }, + None => Ok(()), + }; + await_task(&self.heartbeat_handle).await; + driver_result + } +} + +impl Drop for GrpcStreamConsumer +where + L::Message: GrpcMessage, +{ + fn drop(&mut self) { + self.shutdown.cancel(); + if let Ok(mut guard) = self.heartbeat_handle.try_lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } + if let Ok(mut guard) = self.driver_handle.try_lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } + } +} + +// --------------------------------------------------------------------------- +// GrpcWebhookConsumer +// --------------------------------------------------------------------------- + +/// gRPC webhook consumer — a lightweight RPC-only client. +/// +/// Registers webhook URLs with the runtime via unary gRPC RPCs. The runtime +/// POSTs delivered messages to the registered URL over HTTP; the SDK does +/// not receive messages over gRPC for this consumer. Use a +/// [`WebhookServer`](crate::transport::http::WebhookServer) or your own HTTP +/// endpoint to receive the pushes. +/// +/// A background heartbeat task keeps subscriptions alive. +/// +/// # Example +/// +/// ```ignore +/// # use eventmesh::{config::GrpcClientConfig, grpc::GrpcWebhookConsumer}; +/// # use eventmesh::model::{SubscriptionItem, SubscriptionMode, SubscriptionType}; +/// # #[tokio::main] +/// # async fn main() -> eventmesh::Result<()> { +/// let consumer = GrpcWebhookConsumer::new( +/// GrpcClientConfig::builder().build(), +/// None::>, +/// ).await?; +/// consumer.subscribe_webhook( +/// vec![SubscriptionItem::new("t", SubscriptionMode::CLUSTERING, SubscriptionType::ASYNC)], +/// "http://127.0.0.1:8080/cb", +/// ).await?; +/// consumer.wait_for_shutdown().await; +/// # Ok(()) +/// # } +/// ``` +pub struct GrpcWebhookConsumer { + client: GrpcClient, + config: crate::config::GrpcClientConfig, + subscriptions: Arc>>, + shutdown: CancellationToken, + heartbeat_handle: Mutex>>, +} + +impl GrpcWebhookConsumer { + /// Create a webhook consumer. Spawns a background heartbeat task. + /// + /// `shutdown_signal` is an optional future whose resolution triggers + /// graceful shutdown of the heartbeat. When omitted, shutdown can only be + /// initiated by [`shutdown`](Self::shutdown) or drop. + pub async fn new( + config: crate::config::GrpcClientConfig, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + let client = GrpcClient::new(&config)?; + let subscriptions = Arc::new(Mutex::new(HashMap::new())); + let shutdown = CancellationToken::new(); + + spawn_signal_watcher(shutdown_signal, shutdown.clone()); + + let heartbeat_handle = heartbeat::spawn( + client.clone(), + config.clone(), + Arc::clone(&subscriptions), + // Webhook mode has no stream — stream_tx is always None. + Arc::new(Mutex::new(None)), + shutdown.clone(), + ); + + Ok(Self { + client, + config, + subscriptions, + shutdown, + heartbeat_handle: Mutex::new(Some(heartbeat_handle)), + }) + } + + /// Subscribe via webhook: the server POSTs delivered events to `url`. + pub async fn subscribe_webhook( + &self, + items: Vec, + url: impl Into, + ) -> Result { + subscribe_webhook_rpc(&self.client, &self.config, &self.subscriptions, items, url).await + } + + /// Unsubscribe webhook topics. + /// + /// `url` must be the same webhook URL passed to `subscribe_webhook`. + /// The server matches webhook clients by URL — omitting or mismatching + /// it leaves a ghost subscription that continues to receive pushes. + pub async fn unsubscribe_webhook( + &self, + items: Vec, + url: impl Into, + ) -> Result { + unsubscribe_webhook_rpc(&self.client, &self.config, &self.subscriptions, items, url).await + } + + /// Current consumer group. + pub fn consumer_group(&self) -> &str { + &self.config.identity.consumer_group + } + + /// Explicitly shut down: cancel the heartbeat and await its exit. + pub async fn shutdown(&self) { + self.shutdown.cancel(); + await_task(&self.heartbeat_handle).await; + } + + /// Block until the shutdown signal fires or the heartbeat task exits. + pub async fn wait_for_shutdown(&self) { + self.shutdown.cancelled().await; + await_task(&self.heartbeat_handle).await; + } +} + +impl Drop for GrpcWebhookConsumer { + fn drop(&mut self) { + self.shutdown.cancel(); + if let Ok(mut guard) = self.heartbeat_handle.try_lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } + } +} + +// --------------------------------------------------------------------------- +// Shared RPC helpers +// --------------------------------------------------------------------------- + +/// Apply the config's default request timeout to a short unary RPC. +async fn timed(timeout: Duration, f: impl Future>) -> Result { + tokio::time::timeout(timeout, f) + .await + .map_err(|_| EventMeshError::Timeout(timeout))? +} + +async fn subscribe_webhook_rpc( + client: &GrpcClient, + config: &crate::config::GrpcClientConfig, + subscriptions: &Arc>>, + items: Vec, + url: impl Into, +) -> Result { + let url = url.into(); + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "subscription items must not be empty".into(), + )); + } + let event = codec::build_subscription_event( + config, + EventMeshProtocolType::EventMeshMessage, + Some(&url), + &items, + )?; + let resp = timed(config.timeout, client.subscribe_webhook(event)).await?; + let response = codec::to_response(&resp); + if response.is_success() { + let mut guard = subscriptions.lock().await; + for item in items { + guard.insert( + (item.topic.clone(), url.clone()), + SubscriptionEntry { + item, + url: url.clone(), + }, + ); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "subscribe failed".into()), + }) + } +} + +async fn unsubscribe_stream_rpc( + client: &GrpcClient, + config: &crate::config::GrpcClientConfig, + subscriptions: &Arc>>, + items: Vec, +) -> Result { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "unsubscribe items must not be empty".into(), + )); + } + + // Stream subscriptions: the server matches stream clients by ip+pid, + // not by URL, so url=None is correct here. + let event = codec::build_subscription_event( + config, + EventMeshProtocolType::EventMeshMessage, + None, + &items, + )?; + let resp = timed(config.timeout, client.unsubscribe(event)).await?; + let response = codec::to_response(&resp); + if response.is_success() { + let mut guard = subscriptions.lock().await; + for item in &items { + guard.remove(&(item.topic.clone(), SDK_STREAM_URL.to_string())); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }) + } +} + +async fn unsubscribe_webhook_rpc( + client: &GrpcClient, + config: &crate::config::GrpcClientConfig, + subscriptions: &Arc>>, + items: Vec, + url: impl Into, +) -> Result { + let url = url.into(); + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "unsubscribe items must not be empty".into(), + )); + } + + // Webhook subscriptions: the server matches webhook clients by URL. + // The URL must match the one used at subscribe time, otherwise the + // WebhookTopicConfig entry is not removed and pushes continue. + let url_ref = if url.is_empty() { + None + } else { + Some(url.as_str()) + }; + let event = codec::build_subscription_event( + config, + EventMeshProtocolType::EventMeshMessage, + url_ref, + &items, + )?; + let resp = timed(config.timeout, client.unsubscribe(event)).await?; + let response = codec::to_response(&resp); + if response.is_success() { + let mut guard = subscriptions.lock().await; + for item in &items { + guard.remove(&(item.topic.clone(), url.clone())); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }) + } +} + +// --------------------------------------------------------------------------- +// Stream receive-loop driver (spawned, not public) +// --------------------------------------------------------------------------- + +/// Spawn the stream receive loop as a background task. +/// +/// Dispatches delivered messages to the listener **concurrently** (up to +/// [`GrpcClientConfig::max_concurrent_handlers`] in flight at once) and sends +/// back replies as each handler completes. Concurrency is bounded by a +/// `Semaphore`: when all permits are held by in-flight handlers, the loop +/// stops pulling from the gRPC stream, which engages gRPC flow control and +/// pauses the server — this is the backpressure path. +/// +/// Replies are sent in handler-completion order, **not** message-arrival +/// order. This is a deliberate divergence from the Java SDK (which processes +/// serially and replies in order) chosen for throughput. Each reply carries +/// the original request's attributes (see [`build_reply`]) so the broker can +/// correlate it independently of ordering. Set +/// `max_concurrent_handlers = 1` to restore strict serial / in-order-reply +/// semantics. +/// +/// On shutdown (`shutdown` token cancelled or the stream ends) the loop stops +/// accepting new messages and then **drains** all in-flight handlers to +/// completion (mirroring axum's graceful-shutdown behaviour) before clearing +/// `stream_tx` and returning. `Drop` of the consumer aborts the driver task, +/// which drops the `JoinSet` and aborts any remaining in-flight handlers. +fn spawn_stream_driver( + mut stream: tonic::Streaming, + reply_tx: Arc>, + listener: Arc, + config: crate::config::GrpcClientConfig, + stream_tx: StreamTx, + shutdown: CancellationToken, +) -> JoinHandle> +where + L: MessageListener, + L::Message: GrpcMessage, +{ + tokio::spawn(async move { + let semaphore = Arc::new(Semaphore::new(config.max_concurrent_handlers.max(1))); + let mut join_set: JoinSet> = JoinSet::new(); + let mut terminal_error = None; + + loop { + tokio::select! { + msg = stream.next() => match msg { + None => { + debug!("subscribe stream ended"); + // Cancel the token so wait_for_shutdown() unblocks + // instead of waiting forever for an external signal. + shutdown.cancel(); + break; + } + Some(Err(status)) => { + warn!("stream receive error: {status}"); + terminal_error = Some(EventMeshError::from(status)); + shutdown.cancel(); + break; + } + Some(Ok(cloud_event)) => { + if codec::get_seq_num(&cloud_event).is_empty() { + debug!("skipping control frame (no seqnum)"); + continue; + } + let message = match L::Message::decode_grpc(&cloud_event) { + Ok(message) => message, + Err(error) => { + terminal_error = Some(error); + shutdown.cancel(); + break; + } + }; + // Acquire a permit (bounded concurrency) but allow + // shutdown to interrupt the wait. The permit lives + // for the duration of the spawned handler task. + let permit = tokio::select! { + p = semaphore.clone().acquire_owned() => match p { + Ok(p) => p, + Err(_) => break, // semaphore closed + }, + _ = shutdown.cancelled() => break, + }; + join_set.spawn(handle_one( + cloud_event, + message, + Arc::clone(&listener), + Arc::clone(&reply_tx), + config.clone(), + permit, + )); + } + }, + // Reap completed tasks so the JoinSet does not grow unbounded. + // Guard against busy-spinning: join_next() on an empty JoinSet + // returns Ready(None) immediately, which would make the select! + // fire on this branch every iteration. The async block keeps the + // future Pending when the set is empty, so the loop only wakes + // when the stream delivers, a task completes, or shutdown fires. + completed = async { + if !join_set.is_empty() { + join_set.join_next().await + } else { + std::future::pending().await + } + } => { + match completed { + Some(Ok(Ok(()))) | None => {} + Some(Ok(Err(error))) => { + terminal_error = Some(error); + shutdown.cancel(); + break; + } + Some(Err(error)) => { + terminal_error = Some(EventMeshError::ChannelClosed(format!( + "gRPC message handler task panicked: {error}" + ))); + shutdown.cancel(); + break; + } + } + } + _ = shutdown.cancelled() => { + debug!("subscribe stream shutting down"); + break; + } + } + } + + // Drain: wait for all in-flight handlers to finish (mirrors axum's + // graceful-shutdown semantics). `Drop` of the consumer aborts the + // driver task instead, cancelling these immediately. + while let Some(completed) = join_set.join_next().await { + if terminal_error.is_none() { + terminal_error = match completed { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error), + Err(error) => Some(EventMeshError::ChannelClosed(format!( + "gRPC message handler task panicked: {error}" + ))), + }; + } + } + + *stream_tx.lock().await = None; + terminal_error.map_or(Ok(()), Err) + }) +} + +/// Run a single message through the listener and send any reply. +/// +/// The `_permit` is held for the lifetime of this future; dropping it (when +/// the future completes or is cancelled) releases the concurrency slot back +/// to the semaphore, allowing the receive loop to pull the next message. +async fn handle_one( + cloud_event: crate::proto_gen::PbCloudEvent, + message: L::Message, + listener: Arc, + reply_tx: Arc>, + config: crate::config::GrpcClientConfig, + _permit: tokio::sync::OwnedSemaphorePermit, +) -> Result<()> +where + L::Message: GrpcMessage, +{ + match listener.handle(message).await? { + Some(reply) => { + let reply_event = build_reply(&reply, &cloud_event, &config)?; + reply_tx + .send(reply_event) + .await + .map_err(|_| EventMeshError::ChannelClosed("gRPC reply channel closed".into()))?; + } + None => { /* async ack: nothing to send back */ } + } + Ok(()) +} + +/// Build a reply CloudEvent (used by the stream receive loop when the listener +/// returns `Some(message)`). +/// +/// Mirrors the Java SDK's `SubStreamHandler.buildReplyMessage`: the incoming +/// request's attributes are carried over into the reply so the broker can +/// correlate the reply with the original request. The reply's own attributes +/// take precedence. +pub(crate) fn build_reply( + reply: &M, + request: &crate::proto_gen::PbCloudEvent, + config: &crate::config::GrpcClientConfig, +) -> Result { + let mut event = reply.encode_grpc(config)?; + for (key, value) in &request.attributes { + event + .attributes + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + codec::mark_as_reply(&mut event); + Ok(event) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> crate::config::GrpcClientConfig { + crate::config::GrpcClientConfig::builder().build() + } + + #[test] + fn public_message_preserves_open_protocol() { + let original = Message::Open(crate::model::OpenMessage::new("orders", "created")); + let wire = original + .encode_grpc(&config()) + .expect("encode open message"); + assert_eq!( + wire.attributes + .get(ProtocolKey::PROTOCOL_TYPE) + .map(crate::proto_gen::attr_as_str) + .as_deref(), + Some(EventMeshProtocolType::OpenMessage.as_str()) + ); + let decoded = Message::decode_grpc(&wire).expect("decode open message"); + match decoded { + Message::Open(message) => { + assert_eq!(message.topic.as_deref(), Some("orders")); + assert_eq!(message.body.as_deref(), Some("created")); + } + other => panic!("expected Open message, got {other:?}"), + } + } + + #[cfg(feature = "cloud_events")] + #[test] + fn public_message_preserves_cloud_event_protocol_and_reply_metadata() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("event-1") + .source("urn:test") + .ty("orders.created") + .subject("orders") + .data("application/json", "created") + .build() + .expect("build event"); + let original = Message::CloudEvent(event); + let mut request = original + .encode_grpc(&config()) + .expect("encode CloudEvent request"); + request.attributes.insert( + "correlation-id".into(), + crate::proto_gen::attr_str("request-7"), + ); + + let decoded = Message::decode_grpc(&request).expect("decode CloudEvent request"); + assert!(matches!(decoded, Message::CloudEvent(_))); + let reply = build_reply(&original, &request, &config()).expect("encode reply"); + assert_eq!( + reply + .attributes + .get(ProtocolKey::PROTOCOL_TYPE) + .map(crate::proto_gen::attr_as_str) + .as_deref(), + Some(EventMeshProtocolType::CloudEvents.as_str()) + ); + assert_eq!( + reply + .attributes + .get("correlation-id") + .map(crate::proto_gen::attr_as_str) + .as_deref(), + Some("request-7") + ); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/heartbeat.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/heartbeat.rs new file mode 100644 index 0000000000..1957d79b1f --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/heartbeat.rs @@ -0,0 +1,321 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Background heartbeat loop for the gRPC consumer. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{mpsc, Mutex}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::common::constants::SDK_STREAM_URL; +use crate::common::status_code::StatusCode; +use crate::config::GrpcClientConfig; +use crate::model::{EventMeshProtocolType, SubscriptionItem}; +use crate::proto_gen::PbCloudEvent; +use crate::transport::grpc::client::GrpcClient; +use crate::transport::grpc::codec; +use crate::transport::grpc::consumer::SubscriptionEntry; + +/// Initial delay before the first heartbeat. +const HEARTBEAT_INITIAL_DELAY: Duration = Duration::from_secs(10); +/// Interval between heartbeats. +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); + +/// Type alias for the shared stream sender used to re-send stream subscriptions +/// during resubscribe. `None` when no stream is currently active. +pub(crate) type StreamTx = Arc>>>; + +/// Outcome of an operation bounded by a timeout and consumer shutdown. +pub(crate) enum OperationOutcome { + /// The operation completed before either bound was reached. + Completed(T), + /// The configured timeout elapsed first. + TimedOut, + /// Consumer shutdown was requested first. + Cancelled, +} + +/// Await an operation until it completes, its deadline expires, or shutdown +/// is requested. Shutdown wins when it is already ready so callers never +/// start more work after the consumer begins closing. +pub(crate) async fn await_with_timeout_or_shutdown( + shutdown: &CancellationToken, + timeout: Duration, + operation: impl Future, +) -> OperationOutcome { + tokio::select! { + biased; + _ = shutdown.cancelled() => OperationOutcome::Cancelled, + result = tokio::time::timeout(timeout, operation) => match result { + Ok(value) => OperationOutcome::Completed(value), + Err(_) => OperationOutcome::TimedOut, + }, + } +} + +/// Clone the active stream sender while holding the state lock only long +/// enough to read it. Sending through a bounded channel can await indefinitely +/// under backpressure, so it must always happen after this lock is released. +pub(crate) async fn stream_sender(stream_tx: &StreamTx) -> Option> { + stream_tx.lock().await.clone() +} + +/// Spawn the heartbeat loop. Reads the consumer's current `(topic, url)` +/// subscriptions each tick and reports them to the broker. The loop exits +/// promptly when `shutdown` is cancelled, so dropping / shutting down the +/// consumer no longer leaks a permanently-running task. +/// +/// Scheduling mirrors the Java SDK's `scheduleAtFixedRate`: the first tick +/// fires after `HEARTBEAT_INITIAL_DELAY`, subsequent ticks align to a fixed +/// grid of `HEARTBEAT_INTERVAL`. The default `Burst` missed-tick behavior +/// matches Java's "catch up by one (non-concurrent)" semantics — if a tick +/// overruns, the next fires immediately rather than shifting the grid. +/// +/// When the server returns `CLIENT_RESUBSCRIBE`, the loop automatically +/// re-registers all active subscriptions: webhook subscriptions are re-sent via +/// the `subscribe` RPC, stream subscriptions are re-sent over `stream_tx`. +/// +/// Returns the task's [`JoinHandle`] so the owner can await clean exit. +pub(crate) fn spawn( + client: GrpcClient, + config: GrpcClientConfig, + subscriptions: Arc>>, + stream_tx: StreamTx, + shutdown: CancellationToken, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval_at( + tokio::time::Instant::now() + HEARTBEAT_INITIAL_DELAY, + HEARTBEAT_INTERVAL, + ); + // Default `MissedTickBehavior::Burst` mirrors Java's + // `scheduleAtFixedRate`: an overrun is followed by an immediate + // catch-up tick rather than a delayed one. + loop { + tokio::select! { + _ = interval.tick() => {} + _ = shutdown.cancelled() => return, + } + let items: Vec<(String, String)> = subscriptions + .lock() + .await + .iter() + .map(|((topic, url), _entry)| (topic.clone(), url.clone())) + .collect(); + if items.is_empty() { + debug!("heartbeat tick: no subscriptions yet"); + } else if let Ok(event) = codec::build_heartbeat(&config, &items) { + // Bound the RPC and let shutdown interrupt it, so a network + // black-hole cannot keep the heartbeat task alive indefinitely. + let outcome = await_with_timeout_or_shutdown( + &shutdown, + config.timeout, + client.heartbeat(event), + ) + .await; + match outcome { + OperationOutcome::Completed(Ok(resp)) => { + let response = codec::to_response(&resp); + if response.code == Some(StatusCode::CLIENT_RESUBSCRIBE as i64) { + warn!("server requested resubscribe (CLIENT_RESUBSCRIBE)"); + resubscribe(&client, &config, &subscriptions, &stream_tx, &shutdown) + .await; + if shutdown.is_cancelled() { + return; + } + } + debug!("heartbeat ok: {} items", items.len()); + } + OperationOutcome::Completed(Err(e)) => warn!("heartbeat failed: {e}"), + OperationOutcome::TimedOut => { + warn!("heartbeat timed out after {:?}", config.timeout) + } + OperationOutcome::Cancelled => return, + } + } + } + }) +} + +/// Re-register all active subscriptions after the server signals +/// `CLIENT_RESUBSCRIBE`. +/// +/// Subscriptions are grouped by URL. Webhook groups (url != `SDK_STREAM_URL`) +/// are re-registered via the `subscribe` unary RPC. Stream groups +/// (url == `SDK_STREAM_URL`) are re-sent as a subscription CloudEvent through +/// the active stream sender. If no stream is currently open, a warning is +/// logged and the stream subscriptions are skipped (the user must re-call +/// `subscribe_stream`). +async fn resubscribe( + client: &GrpcClient, + config: &GrpcClientConfig, + subscriptions: &Arc>>, + stream_tx: &StreamTx, + shutdown: &CancellationToken, +) { + // Collect and group subscriptions by URL. We hold the lock only briefly. + let groups: HashMap> = { + let guard = subscriptions.lock().await; + if guard.is_empty() { + return; + } + let mut groups: HashMap> = HashMap::new(); + for entry in guard.values() { + groups + .entry(entry.url.clone()) + .or_default() + .push(entry.item.clone()); + } + groups + }; + + info!("resubscribing {} group(s)", groups.len()); + + for (url, items) in groups { + let is_stream = url == SDK_STREAM_URL; + let event = match codec::build_subscription_event( + config, + EventMeshProtocolType::EventMeshMessage, + if is_stream { None } else { Some(&url) }, + &items, + ) { + Ok(e) => e, + Err(e) => { + warn!("resubscribe: failed to build subscription event for url={url}: {e}"); + continue; + } + }; + + if is_stream { + match stream_sender(stream_tx).await { + Some(tx) => { + // Reserve capacity before moving `event` into the channel. + // A timeout or shutdown therefore cancels only the wait + // for capacity. Crucially, the StreamTx mutex was released + // by `stream_sender` before this potentially long wait. + match await_with_timeout_or_shutdown(shutdown, config.timeout, tx.reserve()) + .await + { + OperationOutcome::Completed(Ok(permit)) => { + permit.send(event); + debug!("resubscribe: re-sent {} stream subscriptions", items.len()); + } + OperationOutcome::Completed(Err(_)) => warn!( + "resubscribe: stream channel closed; \ + stream subscriptions will not be re-sent" + ), + OperationOutcome::TimedOut => warn!( + "resubscribe: stream channel stayed backpressured \ + for {:?}; stream subscriptions will not be re-sent", + config.timeout + ), + OperationOutcome::Cancelled => return, + } + } + None => warn!( + "resubscribe: no active stream; \ + stream subscriptions will not be re-sent" + ), + } + } else { + match await_with_timeout_or_shutdown( + shutdown, + config.timeout, + client.subscribe_webhook(event), + ) + .await + { + OperationOutcome::Completed(Ok(_)) => { + debug!("resubscribe: webhook re-registered for url={url}") + } + OperationOutcome::Completed(Err(e)) => { + warn!("resubscribe: webhook re-register failed for url={url}: {e}") + } + OperationOutcome::TimedOut => warn!( + "resubscribe: webhook re-register timed out for url={url} after {:?}", + config.timeout + ), + OperationOutcome::Cancelled => return, + } + } + } +} + +#[cfg(test)] +mod tests { + use std::future::pending; + + use super::*; + + #[tokio::test] + async fn operation_wait_stops_on_shutdown() { + let shutdown = CancellationToken::new(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task_shutdown = shutdown.clone(); + let task = tokio::spawn(async move { + await_with_timeout_or_shutdown(&task_shutdown, Duration::from_secs(60), async move { + let _ = started_tx.send(()); + pending::<()>().await + }) + .await + }); + + started_rx.await.expect("operation should start"); + shutdown.cancel(); + + assert!(matches!( + task.await.expect("task should not panic"), + OperationOutcome::Cancelled + )); + } + + #[tokio::test(start_paused = true)] + async fn operation_wait_times_out() { + let shutdown = CancellationToken::new(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + await_with_timeout_or_shutdown(&shutdown, Duration::from_secs(1), async move { + let _ = started_tx.send(()); + pending::<()>().await + }) + .await + }); + + started_rx.await.expect("operation should start"); + tokio::time::advance(Duration::from_secs(1)).await; + + assert!(matches!( + task.await.expect("task should not panic"), + OperationOutcome::TimedOut + )); + } + + #[tokio::test] + async fn stream_sender_returns_a_snapshot_without_retaining_the_lock() { + let (tx, _rx) = mpsc::channel(1); + let stream_tx = Arc::new(Mutex::new(Some(tx))); + + assert!(stream_sender(&stream_tx).await.is_some()); + assert!(stream_tx.try_lock().is_ok()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/mod.rs new file mode 100644 index 0000000000..f902dd5376 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/mod.rs @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! gRPC transport for the EventMesh server. +//! +//! - [`GrpcProducer`] implements [`crate::transport::Publisher`]. +//! - [`GrpcStreamConsumer`] opens a bidirectional stream and dispatches +//! delivered messages to a [`crate::MessageListener`]. +//! - [`GrpcWebhookConsumer`] is a lightweight RPC-only client for webhook +//! subscriptions. +//! +//! Wire format is CloudEvents-protobuf; [`EventMeshMessage`] is converted at +//! the boundary by [`codec`]. + +pub mod client; +pub mod codec; +pub mod consumer; +pub mod heartbeat; +pub mod producer; + +pub use client::GrpcClient; +pub use consumer::{GrpcStreamConsumer, GrpcWebhookConsumer}; +pub use producer::GrpcProducer; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/producer.rs new file mode 100644 index 0000000000..03df5eb8b4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/producer.rs @@ -0,0 +1,310 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! gRPC producer. + +use std::time::Duration; + +use tracing::debug; + +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, PublishResponse}; +use crate::transport::grpc::client::GrpcClient; +use crate::transport::grpc::codec; +use crate::transport::Publisher; + +/// gRPC-based producer. +pub struct GrpcProducer { + client: GrpcClient, + config: crate::config::GrpcClientConfig, +} + +impl GrpcProducer { + /// Connect (lazily) using the given config. + pub fn connect(config: crate::config::GrpcClientConfig) -> Result { + let client = GrpcClient::new(&config)?; + Ok(Self { client, config }) + } + + /// Publish fire-and-forget via `publishOneWay` (no reply expected from the + /// broker beyond the RPC ack). Useful when you don't care about per-message + /// broker ack codes. + pub async fn publish_one_way(&self, message: EventMeshMessage) -> Result<()> { + let event = codec::from_event_mesh_message(&message, &self.config)?; + timed(self.config.timeout, self.client.publish_one_way(event)).await?; + Ok(()) + } + + /// Publish an OpenMessaging value while retaining its protocol type. + pub async fn publish_open_message( + &self, + message: crate::model::OpenMessage, + ) -> Result { + validate_publish(&message.to_event_mesh_message())?; + let event = codec::from_open_message(&message, &self.config)?; + let response = + codec::to_response(&timed(self.config.timeout, self.client.publish(event)).await?); + ensure_success(response, "publish failed") + } + + /// Send an OpenMessaging request and decode its reply as OpenMessaging. + pub async fn request_reply_open_message( + &self, + message: crate::model::OpenMessage, + timeout: Duration, + ) -> Result { + validate_publish(&message.to_event_mesh_message())?; + let event = codec::from_open_message(&message, &self.config)?; + let response = tokio::time::timeout(timeout, self.client.request_reply(event)) + .await + .map_err(|_| EventMeshError::Timeout(timeout))??; + ensure_success(codec::to_response(&response), "request-reply failed")?; + Ok(crate::model::OpenMessage::from_event_mesh_message( + codec::to_event_mesh_message(&response), + )) + } + + /// Publish an OpenMessaging value without waiting for a broker response. + pub async fn publish_one_way_open_message( + &self, + message: crate::model::OpenMessage, + ) -> Result<()> { + validate_publish(&message.to_event_mesh_message())?; + let event = codec::from_open_message(&message, &self.config)?; + timed(self.config.timeout, self.client.publish_one_way(event)).await?; + Ok(()) + } + + /// Publish a native/OpenMessaging batch with a protocol discriminator on + /// every element. + pub(crate) async fn publish_message_batch( + &self, + messages: Vec, + ) -> Result { + let mut events = Vec::with_capacity(messages.len()); + for message in messages { + events.push(match message { + crate::message::Message::EventMesh(message) => { + validate_publish(&message)?; + codec::from_event_mesh_message(&message, &self.config)? + } + crate::message::Message::Open(message) => { + validate_publish(&message.to_event_mesh_message())?; + codec::from_open_message(&message, &self.config)? + } + #[cfg(feature = "cloud_events")] + crate::message::Message::CloudEvent(_) => { + return Err(EventMeshError::Unsupported( + "CloudEvents must use the CloudEvents batch path".into(), + )); + } + }); + } + let response = codec::to_response( + &timed( + self.config.timeout, + self.client + .batch_publish(crate::proto_gen::PbCloudEventBatch { events }), + ) + .await?, + ); + ensure_success(response, "batch publish failed") + } + + #[cfg(feature = "cloud_events")] + /// Publish a native CloudEvent. + pub async fn publish_cloud_event(&self, event: cloudevents::Event) -> Result { + use cloudevents::AttributesReader; + + let ce = codec::from_cloudevent(&event, &self.config)?; + let resp = timed(self.config.timeout, self.client.publish(ce)).await?; + let response = codec::to_response(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| "publish failed".into()), + }); + } + debug!("published CloudEvent id={:?}", event.id()); + Ok(response) + } + + /// Publish several native CloudEvents in a single gRPC batch RPC. + #[cfg(feature = "cloud_events")] + pub async fn publish_cloud_event_batch( + &self, + events: Vec, + ) -> Result { + if events.is_empty() { + return Err(EventMeshError::InvalidArgument( + "batch publish requires at least one CloudEvent".into(), + )); + } + let mut wire_events = Vec::with_capacity(events.len()); + for event in &events { + wire_events.push(codec::from_cloudevent(event, &self.config)?); + } + let resp = timed( + self.config.timeout, + self.client + .batch_publish(crate::proto_gen::PbCloudEventBatch { + events: wire_events, + }), + ) + .await?; + let response = codec::to_response(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "CloudEvents batch publish failed".into()), + }); + } + Ok(response) + } + + /// Send a native CloudEvent and wait for a native CloudEvent reply. + #[cfg(feature = "cloud_events")] + pub async fn request_reply_cloud_event( + &self, + event: cloudevents::Event, + timeout: Duration, + ) -> Result { + let event = codec::from_cloudevent(&event, &self.config)?; + let response = tokio::time::timeout(timeout, self.client.request_reply(event)) + .await + .map_err(|_| EventMeshError::Timeout(timeout))??; + let status = codec::to_response(&response); + if !status.is_success() { + return Err(EventMeshError::Server { + code: status.code.unwrap_or(-1) as i32, + message: status + .message + .unwrap_or_else(|| "CloudEvents request/reply failed".into()), + }); + } + codec::to_cloudevent(response) + } +} + +fn ensure_success(response: PublishResponse, fallback: &str) -> Result { + if response.is_success() { + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| fallback.into()), + }) + } +} + +impl Publisher for GrpcProducer { + async fn publish(&self, message: EventMeshMessage) -> Result { + validate_publish(&message)?; + let event = codec::from_event_mesh_message(&message, &self.config)?; + let resp = timed(self.config.timeout, self.client.publish(event)).await?; + let response = codec::to_response(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| "publish failed".into()), + }); + } + debug!("published topic={:?}", message.topic); + Ok(response) + } + + async fn publish_batch(&self, messages: Vec) -> Result { + if messages.is_empty() { + return Err(EventMeshError::InvalidArgument( + "batch publish requires at least one message".into(), + )); + } + for m in &messages { + validate_publish(m)?; + } + let batch = codec::from_event_mesh_messages(&messages, &self.config)?; + let resp = timed(self.config.timeout, self.client.batch_publish(batch)).await?; + let response = codec::to_response(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "batch publish failed".into()), + }); + } + Ok(response) + } + + async fn request_reply( + &self, + message: EventMeshMessage, + timeout: Duration, + ) -> Result { + validate_publish(&message)?; + let event = codec::from_event_mesh_message(&message, &self.config)?; + let fut = self.client.request_reply(event); + let resp = tokio::time::timeout(timeout, fut) + .await + .map_err(|_| EventMeshError::Timeout(timeout))??; + // The runtime reports request/reply failures (ACL denial, invalid + // attributes, timeout waiting for a reply, ...) as a response CloudEvent + // carrying a nonzero status code. Check it before decoding, mirroring + // `publish`, so `?` does not treat a failed reply as a valid message. + let response = codec::to_response(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "request/reply failed".into()), + }); + } + Ok(codec::to_event_mesh_message(&resp)) + } +} + +fn validate_publish(message: &EventMeshMessage) -> Result<()> { + if message + .topic + .as_deref() + .map(|t| t.trim().is_empty()) + .unwrap_or(true) + { + return Err(EventMeshError::InvalidMessage("topic is required".into())); + } + if message + .content + .as_deref() + .map(|c| c.is_empty()) + .unwrap_or(true) + { + return Err(EventMeshError::InvalidMessage("content is required".into())); + } + Ok(()) +} + +/// Apply the config's default request timeout to a short unary RPC. Long-lived +/// RPCs (subscribe stream) and caller-controlled RPCs (request_reply) bypass +/// this and use their own timeouts. +async fn timed(timeout: Duration, f: impl std::future::Future>) -> Result { + tokio::time::timeout(timeout, f) + .await + .map_err(|_| EventMeshError::Timeout(timeout))? +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/client.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/client.rs new file mode 100644 index 0000000000..abe59b47b9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/client.rs @@ -0,0 +1,125 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Low-level HTTP client: reqwest wrapper with connection pooling and +//! load balancing across multiple EventMesh nodes. + +use std::sync::Arc; +use std::time::Duration; + +use reqwest::Client; + +use crate::common::loadbalance::{LoadBalanceSelector, ServerNode}; +use crate::config::HttpClientConfig; +use crate::error::{EventMeshError, Result}; + +/// A pooled, load-balanced HTTP client connected to one or more EventMesh +/// runtime nodes. +/// +/// Cheaply cloneable (wraps `Arc`). +#[derive(Clone)] +pub struct EventMeshHttpClient { + inner: Client, + selector: Arc, + config: Arc, +} + +impl EventMeshHttpClient { + /// Build from a config. + pub fn new(config: HttpClientConfig) -> Result { + let selector = LoadBalanceSelector::new(config.nodes.clone(), config.load_balance)?; + + let mut builder = Client::builder() + .pool_max_idle_per_host(config.pool_size) + .pool_idle_timeout(Some(config.pool_idle_timeout)) + .tcp_nodelay(true); + + if config.use_tls { + builder = builder.https_only(true); + } + + let inner = builder + .build() + .map_err(|e| EventMeshError::Config(format!("reqwest client build error: {e}")))?; + + Ok(Self { + inner, + selector: Arc::new(selector), + config: Arc::new(config), + }) + } + + /// Pick the next server node via the configured load-balance strategy. + pub fn select_node(&self) -> &ServerNode { + self.selector.select() + } + + /// Build the base URL for the next request: `http(s)://host:port`. + pub fn base_url(&self) -> String { + let node = self.select_node(); + let scheme = if self.config.use_tls { "https" } else { "http" }; + format!("{}://{}", scheme, node.addr()) + } + + /// Build a full URL for the given path. + pub fn url_for(&self, path: &str) -> String { + format!("{}{}", self.base_url(), path) + } + + /// Send a POST with form-urlencoded body and extra headers. Returns the + /// response body text. + pub async fn post_form( + &self, + path: &str, + body: &[(String, String)], + headers: &[(&str, String)], + timeout: Duration, + ) -> Result { + let url = self.url_for(path); + tracing::debug!("HTTP POST {} (timeout={:?})", url, timeout); + + let mut req = self.inner.post(&url).form(body).timeout(timeout); + for (k, v) in headers { + req = req.header(*k, v); + } + + let resp = req.send().await.map_err(|e| EventMeshError::Http { + status: 0, + message: format!("request failed: {e}"), + })?; + + let status = resp.status().as_u16(); + let text = resp.text().await.map_err(|e| EventMeshError::Http { + status, + message: format!("failed to read response body: {e}"), + })?; + + if !(200..300).contains(&status) { + return Err(EventMeshError::Http { + status, + message: text, + }); + } + + Ok(text) + } + + /// Reference to the config. + pub fn config(&self) -> &HttpClientConfig { + &self.config + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/codec.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/codec.rs new file mode 100644 index 0000000000..ba6db19fc0 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/codec.rs @@ -0,0 +1,822 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Codec for the EventMesh HTTP wire format. +//! +//! All HTTP request bodies are `application/x-www-form-urlencoded`. Message +//! payloads are serialized as JSON strings and placed in the `content` field. +//! This mirrors the Java SDK's `EventMeshMessageProducer` / +//! `CloudEventProducer` / `EventMeshHttpConsumer` wire format. +//! +//! # Building a custom webhook endpoint +//! +//! Besides the built-in [`WebhookServer`](crate::transport::http::WebhookServer), +//! you can host your own HTTP endpoint (axum, actix, plain hyper, …) and decode +//! runtime pushes with these framework-agnostic helpers: +//! +//! - [`parse_push_body`] — parse the form-urlencoded push body into a +//! [`PushMessageRequestBody`]. +//! - [`PushMessageRequestBody::to_event_mesh_message`] — decode it into an +//! [`EventMeshMessage`]. +//! - [`WebhookReply`] — the JSON acknowledgment the runtime expects +//! ([`WebhookReply::ok()`] returns `retCode: 1`; the runtime also accepts +//! `retCode: 0`. A non-zero code other than 1 requests retry). +//! +//! ```ignore +//! # use eventmesh::http::codec::{parse_push_body, WebhookReply}; +//! # use eventmesh::MessageListener; +//! # use eventmesh::model::EventMeshMessage; +//! # use axum::{extract::State, response::IntoResponse, Json}; +//! # use bytes::Bytes; +//! # use std::sync::Arc; +//! # struct MyListener; +//! # impl MessageListener for MyListener { +//! # type Message = EventMeshMessage; +//! # async fn handle(&self, _: Self::Message) -> Option { None } +//! # } +//! // Axum handler written by the user — no SDK handler type involved. +//! async fn webhook( +//! State(listener): State>, +//! body: Bytes, +//! ) -> impl IntoResponse { +//! let text = match std::str::from_utf8(&body) { +//! Ok(s) => s, +//! Err(_) => return Json(WebhookReply::retry("invalid UTF-8")), +//! }; +//! match parse_push_body(text).and_then(|p| p.to_event_mesh_message()) { +//! Ok(msg) => { +//! listener.handle(msg).await; +//! Json(WebhookReply::ok()) +//! } +//! Err(_) => Json(WebhookReply::retry("decode error")), +//! } +//! } +//! ``` +//! +//! See the `http_consumer_custom` example for a complete, runnable version. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::common::status_code::RequestCode; +use crate::common::util::RandomStringUtils; +use crate::common::{ProtocolKey, DEFAULT_MESSAGE_TTL}; +use crate::config::ClientIdentity; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, EventMeshProtocolType, PublishResponse, SubscriptionItem}; + +/// Default protocol version string sent in the `version` and +/// `protocolversion` headers. +/// +/// Must be `"1.0"` (not `"V1.0"`): the runtime resolves it via +/// `ProtocolVersion.get("1.0")` and compares `protocolversion` against +/// CloudEvents `SpecVersion.V1` (`"1.0"`). +const PROTOCOL_VERSION: &str = "1.0"; + +/// Runtime endpoint paths (mirrors `RequestURI.java`). +/// +/// The EventMesh HTTP server has two routing mechanisms, checked in this order: +/// +/// 1. **Path-based** (`HandlerService`): requests whose URI *starts-with* a +/// registered path are dispatched to that path's processor and the code +/// header is never consulted. `/eventmesh/subscribe/local` and +/// `/eventmesh/unsubscribe/local` are registered this way +/// (`LocalSubscribeEventProcessor` / `LocalUnSubscribeEventProcessor`). +/// These path handlers parse the body as JSON: a form-urlencoded `topic` +/// field becomes a string value that cannot be deserialized as +/// `List`, so **form-based subscribe/unsubscribe must +/// avoid these paths**. +/// 2. **Code-header-based** (`httpRequestProcessorTable`): if no path matches, +/// the runtime reads the `code` header and looks up the processor by request +/// code (SUBSCRIBE, UNSUBSCRIBE, MSG_SEND_ASYNC, HEARTBEAT, …). +/// +/// Because this SDK sends `application/x-www-form-urlencoded` bodies (matching +/// the Java SDK), **all** operations — publish, subscribe, unsubscribe, and +/// heartbeat — must use [`ROOT`] so the request falls through to code-header +/// dispatch. Posting to a path-based handler with a form body breaks body +/// decoding on the runtime side. +pub mod uri { + /// Root path — matches no path-based handler, forcing code-header routing. + pub const ROOT: &str = "/"; + /// Async single-message publish — path-based handler `SendAsyncEventProcessor`. + pub const PUBLISH: &str = "/eventmesh/publish"; + /// Path-based subscribe handler on the runtime side (`LocalSubscribeEventProcessor`). + /// + /// **Do not use for form-based subscribe** — that handler expects a JSON + /// body and fails to deserialize a form-urlencoded `topic` field. Use + /// [`ROOT`] with the `SUBSCRIBE` code header instead. + pub const SUBSCRIBE: &str = "/eventmesh/subscribe/local"; + /// Path-based unsubscribe handler on the runtime side (`LocalUnSubscribeEventProcessor`). + /// + /// **Do not use for form-based unsubscribe** — same issue as [`SUBSCRIBE`]. + /// Use [`ROOT`] with the `UNSUBSCRIBE` code header instead. + pub const UNSUBSCRIBE: &str = "/eventmesh/unsubscribe/local"; + /// Heartbeat — no dedicated path handler; any non-matching path works. + pub const HEARTBEAT: &str = "/eventmesh/heartbeat"; +} + +/// The JSON reply body returned by the EventMesh runtime for publish / +/// subscribe / heartbeat operations. +/// +/// Mirrors `org.apache.eventmesh.common.protocol.http.body.Body`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EventMeshRetObj { + #[serde(rename = "retCode")] + pub ret_code: i64, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "retMsg")] + pub ret_msg: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "resTime")] + pub res_time: Option, +} + +impl EventMeshRetObj { + pub fn is_success(&self) -> bool { + self.ret_code == 0 + } +} + +impl From for PublishResponse { + fn from(obj: EventMeshRetObj) -> Self { + PublishResponse::new(Some(obj.ret_code), obj.ret_msg, obj.res_time) + } +} + +/// The JSON body returned by a webhook consumer to acknowledge a pushed +/// message. The runtime reads `retCode` (`ProtocolKey.RETCODE`) from this +/// JSON to determine delivery success, so the field names **must** be +/// camelCase. +#[derive(Debug, Clone, Serialize)] +pub struct WebhookReply { + #[serde(rename = "retCode")] + pub ret_code: i32, + #[serde(skip_serializing_if = "Option::is_none", rename = "retMsg")] + pub ret_msg: Option, +} + +impl WebhookReply { + pub fn ok() -> Self { + Self { + ret_code: crate::common::status_code::ClientRetCode::Ok as i32, + ret_msg: Some("OK".into()), + } + } + + pub fn retry(msg: impl Into) -> Self { + Self { + ret_code: crate::common::status_code::ClientRetCode::Retry as i32, + ret_msg: Some(msg.into()), + } + } +} + +/// The form-urlencoded body pushed by the runtime to a consumer's webhook URL. +/// +/// Mirrors `PushMessageRequestBody`. The `content` and `extFields` fields are +/// themselves JSON strings embedded inside the form body. +#[derive(Debug, Clone, Deserialize)] +pub struct PushMessageRequestBody { + /// Message payload (typically a JSON-serialized CloudEvent or EventMeshMessage). + pub content: String, + #[serde(default)] + pub bizseqno: Option, + #[serde(default, rename = "uniqueId")] + pub unique_id: Option, + #[serde(default, rename = "randomNo")] + pub random_no: Option, + #[serde(default)] + pub topic: Option, + /// JSON-encoded `Map` of extension attributes. + #[serde(default, rename = "extFields")] + pub extfields: Option, +} + +impl PushMessageRequestBody { + /// Decode the pushed body into an [`EventMeshMessage`]. + /// + /// The `content` field is **always** treated as the business payload — + /// the Runtime puts the original user payload there, not a serialized + /// `EventMeshMessage`. Message metadata (`topic`, `bizseqno`, + /// `uniqueId`, `extFields`) is taken from the form-level fields. + pub fn to_event_mesh_message(&self) -> Result { + let mut msg = EventMeshMessage::builder() + .content(self.content.clone()) + .build(); + msg.topic = self.topic.clone(); + msg.biz_seq_no = self.bizseqno.clone(); + msg.unique_id = self.unique_id.clone(); + + // Parse extFields JSON into props. Invalid JSON is an error — the + // webhook handler returns a retry reply so the runtime redelivers, + // matching the Java runtime's PushMessageRequestBody.buildBody() + // which throws on parse failure. + if let Some(ext) = &self.extfields { + let trimmed = ext.trim(); + if !trimmed.is_empty() { + let props: HashMap = + serde_json::from_str(trimmed).map_err(|e| EventMeshError::Protocol { + transport: "http", + message: format!("failed to parse extFields JSON: {e}"), + })?; + msg.props = props; + } + } + + Ok(msg) + } +} + +// ---------- Encoding helpers (producer side) ---------- + +/// Build the HTTP headers for a request. +/// +/// Identity fields (`env`, `idc`, `sys`, `pid`, `ip`, `username`, `passwd`, +/// `language`, and the optional `token`) are sent as HTTP headers, mirroring +/// the Java SDK's `EventMeshMessageProducer.buildCommonPostParam` / +/// `EventMeshHttpConsumer.buildCommonRequestParam` and the runtime's +/// `ProtocolKey.ClientInstanceKey` handling. The runtime reads identity +/// exclusively from headers — never from the form body. +pub fn build_headers( + code: i32, + protocol_type: EventMeshProtocolType, + identity: &ClientIdentity, +) -> Vec<(&'static str, String)> { + let mut headers = vec![ + ("code", code.to_string()), + ("env", identity.env.clone()), + ("idc", identity.idc.clone()), + ("sys", identity.sys.clone()), + ("pid", identity.pid.clone()), + ("ip", identity.ip.clone()), + ("username", identity.username.clone()), + ("passwd", identity.password.clone()), + ("language", identity.language.clone()), + ("version", PROTOCOL_VERSION.to_string()), + ("protocoltype", protocol_type.as_str().to_string()), + ("protocolversion", PROTOCOL_VERSION.to_string()), + ("protocoldesc", "http".to_string()), + ]; + if let Some(token) = &identity.token { + headers.push(("token", token.clone())); + } + headers +} + +/// Encode an [`EventMeshMessage`] into form-urlencoded body fields for a +/// publish request. +/// +/// Identity fields are NOT included here — they are sent as HTTP headers via +/// [`build_headers`]. Only the message-specific fields (`producergroup`, +/// `topic`, `content`, `ttl`, `bizseqno`, `uniqueid`) go in the body, matching +/// `SendMessageRequestBody` on the Java side. +pub fn encode_publish(msg: &EventMeshMessage, identity: &ClientIdentity) -> Vec<(String, String)> { + let mut fields: Vec<(String, String)> = Vec::new(); + fields.push(("producergroup".into(), identity.producer_group.clone())); + fields.push(("topic".into(), msg.topic.clone().unwrap_or_default())); + fields.push(("content".into(), msg.content.clone().unwrap_or_default())); + // Always emit a `ttl` form field, falling back to `DEFAULT_MESSAGE_TTL` + // when the caller did not set one. The runtime's + // `SendSyncMessageProcessor` rejects a blank TTL with + // `EVENTMESH_PROTOCOL_BODY_ERR` before any defaulting (unlike the async + // processor, which patches in a default after validation), so request-reply + // calls would fail whenever `EventMeshMessage::ttl` / the `ttl` prop is + // unset. This mirrors the gRPC codec (and the Java gRPC SDK's + // `EventMeshCloudEventBuilder`, which falls back to + // `Constants.DEFAULT_EVENTMESH_MESSAGE_TTL`). + // + // NOTE: this intentionally diverges from the Java HTTP SDK's + // `EventMeshMessageProducer.buildCommonPostParam`, which does + // `addBody(TTL, message.getProp("ttl"))` with no fallback — emitting a + // blank `ttl=` when the prop is unset and hitting the same runtime + // rejection on the sync path. Defaulting here keeps the Rust HTTP + // transport consistent with its own gRPC transport. + let ttl = msg + .ttl + .map(|t| t.to_string()) + .or_else(|| msg.get_prop(ProtocolKey::TTL).map(str::to_string)) + .unwrap_or_else(|| DEFAULT_MESSAGE_TTL.to_string()); + fields.push(("ttl".into(), ttl)); + // The runtime's code-header publish processors (MSG_SEND_ASYNC / + // MSG_SEND_SYNC) require non-blank `bizseqno` and `uniqueid` and reject + // with EVENTMESH_PROTOCOL_BODY_ERR when either is missing. Mirror the gRPC + // codec and the Java CloudEventProducer by auto-generating them when the + // caller did not supply values. + let biz = msg + .biz_seq_no + .as_deref() + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + let uid = msg + .unique_id + .as_deref() + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + fields.push(("bizseqno".into(), biz)); + fields.push(("uniqueid".into(), uid)); + if !msg.props.is_empty() { + // Filter out reserved keys that are already emitted as typed form + // fields above. The runtime post-processes `extFields` and would + // reverse-overwrite the typed values (e.g. a stale `ttl` prop + // clobbering the resolved TTL, or an old `bizseqno` overwriting the + // auto-generated one). + const RESERVED_KEYS: &[&str] = &[ + "producergroup", + "topic", + "content", + "ttl", + "bizseqno", + "uniqueid", + ]; + let filtered: HashMap<&String, &String> = msg + .props + .iter() + .filter(|(k, _)| !RESERVED_KEYS.contains(&k.as_str())) + .collect(); + if !filtered.is_empty() { + fields.push(( + "extFields".into(), + serde_json::to_string(&filtered).unwrap_or_default(), + )); + } + } + fields +} + +/// Encode subscribe body fields. +pub fn encode_subscribe( + items: &[SubscriptionItem], + url: &str, + identity: &ClientIdentity, +) -> Vec<(String, String)> { + vec![ + ("consumerGroup".into(), identity.consumer_group.clone()), + ( + "topic".into(), + serde_json::to_string(items).unwrap_or_default(), + ), + ("url".into(), url.to_string()), + ] +} + +/// Encode unsubscribe body fields. +pub fn encode_unsubscribe( + topics: &[String], + url: &str, + identity: &ClientIdentity, +) -> Vec<(String, String)> { + vec![ + ("consumerGroup".into(), identity.consumer_group.clone()), + ( + "topic".into(), + serde_json::to_string(topics).unwrap_or_default(), + ), + ("url".into(), url.to_string()), + ] +} + +/// Encode heartbeat body fields. +pub fn encode_heartbeat( + items: &[(String, String)], + identity: &ClientIdentity, +) -> Vec<(String, String)> { + use crate::model::HeartbeatItem; + + let entities: Vec = items + .iter() + .map(|(topic, url)| HeartbeatItem::new(topic.clone(), url.clone())) + .collect(); + vec![ + ("consumerGroup".into(), identity.consumer_group.clone()), + ("clientType".into(), "2".into()), // SUB + ( + "heartbeatEntities".into(), + serde_json::to_string(&entities).unwrap_or_default(), + ), + ] +} + +/// Parse a `EventMeshRetObj` from the response body text, returning a +/// [`PublishResponse`]. +pub fn parse_response(body: &str) -> Result { + let obj: EventMeshRetObj = serde_json::from_str(body)?; + Ok(obj.into()) +} + +/// The reply payload returned inside the `retMsg` field of a request-reply +/// `EventMeshRetObj`. +/// +/// Mirrors `SendMessageResponseBody.ReplyMessage` on the Java side: +/// `topic`, `body`, and `properties`. +#[derive(Debug, Clone, Deserialize)] +pub struct ReplyMessage { + #[serde(default)] + pub topic: Option, + #[serde(default)] + pub body: Option, + #[serde(default)] + pub properties: HashMap, +} + +/// Parse the reply message from the `retMsg` field of a request-reply +/// response, mapping `body` → `content`, `topic` → `topic`, and +/// `properties` → `props`. +/// +/// Mirrors the Java SDK's `EventMeshMessageProducer.transformMessage`, which +/// deserializes `retMsg` as a `SendMessageResponseBody.ReplyMessage`. +pub fn parse_reply(ret_msg: &str) -> Result { + let reply: ReplyMessage = serde_json::from_str(ret_msg)?; + Ok(EventMeshMessage::builder() + .topic(reply.topic.unwrap_or_default()) + .content(reply.body.unwrap_or_default()) + .props(reply.properties) + .build()) +} + +/// Form-encode a list of `(key, value)` pairs into a URL-encoded body string. +pub fn form_encode(fields: &[(String, String)]) -> String { + serde_urlencoded::to_string(fields).unwrap_or_default() +} + +/// Request code for the given operation. +pub fn publish_code() -> i32 { + RequestCode::MSG_SEND_ASYNC +} + +/// Request code for synchronous request-reply (code-based routing). +pub fn publish_sync_code() -> i32 { + RequestCode::MSG_SEND_SYNC +} + +pub fn subscribe_code() -> i32 { + RequestCode::SUBSCRIBE +} + +pub fn unsubscribe_code() -> i32 { + RequestCode::UNSUBSCRIBE +} + +pub fn heartbeat_code() -> i32 { + RequestCode::HEARTBEAT +} + +/// Decode a webhook push body (form-urlencoded) into fields. +pub fn parse_push_body(body: &str) -> Result { + serde_urlencoded::from_str(body).map_err(|e| EventMeshError::Protocol { + transport: "http", + message: format!("form decode error: {e}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_publish_round_trip() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("test-topic") + .content("hello") + .biz_seq_no("seq-1") + .build(); + let fields = encode_publish(&msg, &identity); + let encoded = form_encode(&fields); + assert!(encoded.contains("topic=test-topic")); + assert!(encoded.contains("bizseqno=seq-1")); + // content should be the raw content string, NOT the whole message + // serialized as JSON (matches the Java SDK's EventMeshMessageProducer). + assert!(encoded.contains("content=hello")); + assert!(!encoded.contains("biz_seq_no")); + } + + #[test] + fn encode_publish_auto_generates_ids_when_missing() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder().topic("t").content("c").build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + let biz = map + .get("bizseqno") + .expect("bizseqno should be auto-generated"); + let uid = map + .get("uniqueid") + .expect("uniqueid should be auto-generated"); + assert!(!biz.is_empty()); + assert!(!uid.is_empty()); + assert!(biz.chars().all(|c| c.is_ascii_digit())); + assert!(uid.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn encode_publish_keeps_caller_supplied_ids() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .biz_seq_no("my-seq") + .unique_id("my-uid") + .build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + assert_eq!(map.get("bizseqno"), Some(&"my-seq".to_string())); + assert_eq!(map.get("uniqueid"), Some(&"my-uid".to_string())); + } + + #[test] + fn encode_publish_keeps_identity_out_of_body() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder().topic("t").content("c").build(); + let fields = encode_publish(&msg, &identity); + let encoded = form_encode(&fields); + // Identity must be in headers, not body. + assert!(!encoded.contains("env=")); + assert!(!encoded.contains("username=")); + assert!(!encoded.contains("passwd=")); + assert!(!encoded.contains("pid=")); + } + + #[test] + fn encode_publish_includes_ext_fields() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .prop("key1", "val1") + .prop("key2", "val2") + .build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + let ext = map.get("extFields").expect("extFields should be present"); + let props: HashMap = serde_json::from_str(ext).unwrap(); + assert_eq!(props.get("key1"), Some(&"val1".to_string())); + assert_eq!(props.get("key2"), Some(&"val2".to_string())); + } + + #[test] + fn encode_publish_filters_reserved_keys_from_ext_fields() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .ttl_millis(7_000) + .biz_seq_no("my-seq") + .unique_id("my-uid") + .prop("key1", "val1") + .prop("ttl", "99000") + .prop("bizseqno", "stale-seq") + .prop("uniqueid", "stale-uid") + .prop("topic", "stale-topic") + .prop("content", "stale-content") + .prop("producergroup", "stale-group") + .build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + let ext = map.get("extFields").expect("extFields should be present"); + let props: HashMap = serde_json::from_str(ext).unwrap(); + // Non-reserved keys survive. + assert_eq!(props.get("key1"), Some(&"val1".to_string())); + // Reserved keys are filtered out — they are already emitted as typed + // form fields and must not reverse-overwrite via extFields. + assert!(!props.contains_key("ttl")); + assert!(!props.contains_key("bizseqno")); + assert!(!props.contains_key("uniqueid")); + assert!(!props.contains_key("topic")); + assert!(!props.contains_key("content")); + assert!(!props.contains_key("producergroup")); + } + + #[test] + fn encode_publish_omits_ext_fields_when_all_props_filtered() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .prop("ttl", "99000") + .prop("bizseqno", "stale") + .build(); + let fields = encode_publish(&msg, &identity); + // All props were reserved keys → no extFields field should be emitted. + assert!(!fields.iter().any(|(k, _)| k == "extFields")); + } + + #[test] + fn encode_publish_omits_ext_fields_when_empty() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder().topic("t").content("c").build(); + let fields = encode_publish(&msg, &identity); + assert!(!fields.iter().any(|(k, _)| k == "extFields")); + } + + #[test] + fn encode_publish_defaults_ttl_when_unset() { + // The runtime's SendSyncMessageProcessor rejects a blank TTL with + // EVENTMESH_PROTOCOL_BODY_ERR, so encode_publish must always emit one. + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder().topic("t").content("c").build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + let ttl = map.get("ttl").expect("ttl should always be present"); + assert_eq!(ttl, &DEFAULT_MESSAGE_TTL.to_string()); + } + + #[test] + fn encode_publish_keeps_caller_supplied_ttl() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .ttl_millis(30_000) + .build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + assert_eq!(map.get("ttl"), Some(&"30000".to_string())); + } + + #[test] + fn encode_publish_keeps_ttl_from_prop_when_field_unset() { + // A `ttl` prop should be honored when the typed `ttl` field is None, + // matching the gRPC codec's fallback chain. + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .prop(ProtocolKey::TTL, "99000") + .build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + assert_eq!(map.get("ttl"), Some(&"99000".to_string())); + } + + #[test] + fn encode_publish_typed_ttl_takes_precedence_over_prop() { + let identity = ClientIdentity::detect(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .ttl_millis(7_000) + .prop(ProtocolKey::TTL, "99000") + .build(); + let fields = encode_publish(&msg, &identity); + let map: HashMap = fields.into_iter().collect(); + assert_eq!(map.get("ttl"), Some(&"7000".to_string())); + } + + #[test] + fn build_headers_carries_identity_and_token() { + let mut identity = ClientIdentity::detect(); + identity.token = Some("my-jwt".into()); + let headers = build_headers( + RequestCode::MSG_SEND_ASYNC, + EventMeshProtocolType::EventMeshMessage, + &identity, + ); + let header_str: String = headers + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("\n"); + assert!(header_str.contains("env=")); + assert!(header_str.contains("username=")); + assert!(header_str.contains("passwd=")); + assert!(header_str.contains("pid=")); + assert!(header_str.contains("token=my-jwt")); + } + + #[test] + fn build_headers_omits_token_when_unset() { + let identity = ClientIdentity::detect(); + assert!(identity.token.is_none()); + let headers = build_headers( + RequestCode::MSG_SEND_ASYNC, + EventMeshProtocolType::EventMeshMessage, + &identity, + ); + assert!(!headers.iter().any(|(k, _)| *k == "token")); + } + + #[test] + fn publish_sync_code_is_101() { + assert_eq!(publish_sync_code(), RequestCode::MSG_SEND_SYNC); + assert_eq!(publish_sync_code(), 101); + } + + #[test] + fn parse_response_success() { + let body = r#"{"retCode":0,"retMsg":"success","resTime":42}"#; + let resp = parse_response(body).unwrap(); + assert!(resp.is_success()); + assert_eq!(resp.time, Some(42)); + } + + #[test] + fn parse_response_missing_ret_code_is_error() { + let body = r#"{"retMsg":"oops"}"#; + assert!(parse_response(body).is_err()); + } + + #[test] + fn parse_response_empty_object_is_error() { + assert!(parse_response("{}").is_err()); + } + + #[test] + fn parse_response_non_numeric_ret_code_is_error() { + let body = r#"{"retCode":"abc"}"#; + assert!(parse_response(body).is_err()); + } + + #[test] + fn parse_reply_from_ret_msg() { + let ret_msg = r#"{"topic":"reply-topic","body":"reply-body","properties":{"k":"v"}}"#; + let msg = parse_reply(ret_msg).unwrap(); + assert_eq!(msg.topic.as_deref(), Some("reply-topic")); + assert_eq!(msg.content.as_deref(), Some("reply-body")); + assert_eq!(msg.get_prop("k"), Some("v")); + } + + #[test] + fn parse_push_body_form_urlencoded() { + let body = "content=hello&topic=test-topic&bizseqno=seq1"; + let parsed = parse_push_body(body).unwrap(); + assert_eq!(parsed.content, "hello"); + assert_eq!(parsed.topic.as_deref(), Some("test-topic")); + } + + #[test] + fn push_body_to_message_with_json_content() { + // The Runtime puts the *business payload* in `content`, not a + // serialized EventMeshMessage. A JSON payload that happens to + // contain a `create_time` field must NOT be misinterpreted as a + // full EventMeshMessage — it must be preserved verbatim and the + // form-level metadata (topic, bizseqno, extFields) must be applied. + let business_json = r#"{"create_time":123,"order_id":"x"}"#; + let body = form_encode(&[ + ("content".to_string(), business_json.to_string()), + ("topic".to_string(), "test-topic".to_string()), + ("bizseqno".to_string(), "seq-1".to_string()), + ]); + let parsed = parse_push_body(&body).unwrap(); + let msg = parsed.to_event_mesh_message().unwrap(); + assert_eq!(msg.content.as_deref(), Some(business_json)); + assert_eq!(msg.topic.as_deref(), Some("test-topic")); + assert_eq!(msg.biz_seq_no.as_deref(), Some("seq-1")); + } + + #[test] + fn push_body_decodes_ext_fields_camel_case() { + // The runtime sends extFields (camelCase) as a JSON-encoded map string. + let props_json = r#"{"prop1":"val1","prop2":"val2"}"#; + let body = form_encode(&[ + ("content".to_string(), "hello".to_string()), + ("extFields".to_string(), props_json.to_string()), + ]); + let parsed = parse_push_body(&body).unwrap(); + assert_eq!(parsed.extfields.as_deref(), Some(props_json)); + let msg = parsed.to_event_mesh_message().unwrap(); + assert_eq!(msg.get_prop("prop1"), Some("val1")); + assert_eq!(msg.get_prop("prop2"), Some("val2")); + } + + #[test] + fn push_body_without_ext_fields() { + let body = "content=hello&topic=t"; + let parsed = parse_push_body(body).unwrap(); + assert!(parsed.extfields.is_none()); + let msg = parsed.to_event_mesh_message().unwrap(); + assert!(msg.props.is_empty()); + } + + #[test] + fn push_body_invalid_ext_fields_returns_error() { + let body = form_encode(&[ + ("content".to_string(), "hello".to_string()), + ("extFields".to_string(), "not valid json".to_string()), + ]); + let parsed = parse_push_body(&body).unwrap(); + assert!(parsed.to_event_mesh_message().is_err()); + } + + #[test] + fn form_encode_special_chars() { + let fields = vec![("key".to_string(), "val ue".to_string())]; + let encoded = form_encode(&fields); + // serde_urlencoded encodes spaces as '+'. + assert!(encoded.contains("key=val+ue")); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/consumer.rs new file mode 100644 index 0000000000..b80fb9a2a2 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/consumer.rs @@ -0,0 +1,387 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! HTTP consumer. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; + +use crate::config::HttpClientConfig; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshProtocolType, PublishResponse, SubscriptionItem, SubscriptionType}; +use crate::transport::http::client::EventMeshHttpClient; +use crate::transport::http::codec::{self, uri}; + +/// Heartbeat interval (mirrors the Java SDK: 30s). +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); +/// Initial delay before the first heartbeat. +const HEARTBEAT_INITIAL_DELAY: Duration = Duration::from_secs(10); + +/// A single subscription entry recorded locally for heartbeat/unsubscribe. +#[derive(Debug, Clone)] +struct SubscriptionEntry { + #[allow(dead_code)] + item: SubscriptionItem, + #[allow(dead_code)] + url: String, +} + +/// HTTP-based consumer. +/// +/// The consumer registers a webhook URL with the EventMesh runtime and sends +/// periodic heartbeats. The runtime pushes messages to that URL — serve it +/// either with the built-in [`WebhookServer`](crate::transport::http::WebhookServer) +/// or your own HTTP endpoint built on the +/// [`codec`](crate::transport::http::codec) helpers. +/// +/// A background heartbeat task is spawned on construction and stopped on drop +/// or via [`HttpConsumer::shutdown`] / [`HttpConsumer::wait_for_shutdown`]. +pub struct HttpConsumer { + client: EventMeshHttpClient, + subscriptions: Arc>>, + shutdown: CancellationToken, + heartbeat_handle: Mutex>>, +} + +impl HttpConsumer { + /// Create a consumer. Spawns a background heartbeat task. + /// + /// `shutdown_signal` is an optional future whose resolution triggers + /// graceful shutdown of the heartbeat. When omitted, shutdown can only be + /// initiated by [`shutdown`](Self::shutdown) or drop. + pub fn new( + config: HttpClientConfig, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + let client = EventMeshHttpClient::new(config)?; + let subscriptions = Arc::new(Mutex::new(HashMap::new())); + let shutdown = CancellationToken::new(); + + // Signal watcher. + if let Some(signal) = shutdown_signal { + let token = shutdown.clone(); + tokio::spawn(async move { + tokio::select! { + _ = signal => token.cancel(), + _ = token.cancelled() => {} + } + }); + } + + let heartbeat_handle = + spawn_heartbeat(client.clone(), Arc::clone(&subscriptions), shutdown.clone()); + + Ok(Self { + client, + subscriptions, + shutdown, + heartbeat_handle: Mutex::new(Some(heartbeat_handle)), + }) + } + + /// Subscribe to topics with a webhook URL. The EventMesh runtime will + /// POST messages to `url`. + pub async fn subscribe_webhook( + &self, + items: Vec, + url: impl Into, + ) -> Result { + let url = url.into(); + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "subscription items must not be empty".into(), + )); + } + // HTTP SYNC (request/reply) subscriptions are not supported. The + // runtime's CloudEvents protocol adaptor cannot deserialize a + // REPLY_MESSAGE (code 301) request — its switch only handles + // MSG_SEND_SYNC/MSG_SEND_ASYNC/MSG_BATCH_SEND* and throws on anything + // else — so there is no wire path to deliver listener replies back to + // the original requester. Use the gRPC transport for request/reply. + if items.iter().any(|i| i.r#type == SubscriptionType::SYNC) { + return Err(EventMeshError::InvalidArgument( + "HTTP transport does not support SYNC (request/reply) subscriptions; \ + use the gRPC transport for request/reply" + .into(), + )); + } + let config = self.client.config(); + let body = codec::encode_subscribe(&items, &url, &config.identity); + let code = codec::subscribe_code(); + let headers = codec::build_headers( + code, + EventMeshProtocolType::EventMeshMessage, + &config.identity, + ); + let timeout = config.timeout; + let text = self + .client + .post_form(uri::ROOT, &body, &headers, timeout) + .await?; + let response = codec::parse_response(&text)?; + if response.is_success() { + let mut guard = self.subscriptions.lock().await; + for item in items { + guard.insert( + (item.topic.clone(), url.clone()), + SubscriptionEntry { + item, + url: url.clone(), + }, + ); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "subscribe failed".into()), + }) + } + } + + /// Current consumer group. + pub fn consumer_group(&self) -> &str { + &self.client.config().identity.consumer_group + } + + /// Explicitly shut down: cancel heartbeat and await its exit. + pub async fn shutdown(&self) { + self.shutdown.cancel(); + if let Some(handle) = self.heartbeat_handle.lock().await.take() { + let _ = handle.await; + } + } + + /// Block until the shutdown signal fires or the heartbeat task exits. + /// + /// If no shutdown signal was provided at construction time, this blocks + /// until the heartbeat task exits (which typically only happens on + /// explicit shutdown or drop). + pub async fn wait_for_shutdown(&self) { + self.shutdown.cancelled().await; + if let Some(handle) = self.heartbeat_handle.lock().await.take() { + let _ = handle.await; + } + } +} + +impl HttpConsumer { + /// Unsubscribe from topics. + pub async fn unsubscribe(&self, items: Vec) -> Result { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "unsubscribe items must not be empty".into(), + )); + } + // Group topics by their registered webhook URL. The runtime removes a + // subscription only when BOTH topic AND url match, and the wire format + // carries a single `url` per request — so topics registered under + // different URLs must be sent in separate requests. A topic may be + // registered under multiple URLs, so we scan all (topic, url) keys. + let url_groups: HashMap> = { + let guard = self.subscriptions.lock().await; + let topics: Vec = items.iter().map(|i| i.topic.clone()).collect(); + let mut groups: HashMap> = HashMap::new(); + for ((topic, url), _) in guard.iter() { + if topics.contains(topic) { + groups.entry(url.clone()).or_default().push(topic.clone()); + } + } + groups + }; + if url_groups.is_empty() { + return Err(EventMeshError::InvalidArgument( + "none of the topics are currently subscribed".into(), + )); + } + let config = self.client.config(); + let code = codec::unsubscribe_code(); + let headers = codec::build_headers( + code, + EventMeshProtocolType::EventMeshMessage, + &config.identity, + ); + let timeout = config.timeout; + let mut last_response = None; + for (url, topics) in &url_groups { + let body = codec::encode_unsubscribe(topics, url, &config.identity); + let text = self + .client + .post_form(uri::ROOT, &body, &headers, timeout) + .await?; + let response = codec::parse_response(&text)?; + if response.is_success() { + let mut guard = self.subscriptions.lock().await; + for topic in topics { + guard.remove(&(topic.clone(), url.clone())); + } + last_response = Some(response); + } else { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }); + } + } + last_response + .ok_or_else(|| EventMeshError::InvalidArgument("no unsubscribe groups to send".into())) + } +} + +impl Drop for HttpConsumer { + fn drop(&mut self) { + self.shutdown.cancel(); + if let Ok(mut guard) = self.heartbeat_handle.try_lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } + } +} + +/// Spawn the heartbeat loop. Reads the consumer's subscriptions each tick and +/// reports them to the broker. +fn spawn_heartbeat( + client: EventMeshHttpClient, + subscriptions: Arc>>, + shutdown: CancellationToken, +) -> JoinHandle<()> { + tokio::spawn(async move { + tokio::select! { + _ = tokio::time::sleep(HEARTBEAT_INITIAL_DELAY) => {} + _ = shutdown.cancelled() => return, + } + loop { + let items: Vec<(String, String)> = subscriptions + .lock() + .await + .iter() + .map(|((topic, url), _entry)| (topic.clone(), url.clone())) + .collect(); + if !items.is_empty() { + let config = client.config(); + let body = codec::encode_heartbeat(&items, &config.identity); + let code = codec::heartbeat_code(); + let headers = codec::build_headers( + code, + EventMeshProtocolType::EventMeshMessage, + &config.identity, + ); + let timeout = config.timeout; + match client + .post_form(uri::HEARTBEAT, &body, &headers, timeout) + .await + { + Ok(text) => { + if let Ok(resp) = codec::parse_response(&text) { + debug!("heartbeat ok: {} items", items.len()); + if !resp.is_success() { + warn!("heartbeat non-success: {:?}", resp); + } + } + } + Err(e) => warn!("heartbeat failed: {e}"), + } + } else { + debug!("heartbeat tick: no subscriptions yet"); + } + tokio::select! { + _ = tokio::time::sleep(HEARTBEAT_INTERVAL) => {} + _ = shutdown.cancelled() => break, + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::SubscriptionMode; + + fn make_consumer() -> HttpConsumer { + HttpConsumer::new(HttpClientConfig::default(), None::>).unwrap() + } + + #[tokio::test] + async fn subscribe_webhook_rejects_sync_only() { + let consumer = make_consumer(); + let item = SubscriptionItem::new( + "sync-topic", + SubscriptionMode::CLUSTERING, + SubscriptionType::SYNC, + ); + let result = consumer + .subscribe_webhook(vec![item], "http://localhost:9999/cb") + .await; + assert!(result.is_err()); + match result.unwrap_err() { + EventMeshError::InvalidArgument(msg) => { + assert!(msg.contains("SYNC"), "error should mention SYNC: {msg}"); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + } + + #[tokio::test] + async fn subscribe_webhook_rejects_mixed_sync_and_async() { + let consumer = make_consumer(); + let items = vec![ + SubscriptionItem::new( + "async-topic", + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + ), + SubscriptionItem::new( + "sync-topic", + SubscriptionMode::CLUSTERING, + SubscriptionType::SYNC, + ), + ]; + let result = consumer + .subscribe_webhook(items, "http://localhost:9999/cb") + .await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EventMeshError::InvalidArgument(_) + )); + } + + #[tokio::test] + async fn subscribe_webhook_rejects_empty_items() { + let consumer = make_consumer(); + let result = consumer + .subscribe_webhook(vec![], "http://localhost:9999/cb") + .await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EventMeshError::InvalidArgument(_) + )); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/mod.rs new file mode 100644 index 0000000000..944394332b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/mod.rs @@ -0,0 +1,57 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! HTTP transport for EventMesh. +//! +//! Provides an HTTP-based [`Publisher`](crate::transport::Publisher) and +//! [`HttpConsumer`] for webhook subscription, plus a built-in +//! [`WebhookServer`] for receiving pushed messages from the EventMesh runtime. +//! +//! # Wire format +//! +//! All requests use `application/x-www-form-urlencoded` bodies with JSON +//! payloads inside the `content` field, mirroring the Java SDK. The runtime +//! pushes messages to the consumer's registered webhook URL in the same +//! format, expecting a JSON reply `{"retCode": }`. +//! +//! # Receiving pushed messages +//! +//! The HTTP consumer is client-only: it registers a webhook URL with the +//! runtime and sends heartbeats, and the runtime POSTs delivered messages to +//! that URL. There are two ways to serve that URL: +//! +//! 1. **Built-in server** — [`WebhookServer`] is a batteries-included axum +//! server. Construct it, register its [`WebhookServer::url`] via +//! [`HttpConsumer::subscribe_webhook`], then `.await` it. See the +//! `http_consumer_server` example. +//! 2. **Your own endpoint** — host any HTTP server (axum, actix, plain hyper, +//! …) and decode pushes with the framework-agnostic +//! [`codec`](crate::transport::http::codec) helpers +//! ([`codec::parse_push_body`], [`codec::PushMessageRequestBody`], +//! [`codec::WebhookReply`]). See the `http_consumer_custom` example. + +pub mod client; +pub mod codec; +pub mod consumer; +pub mod producer; +pub mod server; +mod webhook; + +pub use client::EventMeshHttpClient; +pub use consumer::HttpConsumer; +pub use producer::HttpProducer; +pub use server::WebhookServer; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/producer.rs new file mode 100644 index 0000000000..afec44393b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/producer.rs @@ -0,0 +1,384 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! HTTP producer. + +use std::time::Duration; + +use tracing::debug; + +use crate::config::HttpClientConfig; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, EventMeshProtocolType, PublishResponse}; +use crate::transport::http::client::EventMeshHttpClient; +use crate::transport::http::codec::{self, uri}; +use crate::transport::Publisher; + +/// HTTP-based producer. +/// +/// Implements fire-and-forget publish, batch publish, and synchronous +/// request-reply over the EventMesh HTTP protocol. +pub struct HttpProducer { + client: EventMeshHttpClient, +} + +impl HttpProducer { + /// Create a producer from a config. + pub fn new(config: HttpClientConfig) -> Result { + let client = EventMeshHttpClient::new(config)?; + Ok(Self { client }) + } + + /// Publish a native CloudEvent (JSON-serialized) behind the `cloud_events` + /// feature. The CloudEvent's `subject` is used as the topic. + #[cfg(feature = "cloud_events")] + pub async fn publish_cloud_event( + &self, + mut event: cloudevents::Event, + ) -> Result { + use cloudevents::AttributesReader; + + // The runtime's CloudEvents HTTP resolver reads bizseqno/uniqueid from + // CloudEvent extension attributes — NOT from the form fields generated by + // encode_publish — and rejects the request with EVENTMESH_PROTOCOL_BODY_ERR + // when either is missing. Populate them before serializing. + ensure_ce_extension(&mut event, "bizseqno"); + ensure_ce_extension(&mut event, "uniqueid"); + ensure_ce_ttl(&mut event); + + let topic = event + .subject() + .ok_or_else(|| { + EventMeshError::InvalidMessage("CloudEvent subject (topic) is required".into()) + })? + .to_string(); + let json = serde_json::to_string(&event)?; + let msg = EventMeshMessage::builder() + .topic(topic) + .content(json) + .build(); + self.publish_with_protocol(msg, EventMeshProtocolType::CloudEvents) + .await + } + + /// Send a native CloudEvent and wait for a native CloudEvent reply. + #[cfg(feature = "cloud_events")] + pub async fn request_reply_cloud_event( + &self, + mut event: cloudevents::Event, + timeout: Duration, + ) -> Result { + use cloudevents::AttributesReader; + + ensure_ce_extension(&mut event, "bizseqno"); + ensure_ce_extension(&mut event, "uniqueid"); + ensure_ce_ttl(&mut event); + let topic = event + .subject() + .ok_or_else(|| { + EventMeshError::InvalidMessage("CloudEvent subject (topic) is required".into()) + })? + .to_string(); + let message = EventMeshMessage::builder() + .topic(topic) + .content(serde_json::to_string(&event)?) + .build(); + let reply = self + .request_reply_with_protocol(message, timeout, EventMeshProtocolType::CloudEvents) + .await?; + decode_cloud_event_reply(reply) + } + + /// Publish an OpenMessaging-style message over HTTP using the native, + /// interoperable EventMeshMessage envelope. + pub async fn publish_open_message( + &self, + message: crate::model::OpenMessage, + ) -> Result { + self.publish_with_protocol( + message.to_event_mesh_message(), + EventMeshProtocolType::OpenMessage, + ) + .await + } + + /// Send an OpenMessaging-style message and wait for an OpenMessaging-style reply. + pub async fn request_reply_open_message( + &self, + message: crate::model::OpenMessage, + timeout: Duration, + ) -> Result { + let reply = self + .request_reply_with_protocol( + message.to_event_mesh_message(), + timeout, + EventMeshProtocolType::OpenMessage, + ) + .await?; + Ok(crate::model::OpenMessage::from_event_mesh_message(reply)) + } + + /// Internal publish with a specific protocol type. + async fn publish_with_protocol( + &self, + message: EventMeshMessage, + protocol_type: EventMeshProtocolType, + ) -> Result { + validate_publish(&message)?; + let config = self.client.config(); + let body = codec::encode_publish(&message, &config.identity); + let code = codec::publish_code(); + let headers = codec::build_headers(code, protocol_type, &config.identity); + let timeout = config.timeout; + let text = self + .client + .post_form(uri::ROOT, &body, &headers, timeout) + .await?; + let response = codec::parse_response(&text)?; + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| "publish failed".into()), + }); + } + debug!("published topic={:?}", message.topic); + Ok(response) + } + + async fn request_reply_with_protocol( + &self, + message: EventMeshMessage, + timeout: Duration, + protocol_type: EventMeshProtocolType, + ) -> Result { + validate_publish(&message)?; + let config = self.client.config(); + let body = codec::encode_publish(&message, &config.identity); + let headers = + codec::build_headers(codec::publish_sync_code(), protocol_type, &config.identity); + let text = self + .client + .post_form(uri::ROOT, &body, &headers, timeout) + .await?; + let response = codec::parse_response(&text)?; + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "request-reply failed".into()), + }); + } + codec::parse_reply(response.message.as_deref().unwrap_or("")) + } +} + +impl Publisher for HttpProducer { + async fn publish(&self, message: EventMeshMessage) -> Result { + self.publish_with_protocol(message, EventMeshProtocolType::EventMeshMessage) + .await + } + + async fn publish_batch(&self, _messages: Vec) -> Result { + // The runtime's HTTP batch path (request code 102) expects a legacy + // `batchId`/`size`/`contents`/`producerGroup` body that is routed + // through `ProtocolAdaptor.toBatchCloudEvent`, which does not accept + // `HttpCommand` inputs — only the gRPC `BatchEventMeshCloudEventWrapper`. + // Until a supported HTTP batch format is available, this operation is + // not exposed. + Err(EventMeshError::Unsupported( + "batch publish is not supported over the HTTP transport; use individual publish calls or the gRPC transport" + .into(), + )) + } + + async fn request_reply( + &self, + message: EventMeshMessage, + timeout: Duration, + ) -> Result { + self.request_reply_with_protocol(message, timeout, EventMeshProtocolType::EventMeshMessage) + .await + } +} + +/// Decode the HTTP reply payload into a native CloudEvent. EventMesh +/// installations return either CloudEvents JSON directly or the legacy +/// `ReplyMessage` envelope; accepting both keeps interop with Java runtimes. +#[cfg(feature = "cloud_events")] +fn decode_cloud_event_reply(reply: EventMeshMessage) -> Result { + if let Some(content) = &reply.content { + if let Ok(event) = serde_json::from_str(content) { + return Ok(event); + } + } + use cloudevents::{EventBuilder, EventBuilderV10}; + let topic = reply.topic.unwrap_or_default(); + EventBuilderV10::new() + .id(reply + .unique_id + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())) + .source("eventmesh://http-reply") + .ty("eventmesh.reply") + .subject(topic) + .data("text/plain", reply.content.unwrap_or_default()) + .build() + .map_err(|e| EventMeshError::InvalidMessage(format!("invalid CloudEvent reply: {e}"))) +} + +fn validate_publish(message: &EventMeshMessage) -> Result<()> { + if message + .topic + .as_deref() + .map(|t| t.trim().is_empty()) + .unwrap_or(true) + { + return Err(EventMeshError::InvalidMessage("topic is required".into())); + } + if message + .content + .as_deref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true) + { + return Err(EventMeshError::InvalidMessage("content is required".into())); + } + Ok(()) +} + +/// Ensure a CloudEvent extension attribute is present and non-blank, +/// generating a random 30-digit numeric value if the caller did not supply one. +/// +/// The runtime's CloudEvents HTTP resolver does not bridge `bizseqno` or +/// `uniqueid` from the request form fields into CloudEvent extensions (only +/// `producergroup` is bridged), so they must be embedded inside the JSON +/// content before serialization. +#[cfg(feature = "cloud_events")] +fn ensure_ce_extension(event: &mut cloudevents::Event, key: &str) { + use crate::common::util::RandomStringUtils; + + let missing = match event.extension(key) { + None => true, + Some(v) => v.to_string().trim().is_empty(), + }; + if missing { + event.set_extension(key, RandomStringUtils::generate_num(30)); + } +} + +/// Ensure a CloudEvent contains the TTL extension required by the HTTP sync +/// request processor. Unlike the form field emitted by `encode_publish`, the +/// CloudEvents resolver reads TTL only from the serialized event. +#[cfg(feature = "cloud_events")] +fn ensure_ce_ttl(event: &mut cloudevents::Event) { + use crate::common::DEFAULT_MESSAGE_TTL; + + let missing = match event.extension("ttl") { + None => true, + Some(value) => value.to_string().trim().is_empty(), + }; + if missing { + event.set_extension("ttl", DEFAULT_MESSAGE_TTL.to_string()); + } +} + +#[cfg(all(test, feature = "cloud_events"))] +mod tests { + use super::*; + + fn make_event() -> cloudevents::Event { + use cloudevents::{EventBuilder, EventBuilderV10}; + EventBuilderV10::new() + .id("test-id") + .source("urn:test") + .ty("test-type") + .subject("test-topic") + .data("text/plain", "hello") + .build() + .unwrap() + } + + #[test] + fn ensure_ce_extension_generates_when_missing() { + let mut event = make_event(); + assert!(event.extension("bizseqno").is_none()); + ensure_ce_extension(&mut event, "bizseqno"); + let v = event + .extension("bizseqno") + .expect("extension should be set"); + let s = v.to_string(); + assert!(!s.is_empty()); + assert!(s.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn ensure_ce_extension_preserves_existing_value() { + let mut event = make_event(); + event.set_extension("bizseqno", "caller-supplied-seq".to_string()); + ensure_ce_extension(&mut event, "bizseqno"); + assert_eq!( + event.extension("bizseqno").unwrap().to_string(), + "caller-supplied-seq" + ); + } + + #[test] + fn ensure_ce_extension_overwrites_blank_value() { + let mut event = make_event(); + event.set_extension("uniqueid", "".to_string()); + ensure_ce_extension(&mut event, "uniqueid"); + let v = event.extension("uniqueid").unwrap().to_string(); + assert!(!v.is_empty()); + } + + #[test] + fn ensure_ce_ttl_defaults_when_missing_and_preserves_caller_value() { + let mut event = make_event(); + ensure_ce_ttl(&mut event); + assert_eq!( + event.extension("ttl").unwrap().to_string(), + crate::common::DEFAULT_MESSAGE_TTL.to_string() + ); + + event.set_extension("ttl", "9000".to_string()); + ensure_ce_ttl(&mut event); + assert_eq!(event.extension("ttl").unwrap().to_string(), "9000"); + } + + #[test] + fn publish_cloud_event_serializes_extensions() { + // Verify that after ensure_ce_extension runs, the serialized JSON + // contains the extension attributes — this is what the runtime reads. + let mut event = make_event(); + ensure_ce_extension(&mut event, "bizseqno"); + ensure_ce_extension(&mut event, "uniqueid"); + ensure_ce_ttl(&mut event); + let json = serde_json::to_string(&event).unwrap(); + assert!( + json.contains("bizseqno"), + "serialized CloudEvent must contain bizseqno extension: {json}" + ); + assert!( + json.contains("uniqueid"), + "serialized CloudEvent must contain uniqueid extension: {json}" + ); + assert!( + json.contains("\"ttl\":\"4000\""), + "serialized CloudEvent must contain ttl extension: {json}" + ); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/server.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/server.rs new file mode 100644 index 0000000000..02c9d0e870 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/server.rs @@ -0,0 +1,192 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Built-in webhook server (axum). +//! +//! A batteries-included HTTP server that receives webhook pushes from the +//! EventMesh runtime. For users who don't want to wire up their own axum/hyper +//! application, this provides a one-liner server. +//! +//! # Example +//! +//! ```ignore +//! # use eventmesh::{ +//! # config::HttpClientConfig, http::{HttpConsumer, WebhookServer}, +//! # model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, +//! # MessageListener, +//! # }; +//! # struct MyListener; +//! # impl MessageListener for MyListener { +//! # type Message = EventMeshMessage; +//! # async fn handle(&self, _: Self::Message) -> Option { None } +//! # } +//! # #[tokio::main] +//! # async fn main() -> eventmesh::Result<()> { +//! use std::sync::Arc; +//! +//! let listener = Arc::new(MyListener); +//! let addr: std::net::SocketAddr = "0.0.0.0:8080".parse().unwrap(); +//! let server = WebhookServer::new(addr, listener.clone()); +//! +//! let config = HttpClientConfig::builder() +//! .servers("127.0.0.1:10105") +//! .build()?; +//! let consumer = HttpConsumer::new(config, None::>)?; +//! consumer.subscribe_webhook( +//! vec![SubscriptionItem::new("test-topic", SubscriptionMode::CLUSTERING, SubscriptionType::ASYNC)], +//! server.url(), +//! ).await?; +//! +//! server.await?; // blocks until shutdown +//! # Ok(()) +//! # } +//! ``` + +use std::future::{Future, IntoFuture}; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; + +use axum::{routing::post, Router}; +use tracing::info; + +use crate::error::{EventMeshError, Result}; +use crate::transport::http::webhook::{WebhookHandler, WebhookState}; +use crate::MessageListener; + +/// Default path the webhook server listens on. +pub const DEFAULT_WEBHOOK_PATH: &str = "/eventmesh/callback"; + +/// A built-in axum-based webhook server. +/// +/// Construct with [`WebhookServer::new`], optionally call +/// [`WebhookServer::with_graceful_shutdown`], then `.await` to run. +/// +/// The server binds to `addr`, but the URL registered with the EventMesh +/// runtime (via [`WebhookServer::url`]) must be reachable *from the runtime's +/// perspective*. If the runtime runs in Docker and the consumer on the host, +/// `0.0.0.0` is not a valid target — use [`WebhookServer::with_advertise_url`] +/// to set a URL the runtime can actually POST to. +pub struct WebhookServer { + router: Router, + addr: SocketAddr, + path: String, + advertise_url: Option, + shutdown: Option + Send + 'static>>>, +} + +impl WebhookServer { + /// Create a webhook server bound to `addr`, dispatching pushes to `listener`. + pub fn new(addr: SocketAddr, listener: Arc) -> Self + where + L: MessageListener, + L::Message: crate::transport::http::webhook::WebhookMessage, + { + Self::with_path(addr, listener, DEFAULT_WEBHOOK_PATH) + } + + /// Like [`WebhookServer::new`] but with a custom webhook path. + pub fn with_path(addr: SocketAddr, listener: Arc, path: &str) -> Self + where + L: MessageListener, + L::Message: crate::transport::http::webhook::WebhookMessage, + { + let state = WebhookState::new(listener); + let router = Router::new() + .route(path, post(WebhookHandler::handle)) + .with_state(state); + Self { + router, + addr, + path: path.to_string(), + advertise_url: None, + shutdown: None, + } + } + + /// The full webhook URL that should be registered with the EventMesh runtime. + /// + /// Returns the [`with_advertise_url`](Self::with_advertise_url) value if set; + /// otherwise derives `http://{addr}{path}` from the bind address. Note that + /// when bound to `0.0.0.0` the derived URL is unreachable from another host + /// (or a Docker container) — use `with_advertise_url` in those cases. + pub fn url(&self) -> String { + self.advertise_url + .clone() + .unwrap_or_else(|| format!("http://{}{}", self.addr, self.path)) + } + + /// Override the webhook URL returned by [`url`](Self::url). + /// + /// Use this when the bind address is not reachable from the EventMesh + /// runtime (e.g. bound to `0.0.0.0`, or the runtime is in a Docker + /// container). Example: `http://127.0.0.1:9090/eventmesh/callback`. + pub fn with_advertise_url(mut self, url: impl Into) -> Self { + self.advertise_url = Some(url.into()); + self + } + + /// The address the server will bind to. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Attach a graceful shutdown signal. When `signal` resolves, the server + /// stops accepting new connections and drains active ones. + pub fn with_graceful_shutdown( + mut self, + signal: impl Future + Send + 'static, + ) -> Self { + self.shutdown = Some(Box::pin(signal)); + self + } +} + +impl IntoFuture for WebhookServer { + type Output = Result<()>; + type IntoFuture = Pin> + Send>>; + + fn into_future(self) -> Self::IntoFuture { + let Self { + router, + addr, + path, + advertise_url: _, + shutdown, + } = self; + + Box::pin(async move { + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(EventMeshError::Io)?; + let bound_addr = listener.local_addr().map_err(EventMeshError::Io)?; + info!("webhook server listening on http://{bound_addr}{path}"); + + let serve = axum::serve(listener, router); + let result = if let Some(signal) = shutdown { + serve.with_graceful_shutdown(signal).await + } else { + serve.await + }; + result.map_err(|e| EventMeshError::Protocol { + transport: "http", + message: format!("webhook server error: {e}"), + })?; + Ok(()) + }) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/webhook.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/webhook.rs new file mode 100644 index 0000000000..d613c1afa5 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/webhook.rs @@ -0,0 +1,232 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Internal webhook handler used by the built-in [`WebhookServer`]. +//! +//! This module is **not** part of the public API. It wires the push-body codec +//! ([`crate::transport::http::codec::parse_push_body`]) together with a +//! [`MessageListener`] into an axum handler consumed exclusively by +//! [`WebhookServer`](crate::transport::http::server::WebhookServer). +//! +//! Users who want to host their own HTTP endpoint (with axum, actix, plain +//! hyper, or any other framework) should ignore this module and build on the +//! public codec utilities directly — see the `consumer_custom` example and the +//! [`codec`](crate::transport::http::codec) module docs. + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::HeaderMap; +use axum::response::IntoResponse; +use axum::Json; +use bytes::Bytes; +use tracing::{debug, error, warn}; + +use crate::message::Message; +use crate::model::{EventMeshMessage, EventMeshProtocolType, OpenMessage}; +use crate::transport::http::codec::{parse_push_body, WebhookReply}; +use crate::MessageListener; + +/// Message representation supported by the built-in webhook decoder. +pub trait WebhookMessage: Send + 'static { + fn decode_webhook( + body: &crate::transport::http::codec::PushMessageRequestBody, + headers: &HeaderMap, + ) -> crate::Result + where + Self: Sized; +} + +impl WebhookMessage for EventMeshMessage { + fn decode_webhook( + body: &crate::transport::http::codec::PushMessageRequestBody, + _headers: &HeaderMap, + ) -> crate::Result { + body.to_event_mesh_message() + } +} + +impl WebhookMessage for Message { + fn decode_webhook( + body: &crate::transport::http::codec::PushMessageRequestBody, + headers: &HeaderMap, + ) -> crate::Result { + let protocol_type = headers + .get("protocoltype") + .and_then(|value| value.to_str().ok()) + .unwrap_or(EventMeshProtocolType::EventMeshMessage.as_str()); + + if protocol_type == EventMeshProtocolType::CloudEvents.as_str() { + #[cfg(feature = "cloud_events")] + { + return serde_json::from_str(&body.content) + .map(Self::CloudEvent) + .map_err(crate::error::EventMeshError::Codec); + } + } + + let native = body.to_event_mesh_message()?; + if protocol_type == EventMeshProtocolType::OpenMessage.as_str() { + Ok(Self::Open(OpenMessage::from_event_mesh_message(native))) + } else { + Ok(Self::EventMesh(native)) + } + } +} + +/// Shared state for the webhook handler, holding the message listener. +pub(crate) struct WebhookState +where + L::Message: WebhookMessage, +{ + listener: Arc, +} + +impl WebhookState +where + L::Message: WebhookMessage, +{ + /// Create state wrapping the given listener. + pub(crate) fn new(listener: Arc) -> Self { + Self { listener } + } +} + +impl Clone for WebhookState +where + L::Message: WebhookMessage, +{ + fn clone(&self) -> Self { + Self { + listener: Arc::clone(&self.listener), + } + } +} + +/// Internal axum handler used by [`WebhookServer`](crate::transport::http::server::WebhookServer). +/// +/// Not part of the public API. To receive pushes on your own server, implement +/// a handler with the public [`codec`](crate::transport::http::codec) helpers +/// instead (see the `consumer_custom` example). +pub(crate) struct WebhookHandler; + +impl WebhookHandler { + /// The actual handler function. Extracts the body bytes, parses the + /// form-urlencoded push body, dispatches to the listener, and returns the + /// JSON acknowledgment `{"retCode": }`. + pub(crate) async fn handle( + State(state): State>, + headers: HeaderMap, + body: Bytes, + ) -> impl IntoResponse + where + L::Message: WebhookMessage, + { + let body_str = match std::str::from_utf8(&body) { + Ok(s) => s, + Err(e) => { + warn!("webhook body not UTF-8: {e}"); + return Json(WebhookReply::retry("invalid UTF-8")).into_response(); + } + }; + + let push_body = match parse_push_body(body_str) { + Ok(b) => b, + Err(e) => { + warn!("webhook body parse error: {e}"); + return Json(WebhookReply::retry("form decode error")).into_response(); + } + }; + + let msg = match L::Message::decode_webhook(&push_body, &headers) { + Ok(m) => m, + Err(e) => { + error!("webhook message decode error: {e}"); + return Json(WebhookReply::retry("message decode error")).into_response(); + } + }; + + debug!("webhook received a message"); + + match state.listener.handle(msg).await { + Ok(Some(reply)) => { + // The listener produced a reply, but the HTTP webhook transport + // cannot deliver it: the runtime's protocol adaptor does not + // support REPLY_MESSAGE (code 301) on the CloudEvents path, so + // there is no wire path to route the reply back to the original + // requester. SYNC subscriptions are rejected at subscribe time; + // this warning is a defensive backstop for messages pushed from + // a non-Rust consumer or a legacy subscription. + warn!( + "listener produced a reply (type={}) but the HTTP webhook \ + transport cannot deliver replies; use the gRPC transport for \ + request/reply", + std::any::type_name_of_val(&reply) + ); + Json(WebhookReply::ok()).into_response() + } + Ok(None) => Json(WebhookReply::ok()).into_response(), + Err(error) => { + warn!(%error, "webhook handler failed; requesting redelivery"); + Json(WebhookReply::retry("handler failed")).into_response() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push(content: String) -> crate::transport::http::codec::PushMessageRequestBody { + crate::transport::http::codec::PushMessageRequestBody { + content, + bizseqno: Some("seq-1".into()), + unique_id: Some("id-1".into()), + random_no: None, + topic: Some("orders".into()), + extfields: None, + } + } + + #[test] + fn public_message_preserves_open_protocol() { + let mut headers = HeaderMap::new(); + headers.insert("protocoltype", "openmessage".parse().unwrap()); + let decoded = Message::decode_webhook(&push("created".into()), &headers).unwrap(); + assert!(matches!(decoded, Message::Open(_))); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn public_message_preserves_cloud_event_protocol() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("event-1") + .source("urn:test") + .ty("orders.created") + .build() + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("protocoltype", "cloudevents".parse().unwrap()); + let decoded = + Message::decode_webhook(&push(serde_json::to_string(&event).unwrap()), &headers) + .unwrap(); + assert_eq!(decoded, Message::CloudEvent(event)); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/mod.rs new file mode 100644 index 0000000000..2d5fbed01a --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/mod.rs @@ -0,0 +1,64 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Transport-agnostic async traits and transport modules. +//! +//! Only the publish side is abstracted into a trait ([`Publisher`]). Each +//! transport exposes its own consumer type with transport-specific subscribe / +//! unsubscribe methods and a background receive loop — see the `grpc`, +//! `http`, and `tcp` modules for details. +//! +//! These traits use native Rust-1.86 `async fn in trait` and are therefore +//! **not object-safe** — use concrete types (`GrpcProducer`, etc.) directly, +//! never `dyn`. + +use std::future::Future; +use std::time::Duration; + +use crate::model::{EventMeshMessage, PublishResponse}; + +/// Publish-side capability. +pub trait Publisher { + /// Fire-and-forget publish; returns the broker ack. + fn publish( + &self, + message: EventMeshMessage, + ) -> impl Future> + Send; + + /// Publish many messages in one RPC. + fn publish_batch( + &self, + messages: Vec, + ) -> impl Future> + Send; + + /// Synchronous request/reply. `timeout` bounds how long we wait for the + /// consumer reply. + fn request_reply( + &self, + message: EventMeshMessage, + timeout: Duration, + ) -> impl Future> + Send; +} + +#[cfg(feature = "grpc")] +pub mod grpc; + +#[cfg(feature = "http")] +pub mod http; + +#[cfg(feature = "tcp")] +pub mod tcp; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/codec.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/codec.rs new file mode 100644 index 0000000000..fea09a8037 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/codec.rs @@ -0,0 +1,373 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Binary wire codec for the TCP transport. +//! +//! Frame layout (identical to the Java `Codec`): +//! +//! ```text +//! ┌─────────────┬──────────┬───────────────┬───────────────┬─────────┬────────┐ +//! │ Magic Flag │ Version │ Package Len │ Header Len │ Header │ Body │ +//! │ "EventMesh" │ "0000" │ (i32 BE, 4B) │ (i32 BE, 4B) │ (JSON) │ (bytes)│ +//! │ (9 bytes) │ (4 bytes)│ │ │ │ │ +//! └─────────────┴──────────┴───────────────┴───────────────┴─────────┴────────┘ +//! ``` +//! +//! - **Package length** = `13 + header_len + body_len` (does not include the +//! two 4-byte length fields themselves). +//! - All multi-byte integers are big-endian. + +use bytes::{Buf, BufMut, BytesMut}; +use tokio_util::codec::{Decoder, Encoder}; +use tracing::warn; + +use crate::error::{EventMeshError, Result}; + +use super::frame::{Command, Header, Package, PackageBody, RedirectInfo, Subscription, UserAgent}; + +/// Magic flag prefix (9 bytes). +const MAGIC_FLAG: &[u8] = b"EventMesh"; + +/// Protocol version (4 bytes). +const VERSION: &[u8] = b"0000"; + +/// Length of magic + version (9 + 4 = 13). +const PREFIX_LEN: usize = MAGIC_FLAG.len() + VERSION.len(); + +/// Maximum frame size: 4 MiB. +const FRAME_MAX_LENGTH: usize = 1024 * 1024 * 4; + +/// Header property key for the protocol type (same as +/// `ProtocolKey::PROTOCOL_TYPE` but duplicated here so the tcp module is +/// self-contained). +const PROTOCOL_TYPE_KEY: &str = "protocoltype"; + +/// CloudEvents protocol name. +const CLOUD_EVENTS_PROTOCOL: &str = "cloudevents"; + +/// Tokio codec for encoding/decoding EventMesh TCP frames. +#[derive(Debug, Default)] +pub struct TcpCodec; + +impl TcpCodec { + pub fn new() -> Self { + Self + } +} + +impl Encoder for TcpCodec { + type Error = EventMeshError; + + fn encode(&mut self, pkg: Package, buf: &mut BytesMut) -> Result<()> { + // --- Serialize header --- + let header_bytes = serde_json::to_vec(&pkg.header)?; + + // --- Serialize body --- + let is_cloudevents = pkg + .header + .get_string_property(PROTOCOL_TYPE_KEY) + .map(|v| v == CLOUD_EVENTS_PROTOCOL) + .unwrap_or(false); + + let body_bytes = serialize_body(&pkg.body, is_cloudevents)?; + + let header_len = header_bytes.len(); + let body_len = body_bytes.len(); + let total_len = PREFIX_LEN + header_len + body_len; + + if total_len > FRAME_MAX_LENGTH { + return Err(EventMeshError::InvalidArgument(format!( + "message size {total_len} exceeds limit {FRAME_MAX_LENGTH}" + ))); + } + + // Reserve enough space for the entire frame. + let frame_len = PREFIX_LEN + 4 + 4 + header_len + body_len; + buf.reserve(frame_len); + + // Write frame. + buf.put_slice(MAGIC_FLAG); + buf.put_slice(VERSION); + buf.put_i32(total_len as i32); + buf.put_i32(header_len as i32); + buf.put_slice(&header_bytes); + if body_len > 0 { + buf.put_slice(&body_bytes); + } + + Ok(()) + } +} + +impl Decoder for TcpCodec { + type Item = Package; + type Error = EventMeshError; + + fn decode(&mut self, buf: &mut BytesMut) -> Result> { + // We need at least the prefix + two length fields to know the frame size. + let min_header = PREFIX_LEN + 4 + 4; + if buf.len() < min_header { + return Ok(None); + } + + // Peek at the lengths without consuming. + let magic = &buf[..MAGIC_FLAG.len()]; + if magic != MAGIC_FLAG { + return Err(EventMeshError::Tcp(format!( + "invalid magic flag: expected {:?}, got {:?}", + String::from_utf8_lossy(MAGIC_FLAG), + String::from_utf8_lossy(magic), + ))); + } + + let version = &buf[MAGIC_FLAG.len()..PREFIX_LEN]; + if version != VERSION { + return Err(EventMeshError::Tcp(format!( + "invalid version: expected {:?}, got {:?}", + String::from_utf8_lossy(VERSION), + String::from_utf8_lossy(version), + ))); + } + + // Read package length and header length. + let total_len = (&buf[PREFIX_LEN..PREFIX_LEN + 4]).get_i32() as usize; + let header_len = (&buf[PREFIX_LEN + 4..PREFIX_LEN + 8]).get_i32() as usize; + + if total_len > FRAME_MAX_LENGTH { + return Err(EventMeshError::Tcp(format!( + "frame length {total_len} exceeds limit {FRAME_MAX_LENGTH}" + ))); + } + + let body_len = total_len + .checked_sub(PREFIX_LEN) + .and_then(|v| v.checked_sub(header_len)) + .ok_or_else(|| { + EventMeshError::Tcp(format!( + "invalid frame: total_len={total_len}, header_len={header_len}" + )) + })?; + + // Total bytes on the wire = prefix + 4 (pkg len) + 4 (hdr len) + header + body. + let frame_bytes = PREFIX_LEN + 4 + 4 + header_len + body_len; + if buf.len() < frame_bytes { + // Not enough data yet; wait for more. + buf.reserve(frame_bytes - buf.len()); + return Ok(None); + } + + // Consume the frame. + let mut data = buf.split_to(frame_bytes); + + // Skip past prefix + two length fields. + data.advance(PREFIX_LEN + 4 + 4); + + // Read header. + let header: Header = if header_len > 0 { + let hdr_data = data.copy_to_bytes(header_len); + serde_json::from_slice(&hdr_data)? + } else { + // Java's `parseHeader` returns null when `headerLength <= 0`, + // then the inbound handler does `Preconditions.checkNotNull(header)` + // → exception → `ctx.close()`. We mirror that by erroring here. + // Returning `Ok(None)` would be wrong: this frame has already been + // consumed from `buf` via `split_to`, and `Ok(None)` means "need + // more bytes", so the next call would be fed the body bytes and + // fail with `invalid magic flag`, desyncing the stream. + warn!("received frame with empty header"); + return Err(EventMeshError::Tcp( + "received frame with empty header".into(), + )); + }; + + // Read body bytes. + let body_bytes = if body_len > 0 { + let b = data.copy_to_bytes(body_len); + b.to_vec() + } else { + Vec::new() + }; + + // Deserialize body based on command. + let body = deserialize_body(&header.cmd, &body_bytes); + + Ok(Some(Package { header, body })) + } +} + +/// Serialize a [`PackageBody`] to bytes. +/// +/// CloudEvents bodies (`Bytes` variant when `is_cloudevents` is true) are +/// written as-is; everything else is JSON-serialized (or in the case of +/// `Text`, returned as UTF-8). +fn serialize_body(body: &PackageBody, is_cloudevents: bool) -> Result> { + Ok(match body { + PackageBody::Empty => Vec::new(), + PackageBody::Bytes(b) => { + if is_cloudevents { + b.clone() + } else { + serde_json::to_vec(b)? + } + } + PackageBody::Text(s) => s.as_bytes().to_vec(), + PackageBody::UserAgent(ua) => serde_json::to_vec(ua.as_ref())?, + PackageBody::Subscription(sub) => serde_json::to_vec(sub)?, + PackageBody::RedirectInfo(ri) => serde_json::to_vec(ri)?, + }) +} + +/// Deserialize a body based on the header's command type (mirrors Java +/// `Codec.deserializeBody`). +fn deserialize_body(cmd: &Command, body_bytes: &[u8]) -> PackageBody { + if body_bytes.is_empty() { + return PackageBody::Empty; + } + + let body_str = match std::str::from_utf8(body_bytes) { + Ok(s) => s.to_string(), + Err(_) => return PackageBody::Bytes(body_bytes.to_vec()), + }; + + match cmd { + Command::HelloRequest | Command::RecommendRequest => { + match serde_json::from_str::(&body_str) { + Ok(ua) => PackageBody::UserAgent(Box::new(ua)), + Err(_) => PackageBody::Text(body_str), + } + } + Command::SubscribeRequest | Command::UnsubscribeRequest => { + match serde_json::from_str::(&body_str) { + Ok(sub) => PackageBody::Subscription(sub), + Err(_) => PackageBody::Text(body_str), + } + } + Command::RedirectToClient => match serde_json::from_str::(&body_str) { + Ok(ri) => PackageBody::RedirectInfo(ri), + Err(_) => PackageBody::Text(body_str), + }, + // All message/ACK/response commands: defer to protocol layer as raw text. + _ => PackageBody::Text(body_str), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_heartbeat() { + let mut codec = TcpCodec::new(); + let mut buf = BytesMut::new(); + + let pkg = Package::new(Header::new(Command::HeartbeatRequest, "1234567890")); + codec.encode(pkg.clone(), &mut buf).expect("encode"); + + // Frame should start with magic + version. + assert_eq!(&buf[..9], MAGIC_FLAG); + assert_eq!(&buf[9..13], VERSION); + + let decoded = codec.decode(&mut buf).expect("decode"); + let decoded = decoded.expect("should have a frame"); + + assert_eq!(decoded.header.cmd, Command::HeartbeatRequest); + assert_eq!(decoded.header.seq.as_deref(), Some("1234567890")); + assert!(matches!(decoded.body, PackageBody::Empty)); + assert!(buf.is_empty(), "buffer should be fully consumed"); + } + + #[test] + fn round_trip_with_body() { + let mut codec = TcpCodec::new(); + let mut buf = BytesMut::new(); + + let pkg = Package::new(Header::new(Command::HelloRequest, "abcdefghij")).with_body( + PackageBody::UserAgent(Box::new(UserAgent { + env: "prod".into(), + group: "g1".into(), + purpose: "pub".into(), + pid: 42, + ..Default::default() + })), + ); + + codec.encode(pkg, &mut buf).expect("encode"); + let decoded = codec + .decode(&mut buf) + .expect("decode") + .expect("frame present"); + + assert_eq!(decoded.header.cmd, Command::HelloRequest); + match decoded.body { + PackageBody::UserAgent(ua) => { + assert_eq!(ua.env, "prod"); + assert_eq!(ua.group, "g1"); + assert_eq!(ua.purpose, "pub"); + assert_eq!(ua.pid, 42); + } + other => panic!("expected UserAgent body, got {other:?}"), + } + } + + #[test] + fn partial_frame_returns_none() { + let mut codec = TcpCodec::new(); + let mut buf = BytesMut::new(); + + let pkg = Package::new(Header::new(Command::HeartbeatRequest, "12345")); + codec.encode(pkg, &mut buf).expect("encode"); + + // Only feed the first 10 bytes. + let mut partial = buf.split_to(10); + let result = codec.decode(&mut partial).expect("decode partial"); + assert!(result.is_none(), "should return None for partial frame"); + } + + #[test] + fn invalid_magic_rejected() { + let mut codec = TcpCodec::new(); + let mut buf = BytesMut::new(); + buf.put_slice(b"BADMAGIC!"); + buf.put_slice(VERSION); + buf.put_i32(100); + buf.put_i32(0); + + let result = codec.decode(&mut buf); + assert!(result.is_err(), "should reject bad magic"); + } + + #[test] + fn text_body_round_trip() { + let mut codec = TcpCodec::new(); + let mut buf = BytesMut::new(); + + let json = r#"{"topic":"test","content":"hello"}"#; + let pkg = Package::new(Header::new(Command::AsyncMessageToServerAck, "1234567890")) + .with_body(PackageBody::Text(json.to_string())); + + codec.encode(pkg, &mut buf).expect("encode"); + let decoded = codec + .decode(&mut buf) + .expect("decode") + .expect("frame present"); + + match decoded.body { + PackageBody::Text(s) => assert_eq!(s, json), + other => panic!("expected Text body, got {other:?}"), + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection.rs new file mode 100644 index 0000000000..2489b9e3f1 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection.rs @@ -0,0 +1,846 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! TCP connection engine — the core of the transport. +//! +//! Corresponds to the Java SDK's `TcpClient` abstract base: manages the TCP +//! socket, the read/write loop, heartbeat, and request-response correlation +//! via a `seq`-keyed pending map of `oneshot` channels. +//! +//! ## Reconnect +//! +//! When [`ReconnectConfig::enabled`] is `true` (the default), the background +//! task automatically re-establishes the TCP connection + HELLO handshake after +//! an I/O error or server-side close. An optional reconnect-event channel +//! ([`TcpConnection::take_reconnect_rx`]) lets consumers replay their +//! subscriptions after a successful reconnect. This mirrors the Java SDK's +//! heartbeat-driven reconnect but with exponential backoff. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::task::JoinHandle; +use tokio_stream::StreamExt; +use tokio_util::codec::Framed; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::config::ReconnectConfig; +use crate::error::{EventMeshError, Result}; + +use super::codec::TcpCodec; +use super::frame::{Command, Package}; +use super::message; + +// `SinkExt` is needed for `Framed::send()`. +use futures::SinkExt; + +/// Default channel capacity for outbound and inbound message queues. +const CHANNEL_CAPACITY: usize = 256; + +/// Capacity of the reconnect-event channel. A bounded channel of 1 is enough: +/// `try_send` drops intermediate notifications if the consumer hasn't drained +/// the previous one yet — the consumer re-subscribes to *all* topics each time, +/// so missing an intermediate notification is harmless. +const RECONNECT_CHANNEL_CAPACITY: usize = 1; + +/// Why the inner I/O loop exited. Used by the outer reconnect loop to decide +/// whether to attempt a reconnect. +#[derive(Debug)] +enum IoExitReason { + /// `CancellationToken` was fired (explicit shutdown). + Cancelled, + /// All `mpsc::Sender` clones were dropped (user dropped the connection + /// handle). + AllSendersDropped, + /// A read or write I/O error occurred. + IoError, + /// The server closed the connection (EOF on read). + ServerClosed, + /// The inbound (consumer-facing) channel is full — the consumer is not + /// draining pushes fast enough. Rather than silently dropping server pushes + /// (which would lose unacked messages), we tear down the connection so the + /// server redelivers them after reconnect. + SlowConsumer, +} + +/// A connected TCP transport. +/// +/// Created by [`TcpConnection::connect`], which performs the TCP connect + +/// HELLO handshake. A background task handles all I/O (read, write, heartbeat) +/// and, when [`ReconnectConfig`] is enabled, automatically re-establishes the +/// connection after failures. +/// +/// Call [`TcpConnection::io`] for request-response (blocks until the matching +/// reply arrives, keyed by the header `seq`), or [`TcpConnection::send`] for +/// fire-and-forget writes. +pub struct TcpConnection { + /// Outbound: write packages into the background task's send loop. + outbound_tx: mpsc::Sender, + /// Inbound server-pushed messages (taken by the consumer via + /// [`take_inbound_rx`]). + inbound_rx: Mutex>>, + /// Reconnect-event receiver (taken by the consumer via + /// [`take_reconnect_rx`]). + reconnect_rx: Mutex>>, + /// Pending request-response contexts: `seq → oneshot::Sender`. + pending: Arc>>>, + /// Shutdown signal shared with the background task. + cancel: CancellationToken, + /// Set to `false` by the background task when it exits for any reason + /// (cancellation, I/O error, server close, all-senders-dropped). Mirrors + /// Java's `channel.isActive()` more faithfully than the cancellation token + /// alone, which only flips on explicit shutdown. + alive: Arc, + /// Background task handle. + join: Mutex>>, +} + +impl TcpConnection { + /// Connect to the server, perform the HELLO handshake, and start the + /// background I/O + heartbeat task. + /// + /// `timeout` bounds both the TCP connect and the HELLO response wait, + /// mirroring Java's `TcpClient.hello()` which goes through + /// `io(msg, DEFAULT_TIME_OUT_MILLS)` (20s). + /// + /// The `reconnect` config controls automatic reconnection after I/O errors. + /// When enabled, the background task re-establishes the connection with + /// exponential backoff after failures. + pub async fn connect( + addr: &str, + port: u16, + user_agent: &super::frame::UserAgent, + heartbeat_interval: Duration, + timeout: Duration, + reconnect: ReconnectConfig, + ) -> Result { + // Initial connect is inline so the caller gets immediate feedback. + // Subsequent reconnects happen in the background task. + let framed = Self::establish(addr, port, user_agent, timeout).await?; + + let (outbound_tx, outbound_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (inbound_tx, inbound_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (reconnect_tx, reconnect_rx) = mpsc::channel(RECONNECT_CHANNEL_CAPACITY); + let pending = Arc::new(Mutex::new(HashMap::new())); + let cancel = CancellationToken::new(); + let alive = Arc::new(AtomicBool::new(true)); + + let join = tokio::spawn(Self::run( + addr.to_string(), + port, + user_agent.clone(), + heartbeat_interval, + timeout, + reconnect, + framed, + outbound_rx, + inbound_tx, + reconnect_tx, + Arc::clone(&pending), + cancel.clone(), + Arc::clone(&alive), + )); + + info!(peer = %format!("{addr}:{port}"), "TCP connected"); + + Ok(Self { + outbound_tx, + inbound_rx: Mutex::new(Some(inbound_rx)), + reconnect_rx: Mutex::new(Some(reconnect_rx)), + pending, + cancel, + alive, + join: Mutex::new(Some(join)), + }) + } + + /// Establish a new TCP connection and perform the HELLO handshake. + /// + /// Used both by [`connect`] (initial) and the reconnect loop (subsequent). + /// Bounded by `timeout` for both the TCP connect and the HELLO response. + async fn establish( + addr: &str, + port: u16, + user_agent: &super::frame::UserAgent, + timeout: Duration, + ) -> Result> { + // Defer name resolution to Tokio: `TcpStream::connect` accepts a + // "host:port" string via `ToSocketAddrs`, so DNS names like + // "localhost" (the default `server_addr`) resolve correctly. + let peer = format!("{addr}:{port}"); + debug!(%peer, "connecting TCP"); + let stream = tokio::time::timeout(timeout, TcpStream::connect(&peer)) + .await + .map_err(|_| EventMeshError::Timeout(timeout))??; + stream.set_nodelay(true).ok(); + + let mut framed = Framed::new(stream, TcpCodec::new()); + + // --- HELLO handshake (inline, before starting the I/O loop) --- + // Java's `TcpClient.hello()` routes through `io(msg, timeout)`, so the + // handshake is bounded. We do the same: if the server accepts the TCP + // connection but never writes a HELLO_RESPONSE (half-open proxy, + // network partition, slow server), we fail with `Timeout` instead of + // hanging forever. + debug!("sending HELLO"); + let hello_pkg = message::hello(user_agent); + framed.send(hello_pkg).await?; + match tokio::time::timeout(timeout, framed.next()).await { + Err(_) => Err(EventMeshError::Timeout(timeout)), + Ok(None) => Err(EventMeshError::Tcp("connection closed during HELLO".into())), + Ok(Some(Err(e))) => Err(e), + Ok(Some(Ok(resp))) if resp.header.cmd == Command::HelloResponse => { + // The Java runtime's `HelloProcessor` rejects the handshake + // (OPStatus.FAIL / ACL_FAIL) when the group isn't registered, + // the token is rejected, the server isn't RUNNING yet, or the + // `UserAgent` is invalid — and then closes the session. Treat + // a non-zero code as a failure and surface `desc`. + if resp.header.code != 0 { + return Err(EventMeshError::Server { + code: resp.header.code, + message: resp.header.desc.unwrap_or_else(|| "HELLO rejected".into()), + }); + } + debug!(code = resp.header.code, "HELLO ok"); + Ok(framed) + } + Ok(Some(Ok(resp))) => Err(EventMeshError::Tcp(format!( + "unexpected response to HELLO: {:?}", + resp.header.cmd + ))), + } + } + + /// Request-response: register a pending context keyed by `seq`, send the + /// package, and wait for the matching reply within `timeout`. + /// + /// Corresponds to Java `TcpClient.io()`. + pub async fn io(&self, pkg: Package, timeout: Duration) -> Result { + // Fail fast: if the connection is not active (reconnecting, shut down, + // or backing off), reject the request immediately. Without this the + // package would sit in the outbound mpsc buffer and be sent after a + // successful reconnect — a "ghost write" — even though the caller may + // have already received a Timeout/ChannelClosed and retried. + if !self.is_active() { + return Err(EventMeshError::ChannelClosed( + "connection is not active (reconnecting or shut down)".into(), + )); + } + + // Client-originated frames always carry a seq (see `message::package`), + // so this is `Some` in practice. A `None` would mean a programming + // error; we coalesce it to an empty string so the `pending` lookup + // (keyed by `String`) stays consistent with the run loop below. + let seq = pkg.header.seq.clone().unwrap_or_default(); + let (tx, rx) = oneshot::channel(); + + // Register pending context BEFORE sending so the read loop can match + // the response as soon as it arrives. + { + let mut guard = self.pending.lock().await; + guard.insert(seq.clone(), tx); + } + + // Send the package to the background task. + if self.outbound_tx.send(pkg).await.is_err() { + // The run loop already exited (its `clear()` is why send failed). + // Remove our entry to stay symmetric with the timeout/closed paths + // below, so no stale `oneshot::Sender` lingers in the map. + self.pending.lock().await.remove(&seq); + return Err(EventMeshError::ChannelClosed( + "connection send loop exited".into(), + )); + } + + // Wait for the response. + match tokio::time::timeout(timeout, rx).await { + Ok(Ok(resp)) => Ok(resp), + Ok(Err(_)) => { + self.pending.lock().await.remove(&seq); + Err(EventMeshError::ChannelClosed( + "connection task exited while waiting for response".into(), + )) + } + Err(_) => { + self.pending.lock().await.remove(&seq); + Err(EventMeshError::Timeout(timeout)) + } + } + } + + /// Fire-and-forget send: write the package without waiting for a reply. + /// + /// Corresponds to Java `TcpClient.send()`. + pub async fn send(&self, pkg: Package) -> Result<()> { + // Same fail-fast rationale as `io()`: prevent ghost writes during + // reconnect. + if !self.is_active() { + return Err(EventMeshError::ChannelClosed( + "connection is not active (reconnecting or shut down)".into(), + )); + } + self.outbound_tx + .send(pkg) + .await + .map_err(|_| EventMeshError::ChannelClosed("connection send loop exited".into())) + } + + /// Take ownership of the inbound receiver. Called once by the consumer to + /// start receiving server-pushed messages. + pub async fn take_inbound_rx(&self) -> Option> { + self.inbound_rx.lock().await.take() + } + + /// Take ownership of the reconnect-event receiver. Called once by the + /// consumer to get notified when the connection has been automatically + /// re-established, so it can replay subscriptions. + /// + /// Each `()` received means a reconnect just succeeded and the consumer + /// should re-send `SUBSCRIBE_REQUEST` + `LISTEN_REQUEST`. + pub async fn take_reconnect_rx(&self) -> Option> { + self.reconnect_rx.lock().await.take() + } + + /// Whether the background task is still alive. + /// + /// Mirrors Java's `TcpClient.isActive()` which checks `channel.isActive()`. + /// This flips to `false` for *any* reason the background task exits + /// (cancellation, read/write error, server-side close, all senders + /// dropped) — not just explicit shutdown. During a reconnect backoff it is + /// also `false`; it returns to `true` once the new connection is + /// established. + pub fn is_active(&self) -> bool { + self.alive.load(Ordering::Acquire) + } + + /// Graceful shutdown: send CLIENT_GOODBYE, cancel the task, and join. + pub async fn shutdown(&self) { + // Best-effort goodbye. + let _ = self.send(message::goodbye()).await; + + self.cancel.cancel(); + if let Some(join) = self.join.lock().await.take() { + let _ = join.await; + } + } + + // ----------------------------------------------------------------------- + // Background task: outer reconnect loop + inner I/O loop + // ----------------------------------------------------------------------- + + /// Outer run loop. Owns the channels across reconnects. On I/O error / + /// server close, attempts reconnection with exponential backoff (when + /// enabled). On cancellation / all-senders-dropped, exits immediately. + #[allow(clippy::too_many_arguments)] + async fn run( + addr: String, + port: u16, + user_agent: super::frame::UserAgent, + heartbeat_interval: Duration, + timeout: Duration, + reconnect: ReconnectConfig, + mut framed: Framed, + mut outbound_rx: mpsc::Receiver, + inbound_tx: mpsc::Sender, + reconnect_tx: mpsc::Sender<()>, + pending: Arc>>>, + cancel: CancellationToken, + alive: Arc, + ) { + loop { + // Run the I/O loop with the current framed stream. + let reason = Self::io_loop( + &mut framed, + &mut outbound_rx, + &inbound_tx, + Arc::clone(&pending), + heartbeat_interval, + &cancel, + alive.as_ref(), + ) + .await; + + // Clean up pending requests from the (now dead) connection so + // waiting `io()` callers get a ChannelClosed error instead of + // hanging until timeout. Mirrors Java's behavior where orphaned + // RequestContext entries simply time out, but is more prompt. + pending.lock().await.clear(); + alive.store(false, Ordering::Release); + + // Drain the outbound queue so stale packages from the dead + // connection are never re-sent on the next connection. Without + // this, a package enqueued via io()/send() that hadn't been + // written to the socket yet would survive in the mpsc buffer + // across the reconnect and produce a "ghost write" — a message + // sent to the new connection even though the caller already + // received a Timeout/ChannelClosed error and may have retried. + // (The Java SDK avoids this by writing directly via + // `channel.writeAndFlush()` with no user-space queue.) + while outbound_rx.try_recv().is_ok() { + // Discard; these packages were never written to the wire. + } + + match reason { + IoExitReason::Cancelled | IoExitReason::AllSendersDropped => { + debug!("connection task exiting ({:?})", reason); + return; + } + IoExitReason::IoError | IoExitReason::ServerClosed | IoExitReason::SlowConsumer => { + } + } + + // Decide whether to attempt reconnect. + if !reconnect.enabled || cancel.is_cancelled() { + debug!("reconnect disabled or cancelled, exiting"); + return; + } + + // Reconnect with exponential backoff. + let mut backoff = reconnect.initial_backoff; + let mut attempt: usize = 0; + + loop { + attempt += 1; + if attempt > reconnect.max_retries { + warn!( + attempts = attempt - 1, + "max reconnect attempts ({}) exceeded, giving up", reconnect.max_retries + ); + return; + } + + debug!(attempt, backoff = ?backoff, "reconnect backoff"); + tokio::select! { + biased; + _ = cancel.cancelled() => { + debug!("cancelled during reconnect backoff"); + return; + } + _ = tokio::time::sleep(backoff) => {} + } + backoff = backoff.saturating_mul(2).min(reconnect.max_backoff); + + match Self::establish(&addr, port, &user_agent, timeout).await { + Ok(new_framed) => { + info!( + attempt, + peer = %format!("{addr}:{port}"), + "TCP reconnected" + ); + alive.store(true, Ordering::Release); + + // Notify the consumer that it should replay + // subscriptions. `try_send` drops the notification if + // the channel is full (the consumer hasn't drained the + // previous one) — which is fine because the consumer + // re-subscribes *all* topics each time. + let _ = reconnect_tx.try_send(()); + + framed = new_framed; + break; // Back to outer loop → new io_loop with new framed. + } + Err(e) => { + warn!(attempt, error = %e, "reconnect attempt failed"); + // Continue inner loop to retry with increased backoff. + } + } + } + } + } + + /// Inner I/O loop — read, write, and heartbeat on a single connection. + /// Returns when the connection is lost or the task is cancelled. + #[allow(clippy::too_many_arguments)] + async fn io_loop( + framed: &mut Framed, + outbound_rx: &mut mpsc::Receiver, + inbound_tx: &mpsc::Sender, + pending: Arc>>>, + heartbeat_interval: Duration, + cancel: &CancellationToken, + alive: &AtomicBool, + ) -> IoExitReason { + use tokio::time::MissedTickBehavior; + let _ = alive; // already set to true by caller; no need to touch here + let mut heartbeat = tokio::time::interval(heartbeat_interval); + heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay); + // Skip the immediate first tick. + heartbeat.tick().await; + + loop { + tokio::select! { + biased; + + _ = cancel.cancelled() => { + debug!("connection task cancelled"); + return IoExitReason::Cancelled; + } + + // Write outbound packages from user code. + pkg = outbound_rx.recv() => { + match pkg { + Some(pkg) => { + if let Err(e) = framed.send(pkg).await { + warn!("write error, connection lost: {e}"); + return IoExitReason::IoError; + } + } + None => { + debug!("all senders dropped, stopping connection task"); + return IoExitReason::AllSendersDropped; + } + } + } + + // Read inbound frames from the server. + result = framed.next() => { + match result { + Some(Ok(pkg)) => { + // Heartbeats are sent fire-and-forget below, so + // their responses are never registered in `pending`. + // Drop them here: otherwise they'd be forwarded to + // the inbound channel. Only the consumer drains that + // channel — a producer-only connection would let + // heartbeats pile up until `inbound_tx.send` blocks + // (channel cap 256, ~30s interval), stalling this + // whole select! arm and freezing I/O after ~2 hours. + if pkg.header.cmd == Command::HeartbeatResponse { + debug!("heartbeat response received"); + continue; + } + let seq = pkg.header.seq.clone().unwrap_or_default(); + // Try to match a pending request-response context. + // Server-initiated frames (GOODBYE/REDIRECT) arrive + // with no seq, so `seq` is "" here and never + // matches a client's random 10-char correlation key + // — they fall through to the inbound channel below + // so `handle_inbound` can ACK them. + let entry = { + let mut guard = pending.lock().await; + guard.remove(&seq) + }; + if let Some(tx) = entry { + // A `RESPONSE_TO_CLIENT` (the server's RR reply) + // carries the seq of the originating + // `REQUEST_TO_SERVER`, so it lands here as a + // matched `io()` response rather than as a server + // push. The consumer ACKs pushes via + // `handle_inbound`; mirror the Java client + // (`PubClientImpl` / `AbstractEventMeshTCPPubHandler`) + // by ACKing the RR reply with + // `RESPONSE_TO_CLIENT_ACK` (copied seq + body) + // before handing it to the waiter. Server-side + // this is bookkeeping only (`MessageAckProcessor` + // is a no-op for RR replies). + if pkg.header.cmd == Command::ResponseToClient { + let ack_pkg = message::response_to_client_ack(&pkg); + if let Err(e) = framed.send(ack_pkg).await { + warn!(error = %e, "failed to send RESPONSE_TO_CLIENT_ACK"); + } + } + let _ = tx.send(pkg); + } else { + // An orphan RESPONSE_TO_CLIENT is a late reply + // to an `io()` call that already timed out and + // removed its pending entry. Drop it silently + // rather than forwarding to the inbound channel: + // on a producer-only connection the inbound + // receiver is never drained, so these would + // pile up and eventually trigger a SlowConsumer + // disconnect. + if pkg.header.cmd == Command::ResponseToClient { + debug!("dropping orphan RESPONSE_TO_CLIENT"); + continue; + } + + // Server push → inbound channel for the consumer. + // Use `try_send` instead of a blocking `send().await` + // so a full inbound channel can never stall the I/O + // loop indefinitely. + // + // When the channel is **full**, the consumer is not + // draining fast enough. Rather than silently dropping + // the push (which would lose an unacked message), we + // tear down the connection. The server has not + // received an ACK for this message (ACKs are sent by + // the consumer-side driver *after* it reads from this + // channel), so the server will redeliver after + // reconnect. This mirrors the Java SDK's behavior + // where a slow user callback blocks the Netty event + // loop, creating natural TCP backpressure — but + // avoids stalling heartbeats and writes in our async + // model. + match inbound_tx.try_send(pkg) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + "inbound channel full — disconnecting to \ + trigger server redelivery of unacked messages" + ); + return IoExitReason::SlowConsumer; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!("inbound channel closed (consumer dropped)"); + } + } + } + } + Some(Err(e)) => { + warn!("read error, connection lost: {e}"); + return IoExitReason::IoError; + } + None => { + info!("connection closed by server"); + return IoExitReason::ServerClosed; + } + } + } + + // Heartbeat: fire-and-forget. If the write fails the connection + // is dead and the loop breaks. + _ = heartbeat.tick() => { + let hb = message::heartbeat(); + if let Err(e) = framed.send(hb).await { + warn!("heartbeat send failed: {e}"); + return IoExitReason::IoError; + } + debug!("heartbeat sent"); + } + } + } + } +} + +impl Drop for TcpConnection { + fn drop(&mut self) { + self.cancel.cancel(); + if let Ok(mut guard) = self.join.try_lock() { + if let Some(join) = guard.take() { + join.abort(); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::config::{ReconnectConfig, TcpClientConfig}; + use crate::model::EventMeshMessage; + use crate::transport::tcp::codec::TcpCodec; + use crate::transport::tcp::frame::{Command, Header, Package, PackageBody}; + use crate::transport::Publisher; + + use futures::SinkExt; + use tokio::net::TcpListener; + use tokio_stream::StreamExt; + use tokio_util::codec::Framed; + + /// Loopback test: a request/reply round-trip must produce a + /// `RESPONSE_TO_CLIENT_ACK` back to the server (mirroring the Java client). + #[tokio::test] + async fn request_reply_acks_response_to_client() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let (ack_tx, ack_rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + // 1. HELLO handshake. + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + let hello_resp = Package::new(Header::new(Command::HelloResponse, "hello-seq")); + framed.send(hello_resp).await.unwrap(); + + // 2. Receive REQUEST_TO_SERVER; echo a RESPONSE_TO_CLIENT with the + // same seq + a JSON body (code 0 = success). + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::RequestToServer); + let seq = req.header.seq.clone().unwrap_or_default(); + let body = PackageBody::Text( + serde_json::json!({ + "topic": "reply", + "body": "pong", + }) + .to_string(), + ); + let mut resp_hdr = Header::new(Command::ResponseToClient, seq.clone()); + resp_hdr.code = 0; + framed + .send(Package { + header: resp_hdr, + body, + }) + .await + .unwrap(); + + // 3. Expect the client to ACK with RESPONSE_TO_CLIENT_ACK carrying + // the same seq. Heartbeat frames may interleave, so scan until we + // see the ACK (heartbeat interval is large, so usually first). + let mut got_ack = None; + for _ in 0..8 { + match framed.next().await { + Some(Ok(pkg)) => { + if pkg.header.cmd == Command::ResponseToClientAck { + got_ack = Some(pkg.header.seq.clone().unwrap_or_default()); + break; + } + } + _ => break, + } + } + let _ = ack_tx.send(got_ack); + + // Keep the connection open until the client drops it. + let _ = framed.close().await; + }); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(port) + .producer_group("g") + .timeout(Duration::from_secs(3)) + .heartbeat_interval(Duration::from_secs(60)) + .reconnect(ReconnectConfig::builder().enabled(false).build()) + .build(); + + let producer = crate::transport::tcp::TcpProducer::connect(config) + .await + .expect("connect"); + + let msg = EventMeshMessage::builder() + .topic("t") + .content("ping") + .build(); + let reply = producer + .request_reply(msg, Duration::from_secs(3)) + .await + .expect("request_reply"); + assert_eq!(reply.topic.as_deref(), Some("reply")); + assert_eq!(reply.content.as_deref(), Some("pong")); + + producer.shutdown().await; + + let ack_seq = ack_rx + .await + .expect("server did not observe any frames after the reply") + .expect("no RESPONSE_TO_CLIENT_ACK received by the server"); + // The ACK must echo the RR correlation seq. + assert!( + !ack_seq.is_empty(), + "RESPONSE_TO_CLIENT_ACK must carry the reply seq" + ); + + let _ = server.await; + } + + /// After a server-side close, the connection must automatically reconnect + /// (when enabled) and the consumer must receive a reconnect event so it + /// can replay subscriptions. + #[tokio::test] + async fn reconnect_after_server_close() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + // Server: accept two connections on the same listener. Close the first + // one to force a reconnect, then HELLO the second. + let server = tokio::spawn(async move { + // --- First connection --- + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new(Command::HelloResponse, "hello-1"))) + .await + .unwrap(); + // Drop to force a reconnect. + drop(framed); + + // --- Second connection (the auto-reconnect) --- + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new(Command::HelloResponse, "hello-2"))) + .await + .unwrap(); + + // Keep alive briefly so the reconnect stabilizes. + tokio::time::sleep(Duration::from_secs(1)).await; + }); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(port) + .producer_group("g") + .timeout(Duration::from_secs(3)) + .heartbeat_interval(Duration::from_secs(60)) + .reconnect( + ReconnectConfig::builder() + .enabled(true) + .initial_backoff(Duration::from_millis(100)) + .max_backoff(Duration::from_millis(500)) + .build(), + ) + .build(); + + let user_agent = super::super::frame::UserAgent::from_identity( + &config.identity, + config.server_port, + "pub", + ); + let conn = TcpConnection::connect( + &config.server_addr, + config.server_port, + &user_agent, + config.heartbeat_interval, + config.timeout, + config.reconnect.clone(), + ) + .await + .expect("initial connect"); + + // Wait for the server to close the first connection and the client to + // reconnect. The reconnect event channel fires after the new HELLO. + let mut reconnect_rx = conn.take_reconnect_rx().await.expect("reconnect receiver"); + + let result = tokio::time::timeout(Duration::from_secs(5), reconnect_rx.recv()).await; + assert!( + result.is_ok(), + "should receive a reconnect event within 5 s" + ); + assert!( + conn.is_active(), + "connection should be alive after reconnect" + ); + + conn.shutdown().await; + let _ = server.await; + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/consumer.rs new file mode 100644 index 0000000000..508320dd5c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/consumer.rs @@ -0,0 +1,1287 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! TCP consumer. +//! +//! [`TcpConsumer`] is constructed via [`TcpConsumer::connect`], which opens a +//! TCP connection, performs the HELLO handshake (role = sub), sends +//! `LISTEN_REQUEST`, and spawns the receive loop + heartbeat as background +//! tasks. Subscribe and unsubscribe RPCs can be called at any time after +//! construction — they are sent over the same connection via `conn.io()`. +//! +//! # Example +//! +//! ```ignore +//! use eventmesh::{ +//! config::TcpClientConfig, tcp::TcpConsumer, +//! model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, +//! MessageListener, +//! }; +//! +//! struct MyListener; +//! impl MessageListener for MyListener { +//! type Message = EventMeshMessage; +//! async fn handle(&self, msg: EventMeshMessage) -> Option { +//! println!("received: {:?}", msg.content); +//! None +//! } +//! } +//! +//! #[tokio::main] +//! async fn main() -> eventmesh::Result<()> { +//! let config = TcpClientConfig::builder() +//! .server_addr("127.0.0.1").server_port(10000) +//! .consumer_group("g") +//! .build(); +//! let consumer = TcpConsumer::connect( +//! config, +//! MyListener, +//! async { tokio::signal::ctrl_c().await.ok(); }, +//! ).await?; +//! consumer.subscribe(vec![SubscriptionItem::new( +//! "t", SubscriptionMode::CLUSTERING, SubscriptionType::ASYNC, +//! )]).await?; +//! consumer.wait_for_shutdown().await; +//! Ok(()) +//! } +//! ``` + +use std::future::Future; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::config::TcpClientConfig; +use crate::error::{EventMeshError, Result}; +use crate::message::Message; +use crate::model::{EventMeshMessage, OpenMessage, PublishResponse, SubscriptionItem}; +use crate::transport::tcp::connection::TcpConnection; +use crate::transport::tcp::frame::{Command, Package, PackageBody, RedirectInfo, UserAgent}; +use crate::transport::tcp::message; +use crate::MessageListener; + +/// A message representation supported by the TCP consumer receive loop. +/// +/// This is implemented by the SDK's native EventMeshMessage and, with the +/// `cloud_events` feature, native CloudEvents. It keeps TCP wire codecs out +/// of the normal producer and consumer APIs. +pub trait TcpMessage: Clone + Send + 'static { + #[doc(hidden)] + fn decode_tcp(pkg: &Package) -> Option + where + Self: Sized; + + #[doc(hidden)] + fn encode_tcp_reply(&self) -> Result; + + /// Copy request routing and correlation metadata into a fresh reply when + /// the runtime has sent a request/reply delivery. + #[doc(hidden)] + fn inherit_request_metadata(&mut self, request: &Self); +} + +impl TcpMessage for EventMeshMessage { + fn decode_tcp(pkg: &Package) -> Option { + if message::is_cloudevents(pkg) { + #[cfg(feature = "cloud_events")] + return message::parse_cloud_event(&pkg.body) + .map(|event| message::cloud_event_to_message(&event)); + #[cfg(not(feature = "cloud_events"))] + return None; + } + message::parse_message(&pkg.body) + } + + fn encode_tcp_reply(&self) -> Result { + message::build_message_package(self, Command::ResponseToServer) + } + + fn inherit_request_metadata(&mut self, request: &Self) { + for (key, value) in &request.props { + self.props + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + } +} + +impl TcpMessage for Message { + fn decode_tcp(pkg: &Package) -> Option { + if message::is_cloudevents(pkg) { + #[cfg(feature = "cloud_events")] + return message::parse_cloud_event(&pkg.body).map(Self::CloudEvent); + #[cfg(not(feature = "cloud_events"))] + return None; + } + + let native = message::parse_message(&pkg.body)?; + if message::is_open_message(pkg) { + Some(Self::Open(OpenMessage::from_event_mesh_message(native))) + } else { + Some(Self::EventMesh(native)) + } + } + + fn encode_tcp_reply(&self) -> Result { + match self { + Self::EventMesh(message) => { + message::build_message_package(message, Command::ResponseToServer) + } + Self::Open(message) => { + message::build_open_message_package(message, Command::ResponseToServer) + } + #[cfg(feature = "cloud_events")] + Self::CloudEvent(event) => { + message::build_cloud_event_package(event, Command::ResponseToServer) + } + } + } + + fn inherit_request_metadata(&mut self, request: &Self) { + #[cfg(feature = "cloud_events")] + if let (Self::CloudEvent(reply), Self::CloudEvent(request)) = (&mut *self, request) { + for (key, value) in request.iter_extensions() { + if reply.extension(key).is_none() { + reply.set_extension(key, value.clone()); + } + } + return; + } + + // TCP correlation metadata has no lossless slot in OpenMessage. + // Normalize non-CloudEvent replies to EventMeshMessage before merging + // request metadata, matching the previous public adapter's behavior. + let mut reply = match self.clone() { + Self::EventMesh(message) => message, + Self::Open(message) => message.to_event_mesh_message(), + #[cfg(feature = "cloud_events")] + Self::CloudEvent(event) => message::cloud_event_to_message(&event), + }; + let request = match request.clone() { + Self::EventMesh(message) => message, + Self::Open(message) => message.to_event_mesh_message(), + #[cfg(feature = "cloud_events")] + Self::CloudEvent(event) => message::cloud_event_to_message(&event), + }; + ::inherit_request_metadata(&mut reply, &request); + *self = Self::EventMesh(reply); + } +} + +#[cfg(feature = "cloud_events")] +impl TcpMessage for cloudevents::Event { + fn decode_tcp(pkg: &Package) -> Option { + if message::is_cloudevents(pkg) { + message::parse_cloud_event(&pkg.body) + } else { + message::parse_message(&pkg.body) + .and_then(|message| message::message_to_cloud_event(&message).ok()) + } + } + + fn encode_tcp_reply(&self) -> Result { + message::build_cloud_event_package(self, Command::ResponseToServer) + } + + fn inherit_request_metadata(&mut self, request: &Self) { + for (key, value) in request.iter_extensions() { + if self.extension(key).is_none() { + self.set_extension(key, value.clone()); + } + } + } +} + +/// Why the TCP consumer's receive loop stopped. +/// +/// Returned by [`TcpConsumer::wait_for_shutdown`] so the caller can react to +/// server-driven events like redirect. +#[derive(Debug, Clone)] +pub enum ShutdownReason { + /// The shutdown token was cancelled — either by the user-supplied + /// shutdown signal, an explicit `shutdown()` call, or the driver itself + /// after a clean exit. + Cancelled, + /// The server sent `REDIRECT_TO_CLIENT` with a target address. The caller + /// should connect to the advertised EventMesh node. + /// + /// (The Java SDK ignores this frame entirely — it falls into the `default` + /// branch of the handler switch and is logged as a warning. The Rust SDK + /// surfaces it so the caller can act on it.) + Redirect(RedirectInfo), + /// The inbound channel closed (the connection was lost and not + /// re-established, or the consumer was dropped). + ChannelClosed, + /// The receive-loop driver task exited abnormally (panic or error). + Error(String), +} + +/// Result of dispatching a single inbound package. +enum InboundResult { + /// Keep the receive loop running. + Continue, + /// Stop the loop and report a redirect target to the caller. + Redirect(RedirectInfo), + /// Stop the loop (parse failure, unknown command, etc.). + Stop, +} + +/// TCP-based consumer, generic over the user's [`MessageListener`] type. +/// +/// Created via [`TcpConsumer::connect`], which opens a TCP connection, performs +/// the HELLO handshake (role = sub), sends `LISTEN_REQUEST`, and spawns the +/// receive loop + heartbeat as background tasks. +/// +/// Subscribe and unsubscribe RPCs can be called at any time after construction. +/// The background tasks are stopped when the consumer is dropped or explicitly +/// via [`shutdown`](Self::shutdown) / [`wait_for_shutdown`](Self::wait_for_shutdown). +pub struct TcpConsumer { + conn: Arc, + config: TcpClientConfig, + _listener: std::marker::PhantomData>, + shutdown: CancellationToken, + subscriptions: Arc>>, + driver_handle: Mutex>>>, + /// Filled by the driver before it exits, so `wait_for_shutdown` can + /// return a structured [`ShutdownReason`]. + shutdown_reason: Arc>>, +} + +/// TCP consumer facade for [`OpenMessage`]. +/// +/// EventMesh runtimes route TCP messages through the native EventMeshMessage +/// envelope. This facade converts at the SDK boundary, giving OpenMessaging +/// applications a typed listener while retaining interoperable wire data. +pub struct TcpOpenMessageConsumer> { + inner: TcpConsumer>, +} + +struct OpenMessageListener { + listener: Arc, +} + +impl> MessageListener for OpenMessageListener { + type Message = EventMeshMessage; + + async fn handle(&self, message: EventMeshMessage) -> Result> { + Ok(self + .listener + .handle(OpenMessage::from_event_mesh_message(message)) + .await? + .map(|reply| reply.to_event_mesh_message())) + } +} + +impl> TcpOpenMessageConsumer { + /// Connect and start an OpenMessaging-style TCP consumer. + pub async fn connect( + config: TcpClientConfig, + listener: L, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + let listener = OpenMessageListener { + listener: Arc::new(listener), + }; + Ok(Self { + inner: TcpConsumer::connect(config, listener, shutdown_signal).await?, + }) + } + + /// Subscribe to additional topics. + pub async fn subscribe(&self, items: &[SubscriptionItem]) -> Result<()> { + self.inner.subscribe(items).await + } + + /// Unsubscribe from topics. + pub async fn unsubscribe(&self, items: Vec) -> Result { + self.inner.unsubscribe(items).await + } + + /// Shut down the consumer and its background tasks. + pub async fn shutdown(&self) { + self.inner.shutdown().await; + } + + /// Wait for the consumer to stop. + pub async fn wait_for_shutdown(&self) -> ShutdownReason { + self.inner.wait_for_shutdown().await + } +} + +/// Native CloudEvents TCP consumer. +/// +/// Unlike the EventMeshMessage consumer, this type passes the TCP +/// CloudEvents JSON body directly to a `MessageListener` +/// and serializes listener replies back as CloudEvents. +#[cfg(feature = "cloud_events")] +pub type TcpCloudEventConsumer = TcpConsumer; + +impl TcpConsumer +where + L: MessageListener, + L::Message: TcpMessage, +{ + /// Connect to the EventMesh TCP endpoint, perform the HELLO handshake + /// (role = sub), send `LISTEN_REQUEST`, and spawn the receive loop. + /// + /// `shutdown_signal` is an optional future whose resolution triggers + /// graceful shutdown. When omitted, shutdown can only be initiated by + /// [`shutdown`](Self::shutdown) or drop. + /// + /// The reconnect policy from the config controls automatic reconnection + /// after I/O failures (enabled by default). When a reconnect succeeds, + /// the receive loop automatically replays all subscriptions and re-issues + /// `LISTEN_REQUEST`. + pub async fn connect( + config: TcpClientConfig, + listener: L, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + let user_agent = UserAgent::from_identity(&config.identity, config.server_port, "sub"); + let conn = TcpConnection::connect( + &config.server_addr, + config.server_port, + &user_agent, + config.heartbeat_interval, + config.timeout, + config.reconnect.clone(), + ) + .await?; + let conn = Arc::new(conn); + + let shutdown = CancellationToken::new(); + let subscriptions = Arc::new(Mutex::new(Vec::new())); + let shutdown_reason = Arc::new(Mutex::new(None)); + let listener = Arc::new(listener); + + // Signal watcher. + if let Some(signal) = shutdown_signal { + let token = shutdown.clone(); + tokio::spawn(async move { + tokio::select! { + _ = signal => token.cancel(), + _ = token.cancelled() => {} + } + }); + } + + // Send LISTEN_REQUEST and verify it succeeds. + let listen_pkg = message::listen(); + let listen_resp = conn.io(listen_pkg, config.timeout).await?; + let listen_status = message::response_from_pkg(&listen_resp); + if !listen_status.is_success() { + return Err(EventMeshError::Server { + code: listen_status.code.unwrap_or(-1) as i32, + message: listen_status + .message + .unwrap_or_else(|| "listen failed".into()), + }); + } + debug!("LISTEN ok, entering receive loop"); + + // Take the inbound receiver (only available once). + let inbound_rx = conn + .take_inbound_rx() + .await + .ok_or_else(|| EventMeshError::Tcp("inbound receiver already taken".into()))?; + + // Take the reconnect-event receiver. + let reconnect_rx = conn.take_reconnect_rx().await; + + // Spawn the receive-loop driver. + let driver_handle = spawn_driver( + Arc::clone(&conn), + inbound_rx, + reconnect_rx, + Arc::clone(&listener), + config.clone(), + Arc::clone(&subscriptions), + shutdown.clone(), + Arc::clone(&shutdown_reason), + ); + + Ok(Self { + conn, + config, + _listener: std::marker::PhantomData, + shutdown, + subscriptions, + driver_handle: Mutex::new(Some(driver_handle)), + shutdown_reason, + }) + } + + /// Subscribe to additional topics. Sends a `SUBSCRIBE_REQUEST` for each + /// item via `conn.io()` and records it locally after the server confirms. + /// + /// This can be called at any time after construction — the connection is + /// already open and the receive loop is running. + pub async fn subscribe(&self, items: &[SubscriptionItem]) -> Result<()> { + for item in items { + let sub_pkg = message::subscribe(&item.topic, std::slice::from_ref(item)); + let resp = self.conn.io(sub_pkg, self.config.timeout).await?; + let response = message::response_from_pkg(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "subscribe failed".into()), + }); + } + self.subscriptions.lock().await.push(item.clone()); + } + Ok(()) + } + + /// Unsubscribe from topics. Sends an `UNSUBSCRIBE_REQUEST` via + /// `conn.io()`. + /// + /// Note: the runtime's TCP `UnSubscribeProcessor` ignores the request body + /// and drops **all** session topics. The local subscription list is + /// cleared entirely on success. + pub async fn unsubscribe(&self, items: Vec) -> Result { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "unsubscribe items must not be empty".into(), + )); + } + let unsub_pkg = message::unsubscribe(&items); + let resp = self.conn.io(unsub_pkg, self.config.timeout).await?; + let response = message::response_from_pkg(&resp); + + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }); + } + + let mut subs = self.subscriptions.lock().await; + let current: Vec = subs.iter().map(|s| s.topic.clone()).collect(); + let passed_all = + items.len() == current.len() && items.iter().all(|i| current.contains(&i.topic)); + if !passed_all { + warn!( + passed = ?items.iter().map(|i| i.topic.clone()).collect::>(), + current = ?current, + "TCP unsubscribe drops ALL topics on the server (not just \ + the ones passed); clearing local state to match" + ); + } + subs.clear(); + Ok(response) + } + + /// Current config. + pub fn config(&self) -> &TcpClientConfig { + &self.config + } + + /// Explicitly shut down: cancel the shared token, shut down the + /// connection, and await the driver task's exit. + pub async fn shutdown(&self) { + self.shutdown.cancel(); + self.conn.shutdown().await; + if let Some(handle) = self.driver_handle.lock().await.take() { + let _ = handle.await; + } + } + + /// Block until the shutdown signal fires or the receive loop exits on its + /// own (e.g. server redirect or I/O error), then return a + /// [`ShutdownReason`] indicating why the driver stopped. + /// + /// If the driver task panics, the `JoinHandle` resolves with + /// `Err(JoinError)` and this method returns `ShutdownReason::Error` — it + /// does **not** hang forever waiting for the cancellation token (which the + /// panicked driver would never fire). + /// + /// If no shutdown signal was provided at construction time, this blocks + /// until the driver exits naturally. + pub async fn wait_for_shutdown(&self) -> ShutdownReason { + // Take ownership of the driver handle so we can race it against + // cancellation. + let handle = self.driver_handle.lock().await.take(); + + if let Some(handle) = handle { + tokio::select! { + biased; + _ = self.shutdown.cancelled() => { + // Cancellation fired (user signal, `shutdown()` call, or + // the driver itself called `shutdown.cancel()` before + // exiting). If the driver set a reason, it will be + // available below; otherwise the default is `Cancelled`. + self.conn.shutdown().await; + // The handle was consumed by the select — the driver + // task continues asynchronously and will observe the + // cancellation token / closed inbound channel and exit. + } + result = handle => { + // Driver exited before the token was cancelled (redirect, + // channel closed, error, or panic). Cancel to stop any + // signal-watcher task. + self.shutdown.cancel(); + // If the driver panicked, capture the JoinError as a + // shutdown reason. + if let Err(join_err) = result { + *self.shutdown_reason.lock().await = Some(ShutdownReason::Error( + format!("receive-loop driver task panicked: {join_err}"), + )); + } + } + } + } else { + // Handle already taken (concurrent `shutdown()` or previous + // `wait_for_shutdown`). + self.shutdown.cancelled().await; + } + + self.shutdown_reason + .lock() + .await + .take() + .unwrap_or(ShutdownReason::Cancelled) + } +} + +impl Drop for TcpConsumer { + fn drop(&mut self) { + self.shutdown.cancel(); + if let Ok(mut guard) = self.driver_handle.try_lock() { + if let Some(handle) = guard.take() { + handle.abort(); + } + } + } +} + +// --------------------------------------------------------------------------- +// Receive-loop driver (spawned, not public) +// --------------------------------------------------------------------------- + +/// Spawn the receive loop as a background task. +/// +/// Dispatches delivered messages to the listener and sends ACKs / replies. +/// On reconnect, replays all subscriptions and re-issues `LISTEN_REQUEST`. +/// Exits when the shutdown token fires, the inbound channel closes, or a +/// `REDIRECT_TO_CLIENT` frame is received. On exit, cancels the shutdown +/// token so `wait_for_shutdown` unblocks. +#[allow(clippy::too_many_arguments)] +fn spawn_driver( + conn: Arc, + mut inbound_rx: tokio::sync::mpsc::Receiver, + mut reconnect_rx: Option>, + listener: Arc, + config: TcpClientConfig, + subscriptions: Arc>>, + shutdown: CancellationToken, + shutdown_reason: Arc>>, +) -> JoinHandle> +where + L: MessageListener, + L::Message: TcpMessage, +{ + tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = shutdown.cancelled() => { + debug!("receive loop shutting down"); + *shutdown_reason.lock().await = Some(ShutdownReason::Cancelled); + break; + } + + // Reconnect event: the connection task has re-established + // the TCP session. Replay all subscriptions + LISTEN. + event = async { + match reconnect_rx.as_mut() { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + if event.is_none() { + info!("reconnect channel closed, exiting receive loop"); + *shutdown_reason.lock().await = Some(ShutdownReason::ChannelClosed); + break; + } + info!("connection reconnected, replaying subscriptions"); + let subs_snapshot = subscriptions.lock().await.clone(); + let mut all_ok = true; + for item in &subs_snapshot { + let sub_pkg = + message::subscribe(&item.topic, std::slice::from_ref(item)); + match conn.io(sub_pkg, config.timeout).await { + Ok(resp) => { + let r = message::response_from_pkg(&resp); + if !r.is_success() { + warn!( + topic = ?item.topic, + code = r.code, + "re-subscribe after reconnect rejected" + ); + all_ok = false; + } + } + Err(e) => { + warn!( + topic = ?item.topic, + error = %e, + "re-subscribe after reconnect error" + ); + all_ok = false; + } + } + } + if all_ok { + match conn.io(message::listen(), config.timeout).await { + Ok(resp) => { + let r = message::response_from_pkg(&resp); + if !r.is_success() { + warn!(code = r.code, "re-LISTEN after reconnect rejected"); + } else { + debug!("re-LISTEN ok after reconnect"); + } + } + Err(e) => { + warn!(error = %e, "re-LISTEN after reconnect error"); + } + } + } + } + + // Inbound message from the server. + pkg = inbound_rx.recv() => { + match pkg { + Some(pkg) => { + match handle_inbound(&pkg, &conn, &*listener).await { + InboundResult::Continue => {} + InboundResult::Redirect(ri) => { + info!( + ip = %ri.ip, + port = ri.port, + "receive loop stopping after REDIRECT_TO_CLIENT" + ); + *shutdown_reason.lock().await = + Some(ShutdownReason::Redirect(ri)); + break; + } + InboundResult::Stop => { + info!("receive loop stopping (disconnect or parse failure)"); + // The consumer keeps an Arc to the connection after the + // driver exits. Close it here so an unacknowledged delivery + // is released for server-side redelivery instead of leaving + // the I/O task and socket alive until the caller joins. + conn.shutdown().await; + *shutdown_reason.lock().await = + Some(ShutdownReason::ChannelClosed); + break; + } + } + } + None => { + info!("inbound channel closed, exiting receive loop"); + *shutdown_reason.lock().await = Some(ShutdownReason::ChannelClosed); + break; + } + } + } + } + } + + // Signal wait_for_shutdown that we've exited. + shutdown.cancel(); + Ok(()) + }) +} + +/// Dispatch an inbound package: parse the message, invoke the listener, send +/// any reply, then send the matching ACK. +/// +/// Returns [`InboundResult::Continue`] to keep the receive loop running, +/// [`InboundResult::Redirect`] to stop and report a redirect target, or +/// [`InboundResult::Stop`] to stop the loop (which triggers reconnection +/// and server-side redelivery). +/// +/// If the message cannot be parsed, **no ACK is sent** and the function +/// returns [`InboundResult::Stop`] so the connection is torn down — mirroring +/// the Java SDK, where a parse exception propagates to `exceptionCaught` and +/// closes the channel. A listener panic likewise propagates and kills the +/// receive task; it is not silently swallowed. +async fn handle_inbound(pkg: &Package, conn: &TcpConnection, listener: &L) -> InboundResult +where + L: MessageListener, + L::Message: TcpMessage, +{ + let ack_cmd = match pkg.header.cmd { + Command::RequestToClient => Some(Command::RequestToClientAck), + Command::AsyncMessageToClient => Some(Command::AsyncMessageToClientAck), + Command::BroadcastMessageToClient => Some(Command::BroadcastMessageToClientAck), + Command::ServerGoodbyeRequest => { + info!("server goodbye received, sending SERVER_GOODBYE_RESPONSE"); + let resp = message::ack(Command::ServerGoodbyeResponse, pkg); + if let Err(e) = conn.send(resp).await { + warn!(error = %e, "failed to send SERVER_GOODBYE_RESPONSE"); + } + return InboundResult::Continue; + } + Command::RedirectToClient => { + match pkg.body { + PackageBody::RedirectInfo(ref ri) => { + info!( + ip = %ri.ip, + port = ri.port, + "received REDIRECT_TO_CLIENT; stopping receive loop so the \ + caller can reconnect to the advertised EventMesh node" + ); + return InboundResult::Redirect(ri.clone()); + } + PackageBody::Text(ref s) => warn!( + body = %s, + "REDIRECT_TO_CLIENT body did not deserialize into RedirectInfo; \ + stopping receive loop" + ), + ref other => warn!( + body = ?other, + "unexpected body shape for REDIRECT_TO_CLIENT; stopping receive loop" + ), + } + return InboundResult::Stop; + } + cmd => { + warn!(?cmd, "unexpected inbound command, ignoring"); + return InboundResult::Continue; + } + }; + + let msg = match L::Message::decode_tcp(pkg) { + Some(msg) => msg, + None => { + warn!("failed to parse inbound message body; disconnecting without ACK"); + return InboundResult::Stop; + } + }; + + debug!("dispatching to listener"); + // A listener panic propagates naturally and kills the receive task — + // mirroring Java where the exception escapes to exceptionCaught and + // closes the channel. The message is NOT acknowledged. + let request = (pkg.header.cmd == Command::RequestToClient).then(|| msg.clone()); + let reply = match listener.handle(msg).await { + Ok(reply) => reply, + Err(error) => { + warn!(%error, "listener failed; disconnecting without ACK"); + return InboundResult::Stop; + } + }; + if let Some(mut reply) = reply { + if let Some(request) = request.as_ref() { + reply.inherit_request_metadata(request); + } + match reply.encode_tcp_reply() { + Ok(reply_pkg) => { + if let Err(e) = conn.send(reply_pkg).await { + warn!(error = %e, "failed to send reply"); + } + } + Err(e) => warn!(error = %e, "failed to serialize reply"), + } + } + + if let Some(cmd) = ack_cmd { + let ack_pkg = message::ack(cmd, pkg); + if let Err(e) = conn.send(ack_pkg).await { + warn!(error = %e, "failed to send ACK"); + } + } + + InboundResult::Continue +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::config::TcpClientConfig; + use crate::model::{SubscriptionItem, SubscriptionMode, SubscriptionType}; + use crate::transport::tcp::codec::TcpCodec; + use crate::transport::tcp::frame::{Command, Header, Package, PackageBody, RedirectInfo}; + + use futures::SinkExt; + use tokio::net::TcpListener; + use tokio_stream::StreamExt; + use tokio_util::codec::Framed; + + /// A no-op listener used only to satisfy `TcpConsumer`'s type parameter. + struct NoopListener; + impl MessageListener for NoopListener { + type Message = EventMeshMessage; + async fn handle(&self, _: EventMeshMessage) -> Result> { + Ok(None) + } + } + + /// A listener failure must tear down the TCP session without ACKing the + /// delivery so the server can redeliver it on a subsequent connection. + struct FailingListener; + impl MessageListener for FailingListener { + type Message = EventMeshMessage; + async fn handle(&self, _: EventMeshMessage) -> Result> { + Err(EventMeshError::Tcp("listener failure".into())) + } + } + + #[test] + fn public_message_preserves_open_protocol() { + let open = OpenMessage::new("orders", "created"); + let package = + message::build_open_message_package(&open, Command::AsyncMessageToClient).unwrap(); + let decoded = ::decode_tcp(&package).expect("decode message"); + assert_eq!(decoded, Message::Open(open)); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn public_message_preserves_cloud_event_protocol() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("event-1") + .source("urn:test") + .ty("orders.created") + .subject("orders") + .data("application/cloudevents+json", "created") + .build() + .expect("build event"); + let package = + message::build_cloud_event_package(&event, Command::AsyncMessageToClient).unwrap(); + let decoded = ::decode_tcp(&package).expect("decode message"); + match decoded { + Message::CloudEvent(decoded) => { + use cloudevents::AttributesReader; + assert_eq!(decoded.id(), "event-1"); + assert_eq!(decoded.subject(), Some("orders")); + assert!(decoded.data().is_some()); + } + other => panic!("expected CloudEvent, got {other:?}"), + } + } + + #[tokio::test] + async fn listener_error_closes_tcp_connection_without_ack() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new( + Command::HelloResponse, + "hello-seq", + ))) + .await + .unwrap(); + + let listen = framed.next().await.unwrap().unwrap(); + assert_eq!(listen.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + listen.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + let delivery = message::build_message_package( + &EventMeshMessage::builder() + .topic("topic") + .content("payload") + .build(), + Command::AsyncMessageToClient, + ) + .unwrap(); + framed.send(delivery).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(3), async { + loop { + match framed.next().await { + Some(Ok(pkg)) if pkg.header.cmd == Command::ClientGoodbyeRequest => {} + Some(Ok(pkg)) => { + panic!( + "listener failure must not ACK delivery; got {:?}", + pkg.header.cmd + ) + } + Some(Err(_)) | None => break, + } + } + }) + .await + .expect("listener failure should close the TCP connection promptly"); + }); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(port) + .consumer_group("g") + .timeout(Duration::from_secs(3)) + .heartbeat_interval(Duration::from_secs(60)) + .build(); + let consumer = + TcpConsumer::connect(config, FailingListener, None::>) + .await + .expect("connect"); + + server.await.unwrap(); + assert!( + !consumer.conn.is_active(), + "listener failure must stop the connection I/O task without requiring join" + ); + } + + #[test] + fn request_reply_inherits_routing_properties_without_overwriting_reply() { + let request = EventMeshMessage::builder() + .topic("request-topic") + .content("request") + .prop("cluster", "remote-cluster") + .prop("correlation-id", "request-id") + .build(); + let mut reply = EventMeshMessage::builder() + .topic("reply-topic") + .content("reply") + .prop("correlation-id", "reply-id") + .build(); + + reply.inherit_request_metadata(&request); + let pkg = reply.encode_tcp_reply().expect("encode reply"); + let encoded = message::parse_message(&pkg.body).expect("decode reply"); + + assert_eq!(encoded.get_prop("cluster"), Some("remote-cluster")); + assert_eq!(encoded.get_prop("correlation-id"), Some("reply-id")); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloud_event_reply_inherits_request_extensions() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let request = EventBuilderV10::new() + .id("request") + .source("urn:test") + .ty("test") + .extension("cluster", "remote-cluster") + .build() + .expect("build request"); + let mut reply = EventBuilderV10::new() + .id("reply") + .source("urn:test") + .ty("test") + .build() + .expect("build reply"); + + reply.inherit_request_metadata(&request); + assert_eq!( + reply.extension("cluster").unwrap().to_string(), + "remote-cluster" + ); + } + + /// Loopback test: the runtime's TCP `UnSubscribeProcessor` ignores the + /// request body and drops **all** session topics. After subscribing to A + /// and B and calling `unsubscribe([A])`, the local `subscriptions` map + /// must be empty (not just missing A) so it matches the server. + #[tokio::test] + async fn unsubscribe_clears_all_local_state() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + // 1. HELLO handshake. + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + let hello_resp = Package::new(Header::new(Command::HelloResponse, "hello-seq")); + framed.send(hello_resp).await.unwrap(); + + // 2. Reply to LISTEN_REQUEST. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 3. Reply to each SUBSCRIBE_REQUEST with SubscribeResponse (code 0). + for _ in 0..2 { + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::SubscribeRequest); + let resp = Package::new(Header::new( + Command::SubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + )); + framed.send(resp).await.unwrap(); + } + + // 4. Reply to the UNSUBSCRIBE_REQUEST with UnsubscribeResponse (code 0). + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::UnsubscribeRequest); + let resp = Package::new(Header::new( + Command::UnsubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + )); + framed.send(resp).await.unwrap(); + + // Keep the connection alive until the client drops it. + let _ = framed.close().await; + }); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(port) + .consumer_group("g") + .timeout(Duration::from_secs(3)) + .heartbeat_interval(Duration::from_secs(60)) + .build(); + + let consumer = TcpConsumer::connect(config, NoopListener, None::>) + .await + .expect("connect"); + + // Subscribe to two topics. Each call records into `self.subscriptions`. + let item_a = + SubscriptionItem::new("A", SubscriptionMode::CLUSTERING, SubscriptionType::SYNC); + let item_b = + SubscriptionItem::new("B", SubscriptionMode::CLUSTERING, SubscriptionType::SYNC); + consumer + .subscribe(&[item_a, item_b]) + .await + .expect("subscribe A+B"); + { + let subs = consumer.subscriptions.lock().await; + assert_eq!(subs.len(), 2, "both subscriptions should be recorded"); + } + + // Unsubscribe only A. The server drops ALL topics, so the local map + // must be fully cleared — not left with a phantom B entry. + let item_a = + SubscriptionItem::new("A", SubscriptionMode::CLUSTERING, SubscriptionType::SYNC); + consumer + .unsubscribe(vec![item_a]) + .await + .expect("unsubscribe A"); + { + let subs = consumer.subscriptions.lock().await; + assert!( + subs.is_empty(), + "local subscriptions must be fully cleared after unsubscribe, got: {:?}", + *subs + ); + } + + consumer.shutdown().await; + let _ = server.await; + } + + /// When the server returns a non-zero code for UNSUBSCRIBE_REQUEST, the + /// SDK must return `Err(Server)` — not `Ok` with a failed response. + /// Local subscription state must be preserved on failure. + #[tokio::test] + async fn unsubscribe_nonzero_returns_err() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + // 1. HELLO handshake. + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new( + Command::HelloResponse, + "hello-seq", + ))) + .await + .unwrap(); + + // 2. Reply to LISTEN_REQUEST. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 3. Reply to SUBSCRIBE_REQUEST with code 0. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::SubscribeRequest); + framed + .send(Package::new(Header::new( + Command::SubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 4. Reply to UNSUBSCRIBE_REQUEST with code 1 (FAIL). + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::UnsubscribeRequest); + let mut resp = Header::new( + Command::UnsubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + ); + resp.code = 1; + resp.desc = Some("group not found".into()); + framed.send(Package::new(resp)).await.unwrap(); + + let _ = framed.close().await; + }); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(port) + .consumer_group("g") + .timeout(Duration::from_secs(3)) + .heartbeat_interval(Duration::from_secs(60)) + .build(); + + let consumer = TcpConsumer::connect(config, NoopListener, None::>) + .await + .expect("connect"); + + let item = SubscriptionItem::new("A", SubscriptionMode::CLUSTERING, SubscriptionType::SYNC); + consumer.subscribe(&[item]).await.expect("subscribe"); + + // Server returns code 1 → must be Err, not Ok. + let item = SubscriptionItem::new("A", SubscriptionMode::CLUSTERING, SubscriptionType::SYNC); + let err = consumer + .unsubscribe(vec![item]) + .await + .expect_err("should fail"); + assert!( + err.to_string().contains("server error"), + "expected Server error, got: {err}" + ); + + // Local state must be preserved on failure. + let subs = consumer.subscriptions.lock().await; + assert_eq!( + subs.len(), + 1, + "subscriptions must not be cleared on failure" + ); + + consumer.shutdown().await; + let _ = server.await; + } + /// The server sends `REDIRECT_TO_CLIENT` with an `ip`/`port` body. + /// The receive loop must stop promptly and `wait_for_shutdown` must + /// return `ShutdownReason::Redirect` carrying the advertised address. + #[tokio::test] + async fn redirect_to_client_returns_redirect_reason() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + // 1. HELLO handshake. + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new( + Command::HelloResponse, + "hello-seq", + ))) + .await + .unwrap(); + + // 2. Reply to LISTEN_REQUEST. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 3. Send REDIRECT_TO_CLIENT. + let redirect = Package::new(Header::new(Command::RedirectToClient, "redirect-seq")) + .with_body(PackageBody::RedirectInfo(RedirectInfo { + ip: "10.0.0.9".into(), + port: 10000, + })); + framed.send(redirect).await.unwrap(); + + let _ = framed.close().await; + }); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(port) + .consumer_group("g") + .timeout(Duration::from_secs(3)) + .heartbeat_interval(Duration::from_secs(60)) + .build(); + + let consumer = TcpConsumer::connect(config, NoopListener, None::>) + .await + .expect("connect"); + + // The redirect frame should make the driver exit on its own. + // wait_for_shutdown must return promptly with the redirect reason. + let reason = + tokio::time::timeout(Duration::from_secs(10), consumer.wait_for_shutdown()).await; + assert!( + reason.is_ok(), + "REDIRECT_TO_CLIENT should stop the receive loop promptly" + ); + + match reason.unwrap() { + ShutdownReason::Redirect(ri) => { + assert_eq!(ri.ip, "10.0.0.9", "redirect ip must match"); + assert_eq!(ri.port, 10000, "redirect port must match"); + } + other => panic!("expected ShutdownReason::Redirect, got {other:?}"), + } + + let _ = server.await; + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/frame.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/frame.rs new file mode 100644 index 0000000000..24b70ac3d3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/frame.rs @@ -0,0 +1,686 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! TCP wire-frame types: [`Command`], [`Header`], [`Package`], [`UserAgent`]. +//! +//! These mirror `org.apache.eventmesh.common.protocol.tcp.*` on the Java side +//! and are the in-memory representation decoded/encoded by [`super::codec`]. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::{EventMeshError, Result}; +use crate::model::SubscriptionItem; + +// --------------------------------------------------------------------------- +// Command +// --------------------------------------------------------------------------- + +/// All TCP command types (mirrors Java `Command.java`, values 0–36). +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Command { + /// Client sends heartbeat packet to server. + HeartbeatRequest = 0, + /// Server responds to client heartbeat. + HeartbeatResponse = 1, + /// Client sends handshake request. + HelloRequest = 2, + /// Server responds to handshake. + HelloResponse = 3, + /// Client notifies server of active disconnect. + ClientGoodbyeRequest = 4, + /// Server replies to client's disconnect notification. + ClientGoodbyeResponse = 5, + /// Server notifies client of active disconnect. + ServerGoodbyeRequest = 6, + /// Client replies to server's disconnect notification. + ServerGoodbyeResponse = 7, + /// Subscription request. + SubscribeRequest = 8, + /// Server replies to subscription. + SubscribeResponse = 9, + /// Unsubscribe request. + UnsubscribeRequest = 10, + /// Server replies to unsubscribe. + UnsubscribeResponse = 11, + /// Request to start topic listening. + ListenRequest = 12, + /// Server replies to listen request. + ListenResponse = 13, + /// Client sends RR request to server. + RequestToServer = 14, + /// Server pushes RR request to client. + RequestToClient = 15, + /// Client ACKs RR request. + RequestToClientAck = 16, + /// Client sends RR reply to server. + ResponseToServer = 17, + /// Server pushes RR reply to client. + ResponseToClient = 18, + /// Client ACKs RR reply. + ResponseToClientAck = 19, + /// Client sends asynchronous events. + AsyncMessageToServer = 20, + /// Server ACKs asynchronous events. + AsyncMessageToServerAck = 21, + /// Server pushes asynchronous events to client. + AsyncMessageToClient = 22, + /// Client ACKs asynchronous events. + AsyncMessageToClientAck = 23, + /// Client sends broadcast message. + BroadcastMessageToServer = 24, + /// Server ACKs broadcast message. + BroadcastMessageToServerAck = 25, + /// Server pushes broadcast message to client. + BroadcastMessageToClient = 26, + /// Client ACKs broadcast message. + BroadcastMessageToClientAck = 27, + /// Business log reporting. + SysLogToLogServer = 28, + /// RMB tracking log reporting. + TraceLogToLogServer = 29, + /// Server pushes redirection instruction. + RedirectToClient = 30, + /// Client sends registration request. + RegisterRequest = 31, + /// Server sends registration result. + RegisterResponse = 32, + /// Client sends de-registration request. + UnregisterRequest = 33, + /// Server sends de-registration result. + UnregisterResponse = 34, + /// Client sends recommendation request. + RecommendRequest = 35, + /// Server sends recommendation result. + RecommendResponse = 36, +} + +impl Command { + /// Numeric wire value. + pub fn as_u8(self) -> u8 { + self as u8 + } + + /// The wire name — the exact Java `Command` enum constant name in + /// SCREAMING_SNAKE_CASE, which is how the Java runtime (Jackson default) + /// serializes the `cmd` field on the wire. + pub fn name(self) -> &'static str { + match self { + Self::HeartbeatRequest => "HEARTBEAT_REQUEST", + Self::HeartbeatResponse => "HEARTBEAT_RESPONSE", + Self::HelloRequest => "HELLO_REQUEST", + Self::HelloResponse => "HELLO_RESPONSE", + Self::ClientGoodbyeRequest => "CLIENT_GOODBYE_REQUEST", + Self::ClientGoodbyeResponse => "CLIENT_GOODBYE_RESPONSE", + Self::ServerGoodbyeRequest => "SERVER_GOODBYE_REQUEST", + Self::ServerGoodbyeResponse => "SERVER_GOODBYE_RESPONSE", + Self::SubscribeRequest => "SUBSCRIBE_REQUEST", + Self::SubscribeResponse => "SUBSCRIBE_RESPONSE", + Self::UnsubscribeRequest => "UNSUBSCRIBE_REQUEST", + Self::UnsubscribeResponse => "UNSUBSCRIBE_RESPONSE", + Self::ListenRequest => "LISTEN_REQUEST", + Self::ListenResponse => "LISTEN_RESPONSE", + Self::RequestToServer => "REQUEST_TO_SERVER", + Self::RequestToClient => "REQUEST_TO_CLIENT", + Self::RequestToClientAck => "REQUEST_TO_CLIENT_ACK", + Self::ResponseToServer => "RESPONSE_TO_SERVER", + Self::ResponseToClient => "RESPONSE_TO_CLIENT", + Self::ResponseToClientAck => "RESPONSE_TO_CLIENT_ACK", + Self::AsyncMessageToServer => "ASYNC_MESSAGE_TO_SERVER", + Self::AsyncMessageToServerAck => "ASYNC_MESSAGE_TO_SERVER_ACK", + Self::AsyncMessageToClient => "ASYNC_MESSAGE_TO_CLIENT", + Self::AsyncMessageToClientAck => "ASYNC_MESSAGE_TO_CLIENT_ACK", + Self::BroadcastMessageToServer => "BROADCAST_MESSAGE_TO_SERVER", + Self::BroadcastMessageToServerAck => "BROADCAST_MESSAGE_TO_SERVER_ACK", + Self::BroadcastMessageToClient => "BROADCAST_MESSAGE_TO_CLIENT", + Self::BroadcastMessageToClientAck => "BROADCAST_MESSAGE_TO_CLIENT_ACK", + Self::SysLogToLogServer => "SYS_LOG_TO_LOGSERVER", + Self::TraceLogToLogServer => "TRACE_LOG_TO_LOGSERVER", + Self::RedirectToClient => "REDIRECT_TO_CLIENT", + Self::RegisterRequest => "REGISTER_REQUEST", + Self::RegisterResponse => "REGISTER_RESPONSE", + Self::UnregisterRequest => "UNREGISTER_REQUEST", + Self::UnregisterResponse => "UNREGISTER_RESPONSE", + Self::RecommendRequest => "RECOMMEND_REQUEST", + Self::RecommendResponse => "RECOMMEND_RESPONSE", + } + } + + /// Reverse lookup of [`Command::name`]. + pub fn from_name(name: &str) -> Option { + Some(match name { + "HEARTBEAT_REQUEST" => Self::HeartbeatRequest, + "HEARTBEAT_RESPONSE" => Self::HeartbeatResponse, + "HELLO_REQUEST" => Self::HelloRequest, + "HELLO_RESPONSE" => Self::HelloResponse, + "CLIENT_GOODBYE_REQUEST" => Self::ClientGoodbyeRequest, + "CLIENT_GOODBYE_RESPONSE" => Self::ClientGoodbyeResponse, + "SERVER_GOODBYE_REQUEST" => Self::ServerGoodbyeRequest, + "SERVER_GOODBYE_RESPONSE" => Self::ServerGoodbyeResponse, + "SUBSCRIBE_REQUEST" => Self::SubscribeRequest, + "SUBSCRIBE_RESPONSE" => Self::SubscribeResponse, + "UNSUBSCRIBE_REQUEST" => Self::UnsubscribeRequest, + "UNSUBSCRIBE_RESPONSE" => Self::UnsubscribeResponse, + "LISTEN_REQUEST" => Self::ListenRequest, + "LISTEN_RESPONSE" => Self::ListenResponse, + "REQUEST_TO_SERVER" => Self::RequestToServer, + "REQUEST_TO_CLIENT" => Self::RequestToClient, + "REQUEST_TO_CLIENT_ACK" => Self::RequestToClientAck, + "RESPONSE_TO_SERVER" => Self::ResponseToServer, + "RESPONSE_TO_CLIENT" => Self::ResponseToClient, + "RESPONSE_TO_CLIENT_ACK" => Self::ResponseToClientAck, + "ASYNC_MESSAGE_TO_SERVER" => Self::AsyncMessageToServer, + "ASYNC_MESSAGE_TO_SERVER_ACK" => Self::AsyncMessageToServerAck, + "ASYNC_MESSAGE_TO_CLIENT" => Self::AsyncMessageToClient, + "ASYNC_MESSAGE_TO_CLIENT_ACK" => Self::AsyncMessageToClientAck, + "BROADCAST_MESSAGE_TO_SERVER" => Self::BroadcastMessageToServer, + "BROADCAST_MESSAGE_TO_SERVER_ACK" => Self::BroadcastMessageToServerAck, + "BROADCAST_MESSAGE_TO_CLIENT" => Self::BroadcastMessageToClient, + "BROADCAST_MESSAGE_TO_CLIENT_ACK" => Self::BroadcastMessageToClientAck, + "SYS_LOG_TO_LOGSERVER" => Self::SysLogToLogServer, + "TRACE_LOG_TO_LOGSERVER" => Self::TraceLogToLogServer, + "REDIRECT_TO_CLIENT" => Self::RedirectToClient, + "REGISTER_REQUEST" => Self::RegisterRequest, + "REGISTER_RESPONSE" => Self::RegisterResponse, + "UNREGISTER_REQUEST" => Self::UnregisterRequest, + "UNREGISTER_RESPONSE" => Self::UnregisterResponse, + "RECOMMEND_REQUEST" => Self::RecommendRequest, + "RECOMMEND_RESPONSE" => Self::RecommendResponse, + _ => return None, + }) + } +} + +impl TryFrom for Command { + type Error = EventMeshError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0 => Self::HeartbeatRequest, + 1 => Self::HeartbeatResponse, + 2 => Self::HelloRequest, + 3 => Self::HelloResponse, + 4 => Self::ClientGoodbyeRequest, + 5 => Self::ClientGoodbyeResponse, + 6 => Self::ServerGoodbyeRequest, + 7 => Self::ServerGoodbyeResponse, + 8 => Self::SubscribeRequest, + 9 => Self::SubscribeResponse, + 10 => Self::UnsubscribeRequest, + 11 => Self::UnsubscribeResponse, + 12 => Self::ListenRequest, + 13 => Self::ListenResponse, + 14 => Self::RequestToServer, + 15 => Self::RequestToClient, + 16 => Self::RequestToClientAck, + 17 => Self::ResponseToServer, + 18 => Self::ResponseToClient, + 19 => Self::ResponseToClientAck, + 20 => Self::AsyncMessageToServer, + 21 => Self::AsyncMessageToServerAck, + 22 => Self::AsyncMessageToClient, + 23 => Self::AsyncMessageToClientAck, + 24 => Self::BroadcastMessageToServer, + 25 => Self::BroadcastMessageToServerAck, + 26 => Self::BroadcastMessageToClient, + 27 => Self::BroadcastMessageToClientAck, + 28 => Self::SysLogToLogServer, + 29 => Self::TraceLogToLogServer, + 30 => Self::RedirectToClient, + 31 => Self::RegisterRequest, + 32 => Self::RegisterResponse, + 33 => Self::UnregisterRequest, + 34 => Self::UnregisterResponse, + 35 => Self::RecommendRequest, + 36 => Self::RecommendResponse, + other => return Err(EventMeshError::Tcp(format!("unknown command: {other}"))), + }) + } +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- + +/// Frame header — JSON-serialized on the wire. +/// +/// The `cmd` field is serialized/deserialized as the Java `Command` enum +/// constant name string (e.g. `"HELLO_RESPONSE"`) to match the Java server's +/// `Header` JSON (where `Command` is serialized by Jackson's default enum +/// handling). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Header { + /// Command type (serialized as the Java enum constant name). + #[serde(with = "command_serde")] + pub cmd: Command, + /// Status code (0 = success). + pub code: i32, + /// Optional description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + /// Correlation key (random 10-char string generated per request). + /// + /// Optional on the wire: the Java runtime sends some server-initiated + /// frames (`SERVER_GOODBYE_REQUEST`, `REDIRECT_TO_CLIENT`) with `seq = + /// null`, which `JsonUtils` omits. Treating the field as `Option` + /// lets us decode those valid frames instead of rejecting them for a + /// missing required field before `handle_inbound` can ACK them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, + /// Arbitrary key-value properties (e.g. `protocol_type`). + #[serde(default)] + pub properties: HashMap, +} + +impl Header { + /// Create a new header with the given command and a correlation seq. + /// + /// `seq` is stored as `Some(seq)` — client-originated frames always carry + /// a seq so the server can correlate the reply. Server-initiated frames + /// with no seq are only ever *received* (built directly on the wire by the + /// Java runtime), so there is no need to construct a `None`-seq header here. + pub fn new(cmd: Command, seq: impl Into) -> Self { + Self { + cmd, + code: 0, + desc: None, + seq: Some(seq.into()), + properties: HashMap::new(), + } + } + + /// Set a string property. + pub fn set_property(&mut self, key: impl Into, value: impl Into) -> &mut Self { + self.properties + .insert(key.into(), serde_json::Value::String(value.into())); + self + } + + /// Get a string property. + pub fn get_string_property(&self, key: &str) -> Option<&str> { + self.properties.get(key).and_then(|v| v.as_str()) + } +} + +/// Serde module for the `cmd` field. +/// +/// The Java runtime serializes `Command` as its enum constant **name string** +/// (e.g. `"HELLO_RESPONSE"`) via Jackson's default enum handling, so we must do +/// the same when sending. On decode we additionally accept the numeric form for +/// robustness (Jackson falls back to ordinals when given an integer token, so +/// either form may legitimately appear on the wire). +mod command_serde { + use serde::{de, Deserialize, Deserializer, Serializer}; + + use super::Command; + + pub fn serialize(cmd: &Command, s: S) -> Result { + s.serialize_str(cmd.name()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + match v { + serde_json::Value::String(name) => Command::from_name(&name) + .ok_or_else(|| de::Error::custom(format!("unknown command name: {name}"))), + serde_json::Value::Number(n) => { + let value = n.as_i64().ok_or_else(|| { + de::Error::custom(format!("command number out of range: {n}")) + })?; + // Validate the value fits in a `u8` before the narrowing cast; + // `value as u8` would silently wrap for values >= 256 (e.g. + // 256 -> 0 -> HeartbeatRequest) and misinterpret the frame. + u8::try_from(value) + .map_err(|_| de::Error::custom(format!("command number out of range: {value}"))) + .and_then(|b| Command::try_from(b).map_err(de::Error::custom)) + } + other => Err(de::Error::custom(format!( + "expected string or number for `cmd`, got {other}" + ))), + } + } +} + +// --------------------------------------------------------------------------- +// UserAgent +// --------------------------------------------------------------------------- + +/// Client identity sent in the HELLO body (mirrors Java `UserAgent.java`). +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct UserAgent { + #[serde(default)] + pub env: String, + #[serde(default)] + pub subsystem: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub pid: i32, + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: i32, + #[serde(default)] + pub version: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub idc: String, + #[serde(default)] + pub group: String, + #[serde(default)] + pub purpose: String, + #[serde(default)] + pub unack: i32, +} + +impl UserAgent { + /// Build a `UserAgent` from a [`TcpClientConfig`](crate::config::TcpClientConfig)'s + /// identity, tagged with `purpose` ("pub" or "sub"). + /// + /// `host` is the **client's** local IP (from `identity.ip`), NOT the server + /// address. The Java runtime uses `session.getClient().getHost()` to stamp + /// the `RSP_IP` CloudEvent extension on every pushed message, so a wrong + /// value here corrupts tracing/metadata. + pub fn from_identity( + identity: &crate::config::ClientIdentity, + port: u16, + purpose: &str, + ) -> Self { + Self { + env: identity.env.clone(), + subsystem: identity.sys.clone(), + path: String::new(), + pid: identity.pid.parse().unwrap_or(0), + host: identity.ip.clone(), + port: port as i32, + version: "1.0".to_string(), + username: identity.username.clone(), + password: identity.password.clone(), + token: identity.token.clone().unwrap_or_default(), + idc: identity.idc.clone(), + group: if purpose == "pub" { + identity.producer_group.clone() + } else { + identity.consumer_group.clone() + }, + purpose: purpose.to_string(), + unack: 0, + } + } +} + +impl std::fmt::Debug for UserAgent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UserAgent") + .field("env", &self.env) + .field("subsystem", &self.subsystem) + .field("path", &self.path) + .field("pid", &self.pid) + .field("host", &self.host) + .field("port", &self.port) + .field("version", &self.version) + .field("username", &self.username) + .field("password", &"***") + .field("token", &"***") + .field("idc", &self.idc) + .field("group", &self.group) + .field("purpose", &self.purpose) + .field("unack", &self.unack) + .finish() + } +} + +// --------------------------------------------------------------------------- +// Subscription body +// --------------------------------------------------------------------------- + +/// Body for `SUBSCRIBE_REQUEST` / `UNSUBSCRIBE_REQUEST`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Subscription { + /// Matches Java field name `topicList` (camelCase). + #[serde(rename = "topicList")] + pub topic_list: Vec, +} + +impl Subscription { + pub fn new(topics: Vec) -> Self { + Self { topic_list: topics } + } +} + +/// Body for `REDIRECT_TO_CLIENT`. +/// +/// Mirrors `org.apache.eventmesh.common.protocol.tcp.RedirectInfo`, whose +/// fields are `ip` (String) and `port` (int). The runtime emits this in +/// `EventMeshTcp2Client.redirectClient2NewEventMesh` to tell the client which +/// EventMesh node to reconnect to during a rebalance. The previous shape only +/// had a defaulted `redirect_to`, which serde silently discarded the target +/// address for, making any redirect handling impossible. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedirectInfo { + #[serde(default)] + pub ip: String, + #[serde(default)] + pub port: u16, +} + +// --------------------------------------------------------------------------- +// Package +// --------------------------------------------------------------------------- + +/// Type-erased body of a [`Package`]. +/// +/// On the wire, most bodies are JSON. The codec dispatches on the header's +/// `Command` to decide which Rust type to deserialize into (mirrors the Java +/// `Codec.deserializeBody` switch). +#[derive(Debug, Clone, Default)] +pub enum PackageBody { + /// No body (heartbeat, goodbye, listen, ...). + #[default] + Empty, + /// HELLO / RECOMMEND body. + UserAgent(Box), + /// SUBSCRIBE / UNSUBSCRIBE body. + Subscription(Subscription), + /// REDIRECT_TO_CLIENT body. + RedirectInfo(RedirectInfo), + /// A raw JSON string — deferred to the protocol layer (most message / + /// ACK commands). Mirrors the Java "return bodyJsonString" default. + Text(String), + /// Raw bytes — used for CloudEvents bodies (serialized by the caller). + Bytes(Vec), +} + +/// The wire envelope — a [`Header`] plus an optional [`PackageBody`]. +/// +/// Mirrors Java `Package.java`. +#[derive(Debug, Clone)] +pub struct Package { + pub header: Header, + pub body: PackageBody, +} + +impl Package { + pub fn new(header: Header) -> Self { + Self { + header, + body: PackageBody::Empty, + } + } + + pub fn with_body(mut self, body: PackageBody) -> Self { + self.body = body; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Java runtime (Jackson default) serializes `Command` as its enum + /// constant *name string*, not an ordinal. We must emit the same on the wire. + #[test] + fn command_serializes_as_java_enum_name_string() { + let header = Header::new(Command::HelloResponse, "seq-1"); + let json = serde_json::to_string(&header).unwrap(); + assert!( + json.contains("\"cmd\":\"HELLO_RESPONSE\""), + "expected cmd serialized as the Java enum name string, got: {json}" + ); + assert!( + !json.contains("\"cmd\":3"), + "cmd must NOT be serialized as a number, got: {json}" + ); + } + + /// The Java server's HELLO response arrives as `{"cmd":"HELLO_RESPONSE",...}`; + /// the Rust client must be able to decode it. + #[test] + fn decodes_java_wire_format_string_cmd() { + let json = r#"{"cmd":"HELLO_RESPONSE","code":0,"seq":"seq-1"}"#; + let header: Header = serde_json::from_str(json).unwrap(); + assert_eq!(header.cmd, Command::HelloResponse); + } + + /// Ordinal/integer form is also accepted on decode (Jackson falls back to + /// ordinal matching for integer tokens, so this is a legitimate wire shape). + #[test] + fn decodes_numeric_cmd_form() { + let json = r#"{"cmd":18,"code":0,"seq":"seq-1"}"#; + let header: Header = serde_json::from_str(json).unwrap(); + assert_eq!(header.cmd, Command::ResponseToClient); + } + + /// Values >= 256 must be rejected rather than silently wrapped by a + /// narrowing `as u8` cast (e.g. 256 -> 0 -> HeartbeatRequest). + #[test] + fn rejects_out_of_range_numeric_cmd() { + for bad in [256, 1_000, 65_536] { + let json = format!(r#"{{"cmd":{bad},"code":0,"seq":"seq-1"}}"#); + let err = serde_json::from_str::
(&json).unwrap_err(); + assert!( + err.to_string().contains("out of range"), + "value {bad} rejected with unexpected message: {err}" + ); + } + } + + /// Negative numbers are not valid command ordinals. + #[test] + fn rejects_negative_numeric_cmd() { + let json = r#"{"cmd":-1,"code":0,"seq":"seq-1"}"#; + assert!(serde_json::from_str::
(json).is_err()); + } + + /// `name()` / `from_name()` must be exact inverses and cover every variant. + #[test] + fn name_round_trip_all_variants() { + for &cmd in &[ + Command::HeartbeatRequest, + Command::HelloResponse, + Command::SysLogToLogServer, + Command::TraceLogToLogServer, + Command::RecommendResponse, + ] { + let name = cmd.name(); + assert_eq!(Command::from_name(name), Some(cmd), "{name}"); + } + } + + /// Server-initiated frames (`SERVER_GOODBYE_REQUEST`, `REDIRECT_TO_CLIENT`) + /// are built by the Java runtime with `seq = null`, which Jackson omits on + /// the wire. We must accept those frames rather than rejecting them for a + /// missing required field before `handle_inbound` can send the + /// `SERVER_GOODBYE_RESPONSE`. + #[test] + fn accepts_missing_seq_for_server_initiated_frames() { + for cmd_name in ["SERVER_GOODBYE_REQUEST", "REDIRECT_TO_CLIENT"] { + let json = format!(r#"{{"cmd":"{cmd_name}","code":0}}"#); + let header: Header = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("failed to decode {cmd_name} frame without seq: {e}")); + assert_eq!(header.cmd.name(), cmd_name); + assert_eq!(header.seq, None, "{cmd_name} seq should be absent"); + } + } + + /// A header with a seq still round-trips it as `Some`. + #[test] + fn present_seq_decodes_as_some_and_is_omitted_when_none() { + let with_seq = r#"{"cmd":"HELLO_RESPONSE","code":0,"seq":"seq-1"}"#; + let header: Header = serde_json::from_str(with_seq).unwrap(); + assert_eq!(header.seq.as_deref(), Some("seq-1")); + // Serializing a None-seq header must omit the field (matches Java + // JsonUtils, which skips nulls). + let none_seq = Header { + cmd: Command::ServerGoodbyeRequest, + code: 0, + desc: None, + seq: None, + properties: HashMap::new(), + }; + let json = serde_json::to_string(&none_seq).unwrap(); + assert!( + !json.contains("seq"), + "None seq should be omitted, got: {json}" + ); + } + + /// `RedirectInfo` must carry `ip`/`port` (the Java + /// `org.apache.eventmesh.common.protocol.tcp.RedirectInfo` wire shape), not + /// a synthetic `redirect_to`. The runtime serializes it via Jackson with + /// these exact field names; any other shape would make serde silently drop + /// the redirect target on decode. + #[test] + fn redirect_info_round_trips_java_wire_shape() { + let java_json = r#"{"ip":"10.0.0.5","port":10000}"#; + let ri: RedirectInfo = serde_json::from_str(java_json).expect("decode RedirectInfo"); + assert_eq!(ri.ip, "10.0.0.5"); + assert_eq!(ri.port, 10000); + + // Re-serialize and ensure the field names match the Java wire format. + let out = serde_json::to_string(&ri).unwrap(); + assert!( + out.contains("\"ip\":\"10.0.0.5\""), + "expected ip field on the wire, got: {out}" + ); + assert!( + out.contains("\"port\":10000"), + "expected port field on the wire, got: {out}" + ); + assert!( + !out.contains("redirect_to"), + "must NOT emit a redirect_to field, got: {out}" + ); + } + + /// Missing `ip`/`port` default (mirrors Jackson populating `null`/`0` for + /// an absent field rather than rejecting the frame). + #[test] + fn redirect_info_defaults_missing_fields() { + let ri: RedirectInfo = serde_json::from_str("{}").expect("decode empty RedirectInfo"); + assert_eq!(ri.ip, ""); + assert_eq!(ri.port, 0); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/message.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/message.rs new file mode 100644 index 0000000000..0e2c9fcc80 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/message.rs @@ -0,0 +1,641 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Package construction helpers (mirrors Java `MessageUtils`). + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::model::{EventMeshMessage, PublishResponse, SubscriptionItem}; + +use super::frame::{Command, Header, Package, PackageBody, Subscription, UserAgent}; + +/// Length of the random correlation seq (matches Java `SEQ_LENGTH = 10`). +const SEQ_LEN: usize = 10; + +const PROTOCOL_TYPE_KEY: &str = "protocoltype"; +const PROTOCOL_VERSION_KEY: &str = "protocolversion"; +const PROTOCOL_DESC_KEY: &str = "protocoldesc"; +const EM_MESSAGE_PROTOCOL: &str = "eventmeshmessage"; +const OPEN_MESSAGE_PROTOCOL: &str = "openmessage"; +const CLOUD_EVENTS_PROTOCOL: &str = "cloudevents"; +const PROTOCOL_DESC_TCP: &str = "tcp"; + +/// The TCP wire-format body for `eventmeshmessage` protocol messages. +/// +/// This mirrors `org.apache.eventmesh.common.protocol.tcp.EventMeshMessage` +/// (NOT `org.apache.eventmesh.common.EventMeshMessage`). The Java runtime's +/// TCP codec serializes/deserializes the package body as JSON using this class's +/// field names: `topic`, `properties`, `headers`, `body`. +/// +/// The SDK's user-facing [`EventMeshMessage`] uses different field names +/// (`content`, `props`). This struct bridges the two so that messages round-trip +/// correctly through the Java server. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TcpWireMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + topic: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + properties: HashMap, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + headers: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, +} + +impl From<&EventMeshMessage> for TcpWireMessage { + fn from(msg: &EventMeshMessage) -> Self { + Self { + topic: msg.topic.clone(), + properties: msg.props.clone(), + headers: HashMap::new(), + body: msg.content.clone(), + } + } +} + +impl From for EventMeshMessage { + fn from(wire: TcpWireMessage) -> Self { + // Merge wire `headers` into `props` so protocol-level metadata + // (e.g. `datacontenttype`) set by the Java runtime is not lost. + // The Rust SDK's `EventMeshMessage` model does not have a separate + // `headers` field, so `props` is the only place to carry them. + let mut props = wire.properties; + for (k, v) in wire.headers { + props.entry(k).or_insert(v); + } + EventMeshMessage { + topic: wire.topic, + content: wire.body, + props, + ..Default::default() + } + } +} + +/// Generate a random numeric string of length [`SEQ_LEN`] (mirrors Java +/// `MessageUtils.generateRandomString`). +fn random_seq() -> String { + crate::common::RandomStringUtils::generate_num(SEQ_LEN) +} + +/// Build a bare package with the given command and a fresh random seq. +pub fn package(cmd: Command) -> Package { + Package::new(Header::new(cmd, random_seq())) +} + +/// Build an ACK package for an inbound `in_pkg`, copying its seq and body. +/// Mirrors Java `MessageUtils.getPackage(command, in)`. +/// +/// The seq is copied verbatim (including `None`): server-initiated frames such +/// as `SERVER_GOODBYE_REQUEST` arrive without a seq, and the ACK must echo that +/// shape rather than synthesizing one. +pub fn ack(cmd: Command, in_pkg: &Package) -> Package { + let header = Header { + cmd, + code: in_pkg.header.code, + desc: None, + seq: in_pkg.header.seq.clone(), + properties: in_pkg.header.properties.clone(), + }; + Package { + header, + body: in_pkg.body.clone(), + } +} + +// --------------------------------------------------------------------------- +// Control-plane builders +// --------------------------------------------------------------------------- + +/// HELLO_REQUEST with a `UserAgent` body. +pub fn hello(user_agent: &UserAgent) -> Package { + package(Command::HelloRequest).with_body(PackageBody::UserAgent(Box::new(user_agent.clone()))) +} + +/// HEARTBEAT_REQUEST (no body). +pub fn heartbeat() -> Package { + package(Command::HeartbeatRequest) +} + +/// CLIENT_GOODBYE_REQUEST (no body). +pub fn goodbye() -> Package { + package(Command::ClientGoodbyeRequest) +} + +/// LISTEN_REQUEST (no body). +pub fn listen() -> Package { + package(Command::ListenRequest) +} + +/// SUBSCRIBE_REQUEST with a single-item `Subscription` body. +pub fn subscribe(topic: &str, items: &[SubscriptionItem]) -> Package { + let _ = topic; // topic is in the items; kept for API symmetry with Java + let sub = Subscription::new(items.to_vec()); + package(Command::SubscribeRequest).with_body(PackageBody::Subscription(sub)) +} + +/// UNSUBSCRIBE_REQUEST with a `Subscription` body. +pub fn unsubscribe(items: &[SubscriptionItem]) -> Package { + let sub = Subscription::new(items.to_vec()); + package(Command::UnsubscribeRequest).with_body(PackageBody::Subscription(sub)) +} + +// --------------------------------------------------------------------------- +// ACK builders +// --------------------------------------------------------------------------- + +pub fn async_message_ack(in_pkg: &Package) -> Package { + ack(Command::AsyncMessageToClientAck, in_pkg) +} + +pub fn broadcast_message_ack(in_pkg: &Package) -> Package { + ack(Command::BroadcastMessageToClientAck, in_pkg) +} + +pub fn request_to_client_ack(in_pkg: &Package) -> Package { + ack(Command::RequestToClientAck, in_pkg) +} + +pub fn response_to_client_ack(in_pkg: &Package) -> Package { + ack(Command::ResponseToClientAck, in_pkg) +} + +// --------------------------------------------------------------------------- +// User-message builders +// --------------------------------------------------------------------------- + +/// Wrap an [`EventMeshMessage`] into a [`Package`] with the given command. +/// +/// Sets the `protocoltype`/`protocolversion`/`protocoldesc` header properties +/// so the server knows how to deserialize the body. +/// +/// Returns a [`crate::error::EventMeshError::Codec`] if the message cannot be +/// serialized — never silently sends an empty body. +pub fn build_message_package(msg: &EventMeshMessage, cmd: Command) -> Result { + let mut pkg = package(cmd); + pkg.header + .set_property(PROTOCOL_TYPE_KEY, EM_MESSAGE_PROTOCOL); + pkg.header.set_property(PROTOCOL_VERSION_KEY, "1.0"); + pkg.header + .set_property(PROTOCOL_DESC_KEY, PROTOCOL_DESC_TCP); + + // Serialize the message body using the TCP wire format + // (`org.apache.eventmesh.common.protocol.tcp.EventMeshMessage`), which uses + // `body`/`properties` — NOT the SDK's `content`/`props` field names. + let wire = TcpWireMessage::from(msg); + let json = serde_json::to_string(&wire)?; + pkg.body = PackageBody::Text(json); + + // Copy seqnum/uniqueid/ttl into header properties for routing. + if let Some(ref seq) = msg.biz_seq_no { + pkg.header.set_property("seqnum", seq); + } + if let Some(ref uid) = msg.unique_id { + pkg.header.set_property("uniqueid", uid); + } + if let Some(ttl) = msg.ttl { + pkg.header.set_property("ttl", ttl.to_string()); + } + Ok(pkg) +} + +/// Encode an OpenMessaging value using the native TCP body while retaining +/// the original public protocol discriminator. +pub(crate) fn build_open_message_package( + msg: &crate::model::OpenMessage, + cmd: Command, +) -> Result { + let mut pkg = build_message_package(&msg.to_event_mesh_message(), cmd)?; + pkg.header + .set_property(PROTOCOL_TYPE_KEY, OPEN_MESSAGE_PROTOCOL); + Ok(pkg) +} + +/// Convert a server ACK [`Package`] into a [`PublishResponse`]. +/// +/// The Java runtime encodes the ACK result in the `Header`'s dedicated `code` +/// (an `OPStatus` value: `0 = SUCCESS`, `1 = FAIL`, `2 = ACL_FAIL`, +/// `3 = TPS_OVERLOAD`) and `desc` fields. The reply processors +/// (`MessageTransferProcessor`, `SubscribeProcessor`, `UnSubscribeProcessor`) +/// build responses via `new Header(replyCmd, OPStatus..getCode(), desc, +/// seq)`. Reading from `header.properties["statuscode"]` always yields `None` +/// (the server never populates it) and would mask every server-side failure as +/// a success. +pub fn response_from_pkg(pkg: &Package) -> PublishResponse { + PublishResponse::new(Some(pkg.header.code as i64), pkg.header.desc.clone(), None) +} + +/// Parse an inbound message body ([`PackageBody::Text`]) back into an +/// [`EventMeshMessage`]. Returns an empty message on failure. +/// +/// Deserializes the TCP wire format (`body`/`properties` fields) and maps them +/// back to the SDK's `content`/`props` fields. +pub fn parse_message(body: &PackageBody) -> Option { + match body { + PackageBody::Text(s) => { + let wire: TcpWireMessage = serde_json::from_str(s).ok()?; + Some(EventMeshMessage::from(wire)) + } + _ => None, + } +} + +// --------------------------------------------------------------------------- +// CloudEvents wire support +// --------------------------------------------------------------------------- + +/// Whether the given header properties declare a CloudEvents body +/// (`protocoltype == "cloudevents"`). Used by the consumer to decide whether +/// to parse the body as a CloudEvent JSON or a TCP-wire `EventMeshMessage`. +pub fn is_cloudevents(pkg: &Package) -> bool { + pkg.header.get_string_property(PROTOCOL_TYPE_KEY) == Some(CLOUD_EVENTS_PROTOCOL) +} + +/// Whether the package retains an OpenMessaging protocol discriminator. +pub(crate) fn is_open_message(pkg: &Package) -> bool { + pkg.header.get_string_property(PROTOCOL_TYPE_KEY) == Some(OPEN_MESSAGE_PROTOCOL) +} + +/// Wrap a native [`cloudevents::Event`] into a [`Package`] with the given +/// command, using the CloudEvents JSON wire format +/// (`application/cloudevents+json`). +/// +/// Sets `protocoltype=cloudevents`, `protocolversion=`, +/// `protocoldesc=tcp` so the Java runtime's codec writes the body bytes +/// verbatim instead of re-serializing via Jackson. +/// +/// # `datacontenttype` requirement +/// +/// The CloudEvent's `datacontenttype` **must** be set to +/// `application/cloudevents+json`. The Java runtime's +/// `CloudEventsProtocolAdaptor.fromCloudEvent` (downlink path) uses +/// `datacontenttype` to resolve the CloudEvents `EventFormat` serializer +/// via `EventFormatProvider.resolveFormat(dataContentType)`. The only +/// registered format is `application/cloudevents+json`; any other value +/// (e.g. `application/json`, `text/plain`) causes `resolveFormat()` to +/// return null, which triggers an NPE that silently drops the message. +/// +/// This is a known server-side quirk — the Java SDK works around it by +/// always setting `datacontenttype = application/cloudevents+json` for TCP +/// CloudEvents (see `ExampleConstants.CLOUDEVENT_CONTENT_TYPE`). +/// +/// This mirrors Java's `MessageUtils.buildPackage(cloudEvent, command)`: +/// the CloudEvent is serialized to JSON by the cloudevents crate's serde +/// impl (equivalent to `EventFormat.serialize` in Java), and the resulting +/// bytes are stored as [`PackageBody::Bytes`]. The TCP codec detects the +/// `cloudevents` protocol type and writes the raw bytes without further +/// JSON encoding. +#[cfg(feature = "cloud_events")] +pub fn build_cloud_event_package(event: &cloudevents::Event, cmd: Command) -> Result { + use cloudevents::AttributesReader; + + let mut pkg = package(cmd); + pkg.header + .set_property(PROTOCOL_TYPE_KEY, CLOUD_EVENTS_PROTOCOL); + pkg.header + .set_property(PROTOCOL_VERSION_KEY, event.specversion().as_str()); + pkg.header + .set_property(PROTOCOL_DESC_KEY, PROTOCOL_DESC_TCP); + + // Serialize the CloudEvent as CloudEvents JSON + // (application/cloudevents+json). The cloudevents crate's serde impl + // produces the canonical CloudEvents JSON format, matching what the Java + // runtime's `EventFormatProvider.resolveFormat(JsonFormat.CONTENT_TYPE)` + // expects on decode. + let json = serde_json::to_vec(event)?; + pkg.body = PackageBody::Bytes(json); + + Ok(pkg) +} + +/// Parse a CloudEvents body ([`PackageBody::Text`] or [`PackageBody::Bytes`]) +/// back into a native [`cloudevents::Event`]. Returns `None` on failure. +/// +/// On the wire, CloudEvents bodies arrive as a JSON string in `Text` (the +/// codec decodes valid UTF-8 bodies as strings). This function reverses the +/// serialization done by [`build_cloud_event_package`]. +#[cfg(feature = "cloud_events")] +pub fn parse_cloud_event(body: &PackageBody) -> Option { + match body { + PackageBody::Text(s) => serde_json::from_str(s).ok(), + PackageBody::Bytes(b) => serde_json::from_slice(b).ok(), + _ => None, + } +} + +/// Convert a CloudEvent to an [`EventMeshMessage`] so the consumer's existing +/// `MessageListener` can handle CloudEvents +/// deliveries transparently. +/// +/// - `subject` → `topic` +/// - `data` → `content` (string values are kept as-is; JSON values are +/// stringified; binary values are lossily converted to UTF-8) +/// - CloudEvent extensions (e.g. `ttl`, `seqnum`, `uniqueid`) → `props` +/// +/// This mirrors the gRPC codec's `to_event_mesh_message`. +#[cfg(feature = "cloud_events")] +pub fn cloud_event_to_message(event: &cloudevents::Event) -> EventMeshMessage { + use cloudevents::{AttributesReader, Data}; + + let topic = event.subject().map(|s| s.to_string()); + let content = match event.data() { + Some(Data::String(s)) => Some(s.clone()), + Some(Data::Binary(b)) => Some(String::from_utf8_lossy(b).into_owned()), + Some(Data::Json(j)) => Some(j.to_string()), + None => None, + }; + + let mut props = std::collections::HashMap::new(); + for (k, v) in event.iter_extensions() { + props.insert(k.to_string(), v.to_string()); + } + + EventMeshMessage { + topic, + content, + props, + ..Default::default() + } +} + +/// Convert an [`EventMeshMessage`] back into a native [`cloudevents::Event`]. +/// +/// This is the reverse of [`cloud_event_to_message`] and is used when the +/// consumer replies with an `EventMeshMessage` to a CloudEvents +/// `REQUEST_TO_SERVER` — the producer's `request_reply_cloud_event` uses it to +/// produce a uniform `Event` return type. +#[cfg(feature = "cloud_events")] +pub fn message_to_cloud_event(msg: &EventMeshMessage) -> Result { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let source = msg.topic.as_deref().unwrap_or("/").to_string(); + let mut builder = EventBuilderV10::new() + .id(msg + .unique_id + .clone() + .unwrap_or_else(crate::common::RandomStringUtils::generate_uuid)) + .source(source) + .ty("org.apache.eventmesh"); + + if let Some(ref topic) = msg.topic { + builder = builder.subject(topic); + } + if let Some(ref content) = msg.content { + builder = builder.data("text/plain", content.clone()); + } + for (k, v) in &msg.props { + builder = builder.extension(k.as_str(), v.as_str()); + } + builder + .build() + .map_err(|e| crate::error::EventMeshError::Protocol { + transport: "tcp", + message: format!("cloudevents build error: {e}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_seq_length() { + let s = random_seq(); + assert_eq!(s.len(), SEQ_LEN); + assert!(s.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn ack_preserves_seq() { + let pkg = package(Command::AsyncMessageToClient); + let ack_pkg = async_message_ack(&pkg); + assert_eq!(ack_pkg.header.seq, pkg.header.seq); + assert_eq!(ack_pkg.header.cmd, Command::AsyncMessageToClientAck); + } + + #[test] + fn build_message_sets_protocol_type() { + let msg = EventMeshMessage::builder() + .topic("test") + .content("hello") + .build(); + let pkg = build_message_package(&msg, Command::AsyncMessageToServer).expect("build pkg"); + assert_eq!( + pkg.header.get_string_property(PROTOCOL_TYPE_KEY), + Some(EM_MESSAGE_PROTOCOL) + ); + assert_eq!(pkg.header.cmd, Command::AsyncMessageToServer); + } + + #[test] + fn response_reads_header_code_and_desc() { + // Server encodes ACK status in header.code/desc, not in properties. + let mut pkg = package(Command::AsyncMessageToServerAck); + pkg.header.code = 3; // TPS_OVERLOAD + pkg.header.desc = Some("tps overload".into()); + // An irrelevant property that must NOT be read as the status. + pkg.header.set_property("statuscode", "0"); + + let resp = response_from_pkg(&pkg); + assert_eq!(resp.code, Some(3)); + assert!(!resp.is_success()); + assert_eq!(resp.message.as_deref(), Some("tps overload")); + } + + #[test] + fn response_success_when_code_zero() { + let mut pkg = package(Command::AsyncMessageToServerAck); + pkg.header.code = 0; + assert!(response_from_pkg(&pkg).is_success()); + } + + #[test] + fn build_message_uses_tcp_wire_field_names() { + // The Java runtime's TCP protocol deserializes the body into + // `org.apache.eventmesh.common.protocol.tcp.EventMeshMessage`, which + // uses `body` and `properties` — NOT `content` and `props`. If the SDK + // emits the wrong field names, the server reads null for the content. + let msg = EventMeshMessage::builder() + .topic("test-topic") + .content("hello-body") + .prop("ttl", "4000") + .build(); + let pkg = build_message_package(&msg, Command::AsyncMessageToServer).expect("build pkg"); + let json = match &pkg.body { + PackageBody::Text(s) => s.as_str(), + other => panic!("expected Text body, got {other:?}"), + }; + assert!( + json.contains("\"body\":\"hello-body\""), + "body must use Java wire field 'body', got: {json}" + ); + assert!( + json.contains("\"properties\":"), + "body must use Java wire field 'properties', got: {json}" + ); + assert!( + !json.contains("\"content\""), + "must NOT emit SDK field 'content' on the wire, got: {json}" + ); + assert!( + !json.contains("\"props\""), + "must NOT emit SDK field 'props' on the wire, got: {json}" + ); + } + + #[test] + fn parse_message_reads_tcp_wire_field_names() { + // Simulate a JSON body produced by the Java server (uses body/properties). + let server_json = r#"{"topic":"t","properties":{"k":"v"},"body":"payload"}"#; + let msg = parse_message(&PackageBody::Text(server_json.into())).expect("parse"); + assert_eq!(msg.topic.as_deref(), Some("t")); + assert_eq!(msg.content.as_deref(), Some("payload")); + assert_eq!(msg.get_prop("k"), Some("v")); + } + + #[test] + fn parse_message_preserves_wire_headers() { + // The Java runtime puts protocol-level metadata (e.g. + // datacontenttype) in the wire `headers` field. These must not be + // silently discarded when deserializing into EventMeshMessage. + let server_json = r#"{"topic":"t","headers":{"datacontenttype":"application/json"},"properties":{"k":"v"},"body":"payload"}"#; + let msg = parse_message(&PackageBody::Text(server_json.into())).expect("parse"); + assert_eq!(msg.content.as_deref(), Some("payload")); + assert_eq!(msg.get_prop("k"), Some("v")); + assert_eq!( + msg.get_prop("datacontenttype"), + Some("application/json"), + "wire headers must be merged into props" + ); + } + + #[test] + fn wire_format_round_trip() { + let original = EventMeshMessage::builder() + .topic("round-trip") + .content("payload") + .prop("key", "val") + .build(); + let pkg = + build_message_package(&original, Command::AsyncMessageToServer).expect("build pkg"); + let parsed = parse_message(&pkg.body).expect("parse"); + assert_eq!(parsed.topic, original.topic); + assert_eq!(parsed.content, original.content); + assert_eq!(parsed.props, original.props); + } + + #[test] + fn is_cloudevents_detects_protocol() { + let em_pkg = package(Command::AsyncMessageToServer); + assert!(!is_cloudevents(&em_pkg)); + + let mut ce_pkg = package(Command::AsyncMessageToServer); + ce_pkg + .header + .set_property(PROTOCOL_TYPE_KEY, CLOUD_EVENTS_PROTOCOL); + assert!(is_cloudevents(&ce_pkg)); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloudevents_build_sets_protocol_headers() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("ce-1") + .source("https://example.com") + .ty("com.example.test") + .subject("ce-topic") + .data( + "application/cloudevents+json", + serde_json::json!({"hello": "world"}), + ) + .build() + .expect("valid event"); + + let pkg = + build_cloud_event_package(&event, Command::AsyncMessageToServer).expect("build pkg"); + assert_eq!( + pkg.header.get_string_property(PROTOCOL_TYPE_KEY), + Some(CLOUD_EVENTS_PROTOCOL) + ); + assert_eq!( + pkg.header.get_string_property(PROTOCOL_DESC_KEY), + Some(PROTOCOL_DESC_TCP) + ); + assert_eq!( + pkg.header.get_string_property(PROTOCOL_VERSION_KEY), + Some("1.0") + ); + assert_eq!(pkg.header.cmd, Command::AsyncMessageToServer); + // Body must be Bytes (raw JSON, not re-encoded). + assert!(matches!(pkg.body, PackageBody::Bytes(_))); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloudevents_round_trip() { + use cloudevents::{AttributesReader, EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("ce-rt-1") + .source("https://example.com") + .ty("com.example.test") + .subject("ce-round-trip") + .data("text/plain", "hello cloudevents") + .build() + .expect("valid event"); + + let pkg = + build_cloud_event_package(&event, Command::AsyncMessageToServer).expect("build pkg"); + assert!(is_cloudevents(&pkg)); + + // Parse back — the codec would deliver the body as Text (valid UTF-8). + let body_text = match &pkg.body { + PackageBody::Bytes(b) => { + PackageBody::Text(String::from_utf8(b.clone()).expect("cloudevents json is utf-8")) + } + ref other => panic!("expected Bytes body, got {other:?}"), + }; + let parsed = parse_cloud_event(&body_text).expect("parse cloudevent"); + assert_eq!(parsed.subject(), Some("ce-round-trip")); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloudevents_to_message_preserves_topic_and_content() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("ce-conv-1") + .source("https://example.com") + .ty("com.example.test") + .subject("conv-topic") + .data("text/plain", "conv-content") + .extension("ttl", "5000") + .build() + .expect("valid event"); + + let msg = cloud_event_to_message(&event); + assert_eq!(msg.topic.as_deref(), Some("conv-topic")); + assert_eq!(msg.content.as_deref(), Some("conv-content")); + assert_eq!(msg.get_prop("ttl"), Some("5000")); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/mod.rs new file mode 100644 index 0000000000..f0cb6a2f9a --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/mod.rs @@ -0,0 +1,122 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Native TCP transport for the EventMesh Rust SDK. +//! +//! The TCP transport uses the EventMesh binary wire protocol (length-prefixed +//! frames with a `"EventMesh"` magic prefix) and is fully interoperable with +//! the Java runtime's TCP endpoint (default port `10000`). +//! +//! Like the other transports, it normalizes everything onto the +//! [`EventMeshMessage`](crate::model::EventMeshMessage) model. The producer +//! implements the [`Publisher`](crate::transport::Publisher) trait; the +//! consumer exposes transport-specific subscribe / unsubscribe methods with +//! a background receive loop. +//! +//! # Quick example (producer) +//! +//! ```ignore +//! use eventmesh::{ +//! config::TcpClientConfig, tcp::TcpProducer, +//! model::EventMeshMessage, transport::Publisher, +//! }; +//! +//! #[tokio::main] +//! async fn main() -> eventmesh::Result<()> { +//! let config = TcpClientConfig::builder() +//! .server_addr("127.0.0.1").server_port(10000) +//! .producer_group("g") +//! .build(); +//! let producer = TcpProducer::connect(config).await?; +//! let msg = EventMeshMessage::builder().topic("t").content("hi").build(); +//! producer.publish(msg).await?; +//! Ok(()) +//! } +//! ``` +//! +//! # Quick example (consumer) +//! +//! ```ignore +//! use eventmesh::{ +//! config::TcpClientConfig, tcp::TcpConsumer, +//! model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, +//! MessageListener, +//! }; +//! +//! struct MyListener; +//! impl MessageListener for MyListener { +//! type Message = EventMeshMessage; +//! async fn handle(&self, msg: EventMeshMessage) -> Option { +//! println!("received: {:?}", msg.content); +//! None +//! } +//! } +//! +//! #[tokio::main] +//! async fn main() -> eventmesh::Result<()> { +//! let config = TcpClientConfig::builder() +//! .server_addr("127.0.0.1").server_port(10000) +//! .consumer_group("g") +//! .build(); +//! let consumer = TcpConsumer::connect( +//! config, MyListener, +//! async { tokio::signal::ctrl_c().await.ok(); }, +//! ).await?; +//! consumer.wait_for_shutdown().await; +//! Ok(()) +//! } +//! ``` +//! +//! # CloudEvents over TCP +//! +//! With the `cloud_events` feature, the TCP producer can send native +//! [`cloudevents::Event`] values via [`TcpProducer::publish_cloud_event`], +//! [`TcpProducer::broadcast_cloud_event`], and +//! [`TcpProducer::request_reply_cloud_event`]. Use [`TcpCloudEventConsumer`] +//! to receive native [`cloudevents::Event`] values, or [`TcpConsumer`] for +//! the existing EventMeshMessage conversion. +//! +//! **Important:** the event's `datacontenttype` must be set to +//! `application/cloudevents+json`. The Java runtime's downlink codec +//! (`CloudEventsProtocolAdaptor.fromCloudEvent`) uses `datacontenttype` to +//! look up the CloudEvents serializer; only `application/cloudevents+json` +//! is registered. Any other value causes an NPE and the message is silently +//! dropped before reaching consumers. +//! +//! ```ignore +//! use cloudevents::EventBuilderV10; +//! +//! let event = EventBuilderV10::new() +//! .id("1") +//! .source("https://example.com") +//! .ty("com.example.event") +//! .subject(topic) +//! .data("application/cloudevents+json", serde_json::json!({"msg": "hi"})) +//! .build()?; +//! ``` + +pub mod codec; +pub mod connection; +pub mod consumer; +pub mod frame; +pub mod message; +pub mod producer; + +#[cfg(feature = "cloud_events")] +pub use consumer::TcpCloudEventConsumer; +pub use consumer::{ShutdownReason, TcpConsumer, TcpMessage, TcpOpenMessageConsumer}; +pub use producer::TcpProducer; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/producer.rs new file mode 100644 index 0000000000..a40f600a1e --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/producer.rs @@ -0,0 +1,378 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! TCP producer. + +use std::time::Duration; + +use tracing::debug; + +use crate::config::TcpClientConfig; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, PublishResponse}; +use crate::transport::tcp::connection::TcpConnection; +use crate::transport::tcp::frame::{Command, UserAgent}; +use crate::transport::tcp::message; +use crate::transport::Publisher; + +/// TCP-based producer. +/// +/// Created via [`TcpProducer::connect`], which opens a TCP connection, performs +/// the HELLO handshake (role = pub), and starts the background heartbeat. +/// Implements the [`Publisher`] trait. +pub struct TcpProducer { + conn: TcpConnection, + config: TcpClientConfig, +} + +impl TcpProducer { + /// Connect to the EventMesh TCP endpoint and perform the HELLO handshake. + /// + /// The reconnect policy from the config controls automatic reconnection + /// after I/O failures (enabled by default). + pub async fn connect(config: TcpClientConfig) -> Result { + let user_agent = UserAgent::from_identity(&config.identity, config.server_port, "pub"); + let conn = TcpConnection::connect( + &config.server_addr, + config.server_port, + &user_agent, + config.heartbeat_interval, + config.timeout, + config.reconnect.clone(), + ) + .await?; + + Ok(Self { conn, config }) + } + + /// Broadcast a message (fire-and-forget). Corresponds to Java + /// `broadcast` which uses `send()` with `BROADCAST_MESSAGE_TO_SERVER`. + pub async fn broadcast(&self, msg: EventMeshMessage) -> Result<()> { + validate_publish(&msg)?; + let pkg = message::build_message_package(&msg, Command::BroadcastMessageToServer)?; + self.conn.send(pkg).await + } + + /// Publish an OpenMessaging-style message using the interoperable native + /// EventMesh TCP envelope. + pub async fn publish_open_message( + &self, + message: crate::model::OpenMessage, + ) -> Result { + validate_publish(&message.to_event_mesh_message())?; + let pkg = message::build_open_message_package(&message, Command::AsyncMessageToServer)?; + let response = message::response_from_pkg(&self.conn.io(pkg, self.config.timeout).await?); + ensure_success(response, "publish failed") + } + + /// Broadcast an OpenMessaging-style message. + pub async fn broadcast_open_message(&self, message: crate::model::OpenMessage) -> Result<()> { + validate_publish(&message.to_event_mesh_message())?; + let pkg = message::build_open_message_package(&message, Command::BroadcastMessageToServer)?; + self.conn.send(pkg).await + } + + /// Send an OpenMessaging-style request and wait for its reply. + pub async fn request_reply_open_message( + &self, + message: crate::model::OpenMessage, + timeout: Duration, + ) -> Result { + validate_publish(&message.to_event_mesh_message())?; + let pkg = message::build_open_message_package(&message, Command::RequestToServer)?; + let response = self.conn.io(pkg, timeout).await?; + ensure_success( + message::response_from_pkg(&response), + "request-reply failed", + )?; + message::parse_message(&response.body) + .map(crate::model::OpenMessage::from_event_mesh_message) + .ok_or_else(|| { + EventMeshError::Codec(serde::de::Error::custom("failed to parse reply body")) + }) + } + + /// Publish a native CloudEvent over TCP (requires the `cloud_events` + /// feature). + /// + /// The event is serialized as CloudEvents JSON + /// (`application/cloudevents+json`) with `protocoltype=cloudevents`, + /// matching the Java runtime's TCP CloudEvents codec path. + /// + /// # `datacontenttype` requirement + /// + /// The event's `datacontenttype` **must** be `application/cloudevents+json`. + /// The server uses this value to resolve the serializer on the downlink + /// path; other values (e.g. `application/json`, `text/plain`) cause an + /// NPE and the message is silently dropped before reaching consumers. + /// + /// ```ignore + /// EventBuilderV10::new() + /// .id("1").source("...").ty("...").subject(topic) + /// .data("application/cloudevents+json", json!({"msg": "hi"})) + /// .build()?; + /// ``` + #[cfg(feature = "cloud_events")] + pub async fn publish_cloud_event(&self, event: cloudevents::Event) -> Result { + use cloudevents::AttributesReader; + validate_cloud_event(&event)?; + let pkg = message::build_cloud_event_package(&event, Command::AsyncMessageToServer)?; + debug!(topic = ?event.subject(), "publishing CloudEvent via TCP"); + + let resp = self.conn.io(pkg, self.config.timeout).await?; + let response = message::response_from_pkg(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| "publish failed".into()), + }); + } + Ok(response) + } + + /// Broadcast a native CloudEvent (fire-and-forget, requires the + /// `cloud_events` feature). + /// + /// See [`publish_cloud_event`](Self::publish_cloud_event) for the + /// `datacontenttype` requirement. + #[cfg(feature = "cloud_events")] + pub async fn broadcast_cloud_event(&self, event: cloudevents::Event) -> Result<()> { + validate_cloud_event(&event)?; + let pkg = message::build_cloud_event_package(&event, Command::BroadcastMessageToServer)?; + self.conn.send(pkg).await + } + + /// Synchronous request/reply with a native CloudEvent (requires the + /// `cloud_events` feature). + /// + /// See [`publish_cloud_event`](Self::publish_cloud_event) for the + /// `datacontenttype` requirement. + /// + /// Sends the CloudEvent as `REQUEST_TO_SERVER` and waits for the reply. + /// The reply is parsed as a CloudEvent if the server tags it + /// `protocoltype=cloudevents`; otherwise it is parsed as a TCP-wire + /// `EventMeshMessage` and converted to a CloudEvent for a uniform return + /// type. + #[cfg(feature = "cloud_events")] + pub async fn request_reply_cloud_event( + &self, + event: cloudevents::Event, + timeout: Duration, + ) -> Result { + use cloudevents::AttributesReader; + validate_cloud_event(&event)?; + let pkg = message::build_cloud_event_package(&event, Command::RequestToServer)?; + debug!(topic = ?event.subject(), "request-reply CloudEvent via TCP"); + + let resp = self.conn.io(pkg, timeout).await?; + let response = message::response_from_pkg(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "request-reply failed".into()), + }); + } + + // Try CloudEvents first; fall back to EventMeshMessage → convert. + if message::is_cloudevents(&resp) { + message::parse_cloud_event(&resp.body).ok_or_else(|| { + EventMeshError::Codec(serde::de::Error::custom( + "failed to parse CloudEvent reply body", + )) + }) + } else { + let msg = message::parse_message(&resp.body).ok_or_else(|| { + EventMeshError::Codec(serde::de::Error::custom("failed to parse reply body")) + })?; + message::message_to_cloud_event(&msg) + } + } + + /// Access the underlying connection (for testing or advanced use). + pub fn connection(&self) -> &TcpConnection { + &self.conn + } + + /// Graceful shutdown. + pub async fn shutdown(&self) { + self.conn.shutdown().await; + } + + /// Current config. + pub fn config(&self) -> &TcpClientConfig { + &self.config + } +} + +fn ensure_success(response: PublishResponse, fallback: &str) -> Result { + if response.is_success() { + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| fallback.into()), + }) + } +} + +impl Publisher for TcpProducer { + /// Publish a message and wait for the broker ACK. + /// Uses `ASYNC_MESSAGE_TO_SERVER` + `io()` (mirrors the Java SDK). + async fn publish(&self, message: EventMeshMessage) -> Result { + validate_publish(&message)?; + let pkg = super::message::build_message_package(&message, Command::AsyncMessageToServer)?; + debug!(topic = ?message.topic, "publishing via TCP"); + + let resp = self.conn.io(pkg, self.config.timeout).await?; + let response = message::response_from_pkg(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| "publish failed".into()), + }); + } + Ok(response) + } + + /// TCP has no batch semantics — returns [`EventMeshError::Unsupported`]. + async fn publish_batch(&self, _messages: Vec) -> Result { + Err(EventMeshError::Unsupported( + "batch publish is not supported over TCP".into(), + )) + } + + /// Synchronous request/reply. Uses `REQUEST_TO_SERVER` + `io()` and waits + /// for the `RESPONSE_TO_CLIENT` push from the server. + async fn request_reply( + &self, + message: EventMeshMessage, + timeout: Duration, + ) -> Result { + validate_publish(&message)?; + let pkg = super::message::build_message_package(&message, Command::RequestToServer)?; + debug!(topic = ?message.topic, "request-reply via TCP"); + + let resp = self.conn.io(pkg, timeout).await?; + // Surface server-side failures (ACL/TPS/routing) before attempting to + // parse the body. The runtime sets header.code on the RESPONSE_TO_CLIENT + // reply via `new Header(cmd, OPStatus..getCode(), desc, seq)`. + let response = message::response_from_pkg(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "request-reply failed".into()), + }); + } + message::parse_message(&resp.body).ok_or_else(|| { + EventMeshError::Codec(serde::de::Error::custom("failed to parse reply body")) + }) + } +} + +fn validate_publish(message: &EventMeshMessage) -> Result<()> { + if message + .topic + .as_deref() + .map(|t| t.trim().is_empty()) + .unwrap_or(true) + { + return Err(EventMeshError::InvalidMessage("topic is required".into())); + } + if message + .content + .as_deref() + .map(|c| c.trim().is_empty()) + .unwrap_or(true) + { + return Err(EventMeshError::InvalidMessage("content is required".into())); + } + Ok(()) +} + +/// The `datacontenttype` value the Java runtime's TCP CloudEvents codec +/// requires. Any other value causes an NPE on the downlink path and the +/// message is silently dropped before reaching consumers. +#[cfg(feature = "cloud_events")] +const REQUIRED_CE_DATA_CONTENT_TYPE: &str = "application/cloudevents+json"; + +/// Validate that a CloudEvent has the `datacontenttype` required by the TCP +/// transport. +#[cfg(feature = "cloud_events")] +fn validate_cloud_event(event: &cloudevents::Event) -> Result<()> { + use cloudevents::AttributesReader; + match event.datacontenttype() { + Some(REQUIRED_CE_DATA_CONTENT_TYPE) => Ok(()), + Some(other) => Err(EventMeshError::InvalidMessage(format!( + "TCP transport requires datacontenttype = \"{REQUIRED_CE_DATA_CONTENT_TYPE}\", \ + got \"{other}\" — other values cause the server to silently drop the message" + ))), + None => Err(EventMeshError::InvalidMessage(format!( + "TCP transport requires datacontenttype = \"{REQUIRED_CE_DATA_CONTENT_TYPE}\", \ + but none is set — the server would silently drop the message" + ))), + } +} + +#[cfg(all(test, feature = "cloud_events"))] +mod tests { + use super::*; + use cloudevents::{AttributesReader, EventBuilder, EventBuilderV10}; + + fn make_event(datacontenttype: &str) -> cloudevents::Event { + EventBuilderV10::new() + .id("ce-1") + .source("https://example.com") + .ty("com.example.test") + .subject("ce-topic") + .data(datacontenttype, serde_json::json!({"hello": "world"})) + .build() + .expect("valid event") + } + + #[test] + fn accepts_required_content_type() { + assert!(validate_cloud_event(&make_event(REQUIRED_CE_DATA_CONTENT_TYPE)).is_ok()); + } + + #[test] + fn rejects_application_json() { + let event = make_event("application/json"); + let err = validate_cloud_event(&event).unwrap_err(); + assert!( + err.to_string().contains("application/cloudevents+json"), + "error should mention required content type: {err}" + ); + } + + #[test] + fn rejects_missing_content_type() { + let event = EventBuilderV10::new() + .id("ce-1") + .source("https://example.com") + .ty("com.example.test") + .subject("ce-topic") + .build() + .expect("valid event"); + + assert!(event.datacontenttype().is_none()); + assert!(validate_cloud_event(&event).is_err()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/webhook.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/webhook.rs new file mode 100644 index 0000000000..3ef921d6c0 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/webhook.rs @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Semantic HTTP webhook acknowledgement helpers. + +/// The outcome returned to EventMesh after an HTTP webhook delivery. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Ack { + /// The event was accepted. + Ok, + /// Ask EventMesh to redeliver the event. + Retry { message: String }, + /// Reject the event without retrying. + Fail { message: String }, + /// Tell EventMesh that no listener is available. + NoListen { message: String }, +} + +impl Ack { + /// Acknowledge the event. + pub const fn ok() -> Self { + Self::Ok + } + + /// Request redelivery with a diagnostic message. + pub fn retry(message: impl Into) -> Self { + Self::Retry { + message: message.into(), + } + } + + /// Reject the event without retrying. + pub fn fail(message: impl Into) -> Self { + Self::Fail { + message: message.into(), + } + } + + /// Indicate that the listener is unavailable. + pub fn no_listen(message: impl Into) -> Self { + Self::NoListen { + message: message.into(), + } + } +} + +/// Built-in axum webhook server. +#[cfg(feature = "http")] +pub struct WebhookServer { + inner: crate::transport::http::WebhookServer, + _handler: std::marker::PhantomData, +} + +#[cfg(feature = "http")] +impl WebhookServer { + /// Construct a webhook server that delivers to `handler`. + pub fn new(addr: std::net::SocketAddr, handler: H) -> Self { + let inner = crate::transport::http::WebhookServer::new( + addr, + std::sync::Arc::new(crate::handler::PublicHandler::new(handler)), + ); + Self { + inner, + _handler: std::marker::PhantomData, + } + } + + /// Return the URL that should be registered with EventMesh. + pub fn url(&self) -> String { + self.inner.url() + } + + /// Override the externally visible webhook URL. + pub fn with_advertise_url(mut self, url: impl Into) -> Self { + self.inner = self.inner.with_advertise_url(url); + self + } + + /// Configure a graceful shutdown signal. + pub fn with_graceful_shutdown( + mut self, + signal: impl std::future::Future + Send + 'static, + ) -> Self { + self.inner = self.inner.with_graceful_shutdown(signal); + self + } +} + +#[cfg(feature = "http")] +impl std::future::IntoFuture for WebhookServer { + type Output = crate::Result<()>; + type IntoFuture = + std::pin::Pin> + Send>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { self.inner.await }) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/workflow.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/workflow.rs new file mode 100644 index 0000000000..f9e0f98712 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/workflow.rs @@ -0,0 +1,164 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Workflow service client. + +use crate::config::WorkflowClientConfig; +use crate::discovery::ServiceDiscovery; +use crate::error::{EventMeshError, Result}; +use crate::proto_gen::workflow::workflow_client::WorkflowClient as WorkflowGrpcClient; +use crate::service::resolved_grpc_config; +use crate::transport::grpc::GrpcClient; + +pub use crate::proto_gen::workflow::{ExecuteRequest, ExecuteResponse}; + +/// A Workflow client backed by an injected service-discovery implementation. +/// +/// An instance is resolved for every [`execute`](Self::execute) call, matching +/// the Java SDK's `getWorkflowClient()` behavior while avoiding global +/// registration state. +pub struct WorkflowClient { + config: WorkflowClientConfig, + discovery: D, +} + +impl WorkflowClient { + /// Create a Workflow client using `discovery` to resolve + /// [`WorkflowClientConfig::server_name`]. + pub fn new(config: WorkflowClientConfig, discovery: D) -> Self { + Self { config, discovery } + } + + /// Resolve the Workflow service and execute a workflow request. + pub async fn execute(&self, request: ExecuteRequest) -> Result { + let instance = self + .discovery + .select_one(self.config.server_name.clone()) + .await? + .ok_or_else(|| EventMeshError::ServiceUnavailable(self.config.server_name.clone()))?; + let config = resolved_grpc_config( + &self.config.server_name, + instance, + self.config.timeout, + self.config.use_tls, + self.config.tls_config.clone(), + )?; + let channel = GrpcClient::channel(&config)?; + let mut client = WorkflowGrpcClient::new(channel); + let response = tokio::time::timeout(self.config.timeout, client.execute(request)) + .await + .map_err(|_| EventMeshError::Timeout(self.config.timeout))??; + Ok(response.into_inner()) + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + + use super::*; + use crate::discovery::ServiceInstance; + use crate::proto_gen::workflow::workflow_server::{Workflow, WorkflowServer}; + use tokio::sync::oneshot; + use tokio_stream::wrappers::TcpListenerStream; + use tonic::{Request, Response, Status}; + + struct MissingDiscovery; + + impl ServiceDiscovery for MissingDiscovery { + #[allow(clippy::manual_async_fn)] + fn select_one( + &self, + _: String, + ) -> impl Future>> + Send { + async { Ok(None) } + } + } + + struct StaticDiscovery(ServiceInstance); + + impl ServiceDiscovery for StaticDiscovery { + #[allow(clippy::manual_async_fn)] + fn select_one( + &self, + _: String, + ) -> impl Future>> + Send { + let instance = self.0.clone(); + async move { Ok(Some(instance)) } + } + } + + struct TestWorkflow; + + #[tonic::async_trait] + impl Workflow for TestWorkflow { + async fn execute( + &self, + request: Request, + ) -> std::result::Result, Status> { + let request = request.into_inner(); + assert_eq!(request.id, "order-flow"); + assert_eq!(request.task_instance_id, "task-7"); + Ok(Response::new(ExecuteResponse { + instance_id: "instance-9".into(), + })) + } + } + + #[tokio::test] + async fn missing_instance_is_reported_before_connecting() { + let client = WorkflowClient::new(WorkflowClientConfig::default(), MissingDiscovery); + let err = client.execute(ExecuteRequest::default()).await.unwrap_err(); + assert!( + matches!(err, EventMeshError::ServiceUnavailable(name) if name == "eventmesh-workflow") + ); + } + + #[tokio::test] + async fn execute_resolves_endpoint_and_uses_workflow_wire_contract() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(WorkflowServer::new(TestWorkflow)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + let client = WorkflowClient::new( + WorkflowClientConfig::builder() + .timeout(std::time::Duration::from_secs(1)) + .build(), + StaticDiscovery(ServiceInstance::new("127.0.0.1", port)), + ); + let response = client + .execute(ExecuteRequest { + id: "order-flow".into(), + instance_id: "instance-1".into(), + task_instance_id: "task-7".into(), + input: "{}".into(), + }) + .await + .unwrap(); + assert_eq!(response.instance_id, "instance-9"); + let _ = shutdown_tx.send(()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/codec_test.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/codec_test.rs new file mode 100644 index 0000000000..720d9336f4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/codec_test.rs @@ -0,0 +1,68 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, EndpointSet}, + message::{EventMeshMessage, Message, MessageKind, OpenMessage}, + subscription::{DeliveryMode, DeliveryType, Subscription}, +}; + +#[cfg(feature = "http")] +use eventmesh::http::codec::{parse_push_body, PushMessageRequestBody, WebhookReply}; + +#[test] +fn native_and_open_models_round_trip_without_public_wire_codecs() { + let original = OpenMessage::new("orders", "created") + .with_header("traceparent", "00-abc") + .with_property("region", "cn"); + let native = Message::from(original.clone()).into_event_mesh().unwrap(); + let back = Message::from(native).into_open().unwrap(); + assert_eq!(back, original); +} + +#[test] +fn message_kind_is_explicit() { + let message = Message::from(EventMeshMessage::new("orders", "created")); + assert_eq!(message.kind(), MessageKind::EventMesh); +} + +#[test] +fn subscriptions_have_rust_style_defaults_and_setters() { + let subscription = Subscription::new("orders") + .with_delivery_mode(DeliveryMode::Broadcast) + .with_delivery_type(DeliveryType::Async); + assert_eq!(subscription.topic, "orders"); + assert_eq!(subscription.delivery_mode, DeliveryMode::Broadcast); +} + +#[test] +fn endpoint_sets_require_members() { + assert!(EndpointSet::new(Vec::new()).is_err()); + assert_eq!( + Endpoint::new("::1", 10_205).unwrap().authority(), + "[::1]:10205" + ); +} + +#[cfg(feature = "http")] +#[test] +fn custom_webhook_codec_is_public() { + let parsed: PushMessageRequestBody = + parse_push_body("content=hello&topic=orders").expect("decode webhook body"); + assert_eq!(parsed.topic.as_deref(), Some("orders")); + assert_eq!(WebhookReply::ok().ret_code, 1); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_concurrent_dispatch.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_concurrent_dispatch.rs new file mode 100644 index 0000000000..d9d00dfb30 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_concurrent_dispatch.rs @@ -0,0 +1,97 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: bounded concurrent gRPC handler dispatch through the v2 facade. + +use std::time::{Duration, Instant}; + +use eventmesh::{ + config::ConsumerOptions, + message::{EventMeshMessage, Message}, + subscription::Subscription, + MessageHandler, Result, +}; +use tokio::sync::mpsc; + +use crate::harness::{ensure_topic, grpc_client, grpc_producer, let_stream_settle, unique_topic}; +use crate::require_runtime; + +const HANDLER_DELAY: Duration = Duration::from_millis(500); +const COUNT: usize = 5; + +struct SlowHandler { + tx: mpsc::UnboundedSender, +} + +impl MessageHandler for SlowHandler { + async fn handle(&self, _message: Message) -> Result> { + let _ = self.tx.send(Instant::now()); + tokio::time::sleep(HANDLER_DELAY).await; + Ok(None) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_dispatch_overlaps_handlers() { + require_runtime!(); + let topic = unique_topic("concurrent"); + ensure_topic(&topic).await; + let (tx, mut receiver) = mpsc::unbounded_channel(); + let consumer = grpc_client() + .stream_consumer( + ConsumerOptions::new(unique_topic("concurrent-group")).with_concurrency(COUNT), + [Subscription::new(&topic)], + SlowHandler { tx }, + ) + .await + .expect("open gRPC consumer"); + let_stream_settle().await; + + let started = Instant::now(); + let producer = grpc_producer(); + for index in 0..COUNT { + producer + .publish(Message::from(EventMeshMessage::new( + &topic, + format!("m{index}"), + ))) + .await + .expect("publish"); + } + + let mut starts = Vec::with_capacity(COUNT); + for _ in 0..COUNT { + starts.push( + tokio::time::timeout(Duration::from_secs(15), receiver.recv()) + .await + .expect("timed out waiting for handler") + .expect("handler channel closed"), + ); + } + assert!( + started.elapsed() < HANDLER_DELAY * COUNT as u32, + "handlers should overlap" + ); + starts.sort(); + let minimum_gap = starts + .windows(2) + .map(|window| window[1] - window[0]) + .min() + .unwrap(); + assert!(minimum_gap < HANDLER_DELAY, "handler starts should overlap"); + consumer.shutdown().await; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/harness.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/harness.rs new file mode 100644 index 0000000000..5162e83fb3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/harness.rs @@ -0,0 +1,306 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Shared v2 e2e helpers. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use eventmesh::{ + config::{ + ConsumerOptions, Credentials, Endpoint, EndpointSet, GrpcConfig, HttpConfig, Identity, + ProducerOptions, TcpConfig, + }, + grpc::{GrpcClient, GrpcConsumer, GrpcProducer}, + http::{HttpClient, HttpConsumer}, + message::{EventMeshMessage, Message}, + subscription::Subscription, + tcp::{TcpClient, TcpConsumer, TcpProducer}, + webhook::WebhookServer, + MessageHandler, Result, +}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use tracing::{debug, warn}; + +use crate::runtime::{ + ensure_runtime, webhook_host, ADMIN_PORT, GRPC_PORT, HOST, HTTP_PORT, TCP_PORT, +}; + +static SEQ: AtomicU64 = AtomicU64::new(0); + +pub(crate) fn unique_topic(scope: &str) -> String { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("e2e-{scope}-{ts}-{n}") +} + +fn identity() -> Identity { + Identity::default() + .with_env("env") + .with_idc("idc") + .with_system("sys") +} + +fn credentials() -> Credentials { + Credentials::new().with_basic("eventmesh", "eventmesh") +} + +pub(crate) fn producer_options() -> ProducerOptions { + ProducerOptions::new(unique_topic("producer-group")) +} + +pub(crate) fn consumer_options() -> ConsumerOptions { + ConsumerOptions::new(unique_topic("consumer-group")) +} + +pub(crate) fn grpc_client() -> GrpcClient { + let endpoint = Endpoint::new(HOST, GRPC_PORT).expect("valid gRPC endpoint"); + GrpcClient::new( + GrpcConfig::new(endpoint) + .with_identity(identity()) + .with_credentials(credentials()), + ) + .expect("build gRPC client") +} + +pub(crate) fn grpc_producer() -> GrpcProducer { + grpc_client() + .producer(producer_options()) + .expect("build gRPC producer") +} + +pub(crate) fn http_client() -> HttpClient { + let endpoint = Endpoint::new(HOST, HTTP_PORT).expect("valid HTTP endpoint"); + HttpClient::new( + HttpConfig::new(EndpointSet::new([endpoint]).expect("non-empty endpoints")) + .with_identity(identity()) + .with_credentials(credentials()), + ) + .expect("build HTTP client") +} + +pub(crate) fn http_producer() -> eventmesh::http::HttpProducer { + http_client() + .producer(producer_options()) + .expect("build HTTP producer") +} + +pub(crate) fn tcp_client() -> TcpClient { + let endpoint = Endpoint::new(HOST, TCP_PORT).expect("valid TCP endpoint"); + TcpClient::new( + TcpConfig::new(endpoint) + .with_identity(identity()) + .with_credentials(credentials()), + ) +} + +pub(crate) async fn tcp_producer() -> TcpProducer { + tcp_client() + .producer(producer_options()) + .await + .expect("build TCP producer") +} + +pub(crate) async fn ensure_topic(topic: &str) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + let url = format!("http://{HOST}:{ADMIN_PORT}/topic"); + + for attempt in 0..5u8 { + let result = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client") + .post(&url) + .form(&[("name", topic)]) + .send() + .await; + match result { + Ok(response) if response.status().is_success() || response.status().as_u16() == 409 => { + return; + } + Ok(response) => debug!(%topic, status = %response.status(), "ensure_topic response"), + Err(error) => warn!(%topic, attempt, "ensure_topic error: {error}"), + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + warn!(%topic, "ensure_topic gave up after retries; continuing optimistically"); +} + +pub(crate) async fn let_stream_settle() { + tokio::time::sleep(Duration::from_millis(800)).await; +} + +pub(crate) async fn warm_topic( + topic: &str, +) -> ( + GrpcConsumer, + mpsc::UnboundedReceiver, +) { + let (listener, receiver) = CollectingListener::new(); + let consumer = grpc_client() + .stream_consumer(consumer_options(), [Subscription::new(topic)], listener) + .await + .expect("open gRPC stream consumer"); + let_stream_settle().await; + (consumer, receiver) +} + +fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind port probe"); + let port = listener.local_addr().expect("probe local address").port(); + drop(listener); + port +} + +async fn wait_for_listen(address: SocketAddr, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if Instant::now() >= deadline { + panic!("webhook server at {address} did not start within {timeout:?}"); + } + if TcpStream::connect(address).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +pub(crate) struct HttpConsumerHandle { + consumer: HttpConsumer, + server_task: JoinHandle<()>, + shutdown_tx: Option>, +} + +impl HttpConsumerHandle { + pub(crate) fn consumer(&self) -> &HttpConsumer { + &self.consumer + } +} + +impl Drop for HttpConsumerHandle { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + self.server_task.abort(); + } +} + +pub(crate) async fn http_warm_topic( + topic: &str, +) -> ( + HttpConsumerHandle, + mpsc::UnboundedReceiver, +) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + let (listener, receiver) = CollectingListener::new(); + let port = free_port(); + let bind_address: SocketAddr = format!("0.0.0.0:{port}").parse().expect("webhook address"); + let url = format!("http://{}:{port}/eventmesh/callback", webhook_host()); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let server = WebhookServer::new(bind_address, listener) + .with_advertise_url(url.clone()) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(async move { + if let Err(error) = server.await { + warn!(%error, "webhook server exited with error"); + } + }); + wait_for_listen(bind_address, Duration::from_secs(5)).await; + + let consumer = http_client() + .webhook_consumer(consumer_options()) + .expect("build HTTP consumer"); + consumer + .subscribe(Subscription::new(topic), url) + .await + .expect("subscribe HTTP webhook"); + let_stream_settle().await; + + ( + HttpConsumerHandle { + consumer, + server_task, + shutdown_tx: Some(shutdown_tx), + }, + receiver, + ) +} + +pub(crate) async fn tcp_warm_topic( + topic: &str, +) -> ( + TcpConsumer, + mpsc::UnboundedReceiver, +) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + let (listener, receiver) = CollectingListener::new(); + let consumer = tcp_client() + .consumer(consumer_options(), listener) + .await + .expect("open TCP consumer"); + consumer + .subscribe(Subscription::new(topic)) + .await + .expect("subscribe TCP consumer"); + let_stream_settle().await; + (consumer, receiver) +} + +pub(crate) struct CollectingListener { + tx: mpsc::UnboundedSender, +} + +impl CollectingListener { + pub(crate) fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, receiver) = mpsc::unbounded_channel(); + (Self { tx }, receiver) + } +} + +impl MessageHandler for CollectingListener { + async fn handle(&self, message: Message) -> Result> { + let message = message.into_event_mesh()?; + let _ = self.tx.send(message); + Ok(None) + } +} + +pub(crate) struct ReplyingListener { + pub(crate) reply_content: String, +} + +impl MessageHandler for ReplyingListener { + async fn handle(&self, message: Message) -> Result> { + let request = message.into_event_mesh()?; + Ok(Some( + EventMeshMessage::new( + request.topic.unwrap_or_default(), + self.reply_content.clone(), + ) + .into(), + )) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_publish.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_publish.rs new file mode 100644 index 0000000000..73c3e3fafa --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_publish.rs @@ -0,0 +1,54 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: HTTP producer operations through the v2 facade. + +use eventmesh::message::{EventMeshMessage, Message}; + +use crate::harness::{ensure_topic, http_producer, http_warm_topic, unique_topic}; +use crate::require_runtime; +use std::time::Duration; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(15), receiver.recv()) + .await + .expect("timed out waiting for HTTP delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_publish_single() { + require_runtime!(); + let topic = unique_topic("http-pub-single"); + ensure_topic(&topic).await; + let (_handle, mut receiver) = http_warm_topic(&topic).await; + + let receipt = http_producer() + .publish(Message::from(EventMeshMessage::new( + &topic, + "hello from rust http e2e", + ))) + .await + .expect("HTTP publish"); + assert_eq!(receipt.code, 0, "HTTP publish should succeed: {receipt:?}"); + assert_eq!( + receive(&mut receiver).await.content.as_deref(), + Some("hello from rust http e2e") + ); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_subscribe.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_subscribe.rs new file mode 100644 index 0000000000..9ae8c2f575 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_subscribe.rs @@ -0,0 +1,101 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: HTTP webhook subscriptions through the v2 facade. + +use std::time::Duration; + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::Subscription, +}; + +use crate::harness::{ + ensure_topic, http_producer, http_warm_topic, let_stream_settle, unique_topic, +}; +use crate::require_runtime; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(15), receiver.recv()) + .await + .expect("timed out waiting for webhook delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_subscribe_and_receive() { + require_runtime!(); + let topic = unique_topic("http-sub-recv"); + ensure_topic(&topic).await; + let (_handle, mut receiver) = http_warm_topic(&topic).await; + + http_producer() + .publish(Message::from(EventMeshMessage::new( + &topic, + "delivered-via-http", + ))) + .await + .expect("HTTP publish"); + assert_eq!( + receive(&mut receiver).await.content.as_deref(), + Some("delivered-via-http") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_unsubscribe_stops_delivery() { + require_runtime!(); + let topic = unique_topic("http-sub-unsub"); + ensure_topic(&topic).await; + let (handle, mut receiver) = http_warm_topic(&topic).await; + let producer = http_producer(); + + producer + .publish(Message::from(EventMeshMessage::new( + &topic, + "before-http-unsub", + ))) + .await + .expect("HTTP publish before unsubscribe"); + let _ = receive(&mut receiver).await; + + handle + .consumer() + .unsubscribe(Subscription::new(&topic)) + .await + .expect("HTTP unsubscribe"); + let_stream_settle().await; + + match producer + .publish(Message::from(EventMeshMessage::new( + &topic, + "after-http-unsub", + ))) + .await + { + Ok(_) => assert!( + matches!( + tokio::time::timeout(Duration::from_secs(3), receiver.recv()).await, + Err(_) | Ok(None) + ), + "webhook delivery leaked after unsubscribe" + ), + Err(error) => eprintln!("[e2e] broker rejected post-unsubscribe HTTP publish: {error}"), + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/interop.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/interop.rs new file mode 100644 index 0000000000..38e9b61807 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/interop.rs @@ -0,0 +1,126 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Cross-SDK gRPC interoperability with a Java SDK peer started on the host. + +use std::{path::PathBuf, process::Command, sync::OnceLock, time::Duration}; + +use eventmesh::message::{EventMeshMessage, Message}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::Command as TokioCommand, +}; + +use crate::{ + harness::{ensure_topic, grpc_producer, unique_topic, warm_topic}, + require_runtime, +}; + +const PEER_CLASS: &str = "org.apache.eventmesh.client.interop.GrpcInteropPeer"; +static CLASSPATH: OnceLock = OnceLock::new(); + +fn java_sdk_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("eventmesh-sdks directory") + .join("eventmesh-sdk-java") +} + +fn peer_classpath() -> &'static String { + CLASSPATH.get_or_init(|| { + let java_sdk = java_sdk_dir(); + let root = java_sdk.parent().expect("eventmesh-sdks parent").parent().expect("repo root"); + let run = |proxy: bool| { + let mut cmd = Command::new(root.join("gradlew")); + cmd.current_dir(root).args(["--no-daemon", "-q", ":eventmesh-sdks:eventmesh-sdk-java:interopPeerClasspath"]); + if proxy { + cmd.env("GRADLE_OPTS", "-Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=7890 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=7890"); + } + cmd.output().expect("run Gradle for Java interop peer") + }; + let mut output = run(false); + if !output.status.success() { + output = run(true); + } + assert!(output.status.success(), "build Java interop peer: {}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).expect("Gradle stdout").lines() + .find_map(|line| line.strip_prefix("INTEROP_PEER_CLASSPATH=")).expect("Java peer classpath").to_owned() + }) +} + +async fn peer(operation: &str, topic: &str, content: Option<&str>) -> tokio::process::Child { + let mut command = TokioCommand::new("java"); + command.args([ + "-cp", + peer_classpath(), + PEER_CLASS, + operation, + "127.0.0.1", + topic, + ]); + if let Some(content) = content { + command.arg(content); + } + command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("start Java SDK peer") +} + +#[tokio::test(flavor = "multi_thread")] +async fn rust_publishes_to_java_consumer() { + require_runtime!(); + let topic = unique_topic("interop-rust-java"); + ensure_topic(&topic).await; + let mut child = peer("consume", &topic, None).await; + tokio::time::sleep(Duration::from_secs(2)).await; + grpc_producer() + .publish(Message::from(EventMeshMessage::new(&topic, "from-rust"))) + .await + .expect("Rust publish"); + let mut lines = BufReader::new(child.stdout.take().expect("peer stdout")).lines(); + let expected = format!("INTEROP_RECEIVED={topic}\tfrom-rust"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let line = tokio::time::timeout(remaining, lines.next_line()) + .await + .expect("Java receive timeout") + .expect("peer output") + .expect("peer exited before receiving message"); + if line == expected { + break; + } + } + assert!(child.wait().await.expect("wait Java peer").success()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn java_publishes_to_rust_consumer() { + require_runtime!(); + let topic = unique_topic("interop-java-rust"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + let mut child = peer("publish", &topic, Some("from-java")).await; + assert!(child.wait().await.expect("wait Java peer").success()); + let received = tokio::time::timeout(Duration::from_secs(20), receiver.recv()) + .await + .expect("Rust receive timeout") + .expect("Rust listener closed"); + assert_eq!(received.content.as_deref(), Some("from-java")); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/main.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/main.rs new file mode 100644 index 0000000000..f3fad00c2c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/main.rs @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! End-to-end tests for the EventMesh Rust SDK. +//! +//! These tests spin up the EventMesh runtime via `docker compose` (rocketmq +//! profile) and exercise the **gRPC**, **HTTP** and **TCP** producer/consumer +//! against a real server. +//! +//! Gated behind the `e2e` feature so a plain `cargo test` never touches Docker: +//! +//! ```bash +//! cargo test --features e2e +//! ``` +//! +//! The `e2e` feature implies all transports (`grpc`, `http`, `tcp`), so the +//! full suite compiles from a single flag. +//! +//! To run against an already-running server instead of auto-starting one, set +//! `EVENTMESH_E2E_EXTERNAL=1`. When neither Docker nor a server is available the +//! tests skip themselves (rather than fail). +//! +//! **Release CI must set `EVENTMESH_E2E_STRICT=1`.** Without it a test that +//! cannot reach a runtime silently returns and is counted as *passed* by the +//! test harness — a false green. Strict mode turns that into a hard failure +//! so the pipeline goes red when the suite didn't actually execute. + +#![cfg(feature = "e2e")] + +mod grpc_concurrent_dispatch; +mod harness; +mod http_publish; +mod http_subscribe; +#[cfg(feature = "interop_e2e")] +mod interop; +mod publish; +mod request_reply; +mod runtime; +mod subscribe; +mod tcp_cloud_events; +mod tcp_publish; +mod tcp_request_reply; +mod tcp_subscribe; + +/// Guard clause for e2e tests: ensures a runtime is available before +/// proceeding. +/// +/// In normal mode, if no runtime is available the test returns early (counted +/// as *passed* by libtest). In strict mode (`EVENTMESH_E2E_STRICT=1`) the test +/// panics instead, so CI fails when the suite didn't actually run. +macro_rules! require_runtime { + () => { + if !crate::runtime::ensure_runtime() { + if crate::runtime::is_strict() { + panic!( + "EventMesh runtime is not available and EVENTMESH_E2E_STRICT=1; \ + refusing to let the test silently pass" + ); + } + return; + } + }; +} +pub(crate) use require_runtime; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/publish.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/publish.rs new file mode 100644 index 0000000000..81265a532e --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/publish.rs @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: gRPC producer operations through the v2 facade. + +use eventmesh::message::{EventMeshMessage, Message}; + +use crate::harness::{ensure_topic, grpc_producer, unique_topic, warm_topic}; +use crate::require_runtime; +use std::time::Duration; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for gRPC delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn publish_single() { + require_runtime!(); + let topic = unique_topic("pub-single"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + let receipt = grpc_producer() + .publish(Message::from(EventMeshMessage::new( + &topic, + "hello from rust e2e", + ))) + .await + .expect("publish"); + assert_eq!(receipt.code, 0, "publish should succeed: {receipt:?}"); + assert_eq!( + receive(&mut receiver).await.content.as_deref(), + Some("hello from rust e2e") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn publish_batch() { + require_runtime!(); + let topic = unique_topic("pub-batch"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + let messages = (0..3) + .map(|index| { + Message::from(EventMeshMessage::new( + &topic, + format!("batch message #{index}"), + )) + }) + .collect(); + let receipt = grpc_producer() + .publish_batch(messages) + .await + .expect("batch publish"); + assert_eq!(receipt.code, 0, "batch publish should succeed: {receipt:?}"); + let mut contents = Vec::new(); + for _ in 0..3 { + contents.push(receive(&mut receiver).await.content.unwrap_or_default()); + } + contents.sort(); + assert_eq!( + contents, + ["batch message #0", "batch message #1", "batch message #2"] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn publish_one_way() { + require_runtime!(); + let topic = unique_topic("pub-oneway"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + let result = grpc_producer() + .publish_one_way(Message::from(EventMeshMessage::new( + &topic, + "fire-and-forget", + ))) + .await; + match result { + Ok(()) => assert_eq!( + receive(&mut receiver).await.content.as_deref(), + Some("fire-and-forget") + ), + Err(error) => { + let text = error.to_string(); + if !(text.contains("Unimplemented") || text.contains("unimplemented")) { + panic!("publish_one_way: {error}"); + } + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/request_reply.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/request_reply.rs new file mode 100644 index 0000000000..1512dbc8df --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/request_reply.rs @@ -0,0 +1,64 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: gRPC synchronous request/reply through the v2 facade. + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::{DeliveryType, Subscription}, +}; + +use crate::harness::{ + consumer_options, ensure_topic, grpc_client, grpc_producer, let_stream_settle, unique_topic, + ReplyingListener, +}; +use crate::require_runtime; + +#[tokio::test(flavor = "multi_thread")] +async fn request_reply_roundtrip() { + require_runtime!(); + let topic = unique_topic("req-reply"); + ensure_topic(&topic).await; + let consumer = grpc_client() + .stream_consumer( + consumer_options(), + [Subscription::new(&topic).with_delivery_type(DeliveryType::Sync)], + ReplyingListener { + reply_content: "pong".into(), + }, + ) + .await + .expect("open request/reply consumer"); + let_stream_settle().await; + + let reply = grpc_producer() + .request_reply(Message::from(EventMeshMessage::new(&topic, "ping"))) + .await; + match reply { + Ok(Message::EventMesh(message)) => { + let unsupported = message + .get_prop("responsemessage") + .is_some_and(|value| value.to_lowercase().contains("not supported")); + if !unsupported { + assert_eq!(message.content.as_deref(), Some("pong")); + } + } + Ok(other) => panic!("expected native reply, got {other:?}"), + Err(error) => eprintln!("[e2e] broker does not support gRPC request/reply: {error}"), + } + consumer.shutdown().await; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/runtime.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/runtime.rs new file mode 100644 index 0000000000..abf0128c80 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/runtime.rs @@ -0,0 +1,310 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! EventMesh runtime lifecycle for the e2e suite. +//! +//! [`ensure_runtime`] is process-global (guarded by a `OnceLock`): the first +//! caller starts the rocketmq stack via `docker compose` and blocks until its +//! healthcheck passes (`up --wait`). Subsequent callers (other parallel test +//! threads) reuse the already-running server. +//! +//! If **we** started the stack, a [`ctor::dtor`] brings it down once the test +//! binary exits. Set `EVENTMESH_E2E_EXTERNAL=1` to skip Docker entirely and use +//! a server you started yourself. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::Duration; + +use tracing::{info, warn}; + +/// gRPC port of the EventMesh runtime. +pub(crate) const GRPC_PORT: u16 = 10_205; +/// HTTP port of the EventMesh runtime. +pub(crate) const HTTP_PORT: u16 = 10_105; +/// TCP port of the EventMesh runtime. +pub(crate) const TCP_PORT: u16 = 10_000; +/// Admin (HTTP) port, used for topic creation + readiness probes. +pub(crate) const ADMIN_PORT: u16 = 10_106; +/// Host the runtime is reachable on from the test host. +pub(crate) const HOST: &str = "127.0.0.1"; + +/// Set to true iff the harness itself launched `docker compose`, so the dtor +/// only tears down what it started. +static TEARDOWN_NEEDED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Mode { + /// A server was already reachable (or `EVENTMESH_E2E_EXTERNAL` set); we did + /// not start anything. + External, + /// We launched `docker compose` and must stop it on exit. + Started, + /// No Docker and no server — tests must skip. + Unavailable, +} + +static MODE: OnceLock = OnceLock::new(); + +/// Absolute path of this crate's manifest dir, captured at compile time. The +/// `docker-compose.yml` + `docker/conf/` live alongside the crate, keeping the +/// e2e suite fully self-contained. +const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +fn compose_file() -> PathBuf { + PathBuf::from(MANIFEST_DIR).join("docker-compose.yml") +} + +/// Best-effort make the bind-mounted `docker/conf/` dir traversable and its +/// files world-readable. +/// +/// The compose file bind-mounts `./docker/conf/*` into containers that run as +/// a different uid than the host user (e.g. the rocketmq image runs as uid +/// 3000). On hosts where the repo lives behind a restrictive ACL +/// (`other::---`), those containers can't read their own config and the broker +/// crashes on boot with `FileNotFoundException: ... (Permission denied)`. We +/// open the perms once, up front, so the e2e suite is self-contained. +/// +/// No-op where the perms are already open; failures (e.g. read-only fs) are +/// ignored — the real failure will surface as an unreadable-config crash from +/// `docker compose up` below. +fn ensure_conf_readable() { + let dir = PathBuf::from(MANIFEST_DIR).join("docker").join("conf"); + // Directory must be traversable (o+x) for the container to resolve files + // inside it; files must be readable (o+r). + let _ = Command::new("chmod") + .args(["o+rx", dir.to_str().unwrap_or(".")]) + .status(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + let _ = Command::new("chmod") + .args(["o+r", path.to_str().unwrap_or(".")]) + .status(); + } + } +} + +/// Ensure an EventMesh runtime is reachable. Returns `false` (and emits a skip +/// notice) when neither Docker nor a live server is available. +/// +/// Idempotent and thread-safe: the (potentially slow) `docker compose up` runs +/// at most once per process; parallel test threads block on the `OnceLock` +/// until it resolves. +pub(crate) fn ensure_runtime() -> bool { + let &mode = MODE.get_or_init(initialize); + match mode { + Mode::External | Mode::Started => true, + Mode::Unavailable => { + // Already warned during init; just signal "skip". + false + } + } +} + +/// Whether strict mode is enabled (`EVENTMESH_E2E_STRICT=1`). +/// +/// In strict mode a test that cannot reach a runtime **fails** instead of +/// silently passing. This is intended for release CI: the suite must not +/// report "20 passed" when zero tests actually executed. +pub(crate) fn is_strict() -> bool { + std::env::var_os("EVENTMESH_E2E_STRICT") + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +/// The hostname an EventMesh runtime should use to POST webhook callbacks to a +/// server running in this test process. +/// +/// The runtime almost always lives in a container — either the harness started +/// it via `docker compose` (`Mode::Started`), or the user pre-started the very +/// same compose file and we reuse it (`Mode::External`). In both cases the +/// container cannot reach the test process via `127.0.0.1` (that resolves to +/// the container's own loopback), so we advertise `host.docker.internal`, +/// which both profiles in `docker-compose.yml` map to the host gateway. +/// +/// Override with the `EVENTMESH_E2E_WEBHOOK_HOST` env var for non-containerized +/// setups (e.g. a runtime running directly on the host via `bin/start.sh`, in +/// which case `127.0.0.1` is correct) or a server on another host. +pub(crate) fn webhook_host() -> String { + if let Ok(h) = std::env::var("EVENTMESH_E2E_WEBHOOK_HOST") { + return h; + } + match MODE.get() { + // Both compose profiles map host.docker.internal -> host-gateway, so + // callbacks from the container reach the test process on the host. + // This deliberately covers Mode::External too: the common "external" + // case is a user who pre-started this crate's compose file, where the + // runtime is still containerized and 127.0.0.1 would route callbacks + // to the container's loopback and silently time out the webhook tests. + // For a genuinely non-containerized local runtime, set + // EVENTMESH_E2E_WEBHOOK_HOST=127.0.0.1. + Some(&Mode::Started | &Mode::External) => "host.docker.internal".to_string(), + _ => "127.0.0.1".to_string(), + } +} + +/// The resolved runtime mode, or `None` before [`ensure_runtime`] has been +/// called. +/// +/// Tests use this to distinguish the harness-launched broker (always the +/// `rocketmq` profile, where every feature is expected to work) from an +/// externally-provided server (which may be the feature-limited standalone +/// broker). +pub(crate) fn mode() -> Option { + MODE.get().copied() +} + +fn initialize() -> Mode { + // 1) Explicit "use my own server" override. + if std::env::var_os("EVENTMESH_E2E_EXTERNAL").is_some() { + if probe_admin(Duration::from_secs(10)) { + info!("EVENTMESH_E2E_EXTERNAL set and server is reachable"); + return Mode::External; + } + warn!( + "EVENTMESH_E2E_EXTERNAL set but no server on {HOST}:{ADMIN_PORT}; \ + marking unavailable" + ); + return Mode::Unavailable; + } + + // 2) Server already up? Reuse it, start nothing. + if probe_admin(Duration::from_secs(2)) { + info!("found an already-running EventMesh; reusing it"); + return Mode::External; + } + + // 3) Try to launch via docker compose. + if !docker_available() { + eprintln!( + "[e2e] skipping: no EventMesh server on {HOST}:{ADMIN_PORT} and \ + `docker` is not on PATH. Start one with \ + `docker compose --profile rocketmq up -d`, or set \ + EVENTMESH_E2E_EXTERNAL=1." + ); + return Mode::Unavailable; + } + + let compose = compose_file(); + let project_dir = PathBuf::from(MANIFEST_DIR); + + // The compose file bind-mounts ./docker/conf/* into containers that run + // as a different uid (rocketmq runs as uid 3000). On hosts where the + // conf dir/files inherit a restrictive ACL (`other::---`), those + // containers can't even read their own config and the broker crashes on + // boot. Make the conf dir traversable and its files world-readable before + // bringing the stack up so the suite is self-contained regardless of the + // host's umask/ACL. Best-effort: a no-op where the perms are already open. + ensure_conf_readable(); + + info!(?compose, "starting EventMesh via docker compose (rocketmq)"); + let up = Command::new("docker") + .args([ + "compose", + "-f", + compose.to_str().expect("utf-8 compose path"), + "--project-directory", + project_dir.to_str().expect("utf-8 project dir"), + "--profile", + "rocketmq", + "up", + "-d", + "--wait", + ]) + // Run from the crate dir so the relative bind-mounts in the compose file + // (./docker/conf/...) resolve against this crate, not the repo root. + .current_dir(&project_dir) + .status(); + match up { + Ok(s) if s.success() => { + TEARDOWN_NEEDED.store(true, Ordering::SeqCst); + // `--wait` returns once the healthcheck is RUNNING; give the gRPC + // listener a final moment to settle. + wait_for_admin(Duration::from_secs(30)); + info!("EventMesh runtime is up"); + Mode::Started + } + Ok(s) => { + eprintln!("[e2e] `docker compose up` exited with {s}; skipping tests"); + Mode::Unavailable + } + Err(e) => { + eprintln!("[e2e] failed to invoke `docker compose`: {e}; skipping tests"); + Mode::Unavailable + } + } +} + +/// Best-effort teardown of the stack we started. Runs exactly once, at process +/// exit (including panic unwind under the default test profile). +#[ctor::dtor] +fn teardown() { + if !TEARDOWN_NEEDED.load(Ordering::SeqCst) { + return; + } + let compose = compose_file(); + let project_dir = PathBuf::from(MANIFEST_DIR); + info!("stopping EventMesh via docker compose"); + let _ = Command::new("docker") + .args([ + "compose", + "-f", + compose.to_str().expect("utf-8 compose path"), + "--project-directory", + project_dir.to_str().expect("utf-8 project dir"), + "--profile", + "rocketmq", + "down", + ]) + .current_dir(&project_dir) + .status(); +} + +fn docker_available() -> bool { + Command::new("docker") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Poll the admin HTTP port until it accepts a connection or `timeout` elapses. +fn wait_for_admin(timeout: Duration) -> bool { + let deadline = std::time::Instant::now() + timeout; + while std::time::Instant::now() < deadline { + if probe_admin(Duration::from_millis(500)) { + return true; + } + std::thread::sleep(Duration::from_millis(500)); + } + false +} + +/// Try a single TCP connect to the admin port within `per_attempt` (capped). +fn probe_admin(per_attempt: Duration) -> bool { + use std::net::TcpStream; + let addr = format!("{HOST}:{ADMIN_PORT}"); + TcpStream::connect_timeout( + &addr.parse().expect("valid admin addr"), + per_attempt.min(Duration::from_secs(2)), + ) + .is_ok() +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/subscribe.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/subscribe.rs new file mode 100644 index 0000000000..2aa8da2325 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/subscribe.rs @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: gRPC stream subscriptions through the v2 facade. + +use std::time::Duration; + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::Subscription, +}; + +use crate::harness::{ensure_topic, grpc_producer, let_stream_settle, unique_topic, warm_topic}; +use crate::require_runtime; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn subscribe_and_receive() { + require_runtime!(); + let topic = unique_topic("sub-recv"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + grpc_producer() + .publish(Message::from(EventMeshMessage::new( + &topic, + "delivered-payload", + ))) + .await + .expect("publish"); + let received = receive(&mut receiver).await; + assert_eq!(received.content.as_deref(), Some("delivered-payload")); + assert_eq!(received.topic.as_deref(), Some(topic.as_str())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn subscribe_batch_receive() { + require_runtime!(); + let topic = unique_topic("sub-batch"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + let messages = (0..3) + .map(|index| Message::from(EventMeshMessage::new(&topic, format!("m{index}")))) + .collect(); + grpc_producer() + .publish_batch(messages) + .await + .expect("batch publish"); + + let mut contents = Vec::new(); + for _ in 0..3 { + contents.push(receive(&mut receiver).await.content.unwrap_or_default()); + } + contents.sort(); + assert_eq!(contents, ["m0", "m1", "m2"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn unsubscribe_stops_delivery() { + require_runtime!(); + let topic = unique_topic("sub-unsub"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = warm_topic(&topic).await; + let producer = grpc_producer(); + + producer + .publish(Message::from(EventMeshMessage::new(&topic, "before-unsub"))) + .await + .expect("publish before unsubscribe"); + let _ = receive(&mut receiver).await; + + consumer + .unsubscribe(Subscription::new(&topic)) + .await + .expect("unsubscribe"); + let_stream_settle().await; + + match producer + .publish(Message::from(EventMeshMessage::new(&topic, "after-unsub"))) + .await + { + Ok(_) => assert!( + matches!( + tokio::time::timeout(Duration::from_secs(3), receiver.recv()).await, + Err(_) | Ok(None) + ), + "delivery leaked after unsubscribe" + ), + Err(error) => eprintln!("[e2e] broker rejected post-unsubscribe publish: {error}"), + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_cloud_events.rs new file mode 100644 index 0000000000..027ca663ad --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_cloud_events.rs @@ -0,0 +1,87 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: TCP CloudEvents publishing through the v2 message enum. + +use std::time::Duration; + +use cloudevents::{AttributesReader, Event, EventBuilder, EventBuilderV10}; +use eventmesh::{message::Message, MessageHandler, Result}; +use tokio::sync::mpsc; + +use crate::harness::{consumer_options, ensure_topic, tcp_client, tcp_producer, unique_topic}; +use crate::require_runtime; + +struct CloudEventListener { + tx: mpsc::UnboundedSender, +} + +impl MessageHandler for CloudEventListener { + async fn handle(&self, message: Message) -> Result> { + if let Message::CloudEvent(event) = message { + let _ = self.tx.send(event); + } + Ok(None) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_publish_cloud_event() { + require_runtime!(); + let topic = unique_topic("tcp-ce-pub"); + ensure_topic(&topic).await; + let (tx, mut receiver) = mpsc::unbounded_channel(); + let consumer = tcp_client() + .consumer(consumer_options(), CloudEventListener { tx }) + .await + .expect("open TCP CloudEvent consumer"); + consumer + .subscribe(eventmesh::subscription::Subscription::new(&topic)) + .await + .expect("subscribe TCP CloudEvent consumer"); + tokio::time::sleep(Duration::from_millis(800)).await; + let producer = tcp_producer().await; + + let event = EventBuilderV10::new() + .id("tcp-ce-e2e-1") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.someevent") + .subject(&topic) + .data( + "application/cloudevents+json", + r#"{"msg":"hello from rust tcp cloudevents e2e"}"#, + ) + .build() + .expect("valid CloudEvent"); + let receipt = producer + .publish(Message::from(event)) + .await + .expect("publish CloudEvent"); + assert_eq!(receipt.code, 0); + + let received = tokio::time::timeout(Duration::from_secs(35), receiver.recv()) + .await + .expect("timed out waiting for CloudEvent delivery") + .expect("handler channel closed"); + assert_eq!(received.subject(), Some(topic.as_str())); + assert!(serde_json::to_string(&received) + .expect("serialize received CloudEvent") + .contains("hello from rust tcp cloudevents e2e")); + + producer.shutdown().await; + consumer.shutdown().await; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_publish.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_publish.rs new file mode 100644 index 0000000000..ddff382e9b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_publish.rs @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: TCP producer operations through the v2 facade. + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::{DeliveryMode, Subscription}, +}; + +use crate::harness::{ + consumer_options, ensure_topic, tcp_client, tcp_producer, tcp_warm_topic, unique_topic, + CollectingListener, +}; +use crate::require_runtime; +use std::time::Duration; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for TCP delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_publish_single() { + require_runtime!(); + let topic = unique_topic("tcp-pub-single"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = tcp_warm_topic(&topic).await; + + let producer = tcp_producer().await; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + &topic, + "hello from rust TCP e2e", + ))) + .await + .expect("TCP publish"); + assert_eq!(receipt.code, 0, "TCP publish should succeed: {receipt:?}"); + assert_eq!( + receive(&mut receiver).await.content.as_deref(), + Some("hello from rust TCP e2e") + ); + producer.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_broadcast() { + require_runtime!(); + let topic = unique_topic("tcp-broadcast"); + ensure_topic(&topic).await; + let producer = tcp_producer().await; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + &topic, + "warm TCP broadcast topic", + ))) + .await + .expect("warm TCP broadcast topic"); + assert_eq!(receipt.code, 0, "warm publish should succeed: {receipt:?}"); + + let (listener, mut receiver) = CollectingListener::new(); + let consumer = tcp_client() + .consumer(consumer_options(), listener) + .await + .expect("open TCP broadcast consumer"); + consumer + .subscribe(Subscription::new(&topic).with_delivery_mode(DeliveryMode::Broadcast)) + .await + .expect("subscribe TCP broadcast consumer"); + // The RocketMQ broadcast consumer refreshes topic routes on a 30-second + // interval. Wait for that initial rebalance before sending the message + // under test; otherwise the first message can arrive before the consumer + // owns a queue and be skipped by its `CONSUME_FROM_LAST_OFFSET` policy. + tokio::time::sleep(Duration::from_secs(30)).await; + + producer + .broadcast(Message::from(EventMeshMessage::new( + &topic, + "broadcast from rust TCP e2e", + ))) + .await + .expect("TCP broadcast"); + assert_eq!( + tokio::time::timeout(Duration::from_secs(35), receiver.recv()) + .await + .expect("timed out waiting for TCP broadcast delivery") + .expect("broadcast handler channel closed") + .content + .as_deref(), + Some("broadcast from rust TCP e2e") + ); + producer.shutdown().await; + consumer.shutdown().await; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_request_reply.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_request_reply.rs new file mode 100644 index 0000000000..7232b5677b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_request_reply.rs @@ -0,0 +1,67 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: TCP synchronous request/reply through the v2 facade. + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::{DeliveryType, Subscription}, +}; + +use crate::harness::{ + consumer_options, ensure_topic, let_stream_settle, tcp_client, tcp_producer, unique_topic, + ReplyingListener, +}; +use crate::require_runtime; +use crate::runtime::{mode, Mode}; + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_request_reply_roundtrip() { + require_runtime!(); + let topic = unique_topic("tcp-req-reply"); + ensure_topic(&topic).await; + let consumer = tcp_client() + .consumer( + consumer_options(), + ReplyingListener { + reply_content: "pong".into(), + }, + ) + .await + .expect("open TCP request/reply consumer"); + consumer + .subscribe(Subscription::new(&topic).with_delivery_type(DeliveryType::Sync)) + .await + .expect("subscribe TCP request/reply consumer"); + let_stream_settle().await; + + let producer = tcp_producer().await; + let reply = producer + .request_reply(Message::from(EventMeshMessage::new(&topic, "ping"))) + .await; + producer.shutdown().await; + consumer.shutdown().await; + + match reply { + Ok(Message::EventMesh(message)) => assert_eq!(message.content.as_deref(), Some("pong")), + Ok(other) => panic!("expected native reply, got {other:?}"), + Err(error) if mode() != Some(Mode::Started) => { + eprintln!("[e2e] external broker may not support TCP request/reply: {error}"); + } + Err(error) => panic!("TCP request/reply failed on harness runtime: {error}"), + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_subscribe.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_subscribe.rs new file mode 100644 index 0000000000..a1625905d4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_subscribe.rs @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: TCP subscriptions through the v2 facade. + +use std::time::Duration; + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::Subscription, +}; + +use crate::harness::{ensure_topic, tcp_producer, tcp_warm_topic, unique_topic}; +use crate::require_runtime; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for TCP delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_subscribe_and_receive() { + require_runtime!(); + let topic = unique_topic("tcp-sub-recv"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = tcp_warm_topic(&topic).await; + let producer = tcp_producer().await; + + producer + .publish(Message::from(EventMeshMessage::new( + &topic, + "delivered-via-tcp", + ))) + .await + .expect("TCP publish"); + let received = receive(&mut receiver).await; + assert_eq!(received.content.as_deref(), Some("delivered-via-tcp")); + assert_eq!(received.topic.as_deref(), Some(topic.as_str())); + + producer.shutdown().await; + consumer.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_unsubscribe_stops_delivery() { + require_runtime!(); + let topic = unique_topic("tcp-sub-unsub"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = tcp_warm_topic(&topic).await; + let producer = tcp_producer().await; + + producer + .publish(Message::from(EventMeshMessage::new(&topic, "before-unsub"))) + .await + .expect("publish before unsubscribe"); + let _ = receive(&mut receiver).await; + consumer + .unsubscribe(Subscription::new(&topic)) + .await + .expect("TCP unsubscribe"); + + match producer + .publish(Message::from(EventMeshMessage::new(&topic, "after-unsub"))) + .await + { + Ok(_) => assert!( + matches!( + tokio::time::timeout(Duration::from_secs(3), receiver.recv()).await, + Err(_) | Ok(None) + ), + "TCP delivery leaked after unsubscribe" + ), + Err(error) => eprintln!("[e2e] broker rejected post-unsubscribe TCP publish: {error}"), + } + + producer.shutdown().await; + consumer.shutdown().await; +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/eventmesh_message_utils_test.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/eventmesh_message_utils_test.rs deleted file mode 100644 index fc523880ca..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/tests/eventmesh_message_utils_test.rs +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use eventmesh::common::grpc_eventmesh_message_utils::{EventMeshCloudEventUtils, ProtoSupport}; -use eventmesh::config::EventMeshGrpcClientConfig; -use eventmesh::model::EventMeshProtocolType; -use eventmesh::proto_cloud_event::PbAttr; - -#[test] -fn test_proto_support_is_text_content() { - assert!(ProtoSupport::is_text_content("text/plain")); - assert!(ProtoSupport::is_text_content("text/html")); - assert!(ProtoSupport::is_text_content("application/json")); - assert!(ProtoSupport::is_text_content("application/xml")); - assert!(!ProtoSupport::is_text_content("application/json+foo")); - assert!(!ProtoSupport::is_text_content("application/xml+bar")); - assert!(!ProtoSupport::is_text_content("")); - assert!(!ProtoSupport::is_text_content("application/octet-stream")); -} - -#[test] -fn test_proto_support_is_proto_content() { - assert!(ProtoSupport::is_proto_content("application/protobuf")); - assert!(!ProtoSupport::is_proto_content("")); - assert!(!ProtoSupport::is_proto_content("application/json")); - assert!(!ProtoSupport::is_proto_content("text/plain")); -} - -#[test] -fn test_event_mesh_cloud_event_utils_build_common_cloud_event_attributes() { - let client_config = EventMeshGrpcClientConfig::default() - .set_env("test_env".to_string()) - .set_idc("test_idc".to_string()); - let protocol_type = EventMeshProtocolType::CloudEvents; - let attribute_map = EventMeshCloudEventUtils::build_common_cloud_event_attributes( - &client_config, - protocol_type, - ); - - assert_eq!( - *attribute_map.get("env").map(|attr| &attr.attr).unwrap(), - Some(PbAttr::CeString("test_env".to_string())) - ); - assert_eq!( - *attribute_map.get("idc").map(|attr| &attr.attr).unwrap(), - Some(PbAttr::CeString("test_idc".to_string())) - ); -}