diff --git a/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md b/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md new file mode 100644 index 0000000000..cc3081aa04 --- /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.75.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.75). 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..e2c50f987b 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml +++ b/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml @@ -1,86 +1,138 @@ +# 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" edition = "2021" -authors = [ - "mxsm " -] -msrv = "1.75.0" -description = "Rust client for Apache EventMesh" +rust-version = "1.75.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 = [] +default = ["grpc"] +# 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"] +# 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"] -# 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 = "0.1" +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 } +# 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..bbe5d46330 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/README.md +++ b/eventmesh-sdks/eventmesh-sdk-rust/README.md @@ -1,156 +1,201 @@ -## Eventmesh-rust-sdk -Eventmesh rust sdk +# eventmesh-rust-sdk -## Quickstart +A Rust client SDK for [Apache EventMesh](https://eventmesh.apache.org), the +serverless event-driven middleware. -### Requirements +This crate (`eventmesh`) speaks the EventMesh **gRPC** protocol (HTTP and TCP +transports are planned for later phases). Messages are modeled with the simple +[`EventMeshMessage`](src/model/message.rs) type, with optional native +[CloudEvents](https://cloudevents.io) interop behind the `cloud_events` feature. -1. rust toolchain, eventmesh's MSRV is 1.75. -2. protoc 3.15.0+ -3. setup eventmesh runtime +> Phase 1 (this release): **gRPC transport** — publish, batch publish, +> request-reply, stream subscription, webhook subscription, heartbeat, and +> CloudEvents interop. -### Add Dependency +## Requirements + +- Rust toolchain **>= 1.75.0** +- `protoc` >= 3.15 (the Protocol Buffers compiler) on your `PATH` or pointed to + by the `PROTOC` env var. `tonic-build` invokes it at build time. +- A running EventMesh runtime (gRPC on port `10205`). + +### Installing protoc + +```bash +# Ubuntu / Debian +sudo apt-get install -y protobuf-compiler +# Alpine +apk add protobuf-dev protoc +# macOS +brew install protobuf +# or download a release binary from https://github.com/protocolbuffers/protobuf/releases +``` + +## Usage + +Add the dependency: ```toml [dependencies] -eventmesh = { version = "1.9", features = ["default"] } +eventmesh = { version = "1.9", features = ["default"] } # gRPC + EventMeshMessage +# Optional extras: +# eventmesh = { version = "1.9", features = ["grpc", "cloud_events", "tls"] } ``` -### Send message +### Publish a message ```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 - ); +use eventmesh::{ + config::GrpcClientConfig, grpc::GrpcProducer, model::EventMeshMessage, transport::Publisher, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let config = GrpcClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10205) + .env("env").idc("idc").sys("sys") + .username("eventmesh").password("eventmesh") // required by the server + .producer_group("test-producerGroup") + .build(); + + let producer = GrpcProducer::connect(config)?; + + let msg = EventMeshMessage::builder() + .topic("test-topic-rust-sdk") + .content("hello from rust") + .build(); + + let resp = producer.publish(msg).await?; + println!("published: {resp}"); Ok(()) } ``` -### Subscribe message - -```rust -use std::time::Duration; +`producer.publish_batch(vec![...])` sends many messages in one RPC, and +`producer.request_reply(msg, timeout)` performs a synchronous request/reply +(returns the consumer's reply message). -use tracing::info; +### Subscribe (stream) -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}; +```rust +use std::sync::atomic::{AtomicU64, Ordering}; +use eventmesh::{ + config::GrpcClientConfig, grpc::GrpcStreamConsumer, model::{EventMeshMessage, + SubscriptionItem, SubscriptionMode, SubscriptionType}, + MessageListener, +}; -struct EventMeshListener; +struct MyListener { n: AtomicU64 } -impl ReceiveMessageListener for EventMeshListener { +impl MessageListener for MyListener { type Message = EventMeshMessage; - - fn handle(&self, msg: Self::Message) -> eventmesh::Result> { - info!("Receive message from eventmesh================{:?}", msg); - Ok(None) + async fn handle(&self, msg: Self::Message) -> Option { + println!("[{}] topic={:?} content={:?}", self.n.fetch_add(1, Ordering::Relaxed), msg.topic, msg.content); + None // None = ack only; Some(msg) = reply (for request/reply topics) } } -#[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); +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let config = GrpcClientConfig::builder() + .server_addr("127.0.0.1").server_port(10205) + .env("env").idc("idc").sys("sys") + .username("eventmesh").password("eventmesh") + .consumer_group("test-consumerGroup") + .build(); + + let consumer = GrpcStreamConsumer::subscribe_stream( + config, + MyListener { n: AtomicU64::new(0) }, + vec![SubscriptionItem::new( + "test-topic-rust-sdk", SubscriptionMode::CLUSTERING, SubscriptionType::ASYNC, + )], + Some(async { tokio::signal::ctrl_c().await.ok(); }), + ).await?; + + // subscribe/unsubscribe can be called at any time on the open stream + // consumer.subscribe(more_items).await?; + // consumer.unsubscribe(items).await?; + + // blocks until Ctrl-C or the stream closes + consumer.wait_for_shutdown().await; Ok(()) } - ``` -## Development Guide +A webhook subscription (`GrpcWebhookConsumer::new` / `subscribe_webhook(items, url)`) +is also available — the server POSTs delivered events to the given URL. -### Dependencies +## Features -In order to build `tonic` >= 0.8.0, you need the `protoc` Protocol Buffers compiler, along with Protocol Buffers resource files. +| Feature | Description | +|---|---| +| `grpc` (default) | gRPC transport (producer, consumer, heartbeat) | +| `cloud_events` | Native `cloudevents::Event` interop | +| `tls` | TLS for the gRPC channel | +| `full` | `grpc` + `cloud_events` + `tls` | -#### Ubuntu +## Running the examples + +The examples assume a standalone EventMesh is running on `127.0.0.1`: ```bash -sudo apt update && sudo apt upgrade -y -sudo apt install -y protobuf-compiler libprotobuf-dev +# from this crate's directory (docker-compose.yml ships with the SDK) +docker compose --profile standalone up -d ``` -#### Alpine Linux +> **Standalone-broker note:** the in-memory broker requires a topic to be +> created **and** a consumer subscribed before a producer can publish. Create +> the topic once, then start the consumer before the producer: +> +> ```bash +> # create the topic via the admin API (port 10106) +> curl -X POST http://127.0.0.1:10106/topic -H 'Content-Type: application/json' -d '{"name":"test-topic-rust-sdk"}' +> +> # terminal 1 — receive +> cargo run --features grpc --example grpc_consumer +> # terminal 2 — send +> cargo run --features grpc --example grpc_producer +> ``` +> +> (With the RocketMQ backend, topics are auto-created and this dance is not +> needed.) + +## Development -```sh -sudo apk add protoc protobuf-dev +```bash +cargo fmt +cargo clippy --features full --all-targets -- -D warnings +cargo test --features full ``` -#### macOS +## End-to-end tests -Assuming [Homebrew](https://brew.sh/) is already installed. (If not, see instructions for installing Homebrew on [the Homebrew website](https://brew.sh/).) +The `e2e` test suite (`tests/e2e/`) exercises the full gRPC producer/consumer +against a live EventMesh runtime. It is gated behind the `e2e` feature so a +plain `cargo test` never touches Docker. -```zsh -brew install protobuf -``` +```bash +# Auto-start the standalone stack via docker compose, run the suite, then stop it: +cargo test --features e2e -#### Windows +# ...or run against a server you already started yourself: +EVENTMESH_E2E_EXTERNAL=1 cargo test --features e2e +``` -- 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` +When neither Docker nor a reachable server is found, every test skips itself +rather than failing. Tests run in parallel by default; each one uses a unique +topic and consumer group so they never collide on the shared broker. -### Build +> **Standalone limitations:** the in-memory broker requires a topic to be +> created *and* a consumer subscribed before publishing (the harness does this +> automatically), and it does **not** implement synchronous request/reply. The +> request/reply test detects this and skips the assertion on standalone; switch +> to the RocketMQ profile (`docker compose --profile rocketmq up -d`) to exercise +> it fully. -```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..b17172f19a 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/build.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/build.rs @@ -1,30 +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. - */ +// 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"], )?; + + println!("cargo:rerun-if-changed=proto/eventmesh-service.proto"); + println!("cargo:rerun-if-changed=proto/eventmesh-cloudevents.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..9d4b7b5c0c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer.rs @@ -0,0 +1,88 @@ +// 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. + +//! Stream subscription: receive delivered messages via a listener. +//! +//! Assumes `docker compose --profile standalone up` is running (gRPC on +//! `127.0.0.1:10205`). Run the producer example in another terminal to feed +//! this consumer. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use eventmesh::{ + config::GrpcClientConfig, + grpc::GrpcStreamConsumer, + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + MessageListener, +}; + +struct PrintingListener { + count: AtomicU64, +} + +impl MessageListener for PrintingListener { + type Message = EventMeshMessage; + + async fn handle(&self, message: Self::Message) -> Option { + let n = self.count.fetch_add(1, Ordering::Relaxed) + 1; + println!( + "[received #{n}] topic={:?} content={:?}", + message.topic, message.content + ); + None // async ack, no reply + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config = GrpcClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10205) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .consumer_group("test-consumerGroup") + .build(); + + let listener = PrintingListener { + count: AtomicU64::new(0), + }; + + let items = vec![SubscriptionItem::new( + "test-topic-rust-sdk", + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]; + println!("subscribed; waiting for messages (Ctrl-C to stop)..."); + let consumer = GrpcStreamConsumer::subscribe_stream( + config, + listener, + items, + Some(async { + tokio::signal::ctrl_c().await.ok(); + }), + ) + .await?; + consumer.wait_for_shutdown().await; + Ok(()) +} 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..dc2ca257fd --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer.rs @@ -0,0 +1,83 @@ +// 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. + +//! Publish + batch publish + request-reply against a running EventMesh server. +//! +//! Assumes `docker compose --profile standalone up` is running (gRPC on +//! `127.0.0.1:10205`). + +use std::time::Duration; + +use eventmesh::{ + config::GrpcClientConfig, grpc::GrpcProducer, model::EventMeshMessage, transport::Publisher, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config = GrpcClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10205) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group("test-producerGroup") + .build(); + + let producer = GrpcProducer::connect(config)?; + + let topic = "test-topic-rust-sdk"; + + // 1) single publish + let msg = EventMeshMessage::builder() + .topic(topic) + .content("hello from rust sdk") + .build(); + let resp = producer.publish(msg).await?; + println!("[publish] {resp}"); + + // 2) batch publish + let batch: Vec = (0..3) + .map(|i| { + EventMeshMessage::builder() + .topic(topic) + .content(format!("batch message #{i}")) + .build() + }) + .collect(); + let resp = producer.publish_batch(batch).await?; + println!("[batch] {resp}"); + + // 3) request-reply (needs a SYNC consumer subscribed to the topic; will + // time out otherwise) + let rr = EventMeshMessage::builder() + .topic(format!("{topic}-rr")) + .content("ping") + .ttl_millis(4000) + .build(); + match producer.request_reply(rr, Duration::from_secs(6)).await { + Ok(reply) => println!("[request-reply] got reply: {reply}"), + Err(e) => println!("[request-reply] no reply (is a SYNC consumer running?): {e}"), + } + + 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..dc82a7cbb9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_cloud_events.rs @@ -0,0 +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. + +//! Publish native CloudEvents (`cloudevents::Event`) via the gRPC transport. +//! +//! Requires features `grpc` + `cloud_events`. Assumes a standalone EventMesh +//! server is reachable on `127.0.0.1:10205`. + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{config::GrpcClientConfig, grpc::GrpcProducer}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config = GrpcClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10205) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group("test-producerGroup") + .build(); + + let producer = GrpcProducer::connect(config)?; + + let event = EventBuilderV10::new() + .id("1") + .source("https://eventmesh.apache.org/rust-sdk/demo") + .ty("com.example.ping") + .subject("test-topic-rust-sdk") + .data( + "application/json", + serde_json::json!({"msg": "cloudevents hello"}), + ) + .build() + .map_err(|e| eventmesh::EventMeshError::Other(format!("cloudevents build: {e}")))?; + + let resp = producer.publish_cloud_event(event).await?; + println!("[cloudevents publish] {resp}"); + 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..7f3dd72ec0 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_custom.rs @@ -0,0 +1,132 @@ +// 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 webhook consumer with a **user-written handler**. +//! +//! Unlike `consumer_server` (which uses the built-in `WebhookServer`), this +//! example hosts the webhook endpoint on the user's own axum app and decodes +//! pushes with the SDK's framework-agnostic codec helpers: +//! +//! - [`eventmesh::http::codec::parse_push_body`] — parse the form-urlencoded +//! push body sent by the EventMesh runtime. +//! - [`eventmesh::http::codec::PushMessageRequestBody::to_event_mesh_message`] +//! — decode it into an `EventMeshMessage`. +//! - [`eventmesh::http::codec::WebhookReply`] — the JSON acknowledgment +//! (`{"retCode": 0}`) the runtime expects. +//! +//! The same approach works with any framework (actix, hyper, rocket, …) or +//! even a non-Rust server: just POST-decode the form body and reply with the +//! JSON. The SDK does not impose a handler type on you — notice this example +//! doesn't even touch `MessageListener`. +//! +//! Assumes `docker compose --profile standalone up` is running (HTTP on +//! `127.0.0.1:10105`). Run the HTTP producer example in another terminal. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use axum::extract::State; +use axum::response::IntoResponse; +use axum::{routing::post, Json, Router}; +use bytes::Bytes; +use eventmesh::http::codec::{parse_push_body, WebhookReply}; +use eventmesh::{ + config::HttpClientConfig, + http::HttpConsumer, + model::{SubscriptionItem, SubscriptionMode, SubscriptionType}, +}; + +/// The user's own webhook handler. It uses only the public codec helpers — +/// no SDK handler/state type or `MessageListener` trait involved. The state +/// here is just a plain counter shared across requests. +async fn webhook(State(count): State>, body: Bytes) -> impl IntoResponse { + // The runtime sends `application/x-www-form-urlencoded`. + let text = match std::str::from_utf8(&body) { + Ok(s) => s, + Err(_) => return Json(WebhookReply::retry("invalid UTF-8")), + }; + + // Decode the push body into an EventMeshMessage and handle it inline. + match parse_push_body(text).and_then(|p| p.to_event_mesh_message()) { + Ok(msg) => { + let n = count.fetch_add(1, Ordering::Relaxed) + 1; + println!( + "[received #{n}] topic={:?} content={:?}", + msg.topic, msg.content + ); + // `retCode: 0` tells the runtime the delivery succeeded. + Json(WebhookReply::ok()) + } + Err(e) => { + eprintln!("[webhook] decode error: {e}"); + // A non-zero retCode asks the runtime to retry delivery. + Json(WebhookReply::retry("decode error")) + } + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + // The handler state is whatever your app needs — here just a message + // counter. No SDK type is required. + let state = Arc::new(AtomicU64::new(0)); + + // Build the user's own axum app — the route + handler are entirely + // user-owned. The SDK contributes only the codec helpers above. + let app = Router::new() + .route("/my-eventmesh/callback", post(webhook)) + .with_state(state); + + let webhook_url = "http://127.0.0.1:8080/my-eventmesh/callback"; + + // Register the webhook URL with the EventMesh runtime. + let config = HttpClientConfig::builder() + .servers("127.0.0.1:10105") + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .consumer_group("test-consumerGroup-http-custom") + .build()?; + + let consumer = HttpConsumer::new(config, None::>)?; + let items = vec![SubscriptionItem::new( + "test-topic-rust-http", + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]; + consumer.subscribe_webhook(items, webhook_url).await?; + println!("subscribed; serving webhook at {webhook_url} (Ctrl-C to stop)..."); + + let tcp = tokio::net::TcpListener::bind("0.0.0.0:8080") + .await + .map_err(eventmesh::EventMeshError::Io)?; + axum::serve(tcp, app) + .with_graceful_shutdown(async { + tokio::signal::ctrl_c().await.ok(); + }) + .await + .map_err(|e| eventmesh::EventMeshError::Other(format!("server error: {e}")))?; + + consumer.shutdown().await; + Ok(()) +} 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..aaf1e54d27 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_server.rs @@ -0,0 +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. + +//! HTTP webhook consumer using the built-in [`WebhookServer`]. +//! +//! The SDK starts an axum server to receive pushed messages from the +//! EventMesh runtime. This is the "batteries-included" mode. +//! +//! Assumes `docker compose --profile standalone up` is running (HTTP on +//! `127.0.0.1:10105`). Run the HTTP producer example in another terminal. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use eventmesh::{ + config::HttpClientConfig, + http::{HttpConsumer, WebhookServer}, + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + MessageListener, +}; + +struct PrintingListener { + count: AtomicU64, +} + +impl MessageListener for PrintingListener { + type Message = EventMeshMessage; + + async fn handle(&self, message: Self::Message) -> Option { + let n = self.count.fetch_add(1, Ordering::Relaxed) + 1; + println!( + "[received #{n}] topic={:?} content={:?}", + message.topic, message.content + ); + None + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let listener = Arc::new(PrintingListener { + count: AtomicU64::new(0), + }); + + // Start the built-in webhook server on port 9090. + // Bind to 0.0.0.0 so Docker-hosted runtimes can reach us, but advertise + // 127.0.0.1 since that's where the standalone runtime (on the same host) + // can POST back to. + let addr: SocketAddr = "0.0.0.0:9090".parse().expect("valid addr"); + let server = WebhookServer::new(addr, listener.clone()) + .with_advertise_url("http://127.0.0.1:9090/eventmesh/callback"); + + // Register the webhook URL with the EventMesh runtime. + let config = HttpClientConfig::builder() + .servers("127.0.0.1:10105") + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .consumer_group("test-consumerGroup-http") + .build()?; + + let consumer = HttpConsumer::new(config, None::>)?; + + let items = vec![SubscriptionItem::new( + "test-topic-rust-http", + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]; + let webhook_url = server.url(); + println!("webhook URL: {webhook_url}"); + consumer.subscribe_webhook(items, webhook_url).await?; + println!("subscribed; waiting for messages (Ctrl-C to stop)..."); + + // Run the server until Ctrl-C. + server + .with_graceful_shutdown(async { + tokio::signal::ctrl_c().await.ok(); + }) + .await?; + + consumer.shutdown().await; + Ok(()) +} 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..435ca4b9b2 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer.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. + +//! HTTP publish + request-reply against a running EventMesh server. +//! +//! Assumes `docker compose --profile standalone up` is running (HTTP on +//! `127.0.0.1:10105`). +//! +//! Batch publish is not yet supported over HTTP — see `publish_batch` docs. + +use std::time::Duration; + +use eventmesh::{ + config::HttpClientConfig, http::HttpProducer, model::EventMeshMessage, transport::Publisher, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config = HttpClientConfig::builder() + .servers("127.0.0.1:10105") + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group("test-producerGroup") + .build()?; + + let producer = HttpProducer::new(config)?; + + let topic = "test-topic-rust-http"; + + // 1) single publish + let msg = EventMeshMessage::builder() + .topic(topic) + .content("hello from rust http sdk") + .build(); + let resp = producer.publish(msg).await?; + println!("[publish] {resp}"); + + // 2) multiple single publishes (HTTP batch is not yet supported) + for i in 0..3 { + let msg = EventMeshMessage::builder() + .topic(topic) + .content(format!("message #{i}")) + .build(); + let resp = producer.publish(msg).await?; + println!("[publish-{i}] {resp}"); + } + + // 3) request-reply (needs a SYNC consumer subscribed to the topic) + let rr = EventMeshMessage::builder() + .topic(format!("{topic}-rr")) + .content("ping") + .ttl_millis(4000) + .build(); + match producer.request_reply(rr, Duration::from_secs(6)).await { + Ok(reply) => println!("[request-reply] got reply: {reply}"), + Err(e) => println!("[request-reply] no reply (is a SYNC consumer running?): {e}"), + } + + 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..d5912eb094 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/consumer.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. + +//! TCP subscription: receive delivered messages via a listener. +//! +//! Assumes `docker compose --profile standalone up` is running (TCP on +//! `127.0.0.1:10000`). Run the TCP producer example in another terminal to +//! feed this consumer. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use eventmesh::{ + config::TcpClientConfig, + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + tcp::TcpConsumer, + MessageListener, +}; + +struct PrintingListener { + count: AtomicU64, +} + +impl MessageListener for PrintingListener { + type Message = EventMeshMessage; + + async fn handle(&self, message: Self::Message) -> Option { + let n = self.count.fetch_add(1, Ordering::Relaxed) + 1; + println!( + "[received #{n}] topic={:?} content={:?}", + message.topic, message.content + ); + None + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10000) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .consumer_group("test-consumerGroup") + .build(); + + let consumer = TcpConsumer::connect( + config, + PrintingListener { + count: AtomicU64::new(0), + }, + Some(async { + tokio::signal::ctrl_c().await.ok(); + }), + ) + .await?; + + consumer + .subscribe(&[SubscriptionItem::new( + "test-topic-rust-tcp", + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]) + .await?; + + println!("listening (Ctrl-C to stop)..."); + consumer.wait_for_shutdown().await; + Ok(()) +} 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..33cc02f636 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer.rs @@ -0,0 +1,79 @@ +// 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 publish + request-reply against a running EventMesh server. +//! +//! Assumes `docker compose --profile standalone up` is running (TCP on +//! `127.0.0.1:10000`). + +use std::time::Duration; + +use eventmesh::{ + config::TcpClientConfig, model::EventMeshMessage, tcp::TcpProducer, transport::Publisher, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10000) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group("test-producerGroup") + .build(); + + let producer = TcpProducer::connect(config).await?; + + let topic = "test-topic-rust-tcp"; + + // 1) single publish + let msg = EventMeshMessage::builder() + .topic(topic) + .content("hello from rust tcp sdk") + .build(); + let resp = producer.publish(msg).await?; + println!("[publish] {resp}"); + + // 2) broadcast (fire-and-forget) + let msg = EventMeshMessage::builder() + .topic(topic) + .content("broadcast from rust tcp sdk") + .build(); + producer.broadcast(msg).await?; + println!("[broadcast] sent"); + + // 3) request-reply (needs a SYNC consumer subscribed to the topic) + let rr = EventMeshMessage::builder() + .topic(format!("{topic}-rr")) + .content("ping") + .ttl_millis(4000) + .build(); + match producer.request_reply(rr, Duration::from_secs(6)).await { + Ok(reply) => println!("[request-reply] got reply: {reply}"), + Err(e) => println!("[request-reply] no reply (is a SYNC consumer running?): {e}"), + } + + producer.shutdown().await; + 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..771f8a19d5 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer_cloud_events.rs @@ -0,0 +1,107 @@ +// 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 CloudEvents producer — publish a native CloudEvent over TCP. +//! +//! Requires `docker compose --profile standalone up` running (TCP on +//! `127.0.0.1:10000`). The existing `tcp_consumer` example receives +//! CloudEvents transparently (they are converted to `EventMeshMessage` at the +//! boundary). + +use std::time::Duration; + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{config::TcpClientConfig, tcp::TcpProducer}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let config = TcpClientConfig::builder() + .server_addr("127.0.0.1") + .server_port(10000) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group("test-producerGroup") + .build(); + + let producer = TcpProducer::connect(config).await?; + + let topic = "test-topic-rust-tcp-cloudevents"; + + // 1) publish a CloudEvent + let event = EventBuilderV10::new() + .id("rust-tcp-ce-1") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.someevent") + .subject(topic) + // NOTE: datacontenttype must be "application/cloudevents+json" for TCP + // CloudEvents — the server uses it to resolve the serializer. + .data( + "application/cloudevents+json", + serde_json::json!({"msg": "hello from rust tcp cloudevents"}), + ) + .build() + .expect("valid CloudEvent"); + + match producer.publish_cloud_event(event).await { + Ok(resp) => println!("[publish] {resp}"), + Err(e) => println!("[publish] error: {e}"), + } + + // 2) broadcast a CloudEvent (fire-and-forget) + let event = EventBuilderV10::new() + .id("rust-tcp-ce-broadcast") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.someevent") + .subject(topic) + .data( + "application/cloudevents+json", + "broadcast from rust tcp cloudevents", + ) + .build() + .expect("valid CloudEvent"); + + producer.broadcast_cloud_event(event).await?; + println!("[broadcast] sent"); + + // 3) request-reply with a CloudEvent (needs a SYNC consumer) + let event = EventBuilderV10::new() + .id("rust-tcp-ce-rr") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.someevent") + .subject(format!("{topic}-rr")) + .data("application/cloudevents+json", "ping") + .build() + .expect("valid CloudEvent"); + + match producer + .request_reply_cloud_event(event, Duration::from_secs(6)) + .await + { + Ok(reply) => println!("[request-reply] got reply: {reply}"), + Err(e) => println!("[request-reply] no reply (is a SYNC consumer running?): {e}"), + } + + producer.shutdown().await; + Ok(()) +} 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/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..e4b8451154 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/loadbalance.rs @@ -0,0 +1,210 @@ +// 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 expanded into a weighted list for O(1) random pick. + expanded: Vec, + }, + WeightRoundRobin { + nodes: Vec, + /// Current weighted round-robin counters. + 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 mut expanded = Vec::new(); + for n in &nodes { + for _ in 0..n.weight.max(1) { + expanded.push(n.clone()); + } + } + Self::WeightRandom { expanded } + } + LoadBalance::WeightRoundRobin => { + let counters = Mutex::new(vec![0; nodes.len()]); + Self::WeightRoundRobin { nodes, 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 { expanded } => { + let idx = rand::thread_rng().gen_range(0..expanded.len()); + &expanded[idx] + } + Self::WeightRoundRobin { nodes, counters } => { + // Smooth weighted round-robin (nginx-style). + let mut guard = counters.lock().expect("counter lock poisoned"); + let total: i32 = nodes.iter().map(|n| n.weight).sum(); + let mut best = 0usize; + for (i, n) in nodes.iter().enumerate() { + guard[i] += n.weight; + if guard[i] > guard[best] { + best = i; + } + } + guard[best] -= total; + &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}"); + } +} 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/grpc.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc.rs new file mode 100644 index 0000000000..d36bf93578 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/grpc.rs @@ -0,0 +1,204 @@ +// 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); + +/// 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, +} + +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(), + } + } +} + +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(Debug, Clone, Default)] +pub struct GrpcClientConfigBuilder { + server_addr: Option, + server_port: Option, + use_tls: Option, + tls_config: 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 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 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, + 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, + } + } +} 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..0792c3daba --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/http.rs @@ -0,0 +1,251 @@ +// 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(Debug, 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, + }) + } +} 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..0adc3af3b3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/identity.rs @@ -0,0 +1,71 @@ +// 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(Debug, 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() + } +} 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..5f113cd8b2 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/mod.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. + +//! Client configuration. + +pub mod grpc; +pub mod identity; +pub mod tls; + +pub use grpc::GrpcClientConfig; +pub use identity::ClientIdentity; +pub use tls::{TlsClientIdentity, TlsConfig, TlsConfigBuilder}; + +#[cfg(feature = "http")] +pub mod http; + +#[cfg(feature = "http")] +pub use http::HttpClientConfig; + +#[cfg(feature = "tcp")] +pub mod tcp; + +#[cfg(feature = "tcp")] +pub use tcp::{ReconnectConfig, ReconnectConfigBuilder, 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..02c47eb49d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/tcp.rs @@ -0,0 +1,305 @@ +// 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(Debug, 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, + } + } +} 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..a236d70c46 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/tls.rs @@ -0,0 +1,213 @@ +// 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`, +/// tonic uses its built-in defaults (system trust store, endpoint authority +/// as SNI domain). +/// +/// # Examples +/// +/// ```no_run +/// 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(Debug, 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(Debug, 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(Debug, 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, + } + } +} + +#[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()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs index 3315a0ae4f..66acc5b8d9 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs @@ -1,58 +1,102 @@ -/* - * 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)] +// 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. +/// +/// Intentionally `pub` (the previous SDK hid this as `pub(crate)` and erased +/// it to `anyhow`), so callers can `match` on concrete variants. +#[derive(Debug, thiserror::Error)] pub enum EventMeshError { - /// Invalid arguments - InvalidArgs(String), + /// A client configuration problem (missing field, bad URL, ...). + #[error("config error: {0}")] + Config(String), - /// gRpc status + /// 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: {0}")] + Grpc(Box), + + /// A gRPC transport layer (channel/connect) error. + #[cfg(feature = "grpc")] + #[error("grpc transport error: {0}")] + GrpcTransport(Box), + + /// 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), - EventMeshLocal(String), + /// A message failed validation before it was sent on the wire. + #[error("invalid message: {0}")] + InvalidMessage(String), - EventMeshRemote(String), + /// An operation did not complete within its timeout. + #[error("operation timed out after {0:?}")] + Timeout(Duration), - 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), + + /// Anything else, with a free-form message. + #[error("{0}")] + Other(String), +} + +/// Convenience `Result` alias used throughout the SDK. +pub type Result = std::result::Result; + +#[cfg(feature = "grpc")] +impl From for EventMeshError { + fn from(status: tonic::Status) -> Self { + Self::Grpc(Box::new(status)) + } } -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) - } - } +#[cfg(feature = "grpc")] +impl From for EventMeshError { + fn from(err: tonic::transport::Error) -> Self { + Self::GrpcTransport(Box::new(err)) } } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs deleted file mode 100644 index 30046f8c5a..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs +++ /dev/null @@ -1,34 +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. - */ - -//! 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; 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/lib.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs index a03f8cfa2d..c8f7bfeef0 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs @@ -1,323 +1,113 @@ -/* - * 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. //! +//! A client library for the [Apache EventMesh](https://eventmesh.apache.org) +//! serverless event-driven middleware. The SDK speaks the EventMesh wire +//! protocols and normalizes everything onto a simple [`EventMeshMessage`] +//! model (with optional native CloudEvents interop behind the `cloud_events` +//! feature). //! +//! # Transports +//! +//! The SDK ships three transports, each gated by its own feature flag: +//! +//! - **gRPC** (default, `grpc` feature) — [`grpc::GrpcProducer`] for +//! publish / batch / request-reply and [`grpc::GrpcStreamConsumer`] / +//! [`grpc::GrpcWebhookConsumer`] for stream and webhook subscription. +//! - **HTTP** (`http` feature) — [`http::HttpProducer`] for publish / +//! request-reply and [`http::HttpConsumer`] for subscribe / heartbeat; +//! receive pushes via the built-in [`http::WebhookServer`] or your own +//! endpoint built on the [`http::codec`] helpers. +//! - **TCP** (`tcp` feature) — [`tcp::TcpProducer`] for publish / broadcast / +//! request-reply and [`tcp::TcpConsumer`] for subscribe + receive loop, +//! over the native binary wire protocol with auto-reconnect. +//! +//! # Quick example (gRPC producer) +//! +//! Requires the `grpc` feature. The example is compiled by rustdoc only when +//! `grpc` is enabled; on HTTP-only builds it is marked `ignore` so that +//! `cargo test --no-default-features --features http` does not try to compile +//! the `eventmesh::grpc` re-export. +//! +#![cfg_attr(feature = "grpc", doc = "```no_run")] +#![cfg_attr(not(feature = "grpc"), doc = "```ignore")] +//! use eventmesh::{ +//! config::GrpcClientConfig, grpc::GrpcProducer, model::EventMeshMessage, +//! transport::Publisher, +//! }; +//! +//! #[tokio::main] +//! async fn main() -> eventmesh::Result<()> { +//! let config = GrpcClientConfig::builder() +//! .server_addr("127.0.0.1") +//! .server_port(10205) +//! .env("env").idc("idc").sys("sys") +//! .producer_group("test-producerGroup") +//! .build(); +//! let mut producer = GrpcProducer::connect(config)?; +//! let msg = EventMeshMessage::builder() +//! .topic("test-topic") +//! .content("hello from rust") +//! .build(); +//! let resp = producer.publish(msg).await?; +//! println!("published: {resp:?}"); +//! Ok(()) +//! } +//! ``` + +#![deny(unsafe_code)] -/// 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. -pub mod config; - -/// Errors. -pub(crate) mod error; - -/// Network utils. -mod net; - -/// Common utilities. pub mod common; - -/// gRPC client implementations. -pub mod grpc; - -/// Logging. -pub mod log; - -/// Data models. +pub mod config; +pub mod error; 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 - } +#[cfg(feature = "grpc")] +pub mod proto_gen; - /// Set protocol version attribute. - pub fn with_protocol_version(mut self, protocol_version: impl Into) -> Self { - self.protocol_version = protocol_version.into(); - self - } +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +pub mod transport; - /// Set TTL attribute. - pub fn with_ttl(mut self, ttl: impl Into) -> Self { - self.ttl = ttl.into(); - self - } +/// gRPC transport re-exported at the crate root (`eventmesh::grpc`). +#[cfg(feature = "grpc")] +pub use transport::grpc; - /// Set subject attribute. - pub fn with_subject(mut self, subject: impl Into) -> Self { - self.subject = subject.into(); - self - } +/// HTTP transport re-exported at the crate root (`eventmesh::http`). +#[cfg(feature = "http")] +pub use transport::http; - /// Set producer group attribute. - pub fn with_producergroup(mut self, producergroup: impl Into) -> Self { - self.producergroup = producergroup.into(); - self - } +/// TCP transport re-exported at the crate root (`eventmesh::tcp`). +#[cfg(feature = "tcp")] +pub use transport::tcp; - /// Set unique ID attribute. - pub fn with_uniqueid(mut self, uniqueid: impl Into) -> Self { - self.uniqueid = uniqueid.into(); - self - } +pub use error::{EventMeshError, Result}; - /// 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 - } +use std::future::Future; - /// 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 - } +/// 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), or `None` for plain async consumption. +pub trait MessageListener: Send + Sync + 'static { + /// The message type this listener accepts. + type Message: Send; - /// 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 - } - } + /// 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/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..dfec346226 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,95 @@ 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 { - 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, - } + /// Start a builder. Equivalent to [`EventMeshMessageBuilder::default`]. + pub fn builder() -> EventMeshMessageBuilder { + EventMeshMessageBuilder::default() } - pub fn add_prop(mut self, key: String, val: String) -> Self { - self.prop.insert(key, val); + /// 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); - self - } +/// 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, +} - pub fn with_biz_seq_no(mut self, biz_seq_no: impl Into) -> Self { - self.biz_seq_no = Some(biz_seq_no.into()); +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 +159,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..178799a38e --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/mod.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. + +//! Message, subscription and response types. + +pub mod message; +pub mod response; +pub mod subscription; + +pub use message::{EventMeshMessage, EventMeshMessageBuilder}; +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 (not implemented by this SDK). + #[allow(dead_code)] + 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/response.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs index 6e269623cb..468f47a186 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs @@ -1,63 +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. - */ +// 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}; +//! Server response type. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[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, +/// 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 reported success (code == 0). + pub fn is_success(&self) -> bool { + self.code.unwrap_or(0) == 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 + ) } } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs index a31cd3b90d..a7d8eb7ad1 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs @@ -1,277 +1,157 @@ -/* - * 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)] 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)] 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..92f14a2671 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/proto_gen.rs @@ -0,0 +1,61 @@ +// 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"); +} + +// ---- 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/transport/grpc/client.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/client.rs new file mode 100644 index 0000000000..d1e39e211d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/client.rs @@ -0,0 +1,199 @@ +// 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 { + 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. + // Gated behind the `tls` cargo feature; without it, `use_tls=true` + // still produces an `https://` URI but tonic falls back to its + // default TLS settings. + #[cfg(feature = "tls")] + let endpoint = if config.use_tls { + Self::apply_tls(endpoint, config)? + } else { + endpoint + }; + + let channel = endpoint.connect_lazy(); + Ok(Self::from_channel(channel)) + } + + /// Configure the tonic [`Endpoint`] with [`tonic::transport::ClientTlsConfig`] + /// derived from [`GrpcClientConfig`]. + /// + /// When `tls_config` is `None`, only the SNI domain is set (to + /// `server_addr`); tonic uses its built-in trust store. 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())); + } + } + + 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. + 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(); + let response = stream_client + .subscribe_stream(Request::new(ReceiverStream::new(rx))) + .await?; + 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()) + } +} 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..e2605eafc3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/codec.rs @@ -0,0 +1,558 @@ +// 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_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) => Some(PbData::ProtoData(PbAny { + type_url: String::new(), + value: content.as_bytes().to_vec(), + })), + Some(content) => Some(PbData::BinaryData(content.as_bytes().to_vec())), + None => None, + }; + + Ok(base_event(attrs, data)) +} + +/// 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)) => String::from_utf8_lossy(&any.value).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)); + + 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(event.id().to_string()), + ); + 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), + ); + } + for (k, v) in event.iter_extensions() { + attrs + .entry(k.to_string()) + .or_insert_with(|| attr_str(v.to_string())); + } + + 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: RandomStringUtils::generate_uuid(), + 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); + let unique_id = get_unique_id(&cloud_event); + let content = get_text_data(&cloud_event); + let source = if cloud_event.source.is_empty() { + DEFAULT_SOURCE.to_string() + } else { + cloud_event.source.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 mut builder = EventBuilderV10::new() + .id(unique_id) + .source(source) + .ty(ProtocolKey::CLOUD_EVENTS_PROTOCOL_NAME) + .data(content_type, content); + if !topic.is_empty() { + builder = builder.subject(topic); + } + for (k, v) in cloud_event.attributes { + // Skip response / envelope fields that don't belong as extensions. + // Use the canonical ProtocolKey spellings so the skip actually matches + // the keys written by the server ("statuscode"/"responsemessage"/"time"). + if matches!( + k.as_str(), + ProtocolKey::GRPC_RESPONSE_CODE + | ProtocolKey::GRPC_RESPONSE_MESSAGE + | ProtocolKey::GRPC_RESPONSE_TIME + | "datacontenttype" + ) { + continue; + } + builder = builder.extension(k.as_str(), attr_as_str(&v)); + } + builder + .build() + .map_err(|e| EventMeshError::Other(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")); + } +} 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..1459bbc567 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/consumer.rs @@ -0,0 +1,618 @@ +// 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`], and +//! [`wait_for_shutdown`]. +//! +//! [`subscribe_webhook`]: GrpcStreamConsumer::subscribe_webhook +//! [`unsubscribe`]: GrpcStreamConsumer::unsubscribe + +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 tonic::codegen::tokio_stream::StreamExt; +use tracing::{debug, error, warn}; + +use crate::common::constants::SDK_STREAM_URL; +use crate::error::{EventMeshError, Result}; +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, +} + +// --------------------------------------------------------------------------- +// 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). +/// +/// 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 +/// +/// ```no_run +/// # 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> { + 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 { + /// 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. + 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(), + 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. + 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, + )?; + let guard = self.stream_tx.lock().await; + match guard.as_ref() { + Some(tx) => { + tx.send(event) + .await + .map_err(|e| EventMeshError::ChannelClosed(format!("subscribe: {e}")))?; + let mut sub_guard = self.subscriptions.lock().await; + for item in &items { + sub_guard.insert( + item.topic.clone(), + SubscriptionEntry { + item: item.clone(), + url: SDK_STREAM_URL.to_string(), + }, + ); + } + Ok(()) + } + 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 from topics (independent unary RPC, not sent over the + /// stream). + pub async fn unsubscribe(&self, items: Vec) -> Result { + unsubscribe_rpc(&self.client, &self.config, &self.subscriptions, items).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) { + self.shutdown.cancelled().await; + await_task(&self.driver_handle).await; + await_task(&self.heartbeat_handle).await; + } +} + +impl> Drop for GrpcStreamConsumer { + 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 +/// +/// ```no_run +/// # 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 from topics. + pub async fn unsubscribe(&self, items: Vec) -> Result { + unsubscribe_rpc(&self.client, &self.config, &self.subscriptions, items).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(), + SubscriptionEntry { + item, + url: url.clone(), + }, + ); + } + } + Ok(response) +} + +async fn unsubscribe_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(), + )); + } + 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); + } + } + Ok(response) +} + +// --------------------------------------------------------------------------- +// Stream receive-loop driver (spawned, not public) +// --------------------------------------------------------------------------- + +/// Spawn the stream receive loop as a background task. +/// +/// Dispatches delivered messages to the listener and sends back replies until +/// the server closes the stream or the shutdown token fires. On exit, clears +/// the shared `stream_tx` so the heartbeat loop knows the stream is gone. +fn spawn_stream_driver( + mut stream: tonic::Streaming, + reply_tx: Arc>, + listener: Arc>, + config: crate::config::GrpcClientConfig, + stream_tx: StreamTx, + shutdown: CancellationToken, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + msg = stream.next() => match msg { + None => { + debug!("subscribe stream ended"); + break; + } + Some(Err(status)) => { + warn!("stream receive error: {status}"); + continue; + } + Some(Ok(cloud_event)) => { + let eventmesh_msg = codec::to_event_mesh_message(&cloud_event); + if eventmesh_msg.biz_seq_no.is_none() { + debug!("skipping control frame (no seqnum)"); + continue; + } + debug!("delivered topic={:?}", eventmesh_msg.topic); + match listener.handle(eventmesh_msg).await { + Some(reply) => match build_reply(reply, &cloud_event, &config) { + Ok(reply_event) => { + if reply_tx.send(reply_event).await.is_err() { + warn!("reply channel closed; stopping receive loop"); + break; + } + } + Err(e) => error!("failed to encode reply: {e}"), + }, + None => { /* async ack: nothing to send back */ } + } + } + }, + _ = shutdown.cancelled() => { + debug!("subscribe stream shutting down"); + break; + } + } + } + + *stream_tx.lock().await = None; + 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: EventMeshMessage, + request: &crate::proto_gen::PbCloudEvent, + config: &crate::config::GrpcClientConfig, +) -> Result { + let mut event = codec::from_event_mesh_message(&reply, 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) +} 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..1f754da30f --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/heartbeat.rs @@ -0,0 +1,181 @@ +// 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::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>>>; + +/// 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(|(t, e)| (t.clone(), e.url.clone())) + .collect(); + if items.is_empty() { + debug!("heartbeat tick: no subscriptions yet"); + } else if let Ok(event) = codec::build_heartbeat(&config, &items) { + match client.heartbeat(event).await { + 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).await; + } + debug!("heartbeat ok: {} items", items.len()); + } + Err(e) => warn!("heartbeat failed: {e}"), + } + } + } + }) +} + +/// 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, +) { + // 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 { + let guard = stream_tx.lock().await; + match guard.as_ref() { + Some(tx) => { + if tx.send(event).await.is_err() { + warn!( + "resubscribe: stream channel closed; \ + stream subscriptions will not be re-sent" + ); + } else { + debug!("resubscribe: re-sent {} stream subscriptions", items.len()); + } + } + None => warn!( + "resubscribe: no active stream; \ + stream subscriptions will not be re-sent" + ), + } + } else { + match client.subscribe_webhook(event).await { + Ok(_) => debug!("resubscribe: webhook re-registered for url={url}"), + Err(e) => warn!("resubscribe: webhook re-register failed for url={url}: {e}"), + } + } + } +} 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..7968b1e1cc --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/producer.rs @@ -0,0 +1,165 @@ +// 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(()) + } + + #[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) + } +} + +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..32a66d8579 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/codec.rs @@ -0,0 +1,718 @@ +// 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). +//! +//! ```no_run +//! # 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(default, 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 treated as the message payload. If it looks like + /// JSON (starts with `{`), we attempt to deserialize it as a full + /// `EventMeshMessage` (which may carry its own `topic`/`bizseqno`/etc.). + /// Otherwise the raw string is stored as `content` and the form-level + /// fields populate the message metadata. + pub fn to_event_mesh_message(&self) -> Result { + // Try to parse content as a full EventMeshMessage JSON object. + if self.content.trim_start().starts_with('{') { + if let Ok(msg) = serde_json::from_str::(&self.content) { + return Ok(msg); + } + } + + // Fall back: build an EventMeshMessage from the form-level fields. + 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. + if let Some(ext) = &self.extfields { + if let Ok(props) = serde_json::from_str::>(ext) { + 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() { + fields.push(( + "extFields".into(), + serde_json::to_string(&msg.props).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::Other(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_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_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() { + let msg_json = + serde_json::to_string(&EventMeshMessage::builder().topic("t").content("c").build()) + .unwrap(); + let body = form_encode(&[("content".to_string(), msg_json)]); + let parsed = parse_push_body(&body).unwrap(); + let msg = parsed.to_event_mesh_message().unwrap(); + assert_eq!(msg.topic.as_deref(), Some("t")); + assert_eq!(msg.content.as_deref(), Some("c")); + } + + #[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 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..7316fe83f9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/consumer.rs @@ -0,0 +1,380 @@ +// 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, + 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(), + 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. + let url_groups: HashMap> = { + let guard = self.subscriptions.lock().await; + items.iter().fold(HashMap::new(), |mut acc, i| { + let url = guard + .get(&i.topic) + .map(|e| e.url.clone()) + .unwrap_or_default(); + acc.entry(url).or_default().push(i.topic.clone()); + acc + }) + }; + 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); + } + 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(|(t, e)| (t.clone(), e.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..02f3463118 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/producer.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. + +//! 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"); + + 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 + } + + /// 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) + } +} + +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 { + validate_publish(&message)?; + let config = self.client.config(); + let body = codec::encode_publish(&message, &config.identity); + let code = codec::publish_sync_code(); + let headers = codec::build_headers( + code, + EventMeshProtocolType::EventMeshMessage, + &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()), + }); + } + // The reply message is inside the retMsg field as a JSON object + // {"topic":...,"body":...,"properties":{...}}, mirroring the Java SDK's + // SendMessageResponseBody.ReplyMessage / transformMessage. + let ret_msg = response.message.as_deref().unwrap_or(""); + codec::parse_reply(ret_msg) + } +} + +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)); + } +} + +#[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 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"); + 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}" + ); + } +} 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..da273371dc --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/server.rs @@ -0,0 +1,188 @@ +// 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 +//! +//! ```no_run +//! # 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::model::EventMeshMessage; +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, + { + 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, + { + 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::Other(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..ef8f5d04d9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/webhook.rs @@ -0,0 +1,128 @@ +// 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::model::EventMeshMessage; +use crate::transport::http::codec::{parse_push_body, WebhookReply}; +use crate::MessageListener; + +/// Shared state for the webhook handler, holding the message listener. +pub(crate) struct WebhookState> { + listener: Arc, +} + +impl> WebhookState { + /// Create state wrapping the given listener. + pub(crate) fn new(listener: Arc) -> Self { + Self { listener } + } +} + +impl> Clone for WebhookState { + 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 { + 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 push_body.to_event_mesh_message() { + Ok(m) => m, + Err(e) => { + error!("webhook message decode error: {e}"); + return Json(WebhookReply::retry("message decode error")).into_response(); + } + }; + + debug!( + "webhook received topic={:?} bizseqno={:?}", + msg.topic, msg.biz_seq_no + ); + + match state.listener.handle(msg).await { + 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 (topic={:?}) but the HTTP webhook \ + transport cannot deliver replies; use the gRPC transport for \ + request/reply", + reply.topic + ); + Json(WebhookReply::ok()).into_response() + } + None => Json(WebhookReply::ok()).into_response(), + } + } +} 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..3082a43215 --- /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.75 `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..3d77346997 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection.rs @@ -0,0 +1,788 @@ +// 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, +} + +/// 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 { + // 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<()> { + 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); + + match reason { + IoExitReason::Cancelled | IoExitReason::AllSendersDropped => { + debug!("connection task exiting ({:?})", reason); + return; + } + IoExitReason::IoError | IoExitReason::ServerClosed => {} + } + + // 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 { + // 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. On a producer-only connection `take_inbound_rx` + // is never called, so the receiver is never drained; + // 256 unmatched frames (e.g. late replies to timed-out + // `io()` calls) would block forever and freeze + // heartbeats + writes. Dropping with a warning keeps + // the connection alive at the cost of losing pushes + // nobody is consuming. + match inbound_tx.try_send(pkg) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + "inbound channel full, dropping server push \ + (consumer too slow or not consuming)" + ); + } + 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..2b96f59167 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/consumer.rs @@ -0,0 +1,692 @@ +// 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::panic::AssertUnwindSafe; +use std::sync::Arc; + +use futures::FutureExt; +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::model::{EventMeshMessage, PublishResponse, SubscriptionItem}; +use crate::transport::tcp::connection::TcpConnection; +use crate::transport::tcp::frame::{Command, Package, PackageBody, UserAgent}; +use crate::transport::tcp::message; +use crate::MessageListener; + +/// 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>>>, +} + +impl> TcpConsumer { + /// 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 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(), + ); + + Ok(Self { + conn, + config, + _listener: std::marker::PhantomData, + shutdown, + subscriptions, + driver_handle: Mutex::new(Some(driver_handle)), + }) + } + + /// 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() { + 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 goodbye, redirect, or I/O error), then await the + /// driver task's clean exit. + /// + /// If no shutdown signal was provided at construction time, this blocks + /// until the driver exits naturally. + pub async fn wait_for_shutdown(&self) { + self.shutdown.cancelled().await; + self.conn.shutdown().await; + if let Some(handle) = self.driver_handle.lock().await.take() { + let _ = handle.await; + } + } +} + +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, +) -> JoinHandle> { + tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = shutdown.cancelled() => { + debug!("receive loop shutting down"); + 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"); + 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) => { + if !handle_inbound(&pkg, &conn, &*listener).await { + info!("receive loop stopping after REDIRECT_TO_CLIENT"); + break; + } + } + None => { + info!("inbound channel closed, exiting receive loop"); + 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 `true` to keep the receive loop running, or `false` to stop it. +async fn handle_inbound>( + pkg: &Package, + conn: &TcpConnection, + listener: &L, +) -> bool { + 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 true; + } + 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" + ); + } + 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 false; + } + cmd => { + warn!(?cmd, "unexpected inbound command, ignoring"); + return true; + } + }; + + let parsed = if message::is_cloudevents(pkg) { + #[cfg(feature = "cloud_events")] + { + message::parse_cloud_event(&pkg.body).map(|ev| message::cloud_event_to_message(&ev)) + } + #[cfg(not(feature = "cloud_events"))] + { + warn!( + cmd = ?pkg.header.cmd, + "received CloudEvents message but the cloud_events feature is disabled; skipping" + ); + None + } + } else { + message::parse_message(&pkg.body) + }; + + if let Some(msg) = parsed { + debug!(topic = ?msg.topic, "dispatching to listener"); + let request_props = msg.props.clone(); + match AssertUnwindSafe(listener.handle(msg)).catch_unwind().await { + Ok(Some(mut reply)) => { + for (key, value) in &request_props { + reply + .props + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + match message::build_message_package(&reply, Command::ResponseToServer) { + 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"), + } + } + Ok(None) => {} + Err(_) => warn!("message listener panicked; ACK will still be sent"), + } + } else { + warn!("failed to parse inbound message body"); + } + + 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"); + } + } + + true +} + +#[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) -> Option { + None + } + } + + /// 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; + } + + /// On rebalance the runtime sends `REDIRECT_TO_CLIENT` with an + /// `ip`/`port` body, then closes the session after a 30s grace period. + /// The receive loop must stop promptly so the caller can reconnect. + #[tokio::test] + async fn redirect_to_client_stops_receive_loop() { + 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, which + // cancels the shutdown token. wait_for_shutdown should return promptly. + let result = + tokio::time::timeout(Duration::from_secs(10), consumer.wait_for_shutdown()).await; + assert!( + result.is_ok(), + "REDIRECT_TO_CLIENT should stop the receive loop promptly" + ); + + 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..0980c7832c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/frame.rs @@ -0,0 +1,665 @@ +// 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(Debug, 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, + } + } +} + +// --------------------------------------------------------------------------- +// 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..f11dbd6da9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/message.rs @@ -0,0 +1,593 @@ +// 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 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 { + EventMeshMessage { + topic: wire.topic, + content: wire.body, + props: wire.properties, + ..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) +} + +/// 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) +} + +/// 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::Other(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 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/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..ec0853f8cd --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/mod.rs @@ -0,0 +1,119 @@ +// 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`]. The consumer receives them +//! transparently converted to [`EventMeshMessage`](crate::model::EventMeshMessage). +//! +//! **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; + +pub use consumer::TcpConsumer; +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..e606f672e8 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/producer.rs @@ -0,0 +1,255 @@ +// 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 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; + 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<()> { + 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; + 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 + } +} + +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(()) +} 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..d0728b904b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/codec_test.rs @@ -0,0 +1,120 @@ +// 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. + +//! Integration tests for the message <-> CloudEvent codec (no live server). + +#![cfg(feature = "grpc")] + +use eventmesh::common::ProtocolKey; +use eventmesh::{ + config::GrpcClientConfig, + grpc::codec, + model::{ + EventMeshMessage, EventMeshProtocolType, SubscriptionItem, SubscriptionMode, + SubscriptionType, + }, + proto_gen::{attr_as_str, attr_str, PbCloudEvent, PbData}, +}; + +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_trip_message() { + let cfg = cfg(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .biz_seq_no("b") + .unique_id("u") + .prop("custom", "val") + .build(); + let ce = codec::from_event_mesh_message(&msg, &cfg).unwrap(); + assert_eq!(codec::get_subject(&ce), "t"); + assert_eq!(codec::get_text_data(&ce), "c"); + assert_eq!(ce.attributes.get("custom").map(attr_as_str).unwrap(), "val"); + + let back = codec::to_event_mesh_message(&ce); + assert_eq!(back.topic.as_deref(), Some("t")); + assert_eq!(back.content.as_deref(), Some("c")); +} + +#[test] +fn subscription_event_carries_url_and_items() { + let cfg = cfg(); + let items = vec![SubscriptionItem::new( + "t", + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]; + let ce = codec::build_subscription_event( + &cfg, + EventMeshProtocolType::EventMeshMessage, + Some("http://localhost:8080/cb"), + &items, + ) + .unwrap(); + assert_eq!( + ce.attributes.get("url").map(attr_as_str).unwrap(), + "http://localhost:8080/cb" + ); + assert!(codec::get_text_data(&ce).contains("CLUSTERING")); +} + +#[test] +fn response_code_success() { + let mut ce = PbCloudEvent::default(); + ce.attributes + .insert(ProtocolKey::GRPC_RESPONSE_CODE.into(), attr_str("0")); + let resp = codec::to_response(&ce); + assert!(resp.is_success()); +} + +#[test] +fn batch_encode() { + let cfg = cfg(); + let msgs: Vec = (0..3) + .map(|i| { + EventMeshMessage::builder() + .topic("t") + .content(format!("c{i}")) + .build() + }) + .collect(); + let batch = codec::from_event_mesh_messages(&msgs, &cfg).unwrap(); + assert_eq!(batch.events.len(), 3); +} + +#[test] +fn binary_data_fallback() { + let cfg = cfg(); + let msg = EventMeshMessage::builder() + .topic("t") + .content("raw-bytes") + .prop(ProtocolKey::DATA_CONTENT_TYPE, "application/octet-stream") + .build(); + let ce = codec::from_event_mesh_message(&msg, &cfg).unwrap(); + assert!(matches!(ce.data, Some(PbData::BinaryData(_)))); +} 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..3e1253f2fd --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/harness.rs @@ -0,0 +1,444 @@ +// 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 e2e helpers: config builders, unique resource names, topic creation, +//! and a collecting message listener. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tracing::{debug, warn}; + +use eventmesh::{ + config::GrpcClientConfig, + grpc::GrpcStreamConsumer, + http::{HttpConsumer, WebhookServer}, + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + MessageListener, +}; + +use eventmesh::config::HttpClientConfig; + +use crate::runtime::{ + ensure_runtime, webhook_host, ADMIN_PORT, GRPC_PORT, HOST, HTTP_PORT, TCP_PORT, +}; + +/// Monotonic counter to make every resource name globally unique, so parallel +/// tests never collide on a topic or consumer group. +static SEQ: AtomicU64 = AtomicU64::new(0); + +/// A topic name unique to this process + scope. +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}") +} + +/// A producer config pointing at the local runtime, with a unique group. +pub(crate) fn producer_config() -> GrpcClientConfig { + let group = unique_topic("producer-group"); + GrpcClientConfig::builder() + .server_addr(HOST) + .server_port(GRPC_PORT) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group(group) + .build() +} + +/// A consumer config pointing at the local runtime, with a unique group. +pub(crate) fn consumer_config() -> GrpcClientConfig { + let group = unique_topic("consumer-group"); + GrpcClientConfig::builder() + .server_addr(HOST) + .server_port(GRPC_PORT) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .consumer_group(group) + .build() +} + +/// Create a topic via the admin HTTP API (idempotent). The broker requires a +/// topic to exist before a consumer can rebalance onto it (and the standalone +/// in-memory broker before a producer may publish), so this runs ahead of +/// every publish/subscribe test. +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"); + + // The admin `/topic` endpoint parses the POST body as + // application/x-www-form-urlencoded (Netty HttpPostRequestDecoder), not + // JSON — sending a JSON body yields a blank name and "Topic name can not + // be blank". Send the name as a form field instead. + // + // The endpoint occasionally rejects concurrent creates with a 500; retry + // briefly since "already exists" is a perfectly good outcome here. + for attempt in 0..5u8 { + let res = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client") + .post(&url) + .form(&[("name", topic)]) + .send() + .await; + match res { + Ok(resp) => { + let status = resp.status(); + debug!(%topic, %status, "ensure_topic response"); + if status.is_success() || status.as_u16() == 409 { + return; + } + // Fall through to retry for other statuses. + } + Err(e) => warn!(%topic, attempt, "ensure_topic error: {e}"), + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + // Not fatal: a few standalone builds accept the publish anyway. The actual + // publish will surface a real failure if the topic truly isn't there. + warn!(%topic, "ensure_topic gave up after retries; continuing optimistically"); +} + +/// Wait briefly so a freshly-opened subscription stream has registered with the +/// broker before the test starts publishing (matters for the standalone store). +pub(crate) async fn let_stream_settle() { + tokio::time::sleep(Duration::from_millis(800)).await; +} + +/// Create a topic and subscribe a collecting consumer to it, returning the +/// consumer handle (keep it alive for the test) and the message receiver. +/// +/// The standalone in-memory broker rejects publishes to a topic that has no +/// live subscriber, so every publish-oriented test warms the topic this way +/// first. Returns `(consumer, receiver)` so the caller can also assert delivery. +pub(crate) async fn warm_topic( + topic: &str, +) -> ( + GrpcStreamConsumer, + mpsc::UnboundedReceiver, +) { + let (listener, rx) = CollectingListener::new(); + let consumer = GrpcStreamConsumer::subscribe_stream( + consumer_config(), + listener, + vec![SubscriptionItem::new( + topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )], + None::>, + ) + .await + .expect("subscribe_stream"); + let_stream_settle().await; + (consumer, rx) +} + +// --------------------------------------------------------------------------- +// HTTP transport helpers +// --------------------------------------------------------------------------- + +/// An HTTP producer config pointing at the local runtime, with a unique group. +pub(crate) fn http_producer_config() -> HttpClientConfig { + let group = unique_topic("http-producer-group"); + HttpClientConfig::builder() + .servers(format!("{HOST}:{HTTP_PORT}")) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group(group) + .build() + .expect("http producer config") +} + +/// An HTTP consumer config pointing at the local runtime, with a unique group. +pub(crate) fn http_consumer_config() -> HttpClientConfig { + let group = unique_topic("http-consumer-group"); + HttpClientConfig::builder() + .servers(format!("{HOST}:{HTTP_PORT}")) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .consumer_group(group) + .build() + .expect("http consumer config") +} + +/// Find a free TCP port on the host by briefly binding to port 0. +/// +/// There is an inherent TOCTOU race between this function returning and the +/// caller re-binding, but in practice the window is microseconds and perfectly +/// acceptable for parallel test wiring. +fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind for port probe"); + let port = listener.local_addr().expect("local_addr").port(); + drop(listener); + port +} + +/// Poll a TCP address until it accepts a connection or `timeout` elapses. +async fn wait_for_listen(addr: SocketAddr, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if Instant::now() >= deadline { + panic!("webhook server at {addr} did not start within {timeout:?}"); + } + if TcpStream::connect(addr).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// Owns an `HttpConsumer` and its webhook server task, cleaning both up on +/// drop. Tests hold this alive for the duration of the scenario. +pub(crate) struct HttpConsumerHandle { + consumer: Option, + server_task: Option>, + shutdown_tx: Option>, +} + +impl HttpConsumerHandle { + /// Borrow the underlying consumer (for unsubscribe, etc.). + pub(crate) fn consumer(&self) -> &HttpConsumer { + self.consumer.as_ref().expect("consumer present") + } +} + +impl Drop for HttpConsumerHandle { + fn drop(&mut self) { + // Signal the webhook server's graceful-shutdown future. + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + // Abort the server task if it hasn't exited yet. + if let Some(handle) = self.server_task.take() { + handle.abort(); + } + // Dropping the consumer cancels its heartbeat task (see HttpConsumer::drop). + self.consumer.take(); + } +} + +/// Create a topic, start a webhook server, subscribe an HTTP consumer to the +/// topic via webhook, and return the handle plus the message receiver. +/// +/// This mirrors the gRPC [`warm_topic`] but for the HTTP transport: the +/// standalone in-memory broker rejects publishes to topics with no live +/// subscriber, so every publish-oriented HTTP test warms the topic this way +/// first. Returns `(handle, receiver)` so the caller can also assert delivery. +pub(crate) async fn http_warm_topic( + topic: &str, +) -> ( + HttpConsumerHandle, + mpsc::UnboundedReceiver, +) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + + let (listener, rx) = CollectingListener::new(); + let listener = Arc::new(listener); + + // Allocate a webhook port and build the advertise URL the runtime will use. + let port = free_port(); + let bind_addr: SocketAddr = format!("0.0.0.0:{port}") + .parse() + .expect("valid webhook bind addr"); + let whost = webhook_host(); + let webhook_url = format!("http://{whost}:{port}/eventmesh/callback"); + + // Start the built-in webhook server. + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let server = WebhookServer::new(bind_addr, listener.clone()) + .with_advertise_url(webhook_url.clone()) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(async move { + if let Err(e) = server.await { + warn!("webhook server exited with error: {e}"); + } + }); + // Wait for the server to actually bind before subscribing. + wait_for_listen(bind_addr, Duration::from_secs(5)).await; + debug!(%topic, port, url = %webhook_url, "HTTP webhook server ready"); + + // Create the HTTP consumer (spawns heartbeat task) and subscribe. + let consumer = HttpConsumer::new(http_consumer_config(), None::>) + .expect("build http consumer"); + consumer + .subscribe_webhook( + vec![SubscriptionItem::new( + topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )], + webhook_url, + ) + .await + .expect("subscribe_webhook"); + + let_stream_settle().await; + + let handle = HttpConsumerHandle { + consumer: Some(consumer), + server_task: Some(server_task), + shutdown_tx: Some(shutdown_tx), + }; + + (handle, rx) +} + +// --------------------------------------------------------------------------- +// TCP transport helpers +// --------------------------------------------------------------------------- + +use eventmesh::config::TcpClientConfig; +use eventmesh::tcp::TcpConsumer; + +/// A TCP producer config pointing at the local runtime, with a unique group. +pub(crate) fn tcp_producer_config() -> TcpClientConfig { + let group = unique_topic("tcp-producer-group"); + TcpClientConfig::builder() + .server_addr(HOST) + .server_port(TCP_PORT) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .producer_group(group) + .build() +} + +/// A TCP consumer config pointing at the local runtime, with a unique group. +pub(crate) fn tcp_consumer_config() -> TcpClientConfig { + let group = unique_topic("tcp-consumer-group"); + TcpClientConfig::builder() + .server_addr(HOST) + .server_port(TCP_PORT) + .env("env") + .idc("idc") + .sys("sys") + .username("eventmesh") + .password("eventmesh") + .consumer_group(group) + .build() +} + +/// Create a topic and subscribe a TCP collecting consumer to it, returning the +/// consumer (keep it alive for the test) and the message receiver. +/// +/// Mirrors the gRPC [`warm_topic`]. The standalone in-memory broker rejects +/// publishes to topics with no live subscriber, so every publish-oriented TCP +/// test warms the topic this way first. +pub(crate) async fn tcp_warm_topic( + topic: &str, +) -> ( + TcpConsumer, + mpsc::UnboundedReceiver, +) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + + let (listener, rx) = CollectingListener::new(); + let consumer = TcpConsumer::connect( + tcp_consumer_config(), + listener, + None::>, + ) + .await + .expect("connect tcp consumer"); + consumer + .subscribe(&[SubscriptionItem::new( + topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]) + .await + .expect("subscribe"); + let_stream_settle().await; + (consumer, rx) +} + +// --------------------------------------------------------------------------- +// Listeners +// --------------------------------------------------------------------------- + +/// A listener that forwards every delivered message into an mpsc channel, so a +/// test can assert on what was received. Returns `None` (async ack, no reply). +pub(crate) struct CollectingListener { + tx: mpsc::UnboundedSender, +} + +impl CollectingListener { + pub(crate) fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self { tx }, rx) + } +} + +impl MessageListener for CollectingListener { + type Message = EventMeshMessage; + + async fn handle(&self, msg: EventMeshMessage) -> Option { + let _ = self.tx.send(msg); + None + } +} + +/// A listener used for request/reply: it echoes back a fixed reply content so +/// the producer's `request_reply` call receives a deterministic answer. +pub(crate) struct ReplyingListener { + pub(crate) reply_content: String, +} + +impl MessageListener for ReplyingListener { + type Message = EventMeshMessage; + + async fn handle(&self, msg: EventMeshMessage) -> Option { + debug!( + topic = ?msg.topic, + "replying listener received request, echoing reply" + ); + Some( + EventMeshMessage::builder() + .topic(msg.topic.unwrap_or_default()) + .content(self.reply_content.clone()) + .build(), + ) + } +} 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..8badd6f841 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_publish.rs @@ -0,0 +1,74 @@ +// 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-side operations. +//! +//! HTTP batch publish is intentionally not supported by the transport (see +//! `HttpProducer::publish_batch`), so only single-message publish is tested +//! here. + +use eventmesh::{http::HttpProducer, model::EventMeshMessage, transport::Publisher}; + +use crate::harness::{ensure_topic, http_producer_config, http_warm_topic, unique_topic}; +use crate::runtime::ensure_runtime; + +#[tokio::test] +async fn http_publish_single() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("http-pub-single"); + ensure_topic(&topic).await; + let (_handle, _rx) = http_warm_topic(&topic).await; + + let producer = HttpProducer::new(http_producer_config()).expect("build http producer"); + + let msg = EventMeshMessage::builder() + .topic(&topic) + .content("hello from rust http e2e") + .build(); + let resp = producer.publish(msg).await.expect("http publish"); + assert!(resp.is_success(), "http publish should succeed: {resp}"); +} + +/// Verify that batch publish surfaces a clear `Unsupported` error rather than +/// silently succeeding or panicking. This documents the known limitation. +#[tokio::test] +async fn http_publish_batch_unsupported() { + if !ensure_runtime() { + return; + } + let producer = HttpProducer::new(http_producer_config()).expect("build http producer"); + + let batch: Vec = (0..2) + .map(|i| { + EventMeshMessage::builder() + .topic("n/a") + .content(format!("batch-{i}")) + .build() + }) + .collect(); + + let err = producer + .publish_batch(batch) + .await + .expect_err("batch publish should be unsupported over HTTP"); + assert!( + err.to_string().contains("not supported"), + "expected an unsupported error, got: {err}" + ); +} 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..23b32513c3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_subscribe.rs @@ -0,0 +1,169 @@ +// 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 subscription — subscribe via webhook, receive pushed +//! messages, then unsubscribe and confirm delivery stops. + +use std::time::Duration; + +use eventmesh::{ + http::HttpProducer, + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + transport::Publisher, +}; + +use crate::harness::{ + ensure_topic, http_producer_config, http_warm_topic, let_stream_settle, unique_topic, +}; +use crate::runtime::ensure_runtime; + +/// The HTTP transport delivers messages via an HTTP POST callback from the +/// runtime to the consumer's webhook URL, which adds a network hop compared to +/// the gRPC bidirectional stream. Allow a generous timeout for delivery. +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(15); + +/// Helper: receive one message from `rx` or panic after `DELIVERY_TIMEOUT`. +async fn recv_one( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(DELIVERY_TIMEOUT, rx.recv()) + .await + .expect("timed out waiting for a pushed message") + .expect("listener channel closed unexpectedly") +} + +#[tokio::test] +async fn http_subscribe_and_receive() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("http-sub-recv"); + ensure_topic(&topic).await; + let (handle, mut rx) = http_warm_topic(&topic).await; + + let producer = HttpProducer::new(http_producer_config()).expect("build http producer"); + let payload = "delivered-via-http"; + let msg = EventMeshMessage::builder() + .topic(&topic) + .content(payload) + .build(); + producer.publish(msg).await.expect("http publish"); + + let received = recv_one(&mut rx).await; + assert_eq!( + received.content.as_deref(), + Some(payload), + "delivered content mismatch: {received}" + ); + + drop(handle); +} + +#[tokio::test] +async fn http_subscribe_batch_receive() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("http-sub-batch"); + ensure_topic(&topic).await; + let (handle, mut rx) = http_warm_topic(&topic).await; + + let producer = HttpProducer::new(http_producer_config()).expect("build http producer"); + // HTTP batch publish is unsupported, so publish individually. + for i in 0..3 { + let msg = EventMeshMessage::builder() + .topic(&topic) + .content(format!("m{i}")) + .build(); + producer.publish(msg).await.expect("http publish"); + } + + // Expect all three to arrive (order not guaranteed). + let mut contents = Vec::new(); + for _ in 0..3 { + let msg = recv_one(&mut rx).await; + contents.push(msg.content.unwrap_or_default()); + } + contents.sort(); + assert_eq!(contents, vec!["m0", "m1", "m2"]); + + drop(handle); +} + +#[tokio::test] +async fn http_unsubscribe_stops_delivery() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("http-sub-unsub"); + ensure_topic(&topic).await; + let (handle, mut rx) = http_warm_topic(&topic).await; + + let producer = HttpProducer::new(http_producer_config()).expect("build http producer"); + + // First message should arrive. + producer + .publish( + EventMeshMessage::builder() + .topic(&topic) + .content("before-http-unsub") + .build(), + ) + .await + .expect("http publish before unsub"); + let _ = recv_one(&mut rx).await; + + // Unsubscribe, then publish again. + handle + .consumer() + .unsubscribe(vec![SubscriptionItem::new( + &topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]) + .await + .expect("http unsubscribe"); + let_stream_settle().await; + + match producer + .publish( + EventMeshMessage::builder() + .topic(&topic) + .content("after-http-unsub") + .build(), + ) + .await + { + Ok(_) => { + let leaked = tokio::time::timeout(Duration::from_secs(3), rx.recv()).await; + assert!( + leaked.is_err(), + "no message expected after unsubscribe, but got: {:?}", + leaked.ok().flatten() + ); + } + Err(e) => { + // Standalone rejects publish to a topic with no subscribers — the + // unsubscribe worked. + eprintln!( + "[e2e] publish after http unsub rejected by broker (expected on standalone): {e}" + ); + } + } + + drop(handle); +} 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..29b22f9473 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/main.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. + +//! 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). + +#![cfg(feature = "e2e")] + +mod harness; +mod http_publish; +mod http_subscribe; +mod publish; +mod request_reply; +mod runtime; +mod subscribe; +mod tcp_cloud_events; +mod tcp_publish; +mod tcp_request_reply; +mod tcp_subscribe; 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..04162858ec --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/publish.rs @@ -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. + +//! E2e: producer-side operations (publish / batch / one-way). + +use eventmesh::{grpc::GrpcProducer, model::EventMeshMessage, transport::Publisher}; + +use crate::harness::{ensure_topic, producer_config, unique_topic, warm_topic}; +use crate::runtime::ensure_runtime; + +#[tokio::test] +async fn publish_single() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("pub-single"); + ensure_topic(&topic).await; + let (_consumer, _rx) = warm_topic(&topic).await; + + let producer = GrpcProducer::connect(producer_config()).expect("connect producer"); + + let msg = EventMeshMessage::builder() + .topic(&topic) + .content("hello from rust e2e") + .build(); + let resp = producer.publish(msg).await.expect("publish"); + assert!(resp.is_success(), "publish should succeed: {resp}"); +} + +#[tokio::test] +async fn publish_batch() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("pub-batch"); + ensure_topic(&topic).await; + let (_consumer, _rx) = warm_topic(&topic).await; + + let producer = GrpcProducer::connect(producer_config()).expect("connect producer"); + + let batch: Vec = (0..3) + .map(|i| { + EventMeshMessage::builder() + .topic(&topic) + .content(format!("batch message #{i}")) + .build() + }) + .collect(); + let resp = producer.publish_batch(batch).await.expect("batch publish"); + assert!(resp.is_success(), "batch publish should succeed: {resp}"); +} + +#[tokio::test] +async fn publish_one_way() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("pub-oneway"); + ensure_topic(&topic).await; + let (_consumer, _rx) = warm_topic(&topic).await; + + let producer = GrpcProducer::connect(producer_config()).expect("connect producer"); + + let msg = EventMeshMessage::builder() + .topic(&topic) + .content("fire-and-forget") + .build(); + producer + .publish_one_way(msg) + .await + .expect("publish_one_way"); +} 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..90376701a1 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/request_reply.rs @@ -0,0 +1,102 @@ +// 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: synchronous request/reply round-trip. + +use std::time::Duration; + +use eventmesh::{ + grpc::{GrpcProducer, GrpcStreamConsumer}, + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + transport::Publisher, +}; + +use crate::harness::{ + consumer_config, ensure_topic, let_stream_settle, producer_config, unique_topic, + ReplyingListener, +}; +use crate::runtime::ensure_runtime; + +const REPLY: &str = "pong"; + +#[tokio::test] +async fn request_reply_roundtrip() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("req-reply"); + ensure_topic(&topic).await; + + // SYNC consumer: receives the request and echoes a fixed reply. + let listener = ReplyingListener { + reply_content: REPLY.to_string(), + }; + let consumer = GrpcStreamConsumer::subscribe_stream( + consumer_config(), + listener, + vec![SubscriptionItem::new( + &topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::SYNC, + )], + None::>, + ) + .await + .expect("subscribe_stream"); + let_stream_settle().await; + + let producer = GrpcProducer::connect(producer_config()).expect("connect producer"); + let request = EventMeshMessage::builder() + .topic(&topic) + .content("ping") + .ttl_millis(10_000) + .build(); + + let reply = producer + .request_reply(request, Duration::from_secs(15)) + .await + .expect("request_reply RPC should complete"); + + // The standalone (in-memory) broker does not implement synchronous + // request/reply: it bounces the request back with a "Request is not + // supported" message rather than forwarding it to the consumer. Treat that + // specific broker-capability gap as a skip, not a failure, so the suite + // stays green on standalone while still asserting the full round-trip on a + // durable backend (RocketMQ). Any other non-zero status (e.g. a request + // timeout, 10006) is a real failure and must surface here. + let unsupported = reply + .get_prop("responsemessage") + .map(|m| m.to_lowercase().contains("not supported")) + .unwrap_or(false); + if unsupported { + eprintln!( + "[e2e] skipping request_reply assertion: broker does not support \ + sync request/reply (standalone). reply status={:?} msg={:?}", + reply.get_prop("statuscode"), + reply.get_prop("responsemessage") + ); + return; + } + + assert_eq!( + reply.content.as_deref(), + Some(REPLY), + "reply content mismatch: {reply}" + ); + + drop(consumer); +} 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..e7a56816d1 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/runtime.rs @@ -0,0 +1,297 @@ +// 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 + } + } +} + +/// 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..db13e1e1df --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/subscribe.rs @@ -0,0 +1,162 @@ +// 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: stream subscription — subscribe, receive messages, then unsubscribe. + +use std::time::Duration; + +use eventmesh::{grpc::GrpcProducer, model::EventMeshMessage, transport::Publisher}; + +use crate::harness::{ensure_topic, let_stream_settle, producer_config, unique_topic, warm_topic}; +use crate::runtime::ensure_runtime; + +/// Helper: receive one message from `rx` or panic after `timeout`. +async fn recv_one( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + timeout: Duration, +) -> EventMeshMessage { + tokio::time::timeout(timeout, rx.recv()) + .await + .expect("timed out waiting for a delivered message") + .expect("listener channel closed unexpectedly") +} + +#[tokio::test] +async fn subscribe_and_receive() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("sub-recv"); + ensure_topic(&topic).await; + let (consumer, mut rx) = warm_topic(&topic).await; + + let producer = GrpcProducer::connect(producer_config()).expect("connect producer"); + let payload = "delivered-payload"; + let msg = EventMeshMessage::builder() + .topic(&topic) + .content(payload) + .build(); + producer.publish(msg).await.expect("publish"); + + let received = recv_one(&mut rx, Duration::from_secs(10)).await; + assert_eq!(received.content.as_deref(), Some(payload)); + assert_eq!(received.topic.as_deref(), Some(topic.as_str())); + + drop(consumer); +} + +#[tokio::test] +async fn subscribe_batch_receive() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("sub-batch"); + ensure_topic(&topic).await; + let (consumer, mut rx) = warm_topic(&topic).await; + + let producer = GrpcProducer::connect(producer_config()).expect("connect producer"); + let batch: Vec = (0..3) + .map(|i| { + EventMeshMessage::builder() + .topic(&topic) + .content(format!("m{i}")) + .build() + }) + .collect(); + producer.publish_batch(batch).await.expect("batch publish"); + + // Expect all three to arrive (order not strictly guaranteed). + let mut contents = Vec::new(); + for _ in 0..3 { + let msg = recv_one(&mut rx, Duration::from_secs(10)).await; + contents.push(msg.content.unwrap_or_default()); + } + contents.sort(); + assert_eq!(contents, vec!["m0", "m1", "m2"]); + + drop(consumer); +} + +#[tokio::test] +async fn unsubscribe_stops_delivery() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("sub-unsub"); + ensure_topic(&topic).await; + let (consumer, mut rx) = warm_topic(&topic).await; + + let producer = GrpcProducer::connect(producer_config()).expect("connect producer"); + + // First message should arrive. + producer + .publish( + EventMeshMessage::builder() + .topic(&topic) + .content("before-unsub") + .build(), + ) + .await + .expect("publish before unsub"); + let _ = recv_one(&mut rx, Duration::from_secs(10)).await; + + // Unsubscribe, then publish again. + consumer + .unsubscribe(vec![eventmesh::model::SubscriptionItem::new( + &topic, + eventmesh::model::SubscriptionMode::CLUSTERING, + eventmesh::model::SubscriptionType::ASYNC, + )]) + .await + .expect("unsubscribe"); + let_stream_settle().await; + + // Publish again. On the standalone broker (which requires a live subscriber + // to accept a publish) this errors out — which itself confirms the + // unsubscribe took effect. On a durable backend the publish succeeds but + // nothing should be delivered. + match producer + .publish( + EventMeshMessage::builder() + .topic(&topic) + .content("after-unsub") + .build(), + ) + .await + { + Ok(_) => { + let leaked = tokio::time::timeout(Duration::from_secs(3), rx.recv()).await; + // A timeout (`Err`) is the "nothing leaked" outcome. A closed + // channel (`Ok(None)`) also means nothing was delivered — the + // server tore down the subscription stream on unsubscribe — so + // treat that as a pass too. Only an actual delivered message + // (`Ok(Some(..))`) is a real leak. + assert!( + matches!(leaked, Err(_) | Ok(None)), + "no message expected after unsubscribe, but got: {:?}", + leaked.ok().flatten() + ); + } + Err(e) => { + // Standalone rejects publish to a topic with no subscribers — the + // unsubscribe worked. + eprintln!("[e2e] publish after unsub rejected by broker (expected on standalone): {e}"); + } + } + + drop(consumer); +} 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..fad58594b9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_cloud_events.rs @@ -0,0 +1,185 @@ +// 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 — publish a CloudEvent and verify it is received by a +//! TCP consumer (converted to EventMeshMessage at the boundary). + +use std::time::Duration; + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{ + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + tcp::{TcpConsumer, TcpProducer}, +}; + +use crate::harness::{ + ensure_topic, let_stream_settle, tcp_consumer_config, tcp_producer_config, unique_topic, + CollectingListener, +}; +use crate::runtime::ensure_runtime; + +/// Helper: receive one message from `rx` or panic after `timeout`. +async fn recv_one( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + timeout: Duration, +) -> EventMeshMessage { + tokio::time::timeout(timeout, rx.recv()) + .await + .expect("timed out waiting for a delivered message") + .expect("listener channel closed unexpectedly") +} + +#[tokio::test] +async fn tcp_publish_cloud_event() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("tcp-ce-pub"); + ensure_topic(&topic).await; + + // Subscribe a collecting TCP consumer. + let (listener, mut rx) = CollectingListener::new(); + let consumer = TcpConsumer::connect( + tcp_consumer_config(), + listener, + None::>, + ) + .await + .expect("connect consumer"); + consumer + .subscribe(&[SubscriptionItem::new( + &topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]) + .await + .expect("subscribe"); + let_stream_settle().await; + + // Publish a CloudEvent over TCP. + let producer = TcpProducer::connect(tcp_producer_config()) + .await + .expect("connect producer"); + + let payload = r#"{"msg":"hello from rust tcp cloudevents e2e"}"#; + let event = EventBuilderV10::new() + .id("tcp-ce-e2e-1") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.someevent") + .subject(&topic) + // NOTE: datacontenttype must be "application/cloudevents+json" — the + // EventMesh server's fromCloudEvent uses this value to resolve the + // CloudEvents EventFormat serializer. Other values (e.g. + // "application/json", "text/plain") cause an NPE on the downlink path. + .data("application/cloudevents+json", payload) + .build() + .expect("valid CloudEvent"); + + let resp = producer.publish_cloud_event(event).await; + producer.shutdown().await; + let resp = resp.expect("publish_cloud_event should succeed"); + assert!(resp.is_success(), "publish should succeed: {resp}"); + + // The consumer receives the CloudEvent converted to EventMeshMessage. + // RocketMQ's consumer needs time to discover the topic route and rebalance + // (pollNameServerInterval=30s, rebalanceInterval=20s) before pulling. + let received = recv_one(&mut rx, Duration::from_secs(35)).await; + assert_eq!(received.topic.as_deref(), Some(topic.as_str())); + // Content should contain the JSON payload. + assert!( + received + .content + .as_deref() + .map(|c| c.contains("hello from rust tcp cloudevents e2e")) + .unwrap_or(false), + "content should contain the CloudEvent data, got: {:?}", + received.content + ); + + consumer.shutdown().await; +} + +#[tokio::test] +async fn tcp_broadcast_cloud_event() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("tcp-ce-broadcast"); + ensure_topic(&topic).await; + + let (listener, mut rx) = CollectingListener::new(); + let consumer = TcpConsumer::connect( + tcp_consumer_config(), + listener, + None::>, + ) + .await + .expect("connect consumer"); + consumer + .subscribe(&[SubscriptionItem::new( + &topic, + SubscriptionMode::BROADCASTING, + SubscriptionType::ASYNC, + )]) + .await + .expect("subscribe"); + + // RocketMQ broadcast consumers start from CONSUME_FROM_LAST_OFFSET, so any + // message published before the consumer's first rebalance is permanently + // skipped. Wait for the rebalance cycle (~20s) + nameserver route + // discovery (~30s) to complete before publishing. + tokio::time::sleep(Duration::from_secs(25)).await; + + let producer = TcpProducer::connect(tcp_producer_config()) + .await + .expect("connect producer"); + + let event = EventBuilderV10::new() + .id("tcp-ce-broadcast-1") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.someevent") + .subject(&topic) + .data( + "application/cloudevents+json", + "broadcast cloudevents payload", + ) + .build() + .expect("valid CloudEvent"); + + producer + .broadcast_cloud_event(event) + .await + .expect("broadcast"); + + // Broadcasting consumers need extra time: RocketMQ's broadcast consumer + // must discover the topic route (pollNameServerInterval=30s) and + // rebalance (rebalanceInterval=20s) before it can pull messages. + let received = recv_one(&mut rx, Duration::from_secs(35)).await; + assert_eq!(received.topic.as_deref(), Some(topic.as_str())); + assert!( + received + .content + .as_deref() + .map(|c| c.contains("broadcast cloudevents payload")) + .unwrap_or(false), + "content should contain the CloudEvent data, got: {:?}", + received.content + ); + + 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..253f73941d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_publish.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. + +//! E2e: TCP producer-side operations (publish / broadcast). + +use eventmesh::{model::EventMeshMessage, tcp::TcpProducer, transport::Publisher}; + +use crate::harness::{ensure_topic, tcp_producer_config, tcp_warm_topic, unique_topic}; +use crate::runtime::ensure_runtime; + +#[tokio::test] +async fn tcp_publish_single() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("tcp-pub-single"); + ensure_topic(&topic).await; + let (_consumer, _rx) = tcp_warm_topic(&topic).await; + + let producer = TcpProducer::connect(tcp_producer_config()) + .await + .expect("connect producer"); + + let msg = EventMeshMessage::builder() + .topic(&topic) + .content("hello from rust tcp e2e") + .build(); + let resp = producer.publish(msg).await.expect("publish"); + assert!(resp.is_success(), "publish should succeed: {resp}"); + + producer.shutdown().await; +} + +#[tokio::test] +async fn tcp_broadcast() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("tcp-pub-broadcast"); + ensure_topic(&topic).await; + let (_consumer, _rx) = tcp_warm_topic(&topic).await; + + let producer = TcpProducer::connect(tcp_producer_config()) + .await + .expect("connect producer"); + + let msg = EventMeshMessage::builder() + .topic(&topic) + .content("broadcast from rust tcp e2e") + .build(); + producer.broadcast(msg).await.expect("broadcast"); + + producer.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..d65d4625d1 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_request_reply.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 synchronous request/reply round-trip. + +use std::time::Duration; + +use eventmesh::{ + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + tcp::{TcpConsumer, TcpProducer}, + transport::Publisher, +}; + +use crate::harness::{ + ensure_topic, let_stream_settle, tcp_consumer_config, tcp_producer_config, unique_topic, + ReplyingListener, +}; +use crate::runtime::{ensure_runtime, mode, Mode}; + +const REPLY: &str = "pong"; + +#[tokio::test] +async fn tcp_request_reply_roundtrip() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("tcp-req-reply"); + ensure_topic(&topic).await; + + // SYNC consumer: receives the request and echoes a fixed reply. + let listener = ReplyingListener { + reply_content: REPLY.to_string(), + }; + let consumer = TcpConsumer::connect( + tcp_consumer_config(), + listener, + None::>, + ) + .await + .expect("connect consumer"); + consumer + .subscribe(&[SubscriptionItem::new( + &topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::SYNC, + )]) + .await + .expect("subscribe"); + let_stream_settle().await; + + let producer = TcpProducer::connect(tcp_producer_config()) + .await + .expect("connect producer"); + let request = EventMeshMessage::builder() + .topic(&topic) + .content("ping") + .ttl_millis(10_000) + .build(); + + let reply = producer + .request_reply(request, Duration::from_secs(15)) + .await; + + producer.shutdown().await; + consumer.shutdown().await; + + // The harness itself always starts the `rocketmq` profile, where sync + // request/reply is expected to work. Only an externally-provided server + // (set via `EVENTMESH_E2E_EXTERNAL=1` or pre-started by the user) can be + // the standalone (in-memory) broker, which does not implement RR. So fail + // on any error when we launched the stack, and only skip for the + // standalone case on an external server — a timeout, codec regression, + // bad ACK, or connection failure must surface instead of being silently + // swallowed as a skip. + match reply { + Ok(reply) => { + assert_eq!( + reply.content.as_deref(), + Some(REPLY), + "reply content mismatch: {reply}" + ); + } + Err(e) => match mode() { + Some(Mode::Started) => { + panic!( + "tcp request/reply failed on the harness-launched (rocketmq) \ + broker, where it is expected to work: {e}" + ); + } + _ => { + eprintln!( + "[e2e] skipping tcp_request_reply assertion: externally-provided \ + server may not support sync request/reply (standalone). error: {e}" + ); + } + }, + } +} 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..f845eee093 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_subscribe.rs @@ -0,0 +1,179 @@ +// 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 subscription — subscribe, receive messages, then unsubscribe. + +use std::time::Duration; + +use eventmesh::{ + model::{EventMeshMessage, SubscriptionItem, SubscriptionMode, SubscriptionType}, + tcp::TcpProducer, + transport::Publisher, +}; + +use crate::harness::{ + ensure_topic, let_stream_settle, tcp_consumer_config, tcp_producer_config, unique_topic, + CollectingListener, +}; +use crate::runtime::ensure_runtime; + +use eventmesh::tcp::TcpConsumer; + +/// Helper: receive one message from `rx` or panic after `timeout`. +async fn recv_one( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + timeout: Duration, +) -> EventMeshMessage { + tokio::time::timeout(timeout, rx.recv()) + .await + .expect("timed out waiting for a delivered message") + .expect("listener channel closed unexpectedly") +} + +#[tokio::test] +async fn tcp_subscribe_and_receive() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("tcp-sub-recv"); + ensure_topic(&topic).await; + + // Create a TCP consumer with a collecting listener. + let (listener, mut rx) = CollectingListener::new(); + let consumer = TcpConsumer::connect( + tcp_consumer_config(), + listener, + None::>, + ) + .await + .expect("connect consumer"); + consumer + .subscribe(&[SubscriptionItem::new( + &topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]) + .await + .expect("subscribe"); + let_stream_settle().await; + + // Publish via TCP producer. + let producer = TcpProducer::connect(tcp_producer_config()) + .await + .expect("connect producer"); + let payload = "delivered-via-tcp"; + let msg = EventMeshMessage::builder() + .topic(&topic) + .content(payload) + .build(); + producer.publish(msg).await.expect("publish"); + + let received = recv_one(&mut rx, Duration::from_secs(10)).await; + assert_eq!(received.content.as_deref(), Some(payload)); + assert_eq!(received.topic.as_deref(), Some(topic.as_str())); + + producer.shutdown().await; + consumer.shutdown().await; +} + +#[tokio::test] +async fn tcp_unsubscribe_stops_delivery() { + if !ensure_runtime() { + return; + } + let topic = unique_topic("tcp-sub-unsub"); + ensure_topic(&topic).await; + + let (listener, mut rx) = CollectingListener::new(); + let consumer = TcpConsumer::connect( + tcp_consumer_config(), + listener, + None::>, + ) + .await + .expect("connect consumer"); + consumer + .subscribe(&[SubscriptionItem::new( + &topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]) + .await + .expect("subscribe"); + let_stream_settle().await; + + let producer = TcpProducer::connect(tcp_producer_config()) + .await + .expect("connect producer"); + + // First message should arrive. + producer + .publish( + EventMeshMessage::builder() + .topic(&topic) + .content("before-unsub") + .build(), + ) + .await + .expect("publish before unsub"); + let _ = recv_one(&mut rx, Duration::from_secs(10)).await; + + // Unsubscribe, then publish again. + consumer + .unsubscribe(vec![SubscriptionItem::new( + &topic, + SubscriptionMode::CLUSTERING, + SubscriptionType::ASYNC, + )]) + .await + .expect("unsubscribe"); + let_stream_settle().await; + + // Publish again. On the standalone broker (which requires a live subscriber + // to accept a publish) this errors out — which itself confirms the + // unsubscribe took effect. On a durable backend the publish succeeds but + // nothing should be delivered. + match producer + .publish( + EventMeshMessage::builder() + .topic(&topic) + .content("after-unsub") + .build(), + ) + .await + { + Ok(_) => { + let leaked = tokio::time::timeout(Duration::from_secs(3), rx.recv()).await; + // A timeout (`Err`) is the "nothing leaked" outcome. A closed + // channel (`Ok(None)`) also means nothing was delivered — the + // server tore down the subscription on unsubscribe — so treat that + // as a pass too. Only an actual delivered message (`Ok(Some(..))`) + // is a real leak. + assert!( + matches!(leaked, Err(_) | Ok(None)), + "no message expected after unsubscribe, but got: {:?}", + leaked.ok().flatten() + ); + } + Err(e) => { + eprintln!("[e2e] publish after unsub rejected by broker (expected on standalone): {e}"); + } + } + + 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())) - ); -}