diff --git a/.dockerignore b/.dockerignore index c0f64a8361..b3ec46e6c3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,31 @@ dist/ build/ +**/build/ + +# VCS and gradle caches (the wrapper is not used; system gradle lives in the +# builder image). +.git/ +.gitignore +.gradle/ +**/.gradle/ + +# Non-Java SDKs (not included in Gradle build) +eventmesh-sdks/eventmesh-sdk-rust/ +eventmesh-sdks/eventmesh-sdk-go/ +eventmesh-sdks/eventmesh-sdk-c/ + +# Rust / Node build artifacts. Keep the pattern slashless so Docker excludes +# both the target directory itself and everything below it in any SDK context. +**/target +**/node_modules/ + +# IDE / editor cruft. +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# Docs & misc +docs/ +*.md diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000000..0f9cdcae22 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,15 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "lsp": { + "rust-analyzer": { + "initialization_options": { + "cargo": { + "features": "all" + } + } + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md b/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md new file mode 100644 index 0000000000..0172e1f721 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/AGENTS.md @@ -0,0 +1,10 @@ +# Agent guidance for the EventMesh Rust SDK + +This crate is independent of the repository's Gradle build. Before changing it, read the project-owned documentation instead of duplicating it here: + +- [README.md](README.md) — supported features, installation, and public behavior. +- [CONTRIBUTING.md](CONTRIBUTING.md) — prerequisites, checks, end-to-end tests, documentation ownership, and code conventions. +- [ARCHITECTURE.md](ARCHITECTURE.md) — protocol boundaries, generated code, and transport-specific implementation constraints. +- [examples/README.md](examples/README.md) — runnable examples and exact feature flags. + +Keep those files authoritative. Update this file only when instructions specific to coding agents cannot be expressed naturally in the contributor or architecture documentation. diff --git a/eventmesh-sdks/eventmesh-sdk-rust/ARCHITECTURE.md b/eventmesh-sdks/eventmesh-sdk-rust/ARCHITECTURE.md new file mode 100644 index 0000000000..b0476528c2 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/ARCHITECTURE.md @@ -0,0 +1,83 @@ +# EventMesh Rust SDK architecture + +This document records implementation constraints and protocol boundaries. For public usage, see [README.md](README.md); for build and test commands, see [CONTRIBUTING.md](CONTRIBUTING.md). + +## Public API boundaries + +- `src/lib.rs` denies unsafe code. +- Consumers and webhook servers invoke the public `MessageHandler` directly with `Message`. Transport-private helpers decode and encode that envelope; there is no separate listener trait or handler adapter with an associated message type. +- Producers use concrete transport methods behind the public `GrpcProducer`, `HttpProducer`, and `TcpProducer` APIs. Message dialect selection remains in the public producer facade, while each transport owns its wire encoding and supported operations. There is no internal `Publisher` or `RequestReply` trait requiring unused or unsupported methods. +- Subscription is intentionally transport-specific. Each consumer owns its receive loop where applicable and exposes lifecycle methods suited to its protocol. +- `src/common/` contains protocol keys, status codes, constants, and the shared `LoadBalanceSelector`. +- Internal transport helpers and re-exports are gated by their consuming features; `common` and `model` have no module-wide unused-code exemptions. The shared Java wire-key and status/request-code catalogs retain documented `dead_code` exemptions on their constant-only implementations. + +## Generated protobuf code + +`build.rs` uses `tonic-build` to compile `proto/eventmesh-{service,cloudevents}.proto` into Cargo's `OUT_DIR`. It creates client stubs only and enables `--experimental_allow_proto3_optional`. The two `.proto` inputs and the hand-written `src/proto_gen.rs` wrapper are checked in. The generated Rust files remain in `OUT_DIR` and are loaded by `tonic::include_proto!`; under the current build setup, those generated files are not checked in. Add convenience aliases to `proto_gen.rs` rather than editing build output. + +## Wire formats + +`EventMeshMessage` is a business model, not a shared wire DTO. Each transport owns its serialization: + +| Transport | Boundary | Encoding | +| --- | --- | --- | +| gRPC | `src/transport/grpc/codec.rs` | CloudEvents protobuf | +| HTTP | `src/transport/http/codec.rs` | Form URL encoding, with JSON in `content` | +| TCP | `src/transport/tcp/message.rs` | Length-prefixed binary frames with `EventMesh` magic | + +Native messages separate business data from delivery context: + +- `EventMeshMessage` owns topic, content, business/unique IDs, TTL, content type, and business properties. +- `DeliveryContext` in `src/model/delivery.rs` owns received protocol descriptors and known identity/routing attributes. Its public API is read-only; only SDK decoders can attach it. Credentials are redacted in Debug output. +- `decode_native_message` in `src/transport/mod.rs` separates wire attributes for every native decoder. HTTP form fields take precedence over duplicated IDs in `extFields`. Protocol-specific wire representations remain private. +- Property builders and setters reject reserved names using the shared classification in `src/model/delivery.rs`. Encoders retain the same guard as defense in depth and never serialize a delivery context during normal publish, broadcast, or a new request. +- TCP `RESPONSE_TO_SERVER` encoding restores known reply-routing attributes from the original request context, including Runtime `req0*`/`rsp0*`, cluster, and RocketMQ `correlation99id`/`reply99to99client`. The consumer attaches the original request context even if the handler returns a message received elsewhere. gRPC replies retain routing from their original protobuf request. ACK correlation continues to use the original wire frame. + +TTL has one business source: its dedicated field. HTTP/gRPC retain their 4000 ms outbound default; TCP leaves an unset TTL to the Runtime. Content type is also a dedicated field, encoded as a gRPC attribute, TCP message header, or HTTP `extFields` entry. Decoders reject malformed or out-of-i64-range TTL while preserving numeric values until outbound validation. CloudEvents keep their standard attributes/extensions; native-to-CloudEvents reply conversion maps the dedicated fields and reply context explicitly. + +TCP CloudEvents use `protocoltype=cloudevents` and raw `application/cloudevents+json` bytes, matching the Java runtime codec path. + +## Configuration + +- Every transport consumes the public configuration types directly: + `GrpcConfig`, `HttpConfig`, and `TcpConfig`, together with the role + options (`ProducerOptions`, `ConsumerOptions`) passed to each role + factory. There are no transport-private configuration adapters. +- `GrpcChannel::connect` creates the tonic channel on the current Tokio + runtime. Roles receive the channel explicitly and clones share its + multiplexed HTTP/2 connection. Applications using multiple Tokio runtimes + create a separate channel in each runtime. +- `HttpConfig` carries an `EndpointSet`; endpoint weights feed the shared + load balancer and identity/credentials ride as HTTP headers. +- `TcpConfig` keeps connect, protocol-control, business request, heartbeat, + and reconnect timeouts separate for Java compatibility. Heartbeats and + GOODBYE are fire-and-forget. + +## Consumer lifecycle waits + +Consumer lifecycle waits keep task ownership in `transport::task::BackgroundTask`. Waiting borrows the `JoinHandle`; completed results stay in the owner until the remaining cleanup has finished. Cancelling `join()` therefore preserves both unfinished tasks and pending failures for a later wait. Dropping the owner aborts any unfinished task. + +## gRPC stream subscription limitations + +Two known stream lifecycle limitations are retained without behavior changes in this SDK revision: + +- `spawn_stream_driver` ignores frames without `seqnum`, including subscription responses with a non-success `statuscode`. Runtime `SubscribeStreamProcessor` uses `ServiceUtils.sendResponseCompleted` for validation errors and `sendStreamResponseCompleted` for ACL errors, followed by normal stream completion. Consequently, `open()` and `join()` can both succeed for a rejected subscription. In the Java SDK, `EventMeshCloudEventBuilder.buildMessageFromEventMeshCloudEvent` treats an event with neither `seqnum` nor `uniqueid` as subscription-list content; `SubStreamHandler.onNext` logs a resulting `Set` without checking the response status. Its stream `subscribe` returns `void` and does not await acceptance. +- `unsubscribe_stream_rpc` removes only the requested local topics. Runtime `ConsumerManager.deregisterClient` calls `closeEventStream` on the removed topic's emitter, which is shared by all topics on that subscription stream. The Rust driver treats EOF as terminal and cancels the heartbeat; it does not reconnect or replay remaining subscriptions. Java `EventMeshGrpcConsumer.unsubscribe` likewise retains the remaining topics, while `SubStreamHandler.onCompleted` only logs completion. Its `sender` is never reset, so subsequent subscriptions and heartbeat-triggered `resubscribe` reuse the closed stream. Java heartbeats can continue because Runtime `updateClientTime` checks client records, not emitter liveness. + +The relevant Java sources are under `eventmesh-sdks/eventmesh-sdk-java/src/main/java/org/apache/eventmesh/client/grpc/` and `eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/core/protocol/grpc/` in this repository. These observations come from source review; they do not establish live Java end-to-end coverage. Public usage constraints are documented in the README and `GrpcStreamConsumer` rustdoc. + +## HTTP lifecycle and routing + +The managed `HttpConsumer` binds its axum callback server before registration, then owns registration, heartbeat, and shutdown. Applications that host their own endpoint use `WebhookRegistration` and the public codec helpers `parse_push_body`, `PushMessageRequestBody::to_message`, and `WebhookReply`. `to_message` resolves the dialect from the HTTP headers and `extFields`; the built-in handler calls the same decoder. `WebhookHandler` and `WebhookState` in `src/transport/http/webhook.rs` are internal implementation details. + +The consumer owns the spawned server and heartbeat before awaiting registration, so dropping the startup future runs the same local cleanup as dropping an active consumer. + +All SDK HTTP operations use code-header routing at `/`. The bodies are `application/x-www-form-urlencoded`, so sending them to a Runtime path-based handler can select an incompatible JSON model. The heartbeat runs every 30 seconds in a background Tokio task tied to a `CancellationToken`. + +## TCP connection lifecycle + +The consumer invokes each handler inside an asynchronous `catch_unwind` boundary covering both future construction and polling. An unwinding handler panic is logged with the delivery sequence, skips reply/ACK for that delivery, and keeps the receive loop running. The boundary does not restore application state. Explicit handler errors and reply encoding/enqueue failures still close the connection without ACK. + +In `src/transport/tcp/connection.rs`, `establish()` performs the socket and HELLO handshake. `run()` wraps `io_loop()` in the reconnect loop. With reconnect enabled, I/O failures trigger exponential backoff and re-establishment. `take_reconnect_rx()` notifies consumers after successful reconnects so they can replay subscriptions. + +Broadcasts use a driver completion channel to await `Framed::send`, including its socket flush, without waiting for a server ACK. Queue reservation and completion share one control-timeout deadline. A cancelled broadcast still waiting in the outbound queue is skipped; a write already in progress may have reached the server. diff --git a/eventmesh-sdks/eventmesh-sdk-rust/CONTRIBUTING.md b/eventmesh-sdks/eventmesh-sdk-rust/CONTRIBUTING.md new file mode 100644 index 0000000000..56546c1cdf --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to the EventMesh Rust SDK + +This guide covers `eventmesh-sdks/eventmesh-sdk-rust`. Repository-wide Apache EventMesh contribution requirements still apply. + +## Prerequisites + +- Rust 1.86.0 or newer (the crate MSRV) +- `protoc` on `PATH` for builds that enable `grpc`, `full`, or `e2e` +- Docker and a compatible EventMesh runtime only for live end-to-end tests +- Java 8 or newer and Maven for the optional cross-SDK interop tests + +`build.rs` invokes `tonic-build`; generated protobuf code lives in `OUT_DIR` and must not be edited or committed. + +## Local checks + +Run these before submitting a Rust SDK change: + +```bash +cargo fmt --check +cargo clippy --no-default-features --lib -- -D warnings +cargo clippy --features full --all-targets -- -D warnings +cargo test --features full +cargo doc --features full --no-deps +``` + +Use `cargo test --features full --test codec_test` for the codec test binary. Examples are feature-gated in `Cargo.toml`; compile the one you changed with its documented `cargo run --example ... --features ...` command, or compile all supported paths with `cargo check --examples --features full`. + +## End-to-end tests + +The e2e suite is opt-in so a normal `cargo test` never requires Docker: + +```bash +cargo test --features e2e +``` + +The harness starts the `rocketmq` docker-compose profile unless `EVENTMESH_E2E_EXTERNAL=1` points it at an already running runtime. An absent runtime is a failure by default. `EVENTMESH_E2E_ALLOW_SKIP=1` is only for an intentional local skip and must not be used for release verification. + +The bundled compose file pins the Runtime to `apache/eventmesh:v1.12.0`. Run the bidirectional Rust/Java gRPC, HTTP, and TCP checks with: + +```bash +cargo test --features interop_e2e --test e2e interop +``` + +Those tests build `interop/java-peer` with Maven on first use. The standalone peer depends on `org.apache.eventmesh:eventmesh-sdk-java:1.12.0-release`; it does not compile or load the Java SDK source tree from this repository. + +The TCP reconnect test runs in the normal e2e suite. It uses a unique client subsystem and the Runtime admin API to disconnect only its own TCP sessions, so it does not restart or disrupt the shared Runtime container. + +Each test uses a unique topic and consumer group. gRPC and HTTP cases may run in parallel; TCP cases are serialized because Runtime route refresh and RocketMQ rebalance state are shared. The harness creates and warms topics through the admin API before publishing. + +The standalone in-memory broker requires a topic and subscription before the first publish and does not implement request/reply. Use a runtime profile with request/reply support for complete release verification. Topic creation uses form URL encoding at `POST /topic`. + +## Documentation responsibilities + +Keep each document in its intended layer. + +| Change | Update | +| --- | --- | +| Installation, feature choice, or common behavior | `README.md` | +| Public type, method, feature-gated API, or behavior | rustdoc in `src/` | +| Runnable workflow or transport use | the matching file in `examples/` and `examples/README.md` | +| Validation, e2e, or contributor workflow | this file | +| Protocol boundary or internal architecture | `ARCHITECTURE.md` | + +Public rustdoc should state feature requirements, ownership/lifecycle rules, and error or acknowledgement behavior where relevant. Prefer an executable doctest when it has no runtime dependency; otherwise mark the snippet `rust,ignore` and point users to a runnable example. + +## Code conventions + +- Add the Apache license header to every new `.rs` file. +- Mirror the established consuming builder style for configuration additions. +- Keep transport wire formats behind the public v2 API. Transports consume the public configuration types (`GrpcConfig`, `HttpConfig`, `TcpConfig`, role options) directly — do not reintroduce transport-private configuration adapters. + +Follow the additional protocol boundaries and internal constraints in [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml b/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml index 42bf18682c..70a6c2e284 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml +++ b/eventmesh-sdks/eventmesh-sdk-rust/Cargo.toml @@ -1,86 +1,214 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at # -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# http://www.apache.org/licenses/LICENSE-2.0 # +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. [package] name = "eventmesh" -version = "1.9.0" +version = "2.0.0" edition = "2021" -authors = [ - "mxsm " -] -msrv = "1.75.0" -description = "Rust client for Apache EventMesh" +rust-version = "1.86.0" +authors = ["Apache EventMesh "] license = "Apache-2.0" -keywords = ["EventMesh", "SDK", "rust-client", "rust", "eventmesh-rust-sdk"] -readme = "./README.md" -homepage = "https://github.com/apache/eventmesh" +description = "Apache EventMesh Rust SDK" +homepage = "https://eventmesh.apache.org" repository = "https://github.com/apache/eventmesh" +keywords = ["eventmesh", "messaging", "cloud-events", "grpc"] +categories = ["api-bindings", "network-programming"] + +[lib] +name = "eventmesh" +path = "src/lib.rs" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["grpc", "eventmesh_message"] -full = ["grpc", "eventmesh_message","cloud_events"] -eventmesh_message = [] -cloud_events = [] -tls = [] -grpc = [] - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +# Transports are opt-in. This keeps the base crate lightweight and, in +# particular, lets consumers that only use the shared message model avoid +# compiling a protocol implementation. +default = [] +# Transport protocols (implemented incrementally). +grpc = [ + "dep:tonic", + "dep:prost", + "dep:prost-types", + "dep:tonic-build", + "dep:tokio", + "dep:tokio-util", + "dep:tracing", + "dep:rand", + "dep:uuid", + "dep:http", +] +# HTTP transport (producer, consumer, webhook middleware + built-in server). +http = [ + "dep:reqwest", + "dep:serde_urlencoded", + "dep:axum", + "dep:tokio", + "dep:tokio-util", + "dep:tracing", + "dep:rand", + "dep:uuid", + "dep:bytes", + "dep: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 = [ + "dep:tokio", + "dep:tokio-stream", + "dep:tokio-util", + "tokio-util/codec", + "dep:futures", + "dep:tracing", + "dep:rand", + "dep:uuid", + "dep:bytes", +] +# Message models. +cloud_events = ["dep:cloudevents", "dep:chrono"] +# Convenience aggregate. +full = ["grpc", "http", "tcp", "cloud_events"] +# End-to-end tests (tests/e2e/*). Gated off by default so plain `cargo test` +# never needs a live server. Run with `cargo test --features e2e`. Implies all +# transports + cloud_events so the full suite (gRPC + HTTP + TCP + CE) compiles +# from a single flag. +e2e = ["grpc", "http", "tcp", "cloud_events"] +# Cross-SDK E2E: builds the Maven peer in interop/java-peer and starts it on +# the host JVM. The peer is pinned to eventmesh-sdk-java:1.12.0-release. +interop_e2e = ["e2e"] + [dependencies] -# common -anyhow = "1.0" - -#Rust grpc -tonic = "0.10" -prost = "0.12" -prost-types = "0.12" - -#tokio -tokio = { version = "1.32.0", features = ["full"] } - -#serde -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -#log -tracing = "0.1" -tracing-subscriber = "0.3" - -#cloudEvents -cloudevents-sdk = "0.7.0" - -# 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" +tokio = { version = "1", optional = true, features = [ + "macros", + "net", + "rt-multi-thread", + "signal", + "sync", + "time", +] } +tokio-stream = { version = "0.1", optional = true, features = ["net"] } +tokio-util = { version = "0.7", optional = true } +futures = { version = "0.3", optional = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +bytes = { version = "1", optional = true } +rand = { version = "0.8", optional = true } +uuid = { version = "1", optional = true, features = ["v4"] } +tracing = { version = "0.1", optional = true } +http = { version = "1", optional = true } +# gRPC transport. +tonic = { version = "0.12", optional = true } +prost = { version = "0.13", optional = true } +prost-types = { version = "0.13", optional = true } +# CloudEvents interop. +cloudevents = { package = "cloudevents-sdk", version = "0.7", optional = true } +chrono = { version = "0.4", optional = true, default-features = false, features = ["std", "clock"] } +# HTTP transport. +reqwest = { version = "0.12", optional = true, features = ["json"] } +serde_urlencoded = { version = "0.7", optional = true } +axum = { version = "0.7", optional = true } [build-dependencies] -tonic-build = "0.10" +tonic-build = { version = "0.12", optional = true } + +[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" + +[[example]] +name = "grpc_producer" +path = "examples/grpc/producer.rs" +required-features = ["grpc"] + +[[example]] +name = "grpc_consumer" +path = "examples/grpc/consumer.rs" +required-features = ["grpc"] + +[[example]] +name = "grpc_webhook_consumer" +path = "examples/grpc/webhook_consumer.rs" +required-features = ["grpc"] + +[[example]] +name = "grpc_producer_cloud_events" +path = "examples/grpc/producer_cloud_events.rs" +required-features = ["grpc", "cloud_events"] + +[[example]] +name = "grpc_batch" +path = "examples/grpc/batch.rs" +required-features = ["grpc"] + +[[example]] +name = "grpc_request_reply" +path = "examples/grpc/request_reply.rs" +required-features = ["grpc"] + +[[example]] +name = "http_producer" +path = "examples/http/producer.rs" +required-features = ["http"] + +[[example]] +name = "http_producer_cloud_events" +path = "examples/http/producer_cloud_events.rs" +required-features = ["http", "cloud_events"] + +[[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 = "tcp_consumer" +path = "examples/tcp/consumer.rs" +required-features = ["tcp"] + +[[example]] +name = "tcp_producer_cloud_events" +path = "examples/tcp/producer_cloud_events.rs" +required-features = ["tcp", "cloud_events"] [[example]] -name = "producer_example" -path = "examples/grpc/producer_example.rs" -required-features = ["grpc", "eventmesh_message","cloud_events"] +name = "tcp_broadcast" +path = "examples/tcp/broadcast.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_request_reply" +path = "examples/tcp/request_reply.rs" +required-features = ["tcp"] diff --git a/eventmesh-sdks/eventmesh-sdk-rust/README.md b/eventmesh-sdks/eventmesh-sdk-rust/README.md index 04dc48088a..ebd4cb1cac 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/README.md +++ b/eventmesh-sdks/eventmesh-sdk-rust/README.md @@ -1,156 +1,158 @@ -## Eventmesh-rust-sdk +# Apache EventMesh Rust SDK -Eventmesh rust sdk +`eventmesh` is the Rust SDK for [Apache EventMesh](https://eventmesh.apache.org). It provides separate, feature-gated gRPC, HTTP, and TCP clients over a shared message and configuration API. -## Quickstart +## Requirements -### Requirements +- Rust 1.86 or newer +- `protoc` 3.15 or newer when enabling `grpc` (including `full` or `e2e`) +- A compatible EventMesh runtime for network operations -1. rust toolchain, eventmesh's MSRV is 1.75. -2. protoc 3.15.0+ -3. setup eventmesh runtime +## Features -### Add Dependency +The default feature set is empty. Enable the transport(s) your application uses; `full` is primarily convenient for local verification. + +| Feature | Provides | +| --- | --- | +| `grpc` | `GrpcChannel`, producer, stream consumer, and webhook registration | +| `http` | `HttpClient`, managed HTTP consumer, external webhook registration, and webhook codec helpers | +| `tcp` | `TcpClient`, connected producer/consumer, broadcast, and reconnect | +| `cloud_events` | `Message::CloudEvent(cloudevents::Event)` support | +| `full` | All transports and CloudEvents support | +| `e2e` | Live-runtime integration tests; implies all runtime features | +| `interop_e2e` | Bidirectional Rust/Java gRPC, HTTP, and TCP tests against Java SDK 1.12.0; implies `e2e` | ```toml [dependencies] -eventmesh = { version = "1.9", features = ["default"] } +eventmesh = { version = "2", features = ["grpc"] } ``` -### Send message +## Quick start + +The same `Message`, `EventMeshMessage`, `Subscription`, and role options are used by every transport. gRPC connects an explicit channel and passes it to each role; HTTP and TCP use their transport clients as role factories. ```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::{Endpoint, GrpcConfig, ProducerOptions}, + EventMeshMessage, GrpcChannel, GrpcProducer, Message, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let channel = + GrpcChannel::connect(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?)).await?; + let producer = GrpcProducer::new(channel, ProducerOptions::new("orders-producer"))?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + "orders.created", + r#"{"id": 42}"#, + )?)) + .await?; + println!("accepted with code {}", receipt.code); Ok(()) } ``` -### Subscribe message +For a consumer, implement `MessageHandler`. Return `Ok(None)` to acknowledge an asynchronous delivery, `Ok(Some(reply))` for request/reply, and `Err(_)` to report application failure to the transport. -```rust -use std::time::Duration; +```rust,ignore +struct Log; -use tracing::info; +impl eventmesh::MessageHandler for Log { + async fn handle(&self, message: eventmesh::Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} +``` -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}; +See the runnable transport-specific consumer programs in [examples/README.md](examples/README.md). -struct EventMeshListener; +## Transport guide -impl ReceiveMessageListener for EventMeshListener { - type Message = EventMeshMessage; +| Transport | Client | Consumer model | Notable operations | +| --- | --- | --- | --- | +| gRPC | `GrpcChannel` | `GrpcStreamConsumer` invokes a `MessageHandler` | batch publish, request/reply, stream subscriptions with the limitations below | +| HTTP | `HttpClient` | `consumer` binds and runs an axum callback server; `webhook_registration` supports application-owned endpoints | publish, weighted endpoint selection | +| TCP | `TcpClient` | connected `consumer` invokes a `MessageHandler` | broadcast, request/reply, automatic reconnect | - fn handle(&self, msg: Self::Message) -> eventmesh::Result> { - info!("Receive message from eventmesh================{:?}", msg); - Ok(None) - } -} +`HttpClient::consumer` binds its callback socket before registering subscriptions, then owns the axum server, heartbeat, and registration lifecycle. For an application-owned endpoint, use `HttpClient::webhook_registration` with `eventmesh::http::codec::{parse_push_body, WebhookReply}`. Decode each delivery with `parse_push_body(body)?.to_message(&headers)?`, passing its `http::HeaderMap`; this uses the same dialect detection as the built-in server and preserves CloudEvents when `cloud_events` is enabled. TCP unsubscribe is session-wide, so its API is `unsubscribe_all()`. -#[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(()) -} +Cancelling `HttpClient::consumer` during startup stops its local callback server and heartbeat task. A subscription already accepted by the Runtime is not rolled back by cancellation. -``` +TCP `broadcast().await` waits for the local socket write before returning, so a subsequent `shutdown().await` does not discard the broadcast from the SDK queue. It does not wait for a Runtime acknowledgement or guarantee delivery. Queueing and writing share the TCP control timeout. -## Development Guide +TCP consumers isolate unwinding handler panics to one delivery: they log the panic, send no reply or ACK for that delivery, and continue processing subsequent messages on the same connection. Redelivery depends on the Runtime's retry policy. The SDK does not restore handler-owned state after a panic, and `panic = "abort"` cannot be caught. -### Dependencies +All consumers use the same local lifecycle contract: `shutdown()` only signals background work to stop, while `join().await` waits for it and reports task or transport failures. Cancelling a `join()` wait preserves task ownership and pending failures: call `join()` again to finish waiting, or drop the consumer to abort its tasks. HTTP consumers and webhook registrations additionally provide `close().await`, which unregisters remote subscriptions before signalling shutdown and joining. -In order to build `tonic` >= 0.8.0, you need the `protoc` Protocol Buffers compiler, along with Protocol Buffers resource files. +Create each `GrpcChannel` inside the Tokio runtime that will drive it. Clone that +channel to share one multiplexed HTTP/2 connection among producers and consumers +in the same runtime. If an application uses another Tokio runtime, call +`GrpcChannel::connect` again from that runtime instead of carrying over an +existing channel. Both current-thread and multi-thread Tokio runtimes are supported; +keep the owning runtime running to drive the channel and consumer tasks. Opening +a subscription stream waits up to 15 seconds for response headers; this timeout +does not limit the lifetime of an established stream. -#### Ubuntu +Known gRPC stream subscription limitations (retained in this SDK revision): -```bash -sudo apt update && sudo apt upgrade -y -sudo apt install -y protobuf-compiler libprotobuf-dev -``` +- **Subscription rejection is not reported reliably.** `GrpcStreamConsumer::open()` establishes the stream without waiting for a successful subscription acknowledgement, and `subscribe()` queues the request without waiting for acceptance. The receive loop ignores control frames without `seqnum`, including Runtime ACL and validation errors carried in `statuscode` / `responsemessage`. If the Runtime then closes the stream normally, `join()` can return `Ok(())`. These successful returns do not prove that the subscription was accepted; check Runtime logs when diagnosing missing deliveries. The repository's Java SDK stream consumer also does not propagate these rejection statuses to the caller. +- **Unsubscribing one topic can stop every topic on the stream.** With A and B on one stream, `unsubscribe(A)` removes A locally, but the Java Runtime closes their shared emitter. The Rust receive loop and heartbeat stop, B stops receiving, and subsequent `subscribe()` calls fail with `Error::ChannelClosed` once stream teardown is observed. There is no automatic stream recreation or replay of B. Treat unsubscribe as ending the current stream: wait for it to finish, drop the consumer, and explicitly open a new consumer with the desired remaining subscriptions. Delivery is interrupted during this transition. The repository's Java SDK also does not recreate the closed stream; its heartbeat may continue despite the loss of delivery. -#### Alpine Linux +These are documented limitations, not fixes. See [ARCHITECTURE.md](ARCHITECTURE.md#grpc-stream-subscription-limitations) for the Runtime and Java SDK paths behind them. They concern stream subscriptions; gRPC webhook registration uses unary responses. -```sh -sudo apk add protoc protobuf-dev -``` +`GrpcWebhookConsumer` does not automatically unregister remote webhook subscriptions when `shutdown()` or `join()` is called. Retain the subscriptions and webhook URL, call `unsubscribe(...).await` explicitly, and only then call `shutdown()` and `join().await`. See the `grpc_webhook_consumer` example. + +HTTP request/reply is not exposed because the current SDK and stock Runtime do not provide a complete HTTP responder path. Use gRPC or TCP for request/reply. + +`Message` is a public dialect envelope, not a wire format. The selected transport owns protobuf, HTTP form, or TCP frame serialization. With `cloud_events`, CloudEvents remain CloudEvents; `Message::into_event_mesh()` does not silently flatten them into the native EventMesh model. + +`EventMeshMessage` is a business model rather than a stable serde JSON contract. Topic, content, message IDs, TTL, and payload content type have dedicated fields. Set TTL with `EventMeshMessageBuilder::ttl_millis` and content type with `data_content_type`; read them through the matching message accessors. Native decoders preserve numeric TTL until outbound validation and reject malformed or out-of-i64-range TTL. CloudEvents retain their standard attributes and extensions. -#### macOS +`properties()` and `get_prop()` expose only business extensions. Received protocol descriptors, identity, and known routing attributes live in the separate, read-only `DeliveryContext` returned by `message.delivery_context()`. It is `None` on locally built messages. Use `context.protocol_description()` for the source protocol and `context.attribute("cluster")` for received routing metadata. Producers ignore this context and rebuild transport metadata from the destination client. Consumer reply paths automatically restore the original request's routing; ACKs continue to use the received transport frame. -Assuming [Homebrew](https://brew.sh/) is already installed. (If not, see instructions for installing Homebrew on [the Homebrew website](https://brew.sh/).) +Reserved names cannot be injected through business properties. `builder.prop(...)` and `builder.props(...)` report `Error::InvalidArgument` at `build()` for reserved keys; `set_prop` and `with_property` now return `Result` and reject them immediately. For example: -```zsh -brew install protobuf +```rust +use eventmesh::EventMeshMessage; + +let mut message = EventMeshMessage::builder() + .topic("orders") + .content(r#"{"id":42}"#) + .ttl_millis(7_000) + .data_content_type("application/json") + .prop("tenant", "store-a") + .build()?; +message.set_prop("tenant", "store-b")?; +assert!(message.set_prop("protocoldesc", "tcp").is_err()); +# Ok::<(), eventmesh::Error>(()) ``` -#### Windows +Migration: replace `get_prop("ttl")`, `get_prop("seqnum")`, `get_prop("uniqueid")`, and `get_prop("datacontenttype")` with the dedicated accessors. Read protocol/routing attributes from `delivery_context()`; do not copy them into a reply's properties. Add `?` to business-property `set_prop`/`with_property` calls. The `MessageHandler` signature is unchanged. + +## Configuration and errors + +All configurations require a validated `Endpoint`; HTTP uses a non-empty `EndpointSet`. Use `with_*` methods to set optional identity, credentials, timeouts, HTTP TLS, proxy, and reconnect settings. EventMesh Runtime's gRPC endpoint is plaintext and the gRPC client intentionally does not expose TLS configuration. `Debug` output redacts secrets. -- 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` +Default request timeouts are 5 seconds (gRPC), 15 seconds (HTTP), and 20 seconds (TCP). `ClientOptions::with_request_timeout` changes a client's default; gRPC and TCP producers also have `request_reply_with_timeout` for one call. TCP separately has a 1-second connect timeout and a 20-second control timeout. -### Build +Operations return the pattern-matchable `eventmesh::Error`; common variants include `Config`, `InvalidArgument`, `InvalidMessage`, `Timeout`, `Server`, `Protocol`, `Unsupported`, and transport-specific errors. -```shell -cargo build +## API documentation + +Generate and open the API documentation for every supported feature: + +```bash +cargo doc --features full --no-deps --open ``` +The crate root documents the public API map. Module-level rustdoc documents configuration, message models, subscriptions, and each transport. Keep these comments current when changing public behavior; see [CONTRIBUTING.md](CONTRIBUTING.md). + +## Development + +See [CONTRIBUTING.md](CONTRIBUTING.md) for prerequisites, required checks, and live-runtime tests. Implementation boundaries and protocol details are recorded in [ARCHITECTURE.md](ARCHITECTURE.md). + +## 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..776d477e2e 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/build.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/build.rs @@ -1,30 +1,44 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// 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(()); + } + + #[cfg(feature = "grpc")] 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..d802cfe037 --- /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. +# - Override the pinned release image with IMAGE, e.g.: +# IMAGE=yourname/eventmesh:tag docker compose --profile standalone up -d +# +# The default is the Apache EventMesh 1.12.0 release image used by this SDK's +# protocol compatibility suite. +# - `docker compose up` (no profile) starts nothing on purpose, to avoid +# accidentally booting the wrong storage backend. + +services: + + # ==================================================================== + # Profile: standalone + # ==================================================================== + eventmesh-standalone: + image: ${IMAGE:-apache/eventmesh:v1.12.0} + 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:-apache/eventmesh:v1.12.0} + 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..a119bab2a5 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/docker/conf/broker.conf @@ -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. + +# 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/README.md b/eventmesh-sdks/eventmesh-sdk-rust/examples/README.md new file mode 100644 index 0000000000..e0fd7f1606 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/README.md @@ -0,0 +1,33 @@ +# Rust SDK examples + +Each example is a small executable with one responsibility. They use the default EventMesh ports and the topic `test-topic-rust-sdk`; start a compatible runtime before running them. + +| Transport | Example | What it demonstrates | Command | +| --- | --- | --- | --- | +| gRPC | `grpc_producer` | Publish a native EventMesh message | `cargo run --example grpc_producer --features grpc` | +| gRPC | `grpc_consumer` | Stream consumption with `MessageHandler` | `cargo run --example grpc_consumer --features grpc` | +| gRPC | `grpc_webhook_consumer` | Register an application-owned webhook and explicitly unregister it on exit | `cargo run --example grpc_webhook_consumer --features grpc` | +| gRPC | `grpc_producer_cloud_events` | Publish a CloudEvent | `cargo run --example grpc_producer_cloud_events --features grpc,cloud_events` | +| gRPC | `grpc_batch` | Publish several native messages with one batch RPC | `cargo run --example grpc_batch --features grpc` | +| gRPC | `grpc_request_reply` | Run a synchronous subscriber and complete request/reply | `cargo run --example grpc_request_reply --features grpc` | +| HTTP | `http_producer` | Publish over HTTP | `cargo run --example http_producer --features http` | +| HTTP | `http_producer_cloud_events` | Publish a CloudEvent over HTTP | `cargo run --example http_producer_cloud_events --features http,cloud_events` | +| HTTP | `http_consumer_server` | SDK-managed axum callback server | `cargo run --example http_consumer_server --features http` | +| HTTP | `http_consumer_custom` | Application-owned axum webhook endpoint | `cargo run --example http_consumer_custom --features http` | +| TCP | `tcp_producer` | Connected TCP publish | `cargo run --example tcp_producer --features tcp` | +| TCP | `tcp_consumer` | Connected TCP subscribe | `cargo run --example tcp_consumer --features tcp` | +| TCP | `tcp_producer_cloud_events` | Publish a CloudEvent over TCP | `cargo run --example tcp_producer_cloud_events --features tcp,cloud_events` | +| TCP | `tcp_broadcast` | Send a fire-and-forget broadcast | `cargo run --example tcp_broadcast --features tcp` | +| TCP | `tcp_request_reply` | Run a synchronous subscriber and complete request/reply | `cargo run --example tcp_request_reply --features tcp` | + +Run a consumer first, then run its corresponding producer. The HTTP examples listen on ports 8080 (built-in server) and 8081 (custom endpoint); change the advertised callback URL when EventMesh cannot reach `127.0.0.1`. + +The examples intentionally use minimal configuration. For timeouts, identity, credentials, HTTP TLS, endpoint weights, and TCP reconnect tuning, consult the public rustdoc with `cargo doc --features full --no-deps --open`. + +The two HTTP consumer examples handle Ctrl-C and call `close().await`, which unregisters their remote subscriptions before stopping local background work. Use the same shutdown pattern in long-running applications. + +The custom HTTP webhook uses `PushMessageRequestBody::to_message` with the request headers to detect the message dialect. To receive CloudEvents as well as native messages, run `cargo run --example http_consumer_custom --features http,cloud_events`. + +The gRPC webhook consumer has no automatic remote cleanup. Its example retains the subscription and URL, calls `unsubscribe().await` after Ctrl-C, and then stops and joins the local heartbeat task. + +Native handlers can inspect source protocol/routing information through `message.as_event_mesh().and_then(|message| message.delivery_context())`. Business extensions remain in `properties()`. Return a newly built message for request/reply; the SDK supplies the original request context automatically. Configure TTL/content type with dedicated builder methods, and handle the `Result` from `set_prop`/`with_property` when changing business properties. diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/batch.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/batch.rs new file mode 100644 index 0000000000..e14e6f88e7 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/batch.rs @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, GrpcConfig, ProducerOptions}, + EventMeshMessage, GrpcChannel, GrpcProducer, Message, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let channel = + GrpcChannel::connect(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?)).await?; + let producer = GrpcProducer::new(channel, ProducerOptions::new("test-producerGroup"))?; + let messages = (1..=3) + .map(|index| { + EventMeshMessage::new( + "test-topic-rust-sdk", + format!("hello from rust batch #{index}"), + ) + .map(Message::from) + }) + .collect::>>()?; + + println!("published: {:?}", producer.publish_batch(messages).await?); + Ok(()) +} 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..52e9485100 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer.rs @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, GrpcConfig, GrpcConsumerOptions}, + message::Message, + subscription::Subscription, + GrpcChannel, GrpcStreamConsumer, MessageHandler, +}; + +struct PrintHandler; + +impl MessageHandler for PrintHandler { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let channel = + GrpcChannel::connect(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?)).await?; + let consumer = GrpcStreamConsumer::open( + channel, + GrpcConsumerOptions::new("test-consumerGroup"), + [Subscription::new("test-topic-rust-sdk")], + PrintHandler, + ) + .await?; + consumer.join().await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer_example.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer_example.rs deleted file mode 100644 index 8f84875d69..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/consumer_example.rs +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::time::Duration; - -use tracing::info; - -use eventmesh::common::ReceiveMessageListener; -use eventmesh::config::EventMeshGrpcClientConfig; -use eventmesh::grpc::grpc_consumer::EventMeshGrpcConsumer; -use eventmesh::log; -use eventmesh::model::message::EventMeshMessage; -use eventmesh::model::subscription::{SubscriptionItem, SubscriptionMode, SubscriptionType}; - -struct EventMeshListener; - -impl ReceiveMessageListener for EventMeshListener { - type Message = EventMeshMessage; - - fn handle(&self, msg: Self::Message) -> eventmesh::Result> { - info!("Receive message from eventmesh================{:?}", msg); - Ok(None) - } -} - -#[eventmesh::main] -async fn main() -> Result<(), Box> { - log::init_logger(); - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let listener = Box::new(EventMeshListener); - let mut consumer = EventMeshGrpcConsumer::new(grpc_client_config, listener); - //send - let item = SubscriptionItem::new( - "TEST-TOPIC-GRPC-ASYNC", - SubscriptionMode::CLUSTERING, - SubscriptionType::ASYNC, - ); - info!("==========Start consumer======================\n{}", item); - let _response = consumer.subscribe(vec![item.clone()]).await?; - tokio::time::sleep(Duration::from_secs(1000)).await; - info!("=========Unsubscribe start================"); - let response = consumer.unsubscribe(vec![item.clone()]).await?; - println!("unsubscribe result:{}", response); - Ok(()) -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer.rs new file mode 100644 index 0000000000..003ce57ae4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer.rs @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, GrpcConfig, ProducerOptions}, + message::{EventMeshMessage, Message}, + GrpcChannel, GrpcProducer, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let channel = + GrpcChannel::connect(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?)).await?; + let producer = GrpcProducer::new(channel, ProducerOptions::new("test-producerGroup"))?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + "test-topic-rust-sdk", + "hello from rust", + )?)) + .await?; + println!("published: {receipt:?}"); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_cloud_events.rs new file mode 100644 index 0000000000..6e652481a1 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_cloud_events.rs @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{ + config::{Endpoint, GrpcConfig, ProducerOptions}, + message::Message, + Error, GrpcChannel, GrpcProducer, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let event = EventBuilderV10::new() + .id("rust-example-1") + .source("urn:eventmesh:rust-example") + .ty("com.example.created") + .subject("test-topic-rust-sdk") + .data("application/json", serde_json::json!({"message": "hello"})) + .build() + .map_err(|error| Error::InvalidArgument(format!("invalid CloudEvent: {error}")))?; + + let channel = + GrpcChannel::connect(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?)).await?; + let producer = GrpcProducer::new(channel, ProducerOptions::new("test-producerGroup"))?; + println!( + "published: {:?}", + producer.publish(Message::from(event)).await? + ); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_example.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_example.rs deleted file mode 100644 index 959c1a6a02..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/producer_example.rs +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::time::{SystemTime, UNIX_EPOCH}; - -use chrono::Utc; -use cloudevents::{EventBuilder, EventBuilderV10}; -use tracing::info; - -use eventmesh::common::ProtocolKey; -use eventmesh::config::EventMeshGrpcClientConfig; -use eventmesh::grpc::grpc_producer::EventMeshGrpcProducer; -use eventmesh::grpc::GrpcEventMeshProducer; -use eventmesh::log; -use eventmesh::model::message::EventMeshMessage; - -#[eventmesh::main] -async fn main() -> Result<(), Box> { - log::init_logger(); - - //Publish Message - #[cfg(feature = "eventmesh_message")] - { - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let mut producer = GrpcEventMeshProducer::new(grpc_client_config); - info!("Publish Message to EventMesh........"); - let message = EventMeshMessage::default() - .with_biz_seq_no("1") - .with_content("123") - .with_create_time(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64) - .with_topic("123") - .with_unique_id("1111"); - let response = producer.publish(message.clone()).await?; - info!("Publish Message to EventMesh return result: {}", response); - - //Publish batch message - info!("Publish batch message to EventMesh........"); - let messages = vec![message.clone(), message.clone(), message.clone()]; - let response = producer.publish_batch(messages).await?; - info!( - "Publish batch message to EventMesh return result: {}", - response - ); - - //Publish batch message - info!("Publish request reply message to EventMesh........"); - let response = producer.request_reply(message.clone(), 1000).await?; - info!( - "Publish request reply message to EventMesh return result: {}", - response - ); - } - - #[cfg(feature = "cloud_events")] - { - let grpc_client_config = EventMeshGrpcClientConfig::new(); - let mut producer = GrpcEventMeshProducer::new(grpc_client_config); - info!("Publish Message to EventMesh........"); - let message = EventBuilderV10::new() - .id("my_event.my_application") - .source("http://localhost:8080") - .subject("mxsm") - .ty("example.demo") - .time(Utc::now()) - .data(ProtocolKey::CLOUDEVENT_CONTENT_TYPE, "{\"aaa\":\"1111\"}") - .build()?; - let response = producer.publish(message.clone()).await?; - info!("Publish Message to EventMesh return result: {}", response); - - //Publish batch message - info!("Publish batch message to EventMesh........"); - let messages = vec![message.clone(), message.clone(), message.clone()]; - let response = producer.publish_batch(messages).await?; - info!( - "Publish batch message to EventMesh return result: {}", - response - ); - - //Publish batch message - info!("Publish request reply message to EventMesh........"); - let response = producer.request_reply(message.clone(), 1000).await?; - info!( - "Publish request reply message to EventMesh return result: {}", - response - ); - } - - Ok(()) -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/request_reply.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/request_reply.rs new file mode 100644 index 0000000000..259bd797b8 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/request_reply.rs @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, GrpcConfig, GrpcConsumerOptions, ProducerOptions}, + EventMeshMessage, GrpcChannel, GrpcProducer, GrpcStreamConsumer, Message, Subscription, +}; +use std::time::Duration; + +const TOPIC: &str = "test-topic-rust-sdk"; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let channel = + GrpcChannel::connect(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?)).await?; + let consumer = GrpcStreamConsumer::open( + channel.clone(), + GrpcConsumerOptions::new("test-consumerGroup"), + [Subscription::new(TOPIC).with_delivery_type(eventmesh::DeliveryType::Sync)], + |request: Message| async move { + let request = request.into_event_mesh()?; + Ok(Some(Message::from(EventMeshMessage::new( + request.topic(), + "pong", + )?))) + }, + ) + .await?; + let producer = GrpcProducer::new(channel, ProducerOptions::new("test-producerGroup"))?; + + // Give EventMesh time to make the new subscription routable before the + // first synchronous request. + tokio::time::sleep(Duration::from_secs(1)).await; + let reply = producer + .request_reply(Message::from(EventMeshMessage::new(TOPIC, "ping")?)) + .await?; + println!("reply: {reply:?}"); + + consumer.shutdown(); + consumer.join().await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/webhook_consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/webhook_consumer.rs new file mode 100644 index 0000000000..7dd6306fee --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/grpc/webhook_consumer.rs @@ -0,0 +1,48 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, GrpcConfig}, + GrpcChannel, GrpcWebhookConsumer, Subscription, +}; + +const TOPIC: &str = "test-topic-rust-sdk"; +const WEBHOOK_URL: &str = "http://127.0.0.1:8080/eventmesh/callback"; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + // Start an HTTP endpoint at WEBHOOK_URL before running this example. + let channel = + GrpcChannel::connect(GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?)).await?; + let consumer = + GrpcWebhookConsumer::new(channel, ConsumerOptions::new("test-consumerGroup")).await?; + let subscription = Subscription::new(TOPIC); + + consumer + .subscribe([subscription.clone()], WEBHOOK_URL) + .await?; + println!("registered {TOPIC}; press Ctrl-C to unregister and exit"); + tokio::signal::ctrl_c().await?; + + // shutdown() only stops local heartbeat work. Explicitly remove the + // remote registration first so it does not linger until server expiry. + let unsubscribe_result = consumer.unsubscribe([subscription], WEBHOOK_URL).await; + consumer.shutdown(); + let join_result = consumer.join().await; + unsubscribe_result?; + join_result +} 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..407f14d21a --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_custom.rs @@ -0,0 +1,66 @@ +// 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. + +//! Host an EventMesh webhook in an application-owned axum router. + +use axum::{body::Bytes, http::HeaderMap, response::IntoResponse, routing::post, Json, Router}; +use eventmesh::{ + config::{ConsumerOptions, Endpoint, EndpointSet, HttpConfig}, + http::codec::{parse_push_body, WebhookReply}, + subscription::Subscription, + HttpClient, +}; + +async fn webhook(headers: HeaderMap, body: Bytes) -> impl IntoResponse { + let body = match std::str::from_utf8(&body) { + Ok(body) => body, + Err(_) => return Json(WebhookReply::retry("invalid UTF-8")), + }; + match parse_push_body(body).and_then(|push| push.to_message(&headers)) { + Ok(message) => { + println!("received: {message:?}"); + Json(WebhookReply::ok()) + } + Err(error) => { + eprintln!("invalid webhook delivery: {error}"); + Json(WebhookReply::retry("invalid delivery")) + } + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let address: std::net::SocketAddr = "0.0.0.0:8081".parse().expect("valid bind address"); + let webhook_url = "http://127.0.0.1:8081/eventmesh/callback"; + let listener = tokio::net::TcpListener::bind(address).await?; + let app = Router::new().route("/eventmesh/callback", post(webhook)); + + let client = HttpClient::new(HttpConfig::new(EndpointSet::new([Endpoint::new( + "127.0.0.1", + 10_105, + )?])?))?; + let consumer = client.webhook_registration(ConsumerOptions::new("test-consumerGroup"))?; + consumer + .subscribe(Subscription::new("test-topic-rust-sdk"), webhook_url) + .await?; + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await?; + consumer.close().await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_server.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_server.rs new file mode 100644 index 0000000000..3de2b31e50 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/consumer_server.rs @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, EndpointSet, HttpConfig}, + message::Message, + subscription::Subscription, + webhook::WebhookOptions, + HttpClient, MessageHandler, +}; + +struct PrintHandler; + +impl MessageHandler for PrintHandler { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", 10_105)?])?; + let client = HttpClient::new(HttpConfig::new(endpoints))?; + let consumer = client + .consumer( + ConsumerOptions::new("test-consumerGroup"), + WebhookOptions::new("0.0.0.0:8080".parse().unwrap()) + .with_advertise_url("http://127.0.0.1:8080/eventmesh/callback"), + [Subscription::new("test-topic-rust-sdk")], + PrintHandler, + ) + .await?; + tokio::select! { + result = consumer.join() => result, + signal = tokio::signal::ctrl_c() => { + signal?; + consumer.close().await + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer.rs new file mode 100644 index 0000000000..26f20f40e5 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer.rs @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, EndpointSet, HttpConfig, ProducerOptions}, + message::{EventMeshMessage, Message}, + HttpClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", 10_105)?])?; + let client = HttpClient::new(HttpConfig::new(endpoints))?; + let producer = client.producer(ProducerOptions::new("test-producerGroup"))?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + "test-topic-rust-sdk", + "hello from rust", + )?)) + .await?; + println!("published: {receipt:?}"); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer_cloud_events.rs new file mode 100644 index 0000000000..0c81cc8ade --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/http/producer_cloud_events.rs @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{ + config::{Endpoint, EndpointSet, HttpConfig, ProducerOptions}, + Error, HttpClient, Message, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let event = EventBuilderV10::new() + .id("rust-http-example-1") + .source("urn:eventmesh:rust-http-example") + .ty("com.example.created") + .subject("test-topic-rust-sdk") + .data("application/json", serde_json::json!({"message": "hello"})) + .build() + .map_err(|error| Error::InvalidArgument(format!("invalid CloudEvent: {error}")))?; + let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", 10_105)?])?; + let producer = HttpClient::new(HttpConfig::new(endpoints))? + .producer(ProducerOptions::new("test-producerGroup"))?; + + println!( + "published: {:?}", + producer.publish(Message::from(event)).await? + ); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/broadcast.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/broadcast.rs new file mode 100644 index 0000000000..418b36f489 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/broadcast.rs @@ -0,0 +1,37 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, ProducerOptions, TcpConfig}, + EventMeshMessage, Message, TcpClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?))?; + let producer = client + .producer(ProducerOptions::new("test-producerGroup")) + .await?; + producer + .broadcast(Message::from(EventMeshMessage::new( + "test-topic-rust-sdk", + "broadcast from rust", + )?)) + .await?; + producer.shutdown().await; + 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..262b76855f --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/consumer.rs @@ -0,0 +1,44 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, TcpConfig}, + message::Message, + subscription::Subscription, + MessageHandler, TcpClient, +}; + +struct PrintHandler; + +impl MessageHandler for PrintHandler { + async fn handle(&self, message: Message) -> eventmesh::Result> { + println!("received: {message:?}"); + Ok(None) + } +} + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?))?; + let consumer = client + .consumer(ConsumerOptions::new("test-consumerGroup"), PrintHandler) + .await?; + consumer + .subscribe(Subscription::new("test-topic-rust-sdk")) + .await?; + consumer.join().await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer.rs new file mode 100644 index 0000000000..470d132cbd --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer.rs @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, ProducerOptions, TcpConfig}, + message::{EventMeshMessage, Message}, + TcpClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?))?; + let producer = client + .producer(ProducerOptions::new("test-producerGroup")) + .await?; + let receipt = producer + .publish(Message::from(EventMeshMessage::new( + "test-topic-rust-sdk", + "hello from rust", + )?)) + .await?; + println!("published: {receipt:?}"); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer_cloud_events.rs new file mode 100644 index 0000000000..3c0b5b4ec8 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/producer_cloud_events.rs @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use cloudevents::{EventBuilder, EventBuilderV10}; +use eventmesh::{ + config::{Endpoint, ProducerOptions, TcpConfig}, + message::Message, + Error, TcpClient, +}; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + // EventMesh's Java TCP codec currently uses `datacontenttype` to choose + // the serializer for the whole CloudEvent. TCP CloudEvents must therefore + // use `application/cloudevents+json`, even when their data is ordinary + // JSON. HTTP and gRPC do not have this compatibility restriction. + let event = EventBuilderV10::new() + .id("rust-example-1") + .source("urn:eventmesh:rust-example") + .ty("com.example.created") + .subject("test-topic-rust-sdk") + .data( + "application/cloudevents+json", + serde_json::json!({"message": "hello"}), + ) + .build() + .map_err(|error| Error::InvalidArgument(format!("invalid CloudEvent: {error}")))?; + + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?))?; + let producer = client + .producer(ProducerOptions::new("test-producerGroup")) + .await?; + println!( + "published: {:?}", + producer.publish(Message::from(event)).await? + ); + Ok(()) +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/request_reply.rs b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/request_reply.rs new file mode 100644 index 0000000000..3088dddc74 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/examples/tcp/request_reply.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. + +use eventmesh::{ + config::{ConsumerOptions, Endpoint, ProducerOptions, TcpConfig}, + DeliveryType, EventMeshMessage, Message, Subscription, TcpClient, +}; +use std::time::Duration; + +const TOPIC: &str = "test-topic-rust-sdk"; + +#[tokio::main] +async fn main() -> eventmesh::Result<()> { + let client = TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", 10_000)?))?; + let consumer = client + .consumer( + ConsumerOptions::new("test-consumerGroup"), + |request: Message| async move { + let request = request.into_event_mesh()?; + Ok(Some(Message::from(EventMeshMessage::new( + request.topic(), + "pong", + )?))) + }, + ) + .await?; + consumer + .subscribe(Subscription::new(TOPIC).with_delivery_type(DeliveryType::Sync)) + .await?; + let producer = client + .producer(ProducerOptions::new("test-producerGroup")) + .await?; + + // The stock EventMesh runtime refreshes TCP subscription routes + // periodically, so wait for the first refresh before sending a request. + tokio::time::sleep(Duration::from_secs(45)).await; + println!( + "reply: {:?}", + producer + .request_reply(Message::from(EventMeshMessage::new(TOPIC, "ping")?)) + .await? + ); + + producer.shutdown().await; + consumer.shutdown(); + consumer.join().await +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/interop/java-peer/pom.xml b/eventmesh-sdks/eventmesh-sdk-rust/interop/java-peer/pom.xml new file mode 100644 index 0000000000..f601bc2689 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/interop/java-peer/pom.xml @@ -0,0 +1,74 @@ + + + + 4.0.0 + + org.apache.eventmesh + rust-sdk-interop-peer + 1.0.0-SNAPSHOT + + + 8 + UTF-8 + + + + + org.apache.eventmesh + eventmesh-sdk-java + 1.12.0-release + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + + shade + + + eventmesh-java-interop-peer + false + + + + org.apache.eventmesh.interop.JavaInteropPeer + + + + + + + + + diff --git a/eventmesh-sdks/eventmesh-sdk-rust/interop/java-peer/src/main/java/org/apache/eventmesh/interop/JavaInteropPeer.java b/eventmesh-sdks/eventmesh-sdk-rust/interop/java-peer/src/main/java/org/apache/eventmesh/interop/JavaInteropPeer.java new file mode 100644 index 0000000000..1139edd602 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/interop/java-peer/src/main/java/org/apache/eventmesh/interop/JavaInteropPeer.java @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.eventmesh.interop; + +import org.apache.eventmesh.client.grpc.config.EventMeshGrpcClientConfig; +import org.apache.eventmesh.client.grpc.consumer.EventMeshGrpcConsumer; +import org.apache.eventmesh.client.grpc.consumer.ReceiveMsgHook; +import org.apache.eventmesh.client.grpc.producer.EventMeshGrpcProducer; +import org.apache.eventmesh.client.http.conf.EventMeshHttpClientConfig; +import org.apache.eventmesh.client.http.consumer.EventMeshHttpConsumer; +import org.apache.eventmesh.client.http.producer.EventMeshHttpProducer; +import org.apache.eventmesh.client.tcp.EventMeshTCPClient; +import org.apache.eventmesh.client.tcp.EventMeshTCPClientFactory; +import org.apache.eventmesh.client.tcp.common.MessageUtils; +import org.apache.eventmesh.client.tcp.conf.EventMeshTCPClientConfig; +import org.apache.eventmesh.common.Constants; +import org.apache.eventmesh.common.EventMeshMessage; +import org.apache.eventmesh.common.enums.EventMeshProtocolType; +import org.apache.eventmesh.common.protocol.SubscriptionItem; +import org.apache.eventmesh.common.protocol.SubscriptionMode; +import org.apache.eventmesh.common.protocol.SubscriptionType; +import org.apache.eventmesh.common.protocol.tcp.UserAgent; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** Host-process peer used by the Rust SDK cross-SDK E2E suite. */ +public final class JavaInteropPeer { + + private JavaInteropPeer() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 3) { + throw new IllegalArgumentException( + "usage: - host topic [content|callback-host]"); + } + String operation = args[0]; + String host = args[1]; + String topic = args[2]; + if ("grpc-publish".equals(operation)) { + grpcPublish(host, topic, args[3]); + } else if ("grpc-consume".equals(operation)) { + grpcConsume(host, topic); + } else if ("http-publish".equals(operation)) { + httpPublish(host, topic, args[3]); + } else if ("http-consume".equals(operation)) { + httpConsume(host, topic, args[3]); + } else if ("tcp-publish".equals(operation)) { + tcpPublish(host, topic, args[3]); + } else if ("tcp-consume".equals(operation)) { + tcpConsume(host, topic); + } else { + throw new IllegalArgumentException("unknown operation: " + operation); + } + } + + private static EventMeshGrpcClientConfig grpcConfig(String host, String topic) { + String group = "java-interop-" + topic; + return EventMeshGrpcClientConfig.builder() + .serverAddr(host).serverPort(10205).env("env").idc("idc").sys("java-interop") + .producerGroup(group + "-producer").consumerGroup(group + "-consumer") + .userName("eventmesh").password("eventmesh").build(); + } + + private static void grpcPublish(String host, String topic, String content) throws Exception { + try (EventMeshGrpcProducer producer = new EventMeshGrpcProducer(grpcConfig(host, topic))) { + producer.publish(EventMeshMessage.builder().topic(topic).content(content).build()); + } + System.out.println("INTEROP_PUBLISHED"); + } + + private static void grpcConsume(String host, String topic) throws Exception { + CountDownLatch delivered = new CountDownLatch(1); + EventMeshGrpcConsumer consumer = new EventMeshGrpcConsumer(grpcConfig(host, topic)); + consumer.registerListener(new ReceiveMsgHook() { + @Override + public Optional handle(EventMeshMessage message) { + received(message.getTopic(), message.getContent(), delivered); + return Optional.empty(); + } + + @Override + public EventMeshProtocolType getProtocolType() { + return EventMeshProtocolType.EVENT_MESH_MESSAGE; + } + }); + consumer.init(); + consumer.subscribe(Collections.singletonList(subscription(topic))); + System.out.println("INTEROP_READY"); + awaitDelivery(delivered); + consumer.close(); + } + + private static EventMeshHttpClientConfig httpConfig(String host, String topic) { + String group = "java-interop-" + topic; + return EventMeshHttpClientConfig.builder() + .liteEventMeshAddr(host + ":10105") + .producerGroup(group + "-producer").consumerGroup(group + "-consumer") + .env("env").idc("idc").ip("127.0.0.1").pid("1").sys("java-interop") + .userName("eventmesh").password("eventmesh").build(); + } + + private static void httpPublish(String host, String topic, String content) throws Exception { + try (EventMeshHttpProducer producer = new EventMeshHttpProducer(httpConfig(host, topic))) { + producer.publish(EventMeshMessage.builder() + .topic(topic).content(content).bizSeqNo(uniqueId()).uniqueId(uniqueId()).build() + .addProp(Constants.EVENTMESH_MESSAGE_CONST_TTL, "30000")); + } + System.out.println("INTEROP_PUBLISHED"); + } + + private static void httpConsume(String host, String topic, String callbackHost) throws Exception { + CountDownLatch delivered = new CountDownLatch(1); + HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 0), 0); + server.createContext("/eventmesh/callback", exchange -> handleHttpPush(exchange, delivered)); + server.start(); + String callbackUrl = "http://" + callbackHost + ":" + server.getAddress().getPort() + + "/eventmesh/callback"; + try (EventMeshHttpConsumer consumer = new EventMeshHttpConsumer(httpConfig(host, topic))) { + consumer.subscribe(Collections.singletonList(subscription(topic)), callbackUrl); + System.out.println("INTEROP_READY"); + awaitDelivery(delivered); + consumer.unsubscribe(Collections.singletonList(topic), callbackUrl); + } finally { + server.stop(0); + } + } + + private static void handleHttpPush(HttpExchange exchange, CountDownLatch delivered) throws IOException { + Map fields = decodeForm(readBody(exchange.getRequestBody())); + received(fields.get("topic"), fields.get("content"), delivered); + byte[] response = "{\"retCode\":0}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + exchange.close(); + } + + private static UserAgent tcpUserAgent(String topic, boolean subscriber) { + UserAgent base = UserAgent.builder() + .env("PRD").subsystem("java-interop-" + topic).path("/").pid(1) + .host("127.0.0.1").port(0).version("1.0").username("eventmesh") + .password("eventmesh").idc("DEFAULT").group("java-interop-" + topic).build(); + return subscriber ? MessageUtils.generateSubClient(base) : MessageUtils.generatePubClient(base); + } + + private static EventMeshTCPClient + tcpClient(String host, String topic, boolean subscriber) { + EventMeshTCPClientConfig config = EventMeshTCPClientConfig.builder() + .host(host).port(10000).userAgent(tcpUserAgent(topic, subscriber)).build(); + return EventMeshTCPClientFactory.createEventMeshTCPClient( + config, org.apache.eventmesh.common.protocol.tcp.EventMeshMessage.class); + } + + private static org.apache.eventmesh.common.protocol.tcp.EventMeshMessage + tcpMessage(String topic, String content) { + Map properties = new HashMap<>(); + properties.put(Constants.EVENTMESH_MESSAGE_CONST_TTL, "30000"); + Map headers = new HashMap<>(); + headers.put(Constants.DATA_CONTENT_TYPE, "text/plain"); + return new org.apache.eventmesh.common.protocol.tcp.EventMeshMessage( + topic, properties, headers, content); + } + + private static void tcpPublish(String host, String topic, String content) throws Exception { + try (EventMeshTCPClient client = + tcpClient(host, topic, false)) { + client.init(); + client.publish(tcpMessage(topic, content), 20_000L); + } + System.out.println("INTEROP_PUBLISHED"); + } + + private static void tcpConsume(String host, String topic) throws Exception { + CountDownLatch delivered = new CountDownLatch(1); + try (EventMeshTCPClient client = + tcpClient(host, topic, true)) { + client.init(); + client.registerSubBusiHandler(message -> { + received(message.getTopic(), message.getBody(), delivered); + return Optional.empty(); + }); + client.subscribe(topic, SubscriptionMode.CLUSTERING, SubscriptionType.ASYNC); + client.listen(); + System.out.println("INTEROP_READY"); + awaitDelivery(delivered); + } + } + + private static SubscriptionItem subscription(String topic) { + return new SubscriptionItem(topic, SubscriptionMode.CLUSTERING, SubscriptionType.ASYNC); + } + + private static void received(String topic, String content, CountDownLatch delivered) { + System.out.println("INTEROP_RECEIVED=" + topic + "\t" + content); + delivered.countDown(); + } + + private static void awaitDelivery(CountDownLatch delivered) throws InterruptedException { + if (!delivered.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting for message"); + } + } + + private static String uniqueId() { + return Long.toString(System.nanoTime()); + } + + private static String readBody(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + + private static Map decodeForm(String body) throws IOException { + Map fields = new HashMap<>(); + for (String pair : body.split("&")) { + String[] parts = pair.split("=", 2); + fields.put(URLDecoder.decode(parts[0], "UTF-8"), + URLDecoder.decode(parts.length == 2 ? parts[1] : "", "UTF-8")); + } + return fields; + } +} 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..ec1021ad5a 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; @@ -33,9 +34,6 @@ service PublisherService { //publish event with reply rpc requestReply(CloudEvent) returns (CloudEvent); - //publish event one way - rpc publishOneWay(CloudEvent) returns (google.protobuf.Empty); - // publish batch event rpc batchPublish(CloudEventBatch) returns (CloudEvent); 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..d00098add5 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common/constants.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/constants.rs @@ -1,56 +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. - */ -pub const DEFAULT_EVENTMESH_MESSAGE_TTL: i32 = 4000; -pub const SDK_STREAM_URL: &str = "grpc_stream"; - -pub struct DataContentType; - -impl DataContentType { - pub const TEXT_PLAIN: &'static str = "text/plain"; - pub const JSON: &'static str = "application/json"; -} +// 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) struct SpecVersion; +//! Shared protocol/SDK constants. -#[allow(dead_code)] -impl SpecVersion { - pub(crate) const V1: &'static str = "1.0"; - pub(crate) const V03: &'static str = "0.3"; -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ClientType { - PUB, - SUB, -} +/// Default message TTL in milliseconds (mirrors the Java SDK). +pub const DEFAULT_MESSAGE_TTL: i32 = 4_000; -impl ClientType { - pub fn get(type_: i32) -> Option { - match type_ { - 1 => Some(ClientType::PUB), - 2 => Some(ClientType::SUB), - _ => None, - } - } +/// Placeholder URL recorded for stream-mode subscriptions (server treats the +/// stream itself as the delivery channel). +#[cfg(feature = "grpc")] +pub const SDK_STREAM_URL: &str = "grpc_stream"; - pub fn contains(client_type: i32) -> bool { - match client_type { - 1 | 2 => true, - _ => false, - } - } +/// Common `datacontenttype` values. +#[cfg(feature = "grpc")] +pub struct DataContentType; +#[cfg(feature = "grpc")] +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"; } 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..8f0b36e75a --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/loadbalance.rs @@ -0,0 +1,206 @@ +// 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. +//! +//! 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 weighted server endpoint supplied by the validated HTTP configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerNode { + pub host: String, + pub port: u16, + pub weight: i32, +} + +impl ServerNode { + pub fn addr(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + +/// Stateful selector over a set of nodes. +pub enum LoadBalanceSelector { + Random { + nodes: Vec, + }, + WeightRandom { + nodes: Vec, + /// Precomputed sum of all node weights (each clamped to ≥ 1). + /// Stored as `i64` to avoid overflow on large weights. + total_weight: i64, + }, + WeightRoundRobin { + nodes: Vec, + /// Precomputed sum of all node weights (each clamped to ≥ 1). + total_weight: i64, + /// Current weighted round-robin counters (smooth WRR, nginx-style). + /// Stored as `i64` to avoid overflow on large / long-running sums. + counters: Mutex>, + }, +} + +impl LoadBalanceSelector { + /// Build a selector for the given nodes using the chosen strategy. + pub fn new(nodes: Vec, strategy: LoadBalance) -> Result { + if nodes.is_empty() { + return Err(EventMeshError::Config( + "load-balance requires at least one node".into(), + )); + } + Ok(match strategy { + LoadBalance::Random => Self::Random { nodes }, + LoadBalance::WeightRandom => { + let total_weight: i64 = nodes.iter().map(|n| n.weight.max(1) as i64).sum(); + Self::WeightRandom { + nodes, + total_weight, + } + } + LoadBalance::WeightRoundRobin => { + let total_weight: i64 = nodes.iter().map(|n| n.weight.max(1) as i64).sum(); + let counters = Mutex::new(vec![0; nodes.len()]); + Self::WeightRoundRobin { + nodes, + total_weight, + counters, + } + } + }) + } + + /// Pick the next node. + pub fn select(&self) -> &ServerNode { + match self { + Self::Random { nodes } => { + let idx = rand::thread_rng().gen_range(0..nodes.len()); + &nodes[idx] + } + Self::WeightRandom { + nodes, + total_weight, + } => { + // O(n) walk — does NOT expand nodes by weight, so large + // weights cannot cause OOM. Mirrors the Java SDK's + // WeightRandomLoadBalanceSelector. + let mut target = rand::thread_rng().gen_range(0..*total_weight); + for n in nodes.iter() { + target -= n.weight.max(1) as i64; + if target < 0 { + return n; + } + } + // Fallback (defensive — unreachable when total_weight is exact). + &nodes[nodes.len() - 1] + } + Self::WeightRoundRobin { + nodes, + total_weight, + counters, + } => { + // Smooth weighted round-robin (nginx-style). Counters are i64 + // so large weights or long uptimes cannot overflow. + let mut guard = counters.lock().expect("counter lock poisoned"); + let mut best = 0usize; + for (i, n) in nodes.iter().enumerate() { + guard[i] += n.weight.max(1) as i64; + if guard[i] > guard[best] { + best = i; + } + } + guard[best] -= total_weight; + &nodes[best] + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(host: &str, port: u16, weight: i32) -> ServerNode { + ServerNode { + host: host.to_string(), + port, + weight, + } + } + + #[test] + fn random_selects_within_set() { + let nodes = vec![node("a", 1, 1), node("b", 2, 1)]; + 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![node("a", 1, 5), node("b", 1, 1)]; + let sel = LoadBalanceSelector::new(nodes, LoadBalance::WeightRoundRobin).unwrap(); + let mut a = 0; + for _ in 0..60 { + if sel.select().host == "a" { + a += 1; + } + } + // ~5/6 should be 'a'. + assert!(a > 35 && a < 65, "a={a}"); + } + + #[test] + fn weight_random_distributes_proportionally() { + let nodes = vec![node("a", 1, 9), node("b", 1, 1)]; + let sel = LoadBalanceSelector::new(nodes, LoadBalance::WeightRandom).unwrap(); + let mut a = 0; + for _ in 0..1000 { + if sel.select().host == "a" { + a += 1; + } + } + // ~9/10 should be 'a'. + assert!(a > 850 && a < 950, "a={a}"); + } + + #[test] + fn weight_random_handles_large_weight_without_oom() { + // Previously this would expand to a Vec of 1 billion entries. + let nodes = vec![node("a", 1, 1000000), node("b", 1, 1)]; + let sel = LoadBalanceSelector::new(nodes, LoadBalance::WeightRandom).unwrap(); + for _ in 0..10 { + sel.select(); + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/local_ip.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/local_ip.rs deleted file mode 100644 index 4c46a2931d..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/common/local_ip.rs +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use local_ip_address::local_ip; -use std::net::{IpAddr, Ipv4Addr}; - -/// Get local IPv4 address. -/// -/// Get local IPv4 address, fallback to 127.0.0.1 if failed. -/// -/// # Returns -/// -/// The local IPv4 address as string. -pub(crate) fn get_local_ip_v4() -> String { - let ip_addr = local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - ip_addr.to_string() -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/common/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/common/mod.rs new file mode 100644 index 0000000000..33822f524c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/mod.rs @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Cross-protocol constants, protocol keys, helpers and load-balancing. + +#[cfg(any(feature = "grpc", feature = "http"))] +pub mod constants; +#[cfg(feature = "http")] +pub mod loadbalance; +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +pub mod protocol_key; +#[cfg(any(feature = "grpc", feature = "http"))] +pub mod status_code; +pub mod util; + +#[cfg(feature = "http")] +pub use constants::DEFAULT_MESSAGE_TTL; +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +pub use protocol_key::ProtocolKey; +pub use util::local_ip_v4; +#[cfg(any(feature = "grpc", feature = "tcp"))] +pub use util::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..d7c4f5a38d 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,76 @@ -/* - * 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; +// Keep the wire-key catalog shared with Java; usage varies by transport and dialect. #[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..f719ac60c6 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/status_code.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. + +//! 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. +#[cfg(feature = "grpc")] +pub struct StatusCode; +// Keep the Runtime status-code catalog even when only resubscribe is inspected. +#[cfg(feature = "grpc")] +#[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`. +#[cfg(feature = "http")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientRetCode { + /// Healthy consumption. + Ok = 1, + /// Transient failure; broker should retry. + Retry = 2, +} + +/// Request codes for the HTTP `code` header used by root-path routing. +#[cfg(feature = "http")] +pub struct RequestCode; +// Keep the Java HTTP request-code catalog, including operations not exposed here. +#[cfg(feature = "http")] +#[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..0511d89cd1 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/common/util.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. + +//! Small utility helpers: local IP discovery and random string generation. + +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +use rand::Rng; +#[cfg(any( + feature = "grpc", + all(feature = "tcp", feature = "cloud_events"), + all(test, any(feature = "grpc", feature = "http", feature = "tcp")) +))] +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. +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +pub struct RandomStringUtils; +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +impl RandomStringUtils { + /// A random UUID v4 (lowercase, hyphenated). + #[cfg(any( + feature = "grpc", + all(feature = "tcp", feature = "cloud_events"), + all(test, any(feature = "grpc", feature = "http", feature = "tcp")) + ))] + 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() + } +} + +/// 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::*; + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + #[test] + fn uuid_is_unique() { + assert_ne!( + RandomStringUtils::generate_uuid(), + RandomStringUtils::generate_uuid() + ); + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + #[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/client.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/client.rs new file mode 100644 index 0000000000..33eee749c0 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/client.rs @@ -0,0 +1,1019 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Transport configuration types. + +use std::time::Duration; + +use crate::error::{EventMeshError, Result}; + +/// Default timeout for short gRPC operations. +pub const DEFAULT_GRPC_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +/// Default timeout for HTTP requests. +pub const DEFAULT_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +/// Default timeout for TCP request/response operations. +pub const DEFAULT_TCP_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +/// Default timeout for establishing a TCP socket, matching Java TCP. +pub const DEFAULT_TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); +/// Default timeout for TCP protocol-control responses such as HELLO and +/// subscription commands. +pub const DEFAULT_TCP_CONTROL_TIMEOUT: Duration = Duration::from_secs(20); + +/// A validated EventMesh host and port. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Endpoint { + host: String, + port: u16, + weight: u32, +} + +impl Endpoint { + /// Construct an endpoint. Host names and IP literals are accepted; port + /// zero and blank hosts are rejected. + pub fn new(host: impl Into, port: u16) -> Result { + let host = host.into(); + if host.trim().is_empty() { + return Err(EventMeshError::Config( + "endpoint host must not be empty".into(), + )); + } + if host.chars().any(char::is_whitespace) { + return Err(EventMeshError::Config( + "endpoint host must not contain whitespace".into(), + )); + } + if host.contains("://") || host.chars().any(|c| matches!(c, '/' | '?' | '#')) { + return Err(EventMeshError::Config( + "endpoint host must not contain a scheme, path, query, or fragment".into(), + )); + } + if host.contains(':') { + let literal = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(&host); + if literal.parse::().is_err() { + return Err(EventMeshError::Config( + "endpoint host containing ':' must be a valid IPv6 literal".into(), + )); + } + } + if port == 0 { + return Err(EventMeshError::Config( + "endpoint port must not be zero".into(), + )); + } + Ok(Self { + host, + port, + weight: 1, + }) + } + + /// Return the endpoint host. + pub fn host(&self) -> &str { + &self.host + } + + /// Return the endpoint port. + pub const fn port(&self) -> u16 { + self.port + } + + /// Return a copy with a non-zero HTTP load-balancing weight. + pub fn with_weight(mut self, weight: u32) -> Result { + if weight == 0 { + return Err(EventMeshError::Config( + "endpoint weight must be greater than zero".into(), + )); + } + if weight > i32::MAX as u32 { + return Err(EventMeshError::Config(format!( + "endpoint weight must not exceed {}", + i32::MAX + ))); + } + self.weight = weight; + Ok(self) + } + + /// The HTTP load-balancing weight. + pub const fn weight(&self) -> u32 { + self.weight + } + + /// Render an authority, including brackets for IPv6 literals. + pub fn authority(&self) -> String { + format!("{}:{}", self.authority_host(), self.port) + } + + /// Render the host component for an authority, including brackets around + /// bare IPv6 literals. + pub(crate) fn authority_host(&self) -> String { + if self.host.contains(':') && !self.host.starts_with('[') { + format!("[{}]", self.host) + } else { + self.host.clone() + } + } +} + +/// A non-empty collection of HTTP endpoints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointSet(Vec); + +impl EndpointSet { + /// Construct a non-empty endpoint set. + pub fn new(endpoints: impl IntoIterator) -> Result { + let endpoints: Vec<_> = endpoints.into_iter().collect(); + if endpoints.is_empty() { + return Err(EventMeshError::Config( + "at least one endpoint is required".into(), + )); + } + let total_weight = endpoints + .iter() + .try_fold(0u64, |total, endpoint| { + total.checked_add(u64::from(endpoint.weight)) + }) + .ok_or_else(|| EventMeshError::Config("endpoint weight sum overflowed".into()))?; + if total_weight > i32::MAX as u64 { + return Err(EventMeshError::Config(format!( + "endpoint weights must sum to at most {}", + i32::MAX + ))); + } + Ok(Self(endpoints)) + } + + /// Borrow the endpoints in this set. + pub fn endpoints(&self) -> &[Endpoint] { + &self.0 + } +} + +/// Authentication material supplied to EventMesh. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Credentials { + username: Option, + password: Option, + token: Option, +} + +impl Credentials { + /// Start with no credentials. + pub const fn new() -> Self { + Self { + username: None, + password: None, + token: None, + } + } + + /// Configure username/password authentication. + pub fn with_basic(mut self, username: impl Into, password: impl Into) -> Self { + self.username = Some(username.into()); + self.password = Some(password.into()); + self + } + + /// Configure bearer/token authentication. + pub fn with_token(mut self, token: impl Into) -> Self { + self.token = Some(token.into()); + self + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn username(&self) -> &str { + self.username.as_deref().unwrap_or_default() + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn password(&self) -> &str { + self.password.as_deref().unwrap_or_default() + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn token(&self) -> Option<&str> { + self.token.as_deref() + } +} + +impl std::fmt::Debug for Credentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credentials") + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "***")) + .field("token", &self.token.as_ref().map(|_| "***")) + .finish() + } +} + +/// Runtime identity attached to EventMesh requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Identity { + env: String, + idc: String, + system: String, + process_id: String, + ip: String, + language: String, +} + +impl Default for Identity { + fn default() -> Self { + Self { + env: "env".into(), + idc: "default".into(), + system: "sys".into(), + process_id: std::process::id().to_string(), + ip: crate::common::local_ip_v4(), + language: "RUST".into(), + } + } +} + +impl Identity { + /// Set the EventMesh environment label. + pub fn with_env(mut self, env: impl Into) -> Self { + self.env = env.into(); + self + } + + /// Set the data-centre label. + pub fn with_idc(mut self, idc: impl Into) -> Self { + self.idc = idc.into(); + self + } + + /// Set the calling system/application name. + pub fn with_system(mut self, system: impl Into) -> Self { + self.system = system.into(); + self + } + + /// Override the process identifier sent to EventMesh. + pub fn with_process_id(mut self, process_id: impl Into) -> Self { + self.process_id = process_id.into(); + self + } + + /// Override the client IP advertised to EventMesh. + /// + /// EventMesh uses this value together with the process identifier to + /// distinguish gRPC stream subscribers; it does not need to be a local + /// bind address. + pub fn with_ip(mut self, ip: impl Into) -> Self { + self.ip = ip.into(); + self + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn env(&self) -> &str { + &self.env + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn idc(&self) -> &str { + &self.idc + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn system(&self) -> &str { + &self.system + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn process_id(&self) -> &str { + &self.process_id + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn ip(&self) -> &str { + &self.ip + } + + #[cfg(any(feature = "grpc", feature = "http"))] + pub(crate) fn language(&self) -> &str { + &self.language + } +} + +/// Options shared by a protocol client. +#[derive(Debug, Clone, Default)] +pub struct ClientOptions { + request_timeout: Option, +} + +impl ClientOptions { + /// Override the timeout for unary client operations. + pub fn with_request_timeout(mut self, request_timeout: Duration) -> Self { + self.request_timeout = Some(request_timeout); + self + } + + /// Return the request-timeout override, or `None` to use the transport's + /// default. + pub const fn request_timeout(&self) -> Option { + self.request_timeout + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + fn validate(&self) -> Result<()> { + if let Some(timeout) = self.request_timeout { + validate_non_zero_duration("request timeout", timeout)?; + } + Ok(()) + } +} + +/// Options for a producer role. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProducerOptions { + group: String, +} + +impl ProducerOptions { + /// Create options for `group`. + pub fn new(group: impl Into) -> Self { + Self { + group: group.into(), + } + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn group(&self) -> &str { + &self.group + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn validate(&self) -> Result<()> { + validate_group("producer", &self.group) + } +} + +/// Options for a consumer role. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConsumerOptions { + group: String, +} + +impl ConsumerOptions { + /// Create options for `group`. + /// + /// Delivery concurrency is transport-specific. HTTP webhook requests may + /// run concurrently, TCP delivery is serial, and gRPC stream consumers use + /// [`GrpcConsumerOptions`] to configure handler concurrency. + pub fn new(group: impl Into) -> Self { + Self { + group: group.into(), + } + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn group(&self) -> &str { + &self.group + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn validate(&self) -> Result<()> { + validate_group("consumer", &self.group) + } +} + +/// Options for a gRPC stream consumer role. +/// +/// Unlike HTTP webhook delivery and the serial TCP receive loop, gRPC stream +/// delivery supports an explicit bound on concurrently running handlers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GrpcConsumerOptions { + consumer: ConsumerOptions, + max_concurrent_handlers: usize, +} + +impl GrpcConsumerOptions { + /// Create options for `group` with serial handler execution by default. + pub fn new(group: impl Into) -> Self { + Self { + consumer: ConsumerOptions::new(group), + max_concurrent_handlers: 1, + } + } + + /// Allow up to `max_concurrent_handlers` handlers to run concurrently. + pub fn with_max_concurrent_handlers(mut self, max_concurrent_handlers: usize) -> Self { + self.max_concurrent_handlers = max_concurrent_handlers; + self + } + + #[cfg(feature = "grpc")] + pub(crate) const fn consumer(&self) -> &ConsumerOptions { + &self.consumer + } + + #[cfg(feature = "grpc")] + pub(crate) const fn max_concurrent_handlers(&self) -> usize { + self.max_concurrent_handlers + } + + #[cfg(feature = "grpc")] + pub(crate) fn validate(&self) -> Result<()> { + self.consumer.validate()?; + if self.max_concurrent_handlers == 0 { + return Err(EventMeshError::Config( + "gRPC max concurrent handlers must be greater than zero".into(), + )); + } + Ok(()) + } +} + +/// HTTP endpoint selection policy. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum LoadBalance { + /// Choose an endpoint at random. + #[default] + Random, + /// Choose according to configured endpoint weights. + WeightedRandom, + /// Smooth weighted round-robin selection. + WeightedRoundRobin, +} + +#[cfg(feature = "http")] +impl LoadBalance { + /// Map this policy onto the shared load-balancer strategy. + pub(crate) const fn to_wire(self) -> crate::common::loadbalance::LoadBalance { + match self { + Self::Random => crate::common::loadbalance::LoadBalance::Random, + Self::WeightedRandom => crate::common::loadbalance::LoadBalance::WeightRandom, + Self::WeightedRoundRobin => crate::common::loadbalance::LoadBalance::WeightRoundRobin, + } + } +} + +/// gRPC client configuration. +#[derive(Debug, Clone)] +pub struct GrpcConfig { + endpoint: Endpoint, + options: ClientOptions, + identity: Identity, + credentials: Credentials, +} + +impl GrpcConfig { + /// Build a gRPC configuration for `endpoint`. + pub fn new(endpoint: Endpoint) -> Self { + Self { + endpoint, + options: ClientOptions::default(), + identity: Identity::default(), + credentials: Credentials::default(), + } + } + + /// Override common client options. + pub fn with_options(mut self, options: ClientOptions) -> Self { + self.options = options; + self + } + + /// Override request identity. + pub fn with_identity(mut self, identity: Identity) -> Self { + self.identity = identity; + self + } + + /// Set credentials. + pub fn with_credentials(mut self, credentials: Credentials) -> Self { + self.credentials = credentials; + self + } + + /// Return the configured server endpoint. + pub const fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + /// Return shared client options. + pub const fn options(&self) -> &ClientOptions { + &self.options + } + + /// Return the request identity. + pub const fn identity(&self) -> &Identity { + &self.identity + } + + /// Return the configured credentials. + pub const fn credentials(&self) -> &Credentials { + &self.credentials + } + + #[cfg(feature = "grpc")] + pub(crate) fn validate(&self) -> Result<()> { + self.options.validate() + } + + #[cfg(feature = "grpc")] + pub(crate) const fn request_timeout(&self) -> Duration { + match self.options.request_timeout { + Some(timeout) => timeout, + None => DEFAULT_GRPC_REQUEST_TIMEOUT, + } + } +} + +/// HTTP client configuration. +#[derive(Debug, Clone)] +pub struct HttpConfig { + endpoints: EndpointSet, + load_balance: LoadBalance, + options: ClientOptions, + identity: Identity, + credentials: Credentials, + use_tls: bool, + proxy_from_env: bool, +} + +impl HttpConfig { + /// Build an HTTP configuration for a non-empty endpoint set. + pub fn new(endpoints: EndpointSet) -> Self { + Self { + endpoints, + load_balance: LoadBalance::Random, + options: ClientOptions::default(), + identity: Identity::default(), + credentials: Credentials::default(), + use_tls: false, + proxy_from_env: false, + } + } + + /// Set endpoint selection policy. + pub fn with_load_balance(mut self, load_balance: LoadBalance) -> Self { + self.load_balance = load_balance; + self + } + + /// Override common client options. + pub fn with_options(mut self, options: ClientOptions) -> Self { + self.options = options; + self + } + + /// Override request identity. + pub fn with_identity(mut self, identity: Identity) -> Self { + self.identity = identity; + self + } + + /// Set credentials. + pub fn with_credentials(mut self, credentials: Credentials) -> Self { + self.credentials = credentials; + self + } + + /// Enable HTTPS. + pub fn with_tls(mut self) -> Self { + self.use_tls = true; + self + } + + /// Control whether HTTP requests use proxy settings from the environment. + /// + /// Disabled by default, matching the Java SDK's direct connection + /// behavior. When enabled, reqwest honors variables such as `HTTP_PROXY`, + /// `HTTPS_PROXY`, and `NO_PROXY`. + pub fn with_proxy_from_env(mut self, enabled: bool) -> Self { + self.proxy_from_env = enabled; + self + } + + /// Return the configured endpoints. + pub const fn endpoints(&self) -> &EndpointSet { + &self.endpoints + } + + /// Return the endpoint selection policy. + pub const fn load_balance(&self) -> LoadBalance { + self.load_balance + } + + /// Return shared client options. + pub const fn options(&self) -> &ClientOptions { + &self.options + } + + /// Return the request identity. + pub const fn identity(&self) -> &Identity { + &self.identity + } + + /// Return the configured credentials. + pub const fn credentials(&self) -> &Credentials { + &self.credentials + } + + /// Whether HTTPS is enabled. + pub const fn tls_enabled(&self) -> bool { + self.use_tls + } + + /// Whether proxy settings are loaded from the process environment. + pub const fn proxy_from_env(&self) -> bool { + self.proxy_from_env + } + + #[cfg(feature = "http")] + pub(crate) fn validate(&self) -> Result<()> { + self.options.validate() + } + + #[cfg(feature = "http")] + pub(crate) const fn request_timeout(&self) -> Duration { + match self.options.request_timeout { + Some(timeout) => timeout, + None => DEFAULT_HTTP_REQUEST_TIMEOUT, + } + } +} + +/// TCP reconnect settings. +#[derive(Debug, Clone)] +pub struct ReconnectPolicy { + enabled: bool, + max_retries: usize, + initial_backoff: Duration, + max_backoff: Duration, +} + +impl Default for ReconnectPolicy { + fn default() -> Self { + Self { + enabled: true, + max_retries: usize::MAX, + initial_backoff: Duration::from_secs(1), + max_backoff: Duration::from_secs(30), + } + } +} + +impl ReconnectPolicy { + /// Enable or disable automatic reconnect. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Limit reconnect attempts (`usize::MAX` means indefinitely). + pub fn with_max_retries(mut self, max_retries: usize) -> Self { + self.max_retries = max_retries; + self + } + + /// Set the delay before the first reconnect attempt. + pub fn with_initial_backoff(mut self, initial_backoff: Duration) -> Self { + self.initial_backoff = initial_backoff; + self + } + + /// Cap exponential reconnect backoff at this duration. + pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self { + self.max_backoff = max_backoff; + self + } + + /// Whether reconnection is enabled. + pub const fn enabled(&self) -> bool { + self.enabled + } + + /// The maximum number of reconnect attempts. + pub const fn max_retries(&self) -> usize { + self.max_retries + } + + /// The initial reconnect delay. + pub const fn initial_backoff(&self) -> Duration { + self.initial_backoff + } + + /// The maximum reconnect delay. + pub const fn max_backoff(&self) -> Duration { + self.max_backoff + } + + #[cfg(feature = "tcp")] + fn validate(&self) -> Result<()> { + validate_non_zero_duration("TCP reconnect initial backoff", self.initial_backoff)?; + validate_non_zero_duration("TCP reconnect maximum backoff", self.max_backoff)?; + if self.initial_backoff > self.max_backoff { + return Err(EventMeshError::Config( + "TCP reconnect initial backoff must not exceed maximum backoff".into(), + )); + } + Ok(()) + } +} + +/// TCP client configuration. +#[derive(Debug, Clone)] +pub struct TcpConfig { + endpoint: Endpoint, + options: ClientOptions, + identity: Identity, + credentials: Credentials, + reconnect: ReconnectPolicy, + heartbeat_interval: Duration, + connect_timeout: Duration, + control_timeout: Duration, +} + +impl TcpConfig { + /// Build a TCP configuration for `endpoint`. + pub fn new(endpoint: Endpoint) -> Self { + Self { + endpoint, + options: ClientOptions::default(), + identity: Identity::default(), + credentials: Credentials::default(), + reconnect: ReconnectPolicy::default(), + heartbeat_interval: Duration::from_secs(30), + connect_timeout: DEFAULT_TCP_CONNECT_TIMEOUT, + control_timeout: DEFAULT_TCP_CONTROL_TIMEOUT, + } + } + + /// Override common client options. + pub fn with_options(mut self, options: ClientOptions) -> Self { + self.options = options; + self + } + + /// Override request identity. + pub fn with_identity(mut self, identity: Identity) -> Self { + self.identity = identity; + self + } + + /// Set credentials. + pub fn with_credentials(mut self, credentials: Credentials) -> Self { + self.credentials = credentials; + self + } + + /// Configure reconnection. + pub fn with_reconnect(mut self, reconnect: ReconnectPolicy) -> Self { + self.reconnect = reconnect; + self + } + + /// Override the heartbeat interval. + pub fn with_heartbeat_interval(mut self, heartbeat_interval: Duration) -> Self { + self.heartbeat_interval = heartbeat_interval; + self + } + + /// Override the TCP socket connection timeout. + pub fn with_connect_timeout(mut self, connect_timeout: Duration) -> Self { + self.connect_timeout = connect_timeout; + self + } + + /// Override the timeout for HELLO, LISTEN, subscribe, and unsubscribe + /// responses. + pub fn with_control_timeout(mut self, control_timeout: Duration) -> Self { + self.control_timeout = control_timeout; + self + } + + /// Return the configured server endpoint. + pub const fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + /// Return shared client options. + pub const fn options(&self) -> &ClientOptions { + &self.options + } + + /// Return the request identity. + pub const fn identity(&self) -> &Identity { + &self.identity + } + + /// Return the configured credentials. + pub const fn credentials(&self) -> &Credentials { + &self.credentials + } + + /// Return the reconnect policy. + pub const fn reconnect(&self) -> &ReconnectPolicy { + &self.reconnect + } + + /// Return the heartbeat interval. + pub const fn heartbeat_interval(&self) -> Duration { + self.heartbeat_interval + } + + /// Return the TCP socket connection timeout. + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } + + /// Return the TCP protocol-control response timeout. + pub const fn control_timeout(&self) -> Duration { + self.control_timeout + } + + #[cfg(feature = "tcp")] + pub(crate) fn validate(&self) -> Result<()> { + self.options.validate()?; + validate_non_zero_duration("TCP heartbeat interval", self.heartbeat_interval)?; + validate_non_zero_duration("TCP connect timeout", self.connect_timeout)?; + validate_non_zero_duration("TCP control timeout", self.control_timeout)?; + self.reconnect.validate() + } + + #[cfg(feature = "tcp")] + pub(crate) const fn request_timeout(&self) -> Duration { + match self.options.request_timeout { + Some(timeout) => timeout, + None => DEFAULT_TCP_REQUEST_TIMEOUT, + } + } +} + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +fn validate_non_zero_duration(name: &str, value: Duration) -> Result<()> { + if value.is_zero() { + return Err(EventMeshError::Config(format!( + "{name} must be greater than zero" + ))); + } + Ok(()) +} + +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +fn validate_group(role: &str, group: &str) -> Result<()> { + if group.trim().is_empty() { + return Err(EventMeshError::Config(format!( + "{role} group must not be empty" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_brackets_ipv6_authorities() { + let endpoint = Endpoint::new("::1", 10_205).unwrap(); + assert_eq!(endpoint.authority(), "[::1]:10205"); + } + + #[test] + fn endpoint_rejects_a_url_or_embedded_port() { + assert!(Endpoint::new("http://localhost", 10_105).is_err()); + assert!(Endpoint::new("localhost:10105", 10_105).is_err()); + } + + #[cfg(feature = "grpc")] + #[test] + fn grpc_stream_options_own_handler_concurrency() { + let options = GrpcConsumerOptions::new("orders").with_max_concurrent_handlers(8); + assert_eq!(options.consumer().group(), "orders"); + assert_eq!(options.max_concurrent_handlers(), 8); + } + + #[cfg(feature = "http")] + #[test] + fn http_proxy_from_env_is_explicit() { + let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", 10_105).unwrap()]).unwrap(); + assert!(!HttpConfig::new(endpoints.clone()).proxy_from_env()); + assert!(HttpConfig::new(endpoints) + .with_proxy_from_env(true) + .proxy_from_env()); + } + + #[test] + fn endpoint_authority_brackets_ipv6_host() { + assert_eq!( + Endpoint::new("::1", 10_000).unwrap().authority(), + "[::1]:10000" + ); + } + + #[test] + fn endpoint_set_requires_an_endpoint() { + assert!(EndpointSet::new(Vec::new()).is_err()); + } + + #[cfg(all(feature = "grpc", feature = "http", feature = "tcp"))] + #[test] + fn transports_keep_their_protocol_specific_default_timeouts() { + let endpoint = Endpoint::new("127.0.0.1", 10_205).unwrap(); + assert_eq!( + GrpcConfig::new(endpoint.clone()).request_timeout(), + DEFAULT_GRPC_REQUEST_TIMEOUT + ); + let endpoints = EndpointSet::new([endpoint.clone()]).unwrap(); + assert_eq!( + HttpConfig::new(endpoints).request_timeout(), + DEFAULT_HTTP_REQUEST_TIMEOUT + ); + assert_eq!( + TcpConfig::new(endpoint.clone()).request_timeout(), + DEFAULT_TCP_REQUEST_TIMEOUT + ); + let tcp = TcpConfig::new(endpoint); + assert_eq!(tcp.connect_timeout(), DEFAULT_TCP_CONNECT_TIMEOUT); + assert_eq!(tcp.control_timeout(), DEFAULT_TCP_CONTROL_TIMEOUT); + } + + #[test] + fn endpoint_weight_must_fit_the_transport_representation() { + let endpoint = Endpoint::new("127.0.0.1", 10_105).unwrap(); + assert!(endpoint.with_weight(i32::MAX as u32 + 1).is_err()); + } + + #[test] + fn endpoint_set_rejects_a_weight_sum_that_exceeds_i32() { + let first = Endpoint::new("first", 10_105) + .unwrap() + .with_weight(i32::MAX as u32) + .unwrap(); + let second = Endpoint::new("second", 10_105).unwrap(); + assert!(EndpointSet::new([first.clone()]).is_ok()); + assert!(EndpointSet::new([first, second]).is_err()); + } + + #[cfg(feature = "grpc")] + #[test] + fn grpc_config_rejects_zero_request_timeout() { + let config = GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205).unwrap()) + .with_options(ClientOptions::default().with_request_timeout(Duration::ZERO)); + assert!(config.validate().is_err()); + } + + #[cfg(feature = "tcp")] + #[test] + fn tcp_config_rejects_invalid_timing_values() { + let endpoint = Endpoint::new("127.0.0.1", 10_000).unwrap(); + assert!(TcpConfig::new(endpoint.clone()) + .with_heartbeat_interval(Duration::ZERO) + .validate() + .is_err()); + assert!(TcpConfig::new(endpoint.clone()) + .with_connect_timeout(Duration::ZERO) + .validate() + .is_err()); + assert!(TcpConfig::new(endpoint.clone()) + .with_control_timeout(Duration::ZERO) + .validate() + .is_err()); + assert!(TcpConfig::new(endpoint) + .with_reconnect( + ReconnectPolicy::default() + .with_initial_backoff(Duration::from_secs(2)) + .with_max_backoff(Duration::from_secs(1)), + ) + .validate() + .is_err()); + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + #[test] + fn role_groups_must_not_be_blank() { + assert!(ProducerOptions::new(" ").validate().is_err()); + assert!(ConsumerOptions::new("").validate().is_err()); + } + + #[cfg(feature = "grpc")] + #[test] + fn grpc_handler_concurrency_must_not_be_zero() { + assert!(GrpcConsumerOptions::new("orders") + .with_max_concurrent_handlers(0) + .validate() + .is_err()); + } +} 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/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/config/mod.rs new file mode 100644 index 0000000000..3cb9088217 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/config/mod.rs @@ -0,0 +1,31 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Public client configuration. +//! +//! Every transport consumes these types directly: pass the matching config to +//! a transport handle (`HttpClient`, `TcpClient`, `GrpcChannel`) together +//! with role options when creating producers and consumers. + +mod client; + +pub use client::{ + ClientOptions, ConsumerOptions, Credentials, Endpoint, EndpointSet, GrpcConfig, + GrpcConsumerOptions, HttpConfig, Identity, LoadBalance, ProducerOptions, ReconnectPolicy, + TcpConfig, DEFAULT_GRPC_REQUEST_TIMEOUT, DEFAULT_HTTP_REQUEST_TIMEOUT, + DEFAULT_TCP_CONNECT_TIMEOUT, DEFAULT_TCP_CONTROL_TIMEOUT, DEFAULT_TCP_REQUEST_TIMEOUT, +}; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs index 3315a0ae4f..220102f72a 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/error.rs @@ -1,58 +1,144 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::fmt::{Display, Formatter}; -use thiserror::Error; - -#[allow(dead_code)] -#[derive(Debug, Error)] -pub enum EventMeshError { - /// Invalid arguments - InvalidArgs(String), - - /// gRpc status +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Public, pattern-matchable error type for the SDK. + +use std::time::Duration; + +/// All errors produced by the EventMesh SDK. +/// +/// The type is intentionally public and pattern-matchable. Protocol adapters +/// translate their implementation-specific failures into these variants so a +/// caller never needs to depend on tonic, reqwest, or the TCP frame format. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// A client configuration problem (missing field, bad URL, ...). + #[error("config error: {0}")] + Config(String), + + /// The caller supplied an invalid message or argument. + #[error("invalid argument: {0}")] + InvalidArgument(String), + + /// A gRPC transport / status error. #[cfg(feature = "grpc")] - GRpcStatus(#[from] tonic::Status), + #[error("grpc error ({code}): {message}")] + Grpc { + /// gRPC status code rendered by the transport. + code: String, + /// Status description returned by the peer. + message: String, + }, + + /// A gRPC transport layer (channel/connect) error. + #[cfg(feature = "grpc")] + #[error("grpc transport error: {0}")] + GrpcTransport(String), + + /// An HTTP transport error. + #[error("http error: status {status}: {message}")] + Http { + /// HTTP response status code. + status: u16, + /// Error description returned by the HTTP transport or peer. + message: String, + }, + + /// A TCP transport error. + #[error("tcp error: {0}")] + Tcp(String), + + /// Serialization / deserialization failure. + #[error("codec error: {0}")] + Codec(#[from] serde_json::Error), - EventMeshLocal(String), + /// A message failed validation during construction, decoding, or sending. + #[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), + /// A protocol adapter rejected or could not encode a wire-level value. + #[error("{transport} protocol error: {message}")] + Protocol { + /// The protocol that produced the error (for example `grpc` or `tcp`). + transport: &'static str, + /// A stable, human-readable explanation. + message: String, + }, + + /// The EventMesh server returned a non-success response code. + #[error("server error: code={code} message={message}")] + Server { + /// EventMesh server response code. + code: i32, + /// Error description returned by the EventMesh server. + 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), + + /// Both a lifecycle operation and the subsequent shutdown failed. + /// + /// For example, an HTTP consumer may fail to unregister its remote + /// subscriptions and then also fail while joining a background task. + #[error("cleanup failed: operation error: {operation}; shutdown error: {shutdown}")] + Cleanup { + /// Failure from the cleanup operation attempted before shutdown. + operation: Box, + /// Failure encountered while stopping or joining background work. + shutdown: Box, + }, } -impl Display for EventMeshError { - #[inline] - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - #[cfg(feature = "grpc")] - EventMeshError::GRpcStatus(e) => write!(f, "grpc request error: {}", e), - EventMeshError::EventMeshLocal(ref err_msg) => { - write!(f, "EventMesh client error: {}", err_msg) - } - EventMeshError::EventMeshRemote(ref err_msg) => { - write!(f, "EventMesh remote error: {}", err_msg) - } - EventMeshError::EventMeshFromStrError(ref err_msg) => { - write!(f, "EventMesh Parse from String error: {}", err_msg) - } - EventMeshError::InvalidArgs(ref err_msg) => { - write!(f, "Invalid args: {}", err_msg) - } +/// Convenience `Result` alias used throughout the SDK. +pub type Result = std::result::Result; + +// The protocol implementation is migrated in stages. Keep this alias crate +// private so the old spelling remains available to internal modules without +// becoming part of the 2.0 public API. +pub(crate) use Error as EventMeshError; + +#[cfg(feature = "grpc")] +impl From for Error { + fn from(status: tonic::Status) -> Self { + Self::Grpc { + code: status.code().to_string(), + message: status.message().to_owned(), } } } + +#[cfg(feature = "grpc")] +impl From for Error { + fn from(err: tonic::transport::Error) -> Self { + Self::GrpcTransport(err.to_string()) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs index 30046f8c5a..dc36c01ab6 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc.rs @@ -1,34 +1,370 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -//! gRPC client implementations. - -/// EventMesh message types. -pub(crate) mod r#impl; - -/// gRPC consumer client. -pub mod grpc_consumer; - -/// gRPC producer client. -pub mod grpc_producer; - -/// Protobuf generated definitions. -pub(crate) mod pb; - -#[cfg(feature = "grpc")] -/// Re-export gRPC eventmesh message producer when features enabled. -pub use crate::grpc::r#impl::grpc_producer_impl::GrpcEventMeshProducer; +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! gRPC channel and role API. + +use crate::config::{ConsumerOptions, GrpcConfig, GrpcConsumerOptions, ProducerOptions}; +use crate::error::{EventMeshError, Result}; +use crate::message::{Message, PublishReceipt}; +use crate::subscription::Subscription; +use crate::transport::grpc::{ + ChannelClient as TransportChannel, GrpcProducer as TransportProducer, + GrpcStreamConsumer as TransportConsumer, GrpcWebhookConsumer as TransportWebhookConsumer, +}; +use crate::MessageHandler; + +/// A connected EventMesh gRPC channel. +/// +/// Create the channel inside the Tokio runtime that will use it. Producers and +/// consumers built from clones of the channel share one multiplexed HTTP/2 +/// connection. To use EventMesh from another runtime, connect another channel +/// in that runtime instead of moving this value across runtime lifetimes. +#[derive(Clone)] +pub struct GrpcChannel { + config: GrpcConfig, + inner: TransportChannel, +} + +impl GrpcChannel { + /// Validate `config` and connect on the current Tokio runtime. + pub async fn connect(config: GrpcConfig) -> Result { + let inner = TransportChannel::connect(&config).await?; + Ok(Self { config, inner }) + } + + #[cfg(test)] + fn connect_lazy(config: GrpcConfig) -> Result { + let inner = TransportChannel::connect_lazy(&config)?; + Ok(Self { config, inner }) + } +} + +/// gRPC publishing capability. +pub struct GrpcProducer { + inner: TransportProducer, + timeout: std::time::Duration, +} + +/// A long-lived gRPC stream consumer. +pub struct GrpcStreamConsumer { + inner: TransportConsumer, +} + +/// A gRPC consumer that registers HTTP webhook subscriptions. +/// +/// [`shutdown`](Self::shutdown) and [`join`](Self::join) only stop local +/// heartbeat work. Before shutting down, call [`unsubscribe`](Self::unsubscribe) +/// for every remotely registered subscription and webhook URL. +pub struct GrpcWebhookConsumer { + inner: TransportWebhookConsumer, +} + +impl GrpcWebhookConsumer { + /// Create a webhook-registration consumer over `channel`. + /// + /// The EventMesh runtime delivers events to the registered URL over HTTP. + /// Use the SDK's [`crate::webhook::WebhookServer`] or an application-owned + /// HTTP endpoint to receive those deliveries. + pub async fn new(channel: GrpcChannel, options: ConsumerOptions) -> Result { + Ok(Self { + inner: TransportWebhookConsumer::new( + channel.inner, + channel.config, + options, + None::>, + ) + .await?, + }) + } + + /// Register one or more subscriptions to an HTTP webhook URL. + pub async fn subscribe( + &self, + subscriptions: impl IntoIterator, + webhook_url: impl Into, + ) -> Result<()> { + let webhook_url = webhook_url.into(); + crate::webhook::validate_webhook_url(&webhook_url)?; + let subscriptions: Vec<_> = subscriptions.into_iter().collect(); + for subscription in &subscriptions { + subscription.validate()?; + } + self.inner + .subscribe_webhook(subscriptions, webhook_url) + .await + .map(|_| ()) + } + + /// Remove one or more subscriptions from an HTTP webhook URL. + pub async fn unsubscribe( + &self, + subscriptions: impl IntoIterator, + webhook_url: impl Into, + ) -> Result<()> { + let webhook_url = webhook_url.into(); + crate::webhook::validate_webhook_url(&webhook_url)?; + let subscriptions: Vec<_> = subscriptions.into_iter().collect(); + for subscription in &subscriptions { + subscription.validate()?; + } + self.inner + .unsubscribe_webhook(subscriptions, webhook_url) + .await + .map(|_| ()) + } + + /// Signal the heartbeat task to stop. + /// + /// This does not unregister webhook subscriptions from EventMesh. Call + /// [`unsubscribe`](Self::unsubscribe) first when performing a graceful + /// shutdown. + pub fn shutdown(&self) { + self.inner.request_shutdown(); + } + + /// Wait for the heartbeat task to stop and report task failure. + /// + /// Cancelling this wait preserves task ownership and pending results. Call + /// `join()` again to finish waiting, or drop the consumer to abort its tasks. + pub async fn join(&self) -> Result<()> { + self.inner.wait_for_shutdown().await + } +} + +impl GrpcStreamConsumer { + /// Open a bidirectional subscription stream over `channel`. + /// + /// At least one initial subscription is required. Additional subscriptions + /// can be added after the stream opens with [`subscribe`](Self::subscribe). + /// Both current-thread and multi-thread Tokio runtimes are supported. + /// Keep the channel's owning runtime running to drive the stream and its + /// background tasks. + /// + /// # Known limitation + /// + /// Opening the stream does not await subscription acceptance. Runtime + /// rejection control frames (including ACL and validation errors) are + /// currently ignored, so both `open()` and [`join`](Self::join) can succeed + /// for a rejected subscription. Check Runtime logs to diagnose rejection. + pub async fn open( + channel: GrpcChannel, + options: GrpcConsumerOptions, + subscriptions: impl IntoIterator, + handler: H, + ) -> Result { + let subscriptions: Vec<_> = subscriptions.into_iter().collect(); + for subscription in &subscriptions { + subscription.validate()?; + } + let inner = TransportConsumer::subscribe_stream( + channel.inner, + channel.config, + options, + handler, + subscriptions, + None::>, + ) + .await?; + Ok(Self { inner }) + } + + /// Add a subscription to the active stream. + /// + /// Success means the request was queued, not that the Runtime accepted it. + /// Subscription rejection control frames are currently ignored, as with + /// [`open`](Self::open). + pub async fn subscribe(&self, subscription: Subscription) -> Result<()> { + subscription.validate()?; + self.inner.subscribe(vec![subscription]).await + } + + /// Remove a stream subscription. + /// + /// # Known limitation + /// + /// The Java Runtime closes the shared stream even when other topics remain + /// subscribed. This stops their delivery and this consumer's heartbeat; + /// the SDK does not recreate the stream or replay remaining subscriptions. + /// After teardown, [`subscribe`](Self::subscribe) returns + /// [`Error::ChannelClosed`](crate::Error::ChannelClosed). Wait for this + /// consumer to finish, drop it, and explicitly [`open`](Self::open) a new + /// consumer with the remaining subscriptions to resume consumption. + pub async fn unsubscribe(&self, subscription: Subscription) -> Result<()> { + subscription.validate()?; + self.inner + .unsubscribe_stream(vec![subscription]) + .await + .map(|_| ()) + } + + /// Signal graceful stream shutdown. + pub fn shutdown(&self) { + self.inner.request_shutdown(); + } + + /// Wait for stream shutdown. + /// + /// A successful return does not confirm subscription acceptance: rejection + /// control frames followed by normal stream closure are currently ignored. + /// + /// Cancelling this wait preserves task ownership and pending results. Call + /// `join()` again to finish waiting, or drop the consumer to abort its tasks. + pub async fn join(&self) -> Result<()> { + self.inner.wait_for_shutdown().await + } +} + +impl GrpcProducer { + /// Create a publishing role over `channel`. + pub fn new(channel: GrpcChannel, options: ProducerOptions) -> Result { + let timeout = channel.config.request_timeout(); + Ok(Self { + inner: TransportProducer::new(channel.inner, channel.config, options)?, + timeout, + }) + } + + /// Publish one event and wait for the EventMesh acknowledgement. + pub async fn publish(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .publish(message) + .await + .map(PublishReceipt::from_response), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .publish_cloud_event(event) + .await + .map(PublishReceipt::from_response), + } + } + + /// Publish a homogeneous batch of events through gRPC's batch RPC. + pub async fn publish_batch(&self, messages: Vec) -> Result { + if messages.is_empty() { + return Err(EventMeshError::InvalidArgument( + "batch publish requires at least one message".into(), + )); + } + + #[cfg(feature = "cloud_events")] + if messages + .iter() + .all(|message| matches!(message, Message::CloudEvent(_))) + { + let events = messages + .into_iter() + .map(|message| match message { + Message::CloudEvent(event) => event, + _ => unreachable!("all messages were checked above"), + }) + .collect(); + return self + .inner + .publish_cloud_event_batch(events) + .await + .map(PublishReceipt::from_response); + } + + #[cfg(feature = "cloud_events")] + if messages + .iter() + .any(|message| matches!(message, Message::CloudEvent(_))) + { + return Err(EventMeshError::Unsupported( + "mixed EventMesh and CloudEvents gRPC batches".into(), + )); + } + self.inner + .publish_message_batch(messages) + .await + .map(PublishReceipt::from_response) + } + + /// Send an event and await its reply. + pub async fn request_reply(&self, message: Message) -> Result { + self.request_reply_with_timeout(message, self.timeout).await + } + + /// Send an event and await its reply with a per-operation timeout. + pub async fn request_reply_with_timeout( + &self, + message: Message, + timeout: std::time::Duration, + ) -> Result { + if timeout.is_zero() { + return Err(EventMeshError::InvalidArgument( + "request/reply timeout must be greater than zero".into(), + )); + } + match message { + Message::EventMesh(message) => self + .inner + .request_reply(message, timeout) + .await + .map(Message::EventMesh), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .request_reply_cloud_event(event, timeout) + .await + .map(Message::CloudEvent), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Endpoint; + + fn channel() -> GrpcChannel { + let config = GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205).unwrap()); + GrpcChannel::connect_lazy(config).unwrap() + } + + #[tokio::test] + async fn cloned_channels_and_their_producers_share_one_connection() { + let channel = channel(); + let cloned_channel = channel.clone(); + let first = GrpcProducer::new(channel.clone(), ProducerOptions::new("producer-a")).unwrap(); + let second = + GrpcProducer::new(cloned_channel.clone(), ProducerOptions::new("producer-b")).unwrap(); + + assert!(channel.inner.shares_channel_with(&cloned_channel.inner)); + assert!(channel.inner.shares_channel_with(first.inner.client())); + assert!(channel.inner.shares_channel_with(second.inner.client())); + } + + #[tokio::test] + async fn producer_and_webhook_consumer_share_one_channel() { + let channel = channel(); + let producer = + GrpcProducer::new(channel.clone(), ProducerOptions::new("producer")).unwrap(); + let consumer = GrpcWebhookConsumer::new(channel.clone(), ConsumerOptions::new("consumer")) + .await + .unwrap(); + + assert!(channel.inner.shares_channel_with(producer.inner.client())); + assert!(channel.inner.shares_channel_with(consumer.inner.client())); + + consumer.shutdown(); + consumer.join().await.unwrap(); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_consumer.rs deleted file mode 100644 index e31c97a530..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_consumer.rs +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use futures::lock::Mutex; -use tonic::codegen::tokio_stream::StreamExt; -use tracing::error; - -use crate::common::constants::{DataContentType, SDK_STREAM_URL}; -use crate::common::grpc_eventmesh_message_utils::EventMeshCloudEventUtils; -use crate::common::{ProtocolKey, ReceiveMessageListener}; -use crate::config::EventMeshGrpcClientConfig; -use crate::error::EventMeshError::{EventMeshLocal, InvalidArgs}; -use crate::model::message::EventMeshMessage; -use crate::model::response::EventMeshResponse; -use crate::model::subscription::{ - HeartbeatItem, SubscriptionItem, SubscriptionItemWrapper, SubscriptionReply, -}; -use crate::model::EventMeshProtocolType; -use crate::net::GrpcClient; -use crate::proto_cloud_event::{PbAttr, PbCloudEvent, PbCloudEventAttributeValue, PbData}; - -pub struct EventMeshGrpcConsumer { - inner: GrpcClient, - grpc_config: EventMeshGrpcClientConfig, - subscription_map: Arc>>, - listener: Arc>>, -} - -impl EventMeshGrpcConsumer { - pub fn new( - grpc_config: EventMeshGrpcClientConfig, - listener: Box>, - ) -> Self { - let client = GrpcClient::new(&grpc_config).unwrap(); - let subscription_map = Arc::new(Mutex::new(HashMap::with_capacity(16))); - let listener = Arc::new(listener); - let _ = EventMeshGrpcConsumer::heartbeat( - client.clone(), - grpc_config.clone(), - Arc::clone(&subscription_map), - ); - Self { - inner: client, - grpc_config, - subscription_map: Arc::clone(&subscription_map), - listener: Arc::clone(&listener), - } - } - - pub async fn subscribe_webhook( - &mut self, - subscription_items: Vec, - url: impl Into, - ) -> crate::Result { - if subscription_items.is_empty() { - return Err(InvalidArgs("subscription_items is empty".to_string()).into()); - } - let cloud_event = EventMeshCloudEventUtils::build_event_subscription( - &self.grpc_config, - EventMeshProtocolType::EventMeshMessage, - url.into().as_str(), - &subscription_items, - ); - if cloud_event.is_none() { - return Err( - EventMeshLocal("SubscriptionItem switch to CloudEvent error".to_string()).into(), - ); - } - let result = self - .inner - .subscribe_webhook_inner(cloud_event.unwrap()) - .await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - pub async fn subscribe( - &mut self, - subscription_items: Vec, - ) -> crate::Result<()> { - if subscription_items.is_empty() { - return Err(InvalidArgs("subscription_items is empty".to_string()).into()); - } - - let map = self.subscription_map.clone(); - let mut guard = map.lock().await; - subscription_items.iter().for_each(|item| { - guard.insert( - item.topic.clone(), - SubscriptionItemWrapper { - subscription_item: item.clone(), - url: SDK_STREAM_URL.to_string(), - }, - ); - }); - let cloud_event = EventMeshCloudEventUtils::build_event_subscription( - &self.grpc_config, - EventMeshProtocolType::EventMeshMessage, - String::new().as_str(), - &subscription_items, - ); - if cloud_event.is_none() { - return Err( - EventMeshLocal("SubscriptionItem switch to CloudEvent error".to_string()).into(), - ); - } - let (keeper, mut resp_stream) = self.inner.subscribe_bi_inner(cloud_event.unwrap()).await?; - let listener_inner = Arc::clone(&self.listener); - tokio::spawn(async move { - while let Some(received) = resp_stream.next().await { - if let Err(status) = received { - error!("Subscribe receive error, status {}", status); - continue; - } - let mut received = received.unwrap(); - let eventmesh_message = - EventMeshCloudEventUtils::build_message_from_event_mesh_cloud_event::< - EventMeshMessage, - >(&received); - if eventmesh_message.is_none() { - continue; - } - received.attributes.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::JSON.to_string())), - }, - ); - - let handled_msg = listener_inner.handle(eventmesh_message.unwrap()); - if let Ok(msg_option) = handled_msg { - if let Some(_msg) = msg_option { - received.attributes.insert( - ProtocolKey::SUB_MESSAGE_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - SubscriptionReply::SUB_TYPE.to_string(), - )), - }, - ); - received.data = None; - let _ = keeper.sender.send(received).await; - } - } else { - error!("Handle Receive error:{}", handled_msg.unwrap_err()) - } - } - }); - Ok(()) - } - - pub async fn unsubscribe( - &mut self, - unsubscription_items: Vec, - ) -> crate::Result { - if unsubscription_items.is_empty() { - return Err(InvalidArgs("unsubscription_items is empty".to_string()).into()); - } - let map = self.subscription_map.clone(); - let mut guard = map.lock().await; - unsubscription_items.iter().for_each(|item| { - guard.remove(item.topic.as_str()); - }); - let cloud_event = EventMeshCloudEventUtils::build_event_subscription( - &self.grpc_config, - EventMeshProtocolType::EventMeshMessage, - String::new().as_str(), - &unsubscription_items, - ); - if cloud_event.is_none() { - return Err( - EventMeshLocal("SubscriptionItem switch to CloudEvent error".to_string()).into(), - ); - } - let result = self.inner.unsubscribe_inner(cloud_event.unwrap()).await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - fn heartbeat( - mut client: GrpcClient, - grpc_config: EventMeshGrpcClientConfig, - subscription_map: Arc>>, - ) -> crate::Result<()> { - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(20)).await; - let mut attributes = EventMeshCloudEventUtils::build_common_cloud_event_attributes( - &grpc_config, - EventMeshProtocolType::EventMeshMessage, - ); - attributes.insert( - ProtocolKey::CONSUMERGROUP.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString( - grpc_config.consumer_group.clone().unwrap(), - )), - }, - ); - attributes.insert( - ProtocolKey::CLIENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeInteger(2)), - }, - ); - attributes.insert( - ProtocolKey::DATA_CONTENT_TYPE.to_string(), - PbCloudEventAttributeValue { - attr: Some(PbAttr::CeString(DataContentType::JSON.to_string())), - }, - ); - - let map = subscription_map.lock().await; - let heartbeat_items = map - .iter() - .filter_map(|(key, value)| { - Some(HeartbeatItem { - topic: key.to_string(), - url: value.url.clone(), - }) - }) - .collect::>(); - let mut cloud_event = PbCloudEvent::default(); - cloud_event.attributes = attributes; - cloud_event.data = Some(PbData::TextData( - serde_json::to_string(&heartbeat_items).unwrap(), - )); - let _result = client.heartbeat_inner(cloud_event).await; - } - }); - Ok(()) - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_producer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_producer.rs deleted file mode 100644 index 867588fb88..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/grpc_producer.rs +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -//! Trait for gRPC eventmesh producer. - -use crate::model::response::EventMeshResponse; -use std::future::Future; - -/// Trait for gRPC eventmesh producer. -pub trait EventMeshGrpcProducer { - /// Publish a message. - fn publish(&mut self, message: M) -> impl Future>; - - /// Publish a batch of messages. - fn publish_batch( - &mut self, - messages: Vec, - ) -> impl Future>; - - /// Request reply for a message. - fn request_reply( - &mut self, - message: M, - time_out: u64, - ) -> impl Future>; -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl.rs deleted file mode 100644 index edb47941c5..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#[cfg(feature = "grpc")] -pub mod grpc_producer_impl; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl/grpc_producer_impl.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl/grpc_producer_impl.rs deleted file mode 100644 index f81c3f65e7..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/impl/grpc_producer_impl.rs +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::any::Any; -use std::fmt::Debug; -use std::marker::PhantomData; - -use tonic::transport::Uri; - -use crate::common::constants::{DataContentType, DEFAULT_EVENTMESH_MESSAGE_TTL}; -use crate::common::grpc_eventmesh_message_utils::EventMeshCloudEventUtils; -use crate::config::EventMeshGrpcClientConfig; -use crate::error::EventMeshError; -use crate::grpc::grpc_producer::EventMeshGrpcProducer; -use crate::model::message::EventMeshMessage; -use crate::model::response::EventMeshResponse; -use crate::model::EventMeshProtocolType; -use crate::net::GrpcClient; -use crate::proto_cloud_event::{ - EventMeshCloudEventBuilder, PbCloudEvent, PbCloudEventBatch, PbData, -}; - -/// gRPC EventMesh message producer. -pub struct GrpcEventMeshProducer { - /// gRPC client. - inner: GrpcClient, - - /// gRPC configuration. - grpc_config: EventMeshGrpcClientConfig, - - _mark: PhantomData, -} - -impl GrpcEventMeshProducer -where - M: Any, -{ - pub fn new(grpc_config: EventMeshGrpcClientConfig) -> Self { - let client = GrpcClient::new(&grpc_config).unwrap(); - Self { - inner: client, - grpc_config, - _mark: PhantomData::, - } - } - - #[allow(dead_code)] - fn build_event_mesh_cloud_event(&mut self, message: EventMeshMessage) -> PbCloudEvent { - let mut event = EventMeshCloudEventBuilder::default() - .with_env(self.grpc_config.env.clone()) - .with_idc(self.grpc_config.idc.clone()) - .with_ip(crate::common::local_ip::get_local_ip_v4()) - .with_pid(std::process::id().to_string()) - .with_sys(self.grpc_config.sys.clone()) - .with_user_name(self.grpc_config.user_name.clone()) - .with_password(self.grpc_config.password.clone()) - .with_language("Rust") - .with_protocol_type(EventMeshProtocolType::CloudEvents.protocol_type_name()) - .with_ttl(DEFAULT_EVENTMESH_MESSAGE_TTL.to_string()) - .with_subject(message.biz_seq_no.clone().unwrap()) - .with_producergroup( - self.grpc_config - .producer_group - .clone() - .map_or_else(|| String::from("Default_Producer_Group"), |val| val) - .clone(), - ) - .with_uniqueid(message.unique_id.unwrap()) - .with_data_content_type(DataContentType::TEXT_PLAIN) - .build(); - event.id = message.biz_seq_no.clone().unwrap(); - event.source = Uri::builder() - .path_and_query("/") - .build() - .unwrap() - .to_string(); - event.spec_version = "1.0".to_string(); - event.r#type = "Rust".to_string(); - event.data = Some(PbData::TextData(message.content.unwrap().into())); - event - } - - fn build_event_mesh_cloud_event_batch( - &mut self, - messages: Vec, - ) -> Option { - if messages.is_empty() { - return None; - } - - let events = messages - .into_iter() - .map(|msg| { - EventMeshCloudEventUtils::build_event_mesh_cloud_event(msg, &self.grpc_config) - .unwrap() - }) - .collect(); - - let mut cloud_event_batch = PbCloudEventBatch::default(); - cloud_event_batch.events = events; - - Some(cloud_event_batch) - } -} - -/// gRPC EventMesh message producer implementation. -#[allow(unused_variables)] -impl EventMeshGrpcProducer for GrpcEventMeshProducer -where - M: Any + Debug + From, -{ - /// Publish a message. - async fn publish(&mut self, message: M) -> crate::Result { - let event = - EventMeshCloudEventUtils::build_event_mesh_cloud_event(message, &self.grpc_config); - if event.is_none() { - return Err(EventMeshError::EventMeshLocal( - "Create Event Mesh cloud event Error".to_string(), - ) - .into()); - } - let result = self.inner.publish_inner(event.unwrap()).await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - /// Publish a batch of messages. - async fn publish_batch(&mut self, messages: Vec) -> crate::Result { - let events = self.build_event_mesh_cloud_event_batch(messages); - if events.is_none() { - return Err(EventMeshError::EventMeshLocal("Vec is empty".to_string()).into()); - } - let result = self.inner.batch_publish_inner(events.unwrap()).await?; - Ok(EventMeshCloudEventUtils::get_response(&result)) - } - - /// Request reply for a message. - async fn request_reply(&mut self, message: M, time_out: u64) -> crate::Result { - let event = - EventMeshCloudEventUtils::build_event_mesh_cloud_event(message, &self.grpc_config); - let result = self - .inner - .request_reply_inner(event.unwrap(), time_out) - .await?; - Ok(EventMeshCloudEventUtils::build_message_from_event_mesh_cloud_event(&result).unwrap()) - } -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/pb.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/pb.rs deleted file mode 100644 index 26e69e91a9..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/grpc/pb.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -pub mod cloud_events { - tonic::include_proto!("org.apache.eventmesh.cloudevents.v1"); -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/handler.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/handler.rs new file mode 100644 index 0000000000..e50fa73cac --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/handler.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. + +//! Shared handler contract used directly by every transport. + +use std::future::Future; + +use crate::error::Result; +use crate::message::Message; + +/// Handles a delivered EventMesh message. +/// +/// Return `Ok(None)` to acknowledge an asynchronous message, or +/// `Ok(Some(reply))` to reply to a synchronous delivery. Returning an error +/// reports application failure to the transport adapter rather than treating +/// it as a successful business acknowledgement. +/// +/// Native messages expose received metadata through +/// [`crate::EventMeshMessage::delivery_context`]. Return a newly built business +/// reply; the SDK restores the original request routing automatically. +pub trait MessageHandler: Send + Sync + 'static { + /// Handle one delivery. + fn handle(&self, message: Message) -> impl Future>> + Send; +} + +impl MessageHandler for F +where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future>> + Send + 'static, +{ + fn handle(&self, message: Message) -> impl Future>> + Send { + (self)(message) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/http.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/http.rs new file mode 100644 index 0000000000..d92ed63013 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/http.rs @@ -0,0 +1,760 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! HTTP client API. + +/// Framework-independent helpers for custom webhook endpoints. +pub mod codec { + pub use crate::transport::http::codec::{ + parse_push_body, PushMessageRequestBody, WebhookReply, + }; +} + +use crate::config::{ConsumerOptions, HttpConfig, ProducerOptions}; +use crate::error::{EventMeshError, Result}; +use crate::message::{Message, PublishReceipt}; +use crate::subscription::{DeliveryType, Subscription}; +use crate::transport::http::{ + EventMeshHttpClient as TransportClient, HttpConsumer as TransportConsumer, + HttpProducer as TransportProducer, WebhookServer, +}; +use crate::transport::task::BackgroundTask; +use crate::webhook::WebhookOptions; +use crate::MessageHandler; +use std::sync::Arc; +use tokio::sync::Mutex; +#[cfg(test)] +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +/// A configured EventMesh HTTP client. +#[derive(Clone)] +pub struct HttpClient { + config: HttpConfig, +} + +impl HttpClient { + /// Validate and create an HTTP client handle. + pub fn new(config: HttpConfig) -> Result { + config.validate()?; + // Constructing the private HTTP client validates the endpoint set and + // request client without issuing network I/O. + TransportClient::validate(&config)?; + Ok(Self { config }) + } + + /// Create a publishing role. + pub fn producer(&self, options: ProducerOptions) -> Result { + options.validate()?; + Ok(HttpProducer { + inner: TransportProducer::new(self.config.clone(), &options)?, + }) + } + + /// Start an SDK-managed HTTP consumer. + /// + /// The callback socket is bound before subscriptions are registered. The + /// returned consumer owns the axum server, runtime registrations, and + /// heartbeat task as one lifecycle. + /// + /// Cancelling startup stops the local callback server and heartbeat task. + /// A registration already accepted by the runtime is not rolled back by + /// cancellation. + pub async fn consumer( + &self, + options: ConsumerOptions, + webhook: WebhookOptions, + subscriptions: impl IntoIterator, + handler: H, + ) -> Result + where + H: MessageHandler, + { + options.validate()?; + webhook.validate()?; + let subscriptions: Vec<_> = subscriptions.into_iter().collect(); + validate_subscriptions(&subscriptions)?; + + let lifecycle = CancellationToken::new(); + let inner = TransportConsumer::new( + self.config.clone(), + &options, + Some(lifecycle.clone().cancelled_owned()), + )?; + let mut server = WebhookServer::bind(webhook.bind_addr(), Arc::new(handler)).await?; + if let Some(url) = webhook.advertise_url() { + server = server.with_advertise_url(url); + } + let webhook_url = server.url(); + server = server.with_graceful_shutdown(lifecycle.clone().cancelled_owned()); + + let task_lifecycle = lifecycle.clone(); + let server_handle = tokio::spawn(async move { + let result = server.await; + task_lifecycle.cancel(); + result + }); + // Own the tasks before the next await so cancellation during startup + // runs the same Drop cleanup as a fully constructed consumer. + let consumer = HttpConsumer { + inner, + webhook_url, + lifecycle, + server_handle: Mutex::new(BackgroundTask::new(server_handle)), + }; + + if let Err(error) = consumer + .inner + .subscribe_webhook(subscriptions, consumer.webhook_url.clone()) + .await + { + consumer.shutdown(); + let _ = consumer.join().await; + return Err(error); + } + + let server_finished = consumer.server_handle.lock().await.is_finished(); + if server_finished { + consumer.shutdown(); + let _ = consumer.inner.unsubscribe_all().await; + return match consumer.join().await { + Err(error) => Err(error), + Ok(()) => Err(EventMeshError::ChannelClosed( + "HTTP webhook server stopped during consumer startup".into(), + )), + }; + } + + Ok(consumer) + } + + /// Create a registration manager for an application-owned HTTP endpoint. + pub fn webhook_registration(&self, options: ConsumerOptions) -> Result { + options.validate()?; + Ok(WebhookRegistration { + inner: TransportConsumer::new( + self.config.clone(), + &options, + None::>, + )?, + }) + } +} + +/// HTTP publishing capability. +pub struct HttpProducer { + inner: TransportProducer, +} + +impl HttpProducer { + /// Publish one event. + pub async fn publish(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .publish(message) + .await + .map(PublishReceipt::from_response), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .publish_cloud_event(event) + .await + .map(PublishReceipt::from_response), + } + } +} + +/// An SDK-managed HTTP consumer with an embedded axum callback server. +pub struct HttpConsumer { + inner: TransportConsumer, + webhook_url: String, + lifecycle: CancellationToken, + server_handle: Mutex>>, +} + +impl HttpConsumer { + /// Add a subscription to this consumer's callback URL. + pub async fn subscribe(&self, subscription: Subscription) -> Result<()> { + validate_subscriptions(std::slice::from_ref(&subscription))?; + self.inner + .subscribe_webhook(vec![subscription], self.webhook_url.clone()) + .await + .map(|_| ()) + } + + /// Remove a subscription from this consumer's callback URL. + pub async fn unsubscribe(&self, subscription: Subscription) -> Result<()> { + subscription.validate()?; + self.inner + .unsubscribe(vec![subscription], self.webhook_url.clone()) + .await + .map(|_| ()) + } + + /// Return the URL registered with EventMesh. + pub fn webhook_url(&self) -> &str { + &self.webhook_url + } + + /// Signal heartbeat and callback serving to stop. + pub fn shutdown(&self) { + self.inner.request_shutdown(); + self.lifecycle.cancel(); + } + + /// Wait until the callback server exits and report background task failure. + /// + /// Cancelling this wait preserves task ownership and pending results. Call + /// `join()` again to finish waiting, or drop the consumer to abort its tasks. + pub async fn join(&self) -> Result<()> { + let mut server = self.server_handle.lock().await; + server.wait().await; + // A panicked server task cannot cancel the lifecycle token itself. + // Cancel it here so heartbeat shutdown is guaranteed before returning. + self.lifecycle.cancel(); + self.inner.request_shutdown(); + let heartbeat_result = self.inner.wait_for_shutdown().await; + let server_result = match server.take_result() { + Some(result) => result.map_err(server_join_error)?, + None => Ok(()), + }; + server_result.and(heartbeat_result) + } + + /// Unregister all subscriptions, signal shutdown, and join background work. + pub async fn close(&self) -> Result<()> { + let unregister_result = self.inner.unsubscribe_all().await; + self.shutdown(); + let join_result = self.join().await; + combine_cleanup_results(unregister_result, join_result) + } +} + +impl Drop for HttpConsumer { + fn drop(&mut self) { + self.lifecycle.cancel(); + } +} + +/// Registration and heartbeat manager for an application-owned webhook URL. +pub struct WebhookRegistration { + inner: TransportConsumer, +} + +impl WebhookRegistration { + /// Register a subscription to an application-owned callback URL. + pub async fn subscribe( + &self, + subscription: Subscription, + webhook_url: impl Into, + ) -> Result<()> { + validate_subscriptions(std::slice::from_ref(&subscription))?; + let webhook_url = webhook_url.into(); + crate::webhook::validate_webhook_url(&webhook_url)?; + self.inner + .subscribe_webhook(vec![subscription], webhook_url) + .await + .map(|_| ()) + } + + /// Remove a registration using the same URL supplied to [`subscribe`](Self::subscribe). + pub async fn unsubscribe( + &self, + subscription: Subscription, + webhook_url: impl Into, + ) -> Result<()> { + subscription.validate()?; + let webhook_url = webhook_url.into(); + crate::webhook::validate_webhook_url(&webhook_url)?; + self.inner + .unsubscribe(vec![subscription], webhook_url) + .await + .map(|_| ()) + } + + /// Signal the heartbeat task to stop. + pub fn shutdown(&self) { + self.inner.request_shutdown(); + } + + /// Wait for the heartbeat task to stop and report task failure. + /// + /// Cancelling this wait preserves task ownership and pending results. Call + /// `join()` again to finish waiting, or drop the consumer to abort its tasks. + pub async fn join(&self) -> Result<()> { + self.inner.wait_for_shutdown().await + } + + /// Unregister every tracked subscription, shut down, and join. + pub async fn close(&self) -> Result<()> { + let unregister_result = self.inner.unsubscribe_all().await; + self.shutdown(); + let join_result = self.join().await; + combine_cleanup_results(unregister_result, join_result) + } +} + +fn combine_cleanup_results(operation: Result<()>, shutdown: Result<()>) -> Result<()> { + match (operation, shutdown) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(operation), Err(shutdown)) => Err(EventMeshError::Cleanup { + operation: Box::new(operation), + shutdown: Box::new(shutdown), + }), + } +} + +fn validate_subscriptions(subscriptions: &[Subscription]) -> Result<()> { + if subscriptions.is_empty() { + return Err(EventMeshError::InvalidArgument( + "HTTP consumer requires at least one initial subscription".into(), + )); + } + if subscriptions + .iter() + .any(|subscription| subscription.delivery_type == DeliveryType::Sync) + { + return Err(EventMeshError::Unsupported( + "HTTP request/reply subscriptions".into(), + )); + } + for subscription in subscriptions { + subscription.validate()?; + } + Ok(()) +} + +fn server_join_error(error: tokio::task::JoinError) -> EventMeshError { + EventMeshError::Protocol { + transport: "http", + message: format!("webhook server task failed: {error}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{extract::State, http::HeaderMap, routing::post, Json, Router}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use tokio::sync::mpsc; + + #[derive(Clone)] + struct RuntimeState { + codes: Arc>>, + reject_subscribe: Arc, + rejected_unsubscribes_remaining: Arc, + } + + async fn runtime_reply( + State(state): State, + headers: HeaderMap, + ) -> Json { + let code = headers + .get("code") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .unwrap_or_default(); + state.codes.lock().await.push(code); + let reject_subscribe = code == crate::common::status_code::RequestCode::SUBSCRIBE + && state.reject_subscribe.load(Ordering::Relaxed); + let reject_unsubscribe = code == crate::common::status_code::RequestCode::UNSUBSCRIBE + && state + .rejected_unsubscribes_remaining + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| { + remaining.checked_sub(1) + }) + .is_ok(); + let ret_code = if reject_subscribe || reject_unsubscribe { + 17 + } else { + 0 + }; + Json(serde_json::json!({"retCode": ret_code, "retMsg": "test"})) + } + + async fn mock_runtime( + reject_subscribe: bool, + rejected_unsubscribes: usize, + ) -> (HttpClient, Arc>>, JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let codes = Arc::new(Mutex::new(Vec::new())); + let state = RuntimeState { + codes: Arc::clone(&codes), + reject_subscribe: Arc::new(AtomicBool::new(reject_subscribe)), + rejected_unsubscribes_remaining: Arc::new(AtomicUsize::new(rejected_unsubscribes)), + }; + let task = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route("/", post(runtime_reply)) + .with_state(state), + ) + .await + .unwrap(); + }); + let endpoint = crate::config::Endpoint::new("127.0.0.1", address.port()).unwrap(); + let endpoints = crate::config::EndpointSet::new([endpoint]).unwrap(); + let client = HttpClient::new(HttpConfig::new(endpoints)).unwrap(); + (client, codes, task) + } + + fn available_address() -> std::net::SocketAddr { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + address + } + + #[test] + fn webhook_registration_returns_error_without_a_tokio_runtime() { + let endpoint = crate::config::Endpoint::new("127.0.0.1", 10_104).unwrap(); + let endpoints = crate::config::EndpointSet::new([endpoint]).unwrap(); + let client = HttpClient::new(HttpConfig::new(endpoints)).unwrap(); + + let result = client.webhook_registration(ConsumerOptions::new("consumer-group")); + + assert!(matches!( + result, + Err(EventMeshError::Config(message)) if message.contains("active Tokio runtime") + )); + } + + #[test] + fn cleanup_result_preserves_one_or_both_failures() { + assert!(combine_cleanup_results(Ok(()), Ok(())).is_ok()); + + let operation_only = combine_cleanup_results( + Err(EventMeshError::InvalidArgument("unregister".into())), + Ok(()), + ); + assert!(matches!( + operation_only, + Err(EventMeshError::InvalidArgument(message)) if message == "unregister" + )); + + let shutdown_only = + combine_cleanup_results(Ok(()), Err(EventMeshError::ChannelClosed("join".into()))); + assert!(matches!( + shutdown_only, + Err(EventMeshError::ChannelClosed(message)) if message == "join" + )); + + let both = combine_cleanup_results( + Err(EventMeshError::InvalidArgument("unregister".into())), + Err(EventMeshError::ChannelClosed("join".into())), + ); + match both { + Err(EventMeshError::Cleanup { + operation, + shutdown, + }) => { + assert!(matches!( + *operation, + EventMeshError::InvalidArgument(ref message) if message == "unregister" + )); + assert!(matches!( + *shutdown, + EventMeshError::ChannelClosed(ref message) if message == "join" + )); + } + other => panic!("expected combined cleanup error, got {other:?}"), + } + } + + #[tokio::test] + async fn managed_consumer_binds_before_registering() { + let (client, codes, runtime) = mock_runtime(false, 0).await; + let occupied = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = occupied.local_addr().unwrap(); + + let result = client + .consumer( + ConsumerOptions::new("group"), + WebhookOptions::new(address), + [Subscription::new("orders")], + |_message| async { Ok(None) }, + ) + .await; + + assert!(matches!(result, Err(EventMeshError::Io(_)))); + assert!( + codes.lock().await.is_empty(), + "registration must not start before bind" + ); + runtime.abort(); + } + + #[tokio::test] + async fn managed_consumer_serves_and_unregisters_on_close() { + let (client, codes, runtime) = mock_runtime(false, 0).await; + let (tx, mut rx) = mpsc::unbounded_channel(); + let consumer = client + .consumer( + ConsumerOptions::new("group"), + WebhookOptions::new("127.0.0.1:0".parse().unwrap()), + [Subscription::new("orders")], + move |message| { + let tx = tx.clone(); + async move { + tx.send(message).unwrap(); + Ok(None) + } + }, + ) + .await + .unwrap(); + + reqwest::Client::new() + .post(consumer.webhook_url()) + .form(&[("content", "created"), ("topic", "orders")]) + .send() + .await + .unwrap() + .error_for_status() + .unwrap(); + let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(received.as_event_mesh().unwrap().content(), "created"); + + consumer.close().await.unwrap(); + let codes = codes.lock().await.clone(); + assert!(codes.contains(&crate::common::status_code::RequestCode::SUBSCRIBE)); + assert!(codes.contains(&crate::common::status_code::RequestCode::UNSUBSCRIBE)); + runtime.abort(); + } + + #[tokio::test] + async fn cancelled_join_still_waits_for_inflight_http_handler() { + let (client, _, runtime) = mock_runtime(false, 0).await; + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let handler_entered = entered.clone(); + let handler_release = release.clone(); + let consumer = client + .consumer( + ConsumerOptions::new("group"), + WebhookOptions::new("127.0.0.1:0".parse().unwrap()), + [Subscription::new("orders")], + move |_| { + let entered = handler_entered.clone(); + let release = handler_release.clone(); + async move { + entered.notify_one(); + release.notified().await; + Ok(None) + } + }, + ) + .await + .unwrap(); + let url = consumer.webhook_url().to_owned(); + let request = tokio::spawn(async move { + reqwest::Client::new() + .post(url) + .form(&[("content", "created"), ("topic", "orders")]) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + }); + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + consumer.shutdown(); + for _ in 0..2 { + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), consumer.join()) + .await + .is_err(), + "join must keep waiting for the handler after cancellation" + ); + } + release.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(3), consumer.join()) + .await + .unwrap() + .unwrap(); + request.await.unwrap(); + runtime.abort(); + } + + #[tokio::test] + async fn cancelled_join_then_drop_aborts_http_server_task() { + let (client, _, runtime) = mock_runtime(false, 0).await; + let consumer = client + .consumer( + ConsumerOptions::new("group"), + WebhookOptions::new("127.0.0.1:0".parse().unwrap()), + [Subscription::new("orders")], + |_| async { Ok(None) }, + ) + .await + .unwrap(); + let (alive, dropped) = tokio::sync::oneshot::channel::<()>(); + *consumer.server_handle.lock().await = BackgroundTask::new(tokio::spawn(async move { + let _alive = alive; + std::future::pending::>().await + })); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), consumer.join()) + .await + .is_err() + ); + drop(consumer); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(3), dropped) + .await + .expect("Drop must abort the retained server task") + .is_err() + ); + runtime.abort(); + } + + #[tokio::test] + async fn managed_consumer_shutdown_only_signals_local_tasks() { + let (client, codes, runtime) = mock_runtime(false, 0).await; + let consumer = client + .consumer( + ConsumerOptions::new("group"), + WebhookOptions::new("127.0.0.1:0".parse().unwrap()), + [Subscription::new("orders")], + |_message| async { Ok(None) }, + ) + .await + .unwrap(); + + consumer.shutdown(); + consumer.join().await.unwrap(); + + let codes = codes.lock().await.clone(); + assert!(codes.contains(&crate::common::status_code::RequestCode::SUBSCRIBE)); + assert!( + !codes.contains(&crate::common::status_code::RequestCode::UNSUBSCRIBE), + "shutdown must not perform remote cleanup; close owns that operation" + ); + runtime.abort(); + } + + #[tokio::test] + async fn registration_failure_stops_managed_server() { + let (client, _codes, runtime) = mock_runtime(true, 0).await; + let address = available_address(); + let result = client + .consumer( + ConsumerOptions::new("group"), + WebhookOptions::new(address), + [Subscription::new("orders")], + |_message| async { Ok(None) }, + ) + .await; + assert!(matches!(result, Err(EventMeshError::Server { .. }))); + assert!(tokio::net::TcpStream::connect(address).await.is_err()); + runtime.abort(); + } + + #[tokio::test] + async fn cancelled_startup_releases_callback_server() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let runtime_address = listener.local_addr().unwrap(); + let (registration_tx, mut registration_rx) = mpsc::unbounded_channel(); + let runtime = tokio::spawn(async move { + let app = Router::new().route( + "/", + post(move || { + registration_tx.send(()).unwrap(); + std::future::pending::<&'static str>() + }), + ); + axum::serve(listener, app).await.unwrap(); + }); + let endpoint = crate::config::Endpoint::new("127.0.0.1", runtime_address.port()).unwrap(); + let endpoints = crate::config::EndpointSet::new([endpoint]).unwrap(); + let client = HttpClient::new(HttpConfig::new(endpoints)).unwrap(); + let address = available_address(); + let (handler_tx, mut handler_rx) = mpsc::unbounded_channel::<()>(); + let startup = tokio::spawn(async move { + client + .consumer( + ConsumerOptions::new("group"), + WebhookOptions::new(address), + [Subscription::new("orders")], + move |_message| { + let _ = &handler_tx; + std::future::ready(Ok(None)) + }, + ) + .await + }); + let timeout = std::time::Duration::from_secs(2); + tokio::time::timeout(timeout, registration_rx.recv()) + .await + .unwrap() + .expect("registration must start before cancellation"); + assert!(std::net::TcpListener::bind(address).is_err()); + + startup.abort(); + assert!(matches!(startup.await, Err(error) if error.is_cancelled())); + + assert!(tokio::time::timeout(timeout, handler_rx.recv()) + .await + .expect("cancelled startup must drop its callback handler") + .is_none()); + let _rebound = tokio::net::TcpListener::bind(address) + .await + .expect("cancelled startup must release its callback port"); + runtime.abort(); + } + + #[tokio::test] + async fn registration_close_attempts_every_webhook_url() { + let (client, codes, runtime) = mock_runtime(false, 1).await; + let registration = client + .webhook_registration(ConsumerOptions::new("consumer-group")) + .unwrap(); + registration + .subscribe(Subscription::new("orders"), "http://127.0.0.1:30001/orders") + .await + .unwrap(); + registration + .subscribe( + Subscription::new("payments"), + "http://127.0.0.1:30002/payments", + ) + .await + .unwrap(); + + assert!(matches!( + registration.close().await, + Err(EventMeshError::Server { code: 17, .. }) + )); + let unsubscribe_count = codes + .lock() + .await + .iter() + .filter(|code| **code == crate::common::status_code::RequestCode::UNSUBSCRIBE) + .count(); + assert_eq!(unsubscribe_count, 2); + runtime.abort(); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs index a03f8cfa2d..be1197278a 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/lib.rs @@ -1,323 +1,95 @@ -/* - * 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. //! +//! # API map //! +//! Start with a feature-gated transport handle: [`GrpcChannel`] (`grpc`), +//! [`HttpClient`] (`http`), or [`TcpClient`] (`tcp`). A gRPC channel is +//! connected inside its owning Tokio runtime and passed explicitly to gRPC +//! producer and consumer roles. Producers accept [`Message`], while consumers +//! deliver it to [`MessageHandler`]. Use [`config`] for endpoints, identity, +//! credentials, timeouts, HTTP TLS, and reconnect settings; use +//! [`Subscription`] to declare what a consumer receives. +//! +//! `Message` preserves native EventMesh messages and, with `cloud_events`, +//! `cloudevents::Event`. It is not a serialization format: gRPC protobuf, +//! HTTP form, and TCP frame encoding remain transport implementation details. +//! +//! # Features +//! +//! The default feature set is empty. Enable `grpc`, `http`, or `tcp` for a +//! transport; `cloud_events` adds CloudEvents support. `full` enables every +//! runtime feature. See the repository README and +//! `examples/` for runnable programs. +//! +//! # Delivery and lifecycle +//! +//! A [`MessageHandler`] returns `Ok(None)` to acknowledge an asynchronous +//! delivery, `Ok(Some(reply))` to reply to a synchronous delivery, or `Err(_)` +//! to report application failure. Long-lived consumers use a two-step +//! lifecycle: `shutdown` only signals cancellation, while `join` waits for +//! background work and reports failures. Managed HTTP consumers and webhook +//! registrations additionally expose `close` to unregister remotely before +//! shutting down and joining. -/// 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 +#![deny(missing_docs, unsafe_code)] -/// Configurations. +mod common; pub mod config; +mod error; +mod handler; +pub mod message; +mod model; +pub mod subscription; +pub mod webhook; -/// Errors. -pub(crate) mod error; - -/// Network utils. -mod net; +#[cfg(feature = "grpc")] +mod proto_gen; -/// Common utilities. -pub mod common; +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +mod transport; -/// gRPC client implementations. +/// gRPC client API. +#[cfg(feature = "grpc")] pub mod grpc; -/// Logging. -pub mod log; - -/// Data models. -pub mod model; - -/// Module contains Protobuf CloudEvent related types and builder. -pub mod proto_cloud_event { - use cloudevents::Event; - - use crate::common::ProtocolKey; - /// Protobuf CloudEvent attribute value enum. - pub use crate::grpc::pb::cloud_events::cloud_event::cloud_event_attribute_value::Attr as PbAttr; - use crate::grpc::pb::cloud_events::cloud_event::cloud_event_attribute_value::Attr; - pub use crate::grpc::pb::cloud_events::cloud_event::CloudEventAttributeValue as PbCloudEventAttributeValue; - pub use crate::grpc::pb::cloud_events::cloud_event::Data as PbData; - use crate::grpc::pb::cloud_events::cloud_event::{CloudEventAttributeValue, Data}; - /// Protobuf CloudEvent message. - pub use crate::grpc::pb::cloud_events::{ - CloudEvent as PbCloudEvent, CloudEventBatch as PbCloudEventBatch, - }; - use crate::model::message::EventMeshMessage; - - impl ToString for PbAttr { - /// Convert Protobuf attribute to String. - fn to_string(&self) -> String { - match self { - Attr::CeBoolean(value) => value.to_string(), - Attr::CeInteger(value) => value.to_string(), - Attr::CeString(value) => value.clone(), - Attr::CeBytes(value) => unsafe { String::from_utf8_unchecked(value.clone()) }, - Attr::CeUri(value) => value.clone(), - Attr::CeUriRef(value) => value.clone(), - Attr::CeTimestamp(value) => value.to_string(), - } - } - } - - impl From for PbCloudEvent { - fn from(_value: EventMeshMessage) -> Self { - todo!() - } - } - - impl From for PbCloudEvent { - fn from(_value: Event) -> Self { - todo!() - } - } - - impl ToString for PbData { - /// Convert Protobuf data to String. - fn to_string(&self) -> String { - match self { - Data::BinaryData(value) => unsafe { String::from_utf8_unchecked(value.clone()) }, - Data::TextData(value) => value.clone(), - Data::ProtoData(value) => unsafe { - String::from_utf8_unchecked(value.value.clone()) - }, - } - } - } - - /// Builder for constructing Protobuf CloudEvent. - #[derive(Debug, Default)] - pub struct EventMeshCloudEventBuilder { - /// Environment attribute. - pub(crate) env: String, - - /// IDC attribute. - pub(crate) idc: String, - - /// IP address attribute. - pub(crate) ip: String, - - /// Optional process ID attribute. - pub(crate) pid: Option, - - /// System attribute. - pub(crate) sys: String, - - /// Username attribute. - pub(crate) user_name: String, - - /// Password attribute. - pub(crate) password: String, - - /// Language attribute. - pub(crate) language: String, - - /// Protocol type attribute. - pub(crate) protocol_type: String, - - /// Protocol version attribute. - pub(crate) protocol_version: String, - - /// TTL attribute. - pub(crate) ttl: String, - - /// Subject attribute. - pub(crate) subject: String, - - /// Producer group attribute. - pub(crate) producergroup: String, - - /// Unique ID attribute. - pub(crate) uniqueid: String, - - /// Data content type attribute. - pub(crate) data_content_type: String, - } - - impl EventMeshCloudEventBuilder { - /// Set process ID attribute. - pub fn with_pid(mut self, pid: impl Into) -> Self { - self.pid = Some(pid.into()); - self - } - - /// Set environment attribute. - pub fn with_env(mut self, env: impl Into) -> Self { - self.env = env.into(); - self - } - - /// Set IDC attribute. - pub fn with_idc(mut self, idc: impl Into) -> Self { - self.idc = idc.into(); - self - } - - /// Set IP address attribute. - pub fn with_ip(mut self, ip: impl Into) -> Self { - self.ip = ip.into(); - self - } - - /// Set system attribute. - pub fn with_sys(mut self, sys: impl Into) -> Self { - self.sys = sys.into(); - self - } - - /// Set username attribute. - pub fn with_user_name(mut self, user_name: impl Into) -> Self { - self.user_name = user_name.into(); - self - } - - /// Set password attribute. - pub fn with_password(mut self, password: impl Into) -> Self { - self.password = password.into(); - self - } - - /// Set language attribute. - pub fn with_language(mut self, language: impl Into) -> Self { - self.language = language.into(); - self - } - - /// Set protocol type attribute. - pub fn with_protocol_type(mut self, protocol_type: impl Into) -> Self { - self.protocol_type = protocol_type.into(); - self - } - - /// Set protocol version attribute. - pub fn with_protocol_version(mut self, protocol_version: impl Into) -> Self { - self.protocol_version = protocol_version.into(); - self - } - - /// Set TTL attribute. - pub fn with_ttl(mut self, ttl: impl Into) -> Self { - self.ttl = ttl.into(); - self - } - - /// Set subject attribute. - pub fn with_subject(mut self, subject: impl Into) -> Self { - self.subject = subject.into(); - self - } +/// HTTP client API. +#[cfg(feature = "http")] +pub mod http; - /// Set producer group attribute. - pub fn with_producergroup(mut self, producergroup: impl Into) -> Self { - self.producergroup = producergroup.into(); - self - } +/// TCP client API. +#[cfg(feature = "tcp")] +pub mod tcp; - /// Set unique ID attribute. - pub fn with_uniqueid(mut self, uniqueid: impl Into) -> Self { - self.uniqueid = uniqueid.into(); - self - } +pub use error::{Error, Result}; +pub use handler::MessageHandler; +pub use message::{ + DeliveryContext, EventMeshMessage, EventMeshMessageBuilder, Message, MessageKind, + PublishReceipt, +}; +pub use subscription::{DeliveryMode, DeliveryType, Subscription}; - /// 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 - } +#[cfg(feature = "grpc")] +pub use grpc::{GrpcChannel, GrpcProducer, GrpcStreamConsumer, GrpcWebhookConsumer}; - /// 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 - } +#[cfg(feature = "http")] +pub use http::HttpClient; - /// 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 - } - } -} +#[cfg(feature = "tcp")] +pub use tcp::TcpClient; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/log.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/log.rs deleted file mode 100644 index 87f39217bc..0000000000 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/log.rs +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -pub fn init_logger() { - tracing_subscriber::fmt() - .with_thread_names(true) - .with_level(true) - .with_line_number(true) - .with_thread_ids(true) - .with_max_level(tracing::Level::INFO) - .init(); -} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/message.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/message.rs new file mode 100644 index 0000000000..6d2527b162 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/message.rs @@ -0,0 +1,134 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Public EventMesh message models. +//! +//! [`Message`] is the protocol-independent envelope accepted by every v2 +//! producer and delivered to stream consumers. Transport-specific wire +//! encoding remains an implementation detail. [`EventMeshMessage`] deliberately +//! does not implement serde: each transport maps it to a private protobuf, +//! form, or TCP JSON wire DTO. +//! +//! Native messages expose business properties separately from their read-only +//! [`DeliveryContext`]. Publishers ignore that context; consumers use the +//! original request context to correlate replies automatically. CloudEvents +//! keep their standard attributes and extensions. + +#[cfg(feature = "cloud_events")] +use crate::error::EventMeshError; +use crate::error::Result; + +pub use crate::model::{DeliveryContext, EventMeshMessage, EventMeshMessageBuilder}; + +/// Which public event dialect a [`Message`] contains. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum MessageKind { + /// EventMesh's native message model. + EventMesh, + /// A CNCF CloudEvent. + #[cfg(feature = "cloud_events")] + CloudEvent, +} + +/// A public EventMesh event. +/// +/// This enum is intentionally not `serde::Serialize`: serializing an enum +/// would produce an SDK-specific tagged representation, which is not any of +/// the EventMesh protocol wire formats. The selected transport performs the +/// corresponding protobuf, form, or TCP-frame encoding internally. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Message { + /// EventMesh's native envelope. + EventMesh(EventMeshMessage), + /// A native CloudEvent. + #[cfg(feature = "cloud_events")] + CloudEvent(cloudevents::Event), +} + +/// Confirmation returned by a successful EventMesh publish operation. +/// +/// A broker-side rejection is returned as [`crate::Error::Server`], so a +/// receipt always represents an accepted operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublishReceipt { + /// The server's acknowledgement code (normally zero). + pub code: i64, + /// Optional acknowledgement text. + pub message: Option, + /// Optional server processing time in milliseconds. + pub server_time_millis: Option, +} + +impl PublishReceipt { + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn from_response(response: crate::model::PublishResponse) -> Self { + Self { + code: response.code.unwrap_or(0), + message: response.message, + server_time_millis: response.time, + } + } +} + +impl Message { + /// Return the dialect stored in this message. + pub const fn kind(&self) -> MessageKind { + match self { + Self::EventMesh(_) => MessageKind::EventMesh, + #[cfg(feature = "cloud_events")] + Self::CloudEvent(_) => MessageKind::CloudEvent, + } + } + + /// Borrow the native EventMesh message, if this is that dialect. + pub fn as_event_mesh(&self) -> Option<&EventMeshMessage> { + match self { + Self::EventMesh(message) => Some(message), + #[cfg(feature = "cloud_events")] + Self::CloudEvent(_) => None, + } + } + + /// Convert this message to the EventMesh native model. + /// + /// CloudEvents are not silently collapsed here: callers must select a + /// transport that supports the CloudEvents variant directly. + pub fn into_event_mesh(self) -> Result { + match self { + Self::EventMesh(message) => Ok(message), + #[cfg(feature = "cloud_events")] + Self::CloudEvent(_) => Err(EventMeshError::Unsupported( + "converting CloudEvent to EventMeshMessage loses CloudEvents semantics".into(), + )), + } + } +} + +impl From for Message { + fn from(message: EventMeshMessage) -> Self { + Self::EventMesh(message) + } +} + +#[cfg(feature = "cloud_events")] +impl From for Message { + fn from(event: cloudevents::Event) -> Self { + Self::CloudEvent(event) + } +} 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/delivery.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/delivery.rs new file mode 100644 index 0000000000..846ad24c12 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/delivery.rs @@ -0,0 +1,225 @@ +// 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. + +//! Read-only context attached to received native messages. + +use std::collections::HashMap; +use std::fmt; + +/// Protocol and routing metadata associated with a received native message. +/// +/// Created only by SDK decoders and available through +/// [`crate::EventMeshMessage::delivery_context`]. Producers never publish this +/// context. Consumer reply paths use it internally to restore correlation. +/// CloudEvents retain their standard attributes and extensions instead. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct DeliveryContext { + protocol_type: Option, + protocol_version: Option, + protocol_description: Option, + attributes: HashMap, +} + +impl DeliveryContext { + /// The received EventMesh dialect, such as `eventmeshmessage`. + pub fn protocol_type(&self) -> Option<&str> { + self.protocol_type.as_deref() + } + + /// The received EventMesh protocol version. + pub fn protocol_version(&self) -> Option<&str> { + self.protocol_version.as_deref() + } + + /// The received protocol descriptor, such as `http` or `grpc-cloud-event`. + pub fn protocol_description(&self) -> Option<&str> { + self.protocol_description.as_deref() + } + + /// Inspect a received identity, routing, or other known protocol attribute. + /// + /// Values are an inbound snapshot, not configuration for a future publish. + /// Message IDs, TTL, and content type have dedicated message accessors. + pub fn attribute(&self, key: &str) -> Option<&str> { + match key { + "protocoltype" => self.protocol_type(), + "protocolversion" => self.protocol_version(), + "protocoldesc" => self.protocol_description(), + _ => self.attributes.get(key).map(String::as_str), + } + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn take_from(attributes: &mut HashMap) -> Self { + let mut context = Self { + protocol_type: attributes.remove("protocoltype"), + protocol_version: attributes.remove("protocolversion"), + protocol_description: attributes.remove("protocoldesc"), + attributes: HashMap::new(), + }; + attributes.retain(|key, value| { + if is_reserved_property(key) { + context.attributes.insert(key.clone(), value.clone()); + false + } else { + true + } + }); + context + } + + #[cfg(any(feature = "http", feature = "tcp"))] + pub(crate) fn insert_missing(&mut self, key: &str, value: String) { + match key { + "protocoltype" => { + self.protocol_type.get_or_insert(value); + } + "protocolversion" => { + self.protocol_version.get_or_insert(value); + } + "protocoldesc" => { + self.protocol_description.get_or_insert(value); + } + _ if is_transport_property(key) || is_delivery_property(key) => { + self.attributes.entry(key.to_string()).or_insert(value); + } + _ => {} + } + } + + #[cfg(feature = "tcp")] + pub(crate) fn reply_attributes(&self) -> impl Iterator { + self.attributes + .iter() + .filter(|(key, _)| is_delivery_property(key)) + } +} + +impl fmt::Debug for DeliveryContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let attributes: HashMap<_, _> = self + .attributes + .iter() + .map(|(key, value)| { + ( + key.as_str(), + if matches!(key.as_str(), "passwd" | "token") { + "***" + } else { + value.as_str() + }, + ) + }) + .collect(); + f.debug_struct("DeliveryContext") + .field("protocol_type", &self.protocol_type) + .field("protocol_version", &self.protocol_version) + .field("protocol_description", &self.protocol_description) + .field("attributes", &attributes) + .finish() + } +} + +pub(crate) fn is_transport_property(key: &str) -> bool { + matches!( + key, + "protocoltype" + | "protocolversion" + | "protocoldesc" + | "code" + | "version" + | "env" + | "idc" + | "ip" + | "pid" + | "sys" + | "username" + | "passwd" + | "token" + | "language" + | "producergroup" + ) +} + +// Exact runtime/storage keys, not a prefix rule: a business property named +// `requestid`, for example, must not be mistaken for a routing attribute. +pub(crate) fn is_delivery_property(key: &str) -> bool { + matches!( + key, + "cluster" + | "consumergroup" + | "url" + | "clienttype" + | "submessagetype" + | "msgtype" + | "req0sys" + | "req0ip" + | "req0idc" + | "req0group" + | "rsp0sys" + | "rsp0ip" + | "rsp0idc" + | "rsp0group" + | "rsp0url" + | "reqc2eventmeshtimestamp" + | "reqeventmesh2mqtimestamp" + | "reqmq2eventmeshtimestamp" + | "reqeventmesh2ctimestamp" + | "rspc2eventmeshtimestamp" + | "rspeventmesh2mqtimestamp" + | "rspmq2eventmeshtimestamp" + | "rspeventmesh2ctimestamp" + | "reqsendeventmeship" + | "reqreceiveeventmeship" + | "rspsendeventmeship" + | "rspreceiveeventmeship" + | "correlation99id" + | "reply99to99client" + | "arrive99time" + | "push99reply99time" + | "msg99type" + | "bornhost" + | "borntimestamp" + | "storehost" + | "storetimestamp" + ) +} + +pub(crate) fn is_reserved_property(key: &str) -> bool { + is_transport_property(key) + || is_delivery_property(key) + || matches!( + key, + "ttl" + | "seqnum" + | "bizseqno" + | "uniqueid" + | "topic" + | "content" + | "datacontenttype" + | "id" + | "source" + | "specversion" + | "type" + | "dataschema" + | "subject" + | "time" + | "statuscode" + | "responsemessage" + | "subscription_reply" + ) +} 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..af94548bb1 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/message.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/message.rs @@ -1,161 +1,303 @@ -/* - * 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 super::delivery::{is_reserved_property, DeliveryContext}; +use crate::common::util::now_millis; +use crate::error::{EventMeshError, Result}; -#[derive(Debug, Deserialize, Serialize, Clone)] +/// A native EventMesh message with dedicated business fields, business +/// properties, and an optional read-only delivery context. +/// +/// 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`). +/// +/// This business model intentionally does not implement serde. Each transport +/// owns a private wire DTO: protobuf for gRPC, form fields for HTTP, and the +/// Java-compatible `body`/`properties` JSON shape for TCP. +#[derive(Debug, Clone, 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) topic: String, + pub(crate) content: String, + pub(crate) props: HashMap, pub(crate) create_time: u64, + pub(crate) ttl: Option, + pub(crate) data_content_type: Option, + pub(crate) delivery_context: 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 { - biz_seq_no: None, - 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), - } + 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.len()) + .field("props", &self.props) + .field("create_time", &self.create_time) + .finish() } } -#[allow(dead_code)] 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, - } + /// Construct a native EventMesh message with its required fields. + /// + /// Blank topics are rejected. An empty payload is preserved because it is + /// valid on the HTTP transport and may be delivered by another SDK. + pub fn new(topic: impl Into, content: impl Into) -> Result { + Self::builder().topic(topic).content(content).build() } - pub fn add_prop(mut self, key: String, val: String) -> Self { - self.prop.insert(key, val); - self + /// Start a builder. Equivalent to [`EventMeshMessageBuilder::default`]. + pub fn builder() -> EventMeshMessageBuilder { + EventMeshMessageBuilder::default() } - pub fn get_prop(&self, key: &str) -> Option<&String> { - self.prop.get(key) + /// Return the destination topic. + pub fn topic(&self) -> &str { + &self.topic } - pub fn remove_prop_if_present(mut self, key: &str) -> Self { - self.prop.remove(key); - self + /// Return the text payload. + pub fn content(&self) -> &str { + &self.content } - pub fn with_biz_seq_no(mut self, biz_seq_no: impl Into) -> Self { - self.biz_seq_no = Some(biz_seq_no.into()); - self + /// Return the optional business sequence number. + pub fn biz_seq_no(&self) -> Option<&str> { + self.biz_seq_no.as_deref() } - pub fn with_unique_id(mut self, unique_id: impl Into) -> Self { - self.unique_id = Some(unique_id.into()); - self + /// Return the optional application-level unique ID. + pub fn unique_id(&self) -> Option<&str> { + self.unique_id.as_deref() } - pub fn with_topic(mut self, topic: impl Into) -> Self { - self.topic = Some(topic.into()); - self + /// Return the payload's media type, if supplied. + pub fn data_content_type(&self) -> Option<&str> { + self.data_content_type.as_deref() } - pub fn with_content(mut self, content: impl Into) -> Self { - self.content = Some(content.into()); - self + /// Inspect protocol and routing metadata from the received delivery. + /// + /// Locally built messages have no delivery context. Publishing ignores + /// this context; the SDK restores routing only when sending a reply. + pub fn delivery_context(&self) -> Option<&DeliveryContext> { + self.delivery_context.as_deref() } - pub fn with_create_time(mut self, create_time: u64) -> Self { - self.create_time = create_time; - self + /// Return business extension properties, excluding protocol metadata. + pub fn properties(&self) -> &HashMap { + &self.props + } + + /// Return the creation time in epoch milliseconds. + pub fn create_time(&self) -> u64 { + self.create_time + } + + /// Return the optional EventMesh TTL in milliseconds. + /// + /// TTL is stored only in this dedicated field. It is not read from the + /// extension properties. Runtime timeout semantics depend on the transport. + pub fn ttl_millis(&self) -> Option { + self.ttl + } + + /// Insert or overwrite a business extension property. + /// + /// Reserved names such as `ttl`, `protocoldesc`, and `cluster` return + /// [`crate::Error::InvalidArgument`]. Use dedicated builder fields for + /// business metadata and client configuration for transport settings. + pub fn set_prop( + &mut self, + key: impl Into, + value: impl Into, + ) -> Result<&mut Self> { + let key = key.into(); + validate_property_key(&key)?; + self.props.insert(key, value.into()); + Ok(self) + } + + /// Get a property by key. + pub fn get_prop(&self, key: &str) -> Option<&str> { + self.props.get(key).map(|s| s.as_str()) + } + + /// Return a copy with an additional business extension property. + /// + /// Reserved names return [`crate::Error::InvalidArgument`], as with + /// [`Self::set_prop`]. + pub fn with_property( + mut self, + key: impl Into, + value: impl Into, + ) -> Result { + self.set_prop(key, value)?; + Ok(self) + } + + /// Validate requirements shared by all publishing transports. + #[cfg(any(test, feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn validate_for_publish(&self) -> Result<()> { + if self.topic.trim().is_empty() { + return Err(EventMeshError::InvalidMessage("topic is required".into())); + } + + if let Some(ttl) = self.ttl { + validate_ttl(ttl)?; + } + Ok(()) + } + + /// Validate requirements imposed by the gRPC runtime. + #[cfg(any(test, feature = "grpc"))] + pub(crate) fn validate_for_grpc_publish(&self) -> Result<()> { + self.validate_for_publish()?; + if self.content.is_empty() { + return Err(EventMeshError::InvalidMessage("content is required".into())); + } + Ok(()) + } + + /// Validate requirements imposed by the Java-compatible TCP client. + #[cfg(any(test, feature = "tcp"))] + pub(crate) fn validate_for_tcp_publish(&self) -> Result<()> { + self.validate_for_publish()?; + if self.content.trim().is_empty() { + return Err(EventMeshError::InvalidMessage("content is required".into())); + } + Ok(()) } } -impl FromPbCloudEvent for EventMeshMessage { - fn from_pb_cloud_event(event: &PbCloudEvent) -> Option { - Some(EventMeshCloudEventUtils::switch_event_mesh_cloud_event_2_event_mesh_message(event)) +fn validate_property_key(key: &str) -> Result<()> { + if is_reserved_property(key) { + return Err(EventMeshError::InvalidArgument(format!( + "{key:?} is reserved message/transport metadata, not a business property" + ))); } + Ok(()) } -impl From for EventMeshMessage { - fn from(value: PbCloudEvent) -> Self { - EventMeshCloudEventUtils::switch_event_mesh_cloud_event_2_event_mesh_message(&value) +#[cfg(any(test, feature = "grpc", feature = "http", feature = "tcp"))] +fn validate_ttl(ttl: i64) -> Result<()> { + if !(1..=i64::from(i32::MAX)).contains(&ttl) { + return Err(EventMeshError::InvalidMessage(format!( + "ttl must be between 1 and {} milliseconds; EventMesh does not define a never-expire value", + i32::MAX + ))); } + Ok(()) } -#[cfg(feature = "cloud_events")] -impl From for EventMeshMessage { - fn from(value: Event) -> Self { - EventMeshCloudEventUtils::switch_cloud_event_2_event_mesh_message(value) +/// 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, + data_content_type: Option, +} + +impl EventMeshMessageBuilder { + /// Set the optional business sequence number. + pub fn biz_seq_no(mut self, v: impl Into) -> Self { + self.biz_seq_no = Some(v.into()); + self + } + /// Set the optional application-level unique ID. + pub fn unique_id(mut self, v: impl Into) -> Self { + self.unique_id = Some(v.into()); + self + } + /// Set the required destination topic. + pub fn topic(mut self, v: impl Into) -> Self { + self.topic = Some(v.into()); + self + } + /// Set the required text payload. + pub fn content(mut self, v: impl Into) -> Self { + self.content = Some(v.into()); + self + } + /// Set the optional EventMesh TTL in milliseconds in its dedicated field. + /// + /// This does not modify extension properties. Its transport-specific range + /// is validated when the message is sent. + pub fn ttl_millis(mut self, v: i64) -> Self { + self.ttl = Some(v); + self + } + /// Set the payload's media type (for example `application/json`). + pub fn data_content_type(mut self, value: impl Into) -> Self { + self.data_content_type = Some(value.into()); + self + } + /// Insert or overwrite a business property. Reserved names fail at build. + pub fn prop(mut self, key: impl Into, value: impl Into) -> Self { + self.props.insert(key.into(), value.into()); + self + } + /// Replace all business properties. Reserved names fail at build. + pub fn props(mut self, props: HashMap) -> Self { + self.props = props; + self + } + + /// Validate required fields and business property names, then construct the message. + /// + /// The payload must be present but may be empty. Transport-specific + /// constraints such as TTL range are checked when publishing. + pub fn build(self) -> Result { + for key in self.props.keys() { + validate_property_key(key)?; + } + let message = EventMeshMessage { + biz_seq_no: self.biz_seq_no, + unique_id: self.unique_id, + topic: self + .topic + .ok_or_else(|| EventMeshError::InvalidMessage("topic is required".into()))?, + content: self + .content + .ok_or_else(|| EventMeshError::InvalidMessage("content is required".into()))?, + props: self.props, + create_time: now_millis(), + ttl: self.ttl, + data_content_type: self.data_content_type, + delivery_context: None, + }; + if message.topic.trim().is_empty() { + return Err(EventMeshError::InvalidMessage("topic is required".into())); + } + Ok(message) } } @@ -164,43 +306,109 @@ 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() + .unwrap(); + assert_eq!(m.topic(), "t"); + assert_eq!(m.content(), "c"); + assert_eq!(m.get_prop("k"), Some("v")); + assert_eq!(m.ttl_millis(), 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, + fn construction_requires_a_nonblank_topic_but_preserves_empty_content() { + assert!(EventMeshMessage::new(" ", "content").is_err()); + assert_eq!(EventMeshMessage::new("topic", "").unwrap().content(), ""); + assert_eq!( + EventMeshMessage::new("topic", "\t").unwrap().content(), + "\t" ); + } - 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 publish_validation_is_transport_specific() { + let empty = EventMeshMessage::new("topic", "").unwrap(); + assert!(empty.validate_for_publish().is_ok()); + assert!(empty.validate_for_grpc_publish().is_err()); + assert!(empty.validate_for_tcp_publish().is_err()); + + let whitespace = EventMeshMessage::new("topic", "\t").unwrap(); + assert!(whitespace.validate_for_grpc_publish().is_ok()); + assert!(whitespace.validate_for_tcp_publish().is_err()); + } + + #[test] + fn ttl_is_preserved_at_construction_and_validated_for_publish() { + let message = EventMeshMessage::builder() + .topic("topic") + .content("content") + .ttl_millis(0) + .build() + .unwrap(); + assert_eq!(message.ttl_millis(), Some(0)); + assert!(message.validate_for_publish().is_err()); } #[test] - fn test_add_prop() { - let mut msg = EventMeshMessage::default(); + fn business_properties_reject_reserved_metadata() { + for key in [ + "ttl", + "protocoldesc", + "sys", + "passwd", + "seqnum", + "datacontenttype", + "cluster", + "req0sys", + "correlation99id", + ] { + assert!(EventMeshMessage::builder() + .topic("t") + .content("c") + .prop(key, "value") + .build() + .is_err()); + let mut message = EventMeshMessage::new("t", "c").unwrap(); + assert!(message.set_prop(key, "value").is_err()); + assert!(message.properties().is_empty()); + assert!(message.delivery_context().is_none()); + assert!(message.with_property(key, "value").is_err()); + } + let mut message = EventMeshMessage::builder() + .topic("t") + .content("c") + .data_content_type("application/json") + .prop("requestid", "business-id") + .build() + .unwrap(); + message.set_prop("custom", "value").unwrap(); + assert_eq!(message.get_prop("requestid"), Some("business-id")); + assert_eq!(message.data_content_type(), Some("application/json")); + } - msg = msg.add_prop("key1".to_string(), "value1".to_string()); - msg = msg.add_prop("key2".to_string(), "value2".to_string()); + #[test] + fn publish_validation_uses_only_typed_ttl() { + EventMeshMessage::builder() + .topic("topic") + .content("content") + .ttl_millis(4_000) + .build() + .unwrap(); - assert_eq!(msg.get_prop("key1"), Some(&"value1".to_string())); - assert_eq!(msg.get_prop("key2"), Some(&"value2".to_string())); + let invalid = EventMeshMessage::builder() + .topic("topic") + .content("content") + .ttl_millis(-1) + .build() + .unwrap(); + assert!(invalid.validate_for_publish().is_err()); } } 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..8b2f66cbc8 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/mod.rs @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Message, subscription and response types. + +pub(crate) mod delivery; +pub mod message; +#[cfg(any(test, feature = "grpc", feature = "http", feature = "tcp"))] +pub mod response; +#[cfg(any(feature = "grpc", feature = "http"))] +pub mod subscription; + +pub use delivery::DeliveryContext; +pub use message::{EventMeshMessage, EventMeshMessageBuilder}; +#[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] +pub use response::PublishResponse; +#[cfg(any(feature = "grpc", feature = "http"))] +pub use subscription::HeartbeatItem; + +/// Wire protocol the SDK advertises to the server (`protocoltype` attribute). +#[cfg(any(feature = "grpc", feature = "http"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventMeshProtocolType { + /// Native CloudEvents (`io.cloudevents`). + CloudEvents, + /// The SDK's lightweight `EventMeshMessage`. + EventMeshMessage, +} + +#[cfg(any(feature = "grpc", feature = "http"))] +impl EventMeshProtocolType { + pub fn as_str(self) -> &'static str { + match self { + Self::CloudEvents => "cloudevents", + Self::EventMeshMessage => "eventmeshmessage", + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs index 6e269623cb..f732b080da 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/response.rs @@ -1,63 +1,80 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -use std::fmt::{Display, Formatter}; - -use serde::Deserialize; - -#[derive(Debug, Deserialize, Default)] -pub struct EventMeshResponse { - #[serde(rename = "respCode")] - resp_code: Option, - - #[serde(rename = "respMsg")] - resp_msg: Option, - - #[serde(rename = "respTime")] - resp_time: Option, +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Server response type. + +use serde::{Deserialize, Serialize}; + +/// The response returned by the broker for fire-and-forget publish / batch +/// publish / subscribe / unsubscribe / heartbeat operations. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct PublishResponse { + /// Numeric response code (`status_code` attribute). `0` means success. + #[serde(default, rename = "respCode")] + pub code: Option, + /// Human-readable response message. + #[serde(default, rename = "respMsg")] + pub message: Option, + /// Server-side processing time, milliseconds. + #[serde(default, rename = "respTime")] + pub time: Option, } -impl EventMeshResponse { - pub fn new( - resp_code: Option, - resp_msg: Option, - resp_time: Option, - ) -> Self { +impl PublishResponse { + pub fn new(code: Option, message: Option, time: Option) -> Self { Self { - resp_code, - resp_msg, - resp_time, + code, + message, + time, } } + + /// Whether the server explicitly reported success (`code == Some(0)`). + /// + /// A missing or unparseable code is treated as **failure**, not success, + /// so that garbled / incomplete responses can never masquerade as `Ok`. + pub fn is_success(&self) -> bool { + self.code == Some(0) + } } -impl Display for EventMeshResponse { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "EventMeshResponse[")?; - if let Some(ref code) = self.resp_code { - write!(f, "code={code},")?; - } - if let Some(ref msg) = self.resp_msg { - write!(f, "message={msg},")?; - } - if let Some(time) = self.resp_time { - write!(f, "response time={time},")?; - } - write!(f, "]")?; - Ok(()) +impl std::fmt::Display for PublishResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "PublishResponse(code={:?}, msg={:?}, time={:?})", + self.code, self.message, self.time + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn success_only_when_explicit_zero() { + assert!(PublishResponse::new(Some(0), None, None).is_success()); + assert!(!PublishResponse::new(Some(1), None, None).is_success()); + } + + #[test] + fn missing_code_is_not_success() { + assert!(!PublishResponse::new(None, None, None).is_success()); + assert!(!PublishResponse::default().is_success()); } } diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs index a31cd3b90d..9ce5f9e26a 100644 --- a/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/model/subscription.rs @@ -1,277 +1,37 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -use std::collections::HashMap; -use std::fmt; -use std::fmt::{Display, Formatter}; -use std::str::FromStr; +// 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. + +//! Heartbeat subscription payload model. use serde::{Deserialize, Serialize}; -use crate::error::EventMeshError; - -#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Hash, Clone)] -pub struct SubscriptionItem { - pub topic: String, - pub mode: SubscriptionMode, - #[serde(rename = "type")] - pub type_: SubscriptionType, -} - -impl SubscriptionItem { - pub fn new(topic: impl Into, mode: SubscriptionMode, type_: SubscriptionType) -> Self { - SubscriptionItem { - topic: topic.into(), - mode, - type_, - } - } -} - -impl fmt::Display for SubscriptionItem { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "SubscriptionItem {{ topic: {}, mode: {}, type: {} }}", - self.topic, self.mode, self.type_ - ) - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Hash)] -pub enum SubscriptionMode { - BROADCASTING, - CLUSTERING, - UNRECOGNIZED, -} - -impl Display for SubscriptionMode { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - writeln!(f, "{}", self.to_string()) - } -} - -impl SubscriptionMode { - pub fn to_string(&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 - ))), - } - } -} - -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) - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize, Hash)] -pub enum SubscriptionType { - SYNC, - ASYNC, - UNRECOGNIZED, -} - -impl Display for SubscriptionType { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - writeln!(f, "{}", self.to_string()) - } -} - -impl SubscriptionType { - pub fn to_string(&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 - ))), - } - } -} - -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, - } - } -} - -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, } +#[cfg(feature = "http")] impl HeartbeatItem { - pub fn new(topic: String, url: String) -> Self { - Self { topic, url } - } -} - -impl Display for HeartbeatItem { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - writeln!( - f, - "HeartbeatItem {{ - topic: {}, - url: {} - }}", - self.url, self.topic - ) + pub fn new(topic: impl Into, url: impl Into) -> Self { + Self { + topic: topic.into(), + url: url.into(), + } } } 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..b10f15be33 --- /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. + +//! Crate-private generated gRPC stubs, type aliases, and attribute helpers. + +#[allow(clippy::enum_variant_names)] +pub(crate) mod pb { + tonic::include_proto!("org.apache.eventmesh.cloudevents.v1"); +} + +// ---- convenience aliases used throughout the gRPC transport ---- +pub(crate) use pb::cloud_event::cloud_event_attribute_value::Attr as PbAttr; +pub(crate) use pb::cloud_event::CloudEventAttributeValue as PbCloudEventAttributeValue; +pub(crate) use pb::cloud_event::Data as PbData; +pub(crate) use pb::consumer_service_client::ConsumerServiceClient; +pub(crate) use pb::heartbeat_service_client::HeartbeatServiceClient; +pub(crate) use pb::publisher_service_client::PublisherServiceClient; +pub(crate) use pb::CloudEvent as PbCloudEvent; +pub(crate) use pb::CloudEventBatch as PbCloudEventBatch; + +/// Build a string-valued CloudEvent attribute. +pub(crate) fn attr_str(value: impl Into) -> PbCloudEventAttributeValue { + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeString(value.into())), + } +} + +/// Build an int32-valued CloudEvent attribute. +pub(crate) 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(crate) 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/subscription.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/subscription.rs new file mode 100644 index 0000000000..334b982d98 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/subscription.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. + +//! Subscription declarations shared by all transports. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use crate::error::{EventMeshError, Result}; + +/// A requested subscription. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Subscription { + /// The topic to receive. + pub topic: String, + /// How messages are distributed among consumers. + #[serde(rename = "mode")] + pub delivery_mode: DeliveryMode, + /// Whether delivery is asynchronous or request/reply. + #[serde(rename = "type")] + pub delivery_type: DeliveryType, +} + +impl Subscription { + /// Create an asynchronous clustered subscription for `topic`. + pub fn new(topic: impl Into) -> Self { + Self { + topic: topic.into(), + delivery_mode: DeliveryMode::Cluster, + delivery_type: DeliveryType::Async, + } + } + + /// Set the delivery mode. + pub fn with_delivery_mode(mut self, delivery_mode: DeliveryMode) -> Self { + self.delivery_mode = delivery_mode; + self + } + + /// Set the delivery type. + pub fn with_delivery_type(mut self, delivery_type: DeliveryType) -> Self { + self.delivery_type = delivery_type; + self + } + + #[cfg(any(feature = "grpc", feature = "http", feature = "tcp"))] + pub(crate) fn validate(&self) -> crate::Result<()> { + if self.topic.trim().is_empty() { + return Err(crate::Error::InvalidArgument( + "subscription topic must not be empty".into(), + )); + } + Ok(()) + } +} + +impl fmt::Display for Subscription { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Subscription(topic={}, mode={}, type={})", + self.topic, self.delivery_mode, self.delivery_type + ) + } +} + +/// Consumer distribution mode. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum DeliveryMode { + /// Every subscriber receives the event. + #[serde(rename = "BROADCASTING")] + Broadcast, + /// One consumer in the group receives the event. + #[serde(rename = "CLUSTERING")] + Cluster, +} + +impl DeliveryMode { + /// Return the EventMesh wire value. + pub const fn as_str(self) -> &'static str { + match self { + Self::Broadcast => "BROADCASTING", + Self::Cluster => "CLUSTERING", + } + } +} + +impl fmt::Display for DeliveryMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for DeliveryMode { + type Err = EventMeshError; + + fn from_str(value: &str) -> Result { + match value { + "BROADCASTING" => Ok(Self::Broadcast), + "CLUSTERING" => Ok(Self::Cluster), + other => Err(EventMeshError::InvalidArgument(format!( + "unknown DeliveryMode: {other}" + ))), + } + } +} + +/// Consumer delivery semantics. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum DeliveryType { + /// Acknowledged asynchronous delivery. + #[serde(rename = "ASYNC")] + Async, + /// Request/reply delivery. + #[serde(rename = "SYNC")] + Sync, +} + +impl DeliveryType { + /// Return the EventMesh wire value. + pub const fn as_str(self) -> &'static str { + match self { + Self::Async => "ASYNC", + Self::Sync => "SYNC", + } + } +} + +impl fmt::Display for DeliveryType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for DeliveryType { + type Err = EventMeshError; + + fn from_str(value: &str) -> Result { + match value { + "ASYNC" => Ok(Self::Async), + "SYNC" => Ok(Self::Sync), + other => Err(EventMeshError::InvalidArgument(format!( + "unknown DeliveryType: {other}" + ))), + } + } +} + +#[cfg(all(test, any(feature = "grpc", feature = "http", feature = "tcp")))] +mod tests { + use super::{DeliveryMode, DeliveryType, Subscription}; + + #[test] + fn blank_topics_are_rejected() { + assert!(Subscription::new("").validate().is_err()); + assert!(Subscription::new(" \t").validate().is_err()); + assert!(Subscription::new("topic").validate().is_ok()); + } + + #[test] + fn subscription_uses_eventmesh_wire_names() { + let subscription = Subscription::new("t") + .with_delivery_mode(DeliveryMode::Cluster) + .with_delivery_type(DeliveryType::Async); + let json = serde_json::to_string(&subscription).unwrap(); + assert_eq!(json, r#"{"topic":"t","mode":"CLUSTERING","type":"ASYNC"}"#); + let decoded: Subscription = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, subscription); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/tcp.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/tcp.rs new file mode 100644 index 0000000000..52d36542b9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/tcp.rs @@ -0,0 +1,523 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Native TCP client API. + +use crate::config::{ConsumerOptions, ProducerOptions, TcpConfig}; +use crate::error::{EventMeshError, Result}; +use crate::message::{Message, PublishReceipt}; +use crate::subscription::Subscription; +use crate::transport::tcp::consumer::{decode_message, encode_reply}; +use crate::transport::tcp::{TcpConsumer as TransportConsumer, TcpProducer as TransportProducer}; +use crate::MessageHandler; +use tracing::warn; + +/// A configured EventMesh TCP client. +#[derive(Clone)] +pub struct TcpClient { + config: TcpConfig, +} + +impl TcpClient { + /// Validate and create a TCP client handle. Connections are opened by role + /// factories. + pub fn new(config: TcpConfig) -> Result { + config.validate()?; + Ok(Self { config }) + } + + /// Connect a producer role to the TCP endpoint. + pub async fn producer(&self, options: ProducerOptions) -> Result { + options.validate()?; + Ok(TcpProducer { + inner: TransportProducer::connect(self.config.clone(), &options).await?, + timeout: self.config.request_timeout(), + response_driver: tokio::sync::Mutex::new(None), + }) + } + + /// Connect a producer that can handle server `RESPONSE_TO_CLIENT` frames. + /// + /// This is the Rust equivalent of Java TCP's `registerPubBusiHandler`. + /// Normal request/reply responses remain owned by their originating + /// `request_reply` futures; this handler receives unmatched server pushes. + pub async fn producer_with_handler( + &self, + options: ProducerOptions, + handler: H, + ) -> Result + where + H: MessageHandler, + { + let producer = self.producer(options).await?; + producer.start_response_handler(handler).await?; + Ok(producer) + } + + /// Connect a long-lived TCP consumer role. + pub async fn consumer(&self, options: ConsumerOptions, handler: H) -> Result> + where + H: MessageHandler, + { + options.validate()?; + Ok(TcpConsumer { + inner: TransportConsumer::connect( + self.config.clone(), + &options, + handler, + None::>, + ) + .await?, + }) + } +} + +/// TCP publishing capability. +pub struct TcpProducer { + inner: TransportProducer, + timeout: std::time::Duration, + response_driver: tokio::sync::Mutex>>, +} + +/// A long-lived TCP consumer. +/// +/// Unwinding handler panics are logged per delivery. That delivery receives no +/// reply or ACK, and the same consumer continues processing later messages. +/// Redelivery depends on the runtime's retry policy. This does not recover +/// handler-owned state or catch panics compiled with `panic = "abort"`. +pub struct TcpConsumer { + inner: TransportConsumer, +} + +impl TcpConsumer { + /// Add a TCP subscription. + pub async fn subscribe(&self, subscription: Subscription) -> Result<()> { + subscription.validate()?; + self.inner.subscribe(&[subscription]).await + } + + /// Remove every subscription on this TCP consumer session. + /// + /// The EventMesh TCP runtime ignores topics in `UNSUBSCRIBE_REQUEST` and + /// always clears the entire session, matching Java's no-argument + /// `unsubscribe()` API. + pub async fn unsubscribe_all(&self) -> Result<()> { + self.inner.unsubscribe_all().await.map(|_| ()) + } + + /// Signal consumer shutdown. + pub fn shutdown(&self) { + self.inner.request_shutdown(); + } + + /// Wait for TCP consumer shutdown. + /// + /// Cancelling this wait preserves task ownership and pending results. Call + /// `join()` again to finish waiting, or drop the consumer to abort its tasks. + pub async fn join(&self) -> Result<()> { + shutdown_result(self.inner.wait_for_shutdown().await) + } +} + +fn shutdown_result(reason: crate::transport::tcp::ShutdownReason) -> Result<()> { + match reason { + crate::transport::tcp::ShutdownReason::Cancelled => Ok(()), + crate::transport::tcp::ShutdownReason::Redirect(info) => { + Err(crate::error::EventMeshError::Tcp(format!( + "server redirected consumer to {}:{}", + info.ip, info.port + ))) + } + crate::transport::tcp::ShutdownReason::ChannelClosed => Err( + crate::error::EventMeshError::ChannelClosed("TCP consumer connection closed".into()), + ), + crate::transport::tcp::ShutdownReason::Error(message) => { + Err(crate::error::EventMeshError::Tcp(message)) + } + } +} + +impl TcpProducer { + /// Publish one event and wait for EventMesh acknowledgement. + /// + /// # TCP CloudEvents compatibility + /// + /// When `message` is [`Message::CloudEvent`], its `datacontenttype` must be + /// `application/cloudevents+json`. This is a non-standard compatibility + /// requirement of EventMesh's Java TCP codec: it uses `datacontenttype` to + /// select the serializer for the whole CloudEvent rather than only to + /// describe the event's data. The SDK validates this before any network + /// I/O and returns [`EventMeshError::InvalidMessage`] for other values, + /// including the otherwise standard `application/json` and `text/plain`. + pub async fn publish(&self, message: Message) -> Result { + match message { + Message::EventMesh(message) => self + .inner + .publish(message) + .await + .map(PublishReceipt::from_response), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .publish_cloud_event(event) + .await + .map(PublishReceipt::from_response), + } + } + + /// Broadcast an event without waiting for a broker acknowledgement. + /// + /// Returns after the frame is written to the local socket, so a subsequent + /// [`shutdown`](Self::shutdown) does not discard it from the outbound queue. + /// Queueing and writing share the TCP control timeout. A timeout or write + /// failure does not establish whether the server received the event. + /// + /// [`Message::CloudEvent`] has the same TCP-specific `datacontenttype` + /// requirement documented on [`publish`](Self::publish). + pub async fn broadcast(&self, message: Message) -> Result<()> { + match message { + Message::EventMesh(message) => self.inner.broadcast(message).await, + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self.inner.broadcast_cloud_event(event).await, + } + } + + /// Send an event and await its reply. + /// + /// [`Message::CloudEvent`] has the same TCP-specific `datacontenttype` + /// requirement documented on [`publish`](Self::publish). + pub async fn request_reply(&self, message: Message) -> Result { + self.request_reply_with_timeout(message, self.timeout).await + } + + /// Send an event and await its reply with a per-operation timeout. + /// + /// [`Message::CloudEvent`] has the same TCP-specific `datacontenttype` + /// requirement documented on [`publish`](Self::publish). + pub async fn request_reply_with_timeout( + &self, + message: Message, + timeout: std::time::Duration, + ) -> Result { + if timeout.is_zero() { + return Err(EventMeshError::InvalidArgument( + "request/reply timeout must be greater than zero".into(), + )); + } + match message { + Message::EventMesh(message) => self + .inner + .request_reply(message, timeout) + .await + .map(Message::EventMesh), + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => self + .inner + .request_reply_cloud_event(event, timeout) + .await + .map(Message::CloudEvent), + } + } + + /// Shut down the TCP connection. + pub async fn shutdown(&self) { + if let Some(driver) = self.response_driver.lock().await.take() { + driver.abort(); + } + self.inner.shutdown().await; + } + + async fn start_response_handler(&self, handler: H) -> Result<()> + where + H: MessageHandler, + { + let conn = self.inner.connection(); + conn.enable_orphan_response_delivery(); + let mut inbound = conn.take_inbound_rx().await.ok_or_else(|| { + crate::error::EventMeshError::Tcp( + "publisher response handler already registered".into(), + ) + })?; + let connection = self.inner.shared_connection(); + let driver = tokio::spawn(async move { + while let Some(package) = inbound.recv().await { + if package.header.cmd != crate::transport::tcp::frame::Command::ResponseToClient { + continue; + } + let Some(message) = decode_message(&package) else { + warn!("failed to decode publisher-side response; closing without ACK"); + connection.shutdown().await; + break; + }; + match handler.handle(message).await { + Ok(Some(reply)) => match encode_reply(&reply) { + Ok(reply) => { + if let Err(error) = connection.send(reply).await { + warn!(%error, "failed to send publisher-side reply; closing without ACK"); + connection.shutdown().await; + break; + } + } + Err(error) => { + warn!(%error, "failed to encode publisher-side reply; closing without ACK"); + connection.shutdown().await; + break; + } + }, + Ok(None) => {} + Err(error) => { + warn!(%error, "publisher-side handler failed; closing without ACK"); + connection.shutdown().await; + break; + } + } + if let Err(error) = connection + .send(crate::transport::tcp::message::response_to_client_ack( + &package, + )) + .await + { + warn!(%error, "failed to send publisher-side ACK; closing connection"); + connection.shutdown().await; + break; + } + } + }); + *self.response_driver.lock().await = Some(driver); + Ok(()) + } +} + +impl Drop for TcpProducer { + fn drop(&mut self) { + if let Ok(mut driver) = self.response_driver.try_lock() { + if let Some(driver) = driver.take() { + driver.abort(); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::config::{Endpoint, ProducerOptions, TcpConfig}; + use crate::transport::tcp::codec::TcpCodec; + use crate::transport::tcp::frame::RedirectInfo; + use crate::transport::tcp::frame::{Command, Header, Package, PackageBody}; + use crate::transport::tcp::ShutdownReason; + use futures::{SinkExt, StreamExt}; + use tokio::net::TcpListener; + use tokio::sync::{mpsc, oneshot}; + use tokio_util::codec::Framed; + + struct ResponseHandler(mpsc::UnboundedSender); + + impl MessageHandler for ResponseHandler { + async fn handle(&self, message: Message) -> Result> { + let _ = self.0.send(message); + Ok(None) + } + } + + #[test] + fn abnormal_consumer_shutdown_is_an_error() { + assert!(shutdown_result(ShutdownReason::ChannelClosed).is_err()); + assert!(shutdown_result(ShutdownReason::Error("driver failed".into())).is_err()); + let error = shutdown_result(ShutdownReason::Redirect(RedirectInfo { + ip: "127.0.0.2".into(), + port: 10_000, + })) + .unwrap_err(); + assert!(error.to_string().contains("127.0.0.2:10000")); + } + + #[test] + fn cancelled_consumer_shutdown_is_clean() { + assert!(shutdown_result(ShutdownReason::Cancelled).is_ok()); + } + + async fn broadcast_before_shutdown(message: Message) -> Package { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new(Command::HelloResponse, "hello"))) + .await + .unwrap(); + // No broadcast ACK: the caller must only wait for its local write. + framed.next().await + }); + let client = TcpClient::new( + TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_reconnect(crate::config::ReconnectPolicy::default().with_enabled(false)), + ) + .unwrap(); + let producer = client + .producer(ProducerOptions::new("broadcast-test")) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(2), producer.broadcast(message)) + .await + .expect("broadcast must not wait for a server ACK") + .unwrap(); + producer.shutdown().await; + + let package = tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap() + .expect("broadcast must reach the socket before shutdown") + .unwrap(); + assert_eq!(package.header.cmd, Command::BroadcastMessageToServer); + package + } + + #[tokio::test] + async fn broadcast_is_written_before_shutdown() { + let message = crate::EventMeshMessage::new("orders", "created").unwrap(); + let package = broadcast_before_shutdown(Message::EventMesh(message.clone())).await; + let received = decode_message(&package).unwrap().into_event_mesh().unwrap(); + assert_eq!(received.topic(), message.topic()); + assert_eq!(received.content(), message.content()); + } + + #[cfg(feature = "cloud_events")] + #[tokio::test] + async fn cloud_event_broadcast_is_written_before_shutdown() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("broadcast") + .source("urn:test") + .ty("created") + .subject("orders") + .data( + "application/cloudevents+json", + serde_json::json!({"id": 42}), + ) + .build() + .unwrap(); + let package = broadcast_before_shutdown(Message::CloudEvent(event.clone())).await; + let received = decode_message(&package).unwrap(); + assert!(matches!(received, Message::CloudEvent(received) if received == event)); + } + + #[tokio::test] + async fn broadcast_reports_frame_encoding_failure() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + let _hello = framed.next().await.unwrap().unwrap(); + framed + .send(Package::new(Header::new(Command::HelloResponse, "hello"))) + .await + .unwrap(); + framed.next().await + }); + let client = TcpClient::new( + TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_reconnect(crate::config::ReconnectPolicy::default().with_enabled(false)), + ) + .unwrap(); + let producer = client + .producer(ProducerOptions::new("broadcast-test")) + .await + .unwrap(); + // Construction accepts this content, but the TCP codec rejects frames + // larger than 4 MiB. That driver-side error must reach the caller. + let message = crate::EventMeshMessage::new("orders", "x".repeat(4 * 1024 * 1024)).unwrap(); + let result = producer.broadcast(Message::EventMesh(message)).await; + producer.shutdown().await; + + assert!( + matches!(result, Err(EventMeshError::InvalidArgument(message)) + if message.contains("exceeds limit")) + ); + assert!(tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn producer_with_handler_receives_unsolicited_server_response() { + 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(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new(Command::HelloResponse, "hello"))) + .await + .unwrap(); + + let mut header = Header::new(Command::ResponseToClient, "server-push"); + header.code = 0; + framed + .send(Package { + header, + body: PackageBody::Text( + serde_json::json!({"topic": "push-topic", "body": "push-body"}).to_string(), + ), + }) + .await + .unwrap(); + let ack = tokio::time::timeout(Duration::from_secs(2), framed.next()) + .await + .ok() + .flatten() + .and_then(std::result::Result::ok) + .filter(|package| package.header.cmd == Command::ResponseToClientAck); + let _ = ack_tx.send(ack); + }); + + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = + TcpClient::new(TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap())).unwrap(); + let producer = client + .producer_with_handler(ProducerOptions::new("handler-test"), ResponseHandler(tx)) + .await + .unwrap(); + let received = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap() + .into_event_mesh() + .unwrap(); + assert_eq!(received.topic(), "push-topic"); + assert_eq!(received.content(), "push-body"); + assert_eq!( + ack_rx.await.unwrap().unwrap().header.seq.as_deref(), + Some("server-push") + ); + producer.shutdown().await; + } +} 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..127180355d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/client.rs @@ -0,0 +1,274 @@ +// 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 backed by a connected tonic [`Channel`]. + +use std::sync::Arc; +use std::time::Duration; + +use tonic::codegen::tokio_stream::wrappers::ReceiverStream; +use tonic::transport::{Channel, Endpoint}; +use tonic::{Request, Streaming}; + +use crate::config::GrpcConfig; +use crate::error::{EventMeshError, Result}; +use crate::proto_gen::{ + ConsumerServiceClient, HeartbeatServiceClient, PbCloudEvent, PbCloudEventBatch, + PublisherServiceClient, +}; + +/// Message produced by tonic 0.12 when its local `GrpcTimeout` layer expires. +const TONIC_TIMEOUT_EXPIRED_MESSAGE: &str = "Timeout expired"; + +/// A connection to the EventMesh gRPC server. +/// +/// Cheaply cloneable (wraps a multiplexed tonic channel). +#[derive(Clone)] +pub struct ChannelClient { + channel: Arc, +} + +impl ChannelClient { + /// Connect a channel on the current Tokio runtime. + pub async fn connect(config: &GrpcConfig) -> Result { + config.validate()?; + let channel = Self::endpoint(config)?.connect().await?; + Ok(Self { + channel: Arc::new(channel), + }) + } + + fn endpoint(config: &GrpcConfig) -> Result { + let uri = format!("http://{}", config.endpoint().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 RPC. Publish, batch, and + // request_reply calls apply per-request gRPC deadlines instead + // (see `finish_unary`). + .keep_alive_while_idle(true) + .tcp_nodelay(true) + .tcp_keepalive(Some(Duration::from_secs(100))); + Ok(endpoint) + } + + fn channel(&self) -> Channel { + self.channel.as_ref().clone() + } + + #[cfg(test)] + pub(crate) fn shares_channel_with(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.channel, &other.channel) + } + + #[cfg(test)] + pub(crate) fn connect_lazy(config: &GrpcConfig) -> Result { + config.validate()?; + Ok(Self { + channel: Arc::new(Self::endpoint(config)?.connect_lazy()), + }) + } + + /// Publish one event. The timeout is applied per request via tonic's + /// gRPC deadline (`grpc-timeout`); tonic enforces it locally in its + /// channel `GrpcTimeout` layer and servers may enforce it too, so an + /// expired deadline surfaces as [`Error::Timeout`]. + pub async fn publish(&self, event: PbCloudEvent, timeout: Duration) -> Result { + let mut request = Request::new(event); + request.set_timeout(timeout); + let mut client = PublisherServiceClient::new(self.channel()); + Self::finish_unary(client.publish(request), timeout).await + } + + /// Publish a batch of events with the same per-request gRPC deadline as + /// [`ChannelClient::publish`]. + pub async fn batch_publish( + &self, + events: PbCloudEventBatch, + timeout: Duration, + ) -> Result { + let mut request = Request::new(events); + request.set_timeout(timeout); + let mut client = PublisherServiceClient::new(self.channel()); + Self::finish_unary(client.batch_publish(request), timeout).await + } + + /// Send a request and await the reply. `timeout` is applied as a gRPC + /// deadline so the server can observe it; expiry surfaces as + /// [`Error::Timeout`]. + pub async fn request_reply( + &self, + event: PbCloudEvent, + timeout: Duration, + ) -> Result { + let mut request = Request::new(event); + request.set_timeout(timeout); + let mut client = PublisherServiceClient::new(self.channel()); + Self::finish_unary(client.request_reply(request), timeout).await + } + + /// Await a unary RPC and translate deadline expirations into + /// [`Error::Timeout`]. + /// + /// tonic's local `GrpcTimeout` layer reports an expired `grpc-timeout` + /// as a `cancelled` status ("Timeout expired"), while a server that + /// observes the deadline replies `deadline-exceeded`. Only that specific + /// local cancellation means the per-request deadline passed; other + /// `cancelled` statuses are real RPC failures. + async fn finish_unary(call: F, timeout: Duration) -> Result + where + F: std::future::Future, tonic::Status>>, + { + match call.await { + Ok(response) => Ok(response.into_inner()), + Err(status) if Self::is_deadline_expiration(&status) => { + Err(EventMeshError::Timeout(timeout)) + } + Err(status) => Err(EventMeshError::from(status)), + } + } + + fn is_deadline_expiration(status: &tonic::Status) -> bool { + status.code() == tonic::Code::DeadlineExceeded + || (status.code() == tonic::Code::Cancelled + && status.message() == TONIC_TIMEOUT_EXPIRED_MESSAGE) + } + + /// 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(ConsumerServiceClient::new(self.channel()) + .subscribe(event) + .await? + .into_inner()) + } + + /// Open a bidirectional stream subscription. The first message on the + /// request stream should be the subscription CloudEvent. + /// + /// Wait at most 15 seconds for the server's response headers. This + /// establishment timeout does not limit the lifetime of an open stream. + /// Both current-thread and multi-thread Tokio runtimes can drive the + /// connection while this future is awaited. + 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 = ConsumerServiceClient::new(self.channel()); + + // Bound the wait for response headers without setting a deadline on + // the long-lived subscription stream. + const STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(15); + let response = tokio::time::timeout( + STREAM_OPEN_TIMEOUT, + stream_client.subscribe_stream(Request::new(ReceiverStream::new(rx))), + ) + .await + .map_err(|_| EventMeshError::Protocol { + transport: "grpc", + message: format!( + "subscribe_stream did not receive server headers within \ + {STREAM_OPEN_TIMEOUT:?}" + ), + })??; + Ok((tx, response.into_inner())) + } + + pub async fn unsubscribe(&self, event: PbCloudEvent) -> Result { + Ok(ConsumerServiceClient::new(self.channel()) + .unsubscribe(event) + .await? + .into_inner()) + } + + pub async fn heartbeat(&self, event: PbCloudEvent) -> Result { + Ok(HeartbeatServiceClient::new(self.channel()) + .heartbeat(Request::new(event)) + .await? + .into_inner()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Endpoint as EventMeshEndpoint, GrpcConfig}; + use crate::Error; + + #[test] + fn plain_http_endpoint_builds_without_a_tokio_runtime() { + let config = GrpcConfig::new(EventMeshEndpoint::new("127.0.0.1", 10_205).unwrap()); + let _ = ChannelClient::endpoint(&config).unwrap(); + } + + #[test] + fn ipv6_endpoint_builds_from_the_new_config() { + let config = GrpcConfig::new(EventMeshEndpoint::new("::1", 10_205).unwrap()); + let _ = ChannelClient::endpoint(&config).unwrap(); + } + + #[tokio::test] + async fn unary_deadline_exceeded_is_reported_as_timeout() { + let timeout = Duration::from_millis(25); + let result = ChannelClient::finish_unary( + async { Err::, _>(tonic::Status::deadline_exceeded("late")) }, + timeout, + ) + .await; + + assert!(matches!(result, Err(Error::Timeout(value)) if value == timeout)); + } + + #[tokio::test] + async fn tonic_local_timeout_cancellation_is_reported_as_timeout() { + let timeout = Duration::from_millis(25); + let result = ChannelClient::finish_unary( + async { + Err::, _>(tonic::Status::from_error(Box::new( + tonic::TimeoutExpired(()), + ))) + }, + timeout, + ) + .await; + + assert!(matches!(result, Err(Error::Timeout(value)) if value == timeout)); + } + + #[tokio::test] + async fn server_cancellation_remains_a_grpc_error() { + let result = ChannelClient::finish_unary( + async { Err::, _>(tonic::Status::cancelled("server shutdown")) }, + Duration::from_secs(1), + ) + .await; + + assert!(matches!( + result, + Err(Error::Grpc { code, message }) + if code == "The operation was cancelled" && message == "server shutdown" + )); + } +} 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..f161745958 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/codec.rs @@ -0,0 +1,911 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Conversions between user-facing message types and the CloudEvents protobuf +//! wire format. +//! +//! All helpers here are free functions (mirroring the style of +//! [`crate::transport::http::codec`]). Encoding goes user message → +//! `PbCloudEvent`; decoding goes `PbCloudEvent` → user type. + +use std::collections::HashMap; + +use prost::Message as _; +use prost_types::Any as PbAny; + +use crate::common::constants::{DataContentType, DEFAULT_MESSAGE_TTL, SDK_STREAM_URL}; +use crate::common::{ProtocolKey, RandomStringUtils}; +use crate::config::GrpcConfig; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, EventMeshProtocolType, PublishResponse}; +use crate::proto_gen::{ + attr_as_str, attr_int, attr_str, PbAttr, PbCloudEvent, PbCloudEventAttributeValue, PbData, +}; +use crate::subscription::Subscription; + +/// 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: &GrpcConfig, + protocol_type: EventMeshProtocolType, +) -> HashMap { + let identity = config.identity(); + let credentials = config.credentials(); + let mut m = HashMap::with_capacity(16); + m.insert(ProtocolKey::ENV.into(), attr_str(identity.env())); + m.insert(ProtocolKey::IDC.into(), attr_str(identity.idc())); + m.insert(ProtocolKey::IP.into(), attr_str(identity.ip())); + m.insert(ProtocolKey::PID.into(), attr_str(identity.process_id())); + m.insert(ProtocolKey::SYS.into(), attr_str(identity.system())); + m.insert(ProtocolKey::LANGUAGE.into(), attr_str(identity.language())); + m.insert( + ProtocolKey::USERNAME.into(), + attr_str(credentials.username()), + ); + m.insert(ProtocolKey::PASSWD.into(), attr_str(credentials.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) = credentials.token() { + if !token.is_empty() { + m.insert("token".into(), attr_str(token)); + } + } + m +} + +/// Build the subscription CloudEvent (carries the `Subscription` JSON +/// list in `text_data`, plus the optional webhook `url`). +pub fn build_subscription_event( + config: &GrpcConfig, + consumer_group: &str, + protocol_type: EventMeshProtocolType, + url: Option<&str>, + items: &[Subscription], +) -> 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(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: &GrpcConfig, + producer_group: &str, +) -> Result { + let protocol_type = EventMeshProtocolType::EventMeshMessage; + let mut attrs = common_attributes(config, protocol_type); + + let ttl = message + .ttl + .map(|t| t.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(producer_group)); + attrs.insert( + ProtocolKey::PROTOCOL_DESC.into(), + attr_str(ProtocolKey::PROTOCOL_DESC_GRPC_CLOUD_EVENT), + ); + + attrs.insert(ProtocolKey::SUBJECT.into(), attr_str(&message.topic)); + + // Resolve the content type from props (default text/plain). + let data_content_type = message + .data_content_type() + .unwrap_or(DataContentType::TEXT_PLAIN) + .to_string(); + attrs.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(data_content_type.as_str()), + ); + + // Rebuild transport metadata from this client, even when an optional + // attribute such as token is unset. Never inherit a previous sender's + // credentials when forwarding a received message. + for (k, v) in &message.props { + if !crate::model::delivery::is_reserved_property(k) { + attrs.entry(k.clone()).or_insert_with(|| attr_str(v)); + } + } + + let data = match &message.content { + content if is_text_content(&data_content_type) => Some(PbData::TextData(content.clone())), + content if is_proto_content(&data_content_type) => { + // Match the Java SDK: content bytes are a serialized + // `google.protobuf.Any` message (produced by `Any.pack(...)` or + // manual construction, then serialized). Java calls + // `Any.parseFrom(content.getBytes(UTF_8))` on the producer side + // and `any.toByteArray()` on the consumer side. We mirror both + // directions so Rust↔Java `application/protobuf` messages are + // wire-compatible. + let any = PbAny::decode(content.as_bytes()).map_err(|e| EventMeshError::Protocol { + transport: "grpc", + message: format!( + "failed to decode application/protobuf content as google.protobuf.Any: {e}" + ), + })?; + Some(PbData::ProtoData(any)) + } + content => Some(PbData::BinaryData(content.as_bytes().to_vec())), + }; + + Ok(base_event(attrs, data)) +} + +/// Decode a delivered CloudEvent back into an [`EventMeshMessage`]. +pub fn to_event_mesh_message(cloud_event: &PbCloudEvent) -> Result { + 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 content = get_text_data(cloud_event); + crate::transport::decode_native_message(EventMeshMessage::new(topic, content)?, props) +} + +/// 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() +} + +#[cfg(feature = "cloud_events")] +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_text_data(cloud_event: &PbCloudEvent) -> String { + match &cloud_event.data { + Some(PbData::TextData(s)) => s.clone(), + Some(PbData::BinaryData(b)) => String::from_utf8_lossy(b).into_owned(), + Some(PbData::ProtoData(any)) => { + // Match Java: `new String(protoData.toByteArray(), UTF_8)`. + // Re-serializes the `Any` to bytes then decodes as UTF-8 string. + let mut buf = Vec::with_capacity(any.encoded_len()); + let _ = any.encode(&mut buf); + String::from_utf8_lossy(&buf).into_owned() + } + None => String::new(), + } +} + +/// Assemble a base CloudEvent with a fresh id and the common envelope +/// fields. +fn base_event( + attributes: HashMap, + data: Option, +) -> PbCloudEvent { + PbCloudEvent { + id: RandomStringUtils::generate_uuid(), + source: DEFAULT_SOURCE.into(), + spec_version: "1.0".into(), + r#type: CLOUD_EVENT_TYPE.into(), + attributes, + data, + } +} + +/// Mark a CloudEvent as a subscription reply (sent back over the stream). +/// +/// Mirrors the Java SDK's `SubStreamHandler.buildReplyMessage`: +/// - Tags the message with `SUB_MESSAGE_TYPE = SUBSCRIPTION_REPLY`. +/// - Forces `datacontenttype` to `application/json` so cross-SDK consumers +/// that dispatch on content type decode the reply consistently. +/// +/// The reply's data is left intact: EventMesh's `ReplyMessageProcessor` +/// runs `ServiceUtils.validateCloudEventData`, which for text content +/// requires a non-empty `textData` — clearing the data here would make the +/// reply fail validation and never reach `producer.reply()`, breaking +/// request/reply. +pub fn mark_as_reply(cloud_event: &mut PbCloudEvent) { + cloud_event.attributes.insert( + ProtocolKey::SUB_MESSAGE_TYPE.into(), + attr_str(ProtocolKey::SUBSCRIPTION_REPLY), + ); + cloud_event.attributes.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(DataContentType::JSON), + ); +} + +/// (Optional) CloudEvents interop: convert a native [`cloudevents::Event`] +/// to the wire CloudEvent. +#[cfg(feature = "cloud_events")] +pub fn from_cloudevent( + event: &cloudevents::Event, + config: &GrpcConfig, + producer_group: &str, +) -> Result { + use cloudevents::AttributesReader; + + let protocol_type = EventMeshProtocolType::CloudEvents; + let mut attrs = common_attributes(config, protocol_type); + + let ttl = event + .extension(ProtocolKey::TTL) + .map(|v| v.to_string()) + .unwrap_or_else(|| DEFAULT_MESSAGE_TTL.to_string()); + let seq_num = event + .extension(ProtocolKey::SEQ_NUM) + .map(|v| v.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + // UNIQUE_ID: preserve an existing extension, otherwise generate one. + // Do NOT clobber it with the CE id — the CE id travels in the top-level + // `id` field (see below) and cross-language consumers dedup on that. + let unique_id = event + .extension(ProtocolKey::UNIQUE_ID) + .map(|v| v.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| RandomStringUtils::generate_num(30)); + + attrs.insert(ProtocolKey::TTL.into(), attr_str(ttl)); + attrs.insert(ProtocolKey::SEQ_NUM.into(), attr_str(&seq_num)); + attrs.insert(ProtocolKey::UNIQUE_ID.into(), attr_str(&unique_id)); + attrs.insert(ProtocolKey::PRODUCERGROUP.into(), attr_str(producer_group)); + attrs.insert( + ProtocolKey::PROTOCOL_DESC.into(), + attr_str(ProtocolKey::PROTOCOL_DESC_GRPC_CLOUD_EVENT), + ); + if let Some(subject) = event.subject() { + attrs.insert(ProtocolKey::SUBJECT.into(), attr_str(subject)); + } + if let Some(dct) = event.datacontenttype() { + attrs.insert(ProtocolKey::DATA_CONTENT_TYPE.into(), attr_str(dct)); + } else { + attrs.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(DataContentType::TEXT_PLAIN), + ); + } + // Preserve standard CE attributes the previous code dropped. + if let Some(t) = event.time() { + attrs.insert( + ProtocolKey::TIME.into(), + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeTimestamp(prost_types::Timestamp { + seconds: t.timestamp(), + nanos: t.timestamp_subsec_nanos() as i32, + })), + }, + ); + } + if let Some(ds) = event.dataschema() { + attrs.insert( + ProtocolKey::DATA_SCHEMA.into(), + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeUri(ds.to_string())), + }, + ); + } + // Preserve typed extension values (Boolean / Integer) instead of + // stringifying everything. + for (k, v) in event.iter_extensions() { + if attrs.contains_key(k) { + continue; + } + let attr = match v { + cloudevents::event::ExtensionValue::String(s) => attr_str(s), + cloudevents::event::ExtensionValue::Boolean(b) => PbCloudEventAttributeValue { + attr: Some(PbAttr::CeBoolean(*b)), + }, + cloudevents::event::ExtensionValue::Integer(i) => { + let value = i32::try_from(*i).map_err(|_| { + EventMeshError::InvalidMessage(format!( + "CloudEvent integer extension {k:?} value {i} is outside protobuf \ + int32 range" + )) + })?; + PbCloudEventAttributeValue { + attr: Some(PbAttr::CeInteger(value)), + } + } + }; + attrs.insert(k.to_string(), attr); + } + + let data = match event.data() { + Some(cloudevents::Data::String(s)) => Some(PbData::TextData(s.clone())), + Some(cloudevents::Data::Binary(b)) => Some(PbData::BinaryData(b.clone())), + Some(cloudevents::Data::Json(j)) => Some(PbData::TextData(j.to_string())), + None => None, + }; + + Ok(PbCloudEvent { + id: event.id().to_string(), + source: event.source().to_string(), + spec_version: event.specversion().to_string(), + r#type: event.ty().to_string(), + attributes: attrs, + data, + }) +} + +/// (Optional) CloudEvents interop: convert the wire CloudEvent back into a +/// native [`cloudevents::Event`]. +#[cfg(feature = "cloud_events")] +pub fn to_cloudevent(cloud_event: PbCloudEvent) -> Result { + use cloudevents::{Data, EventBuilder, EventBuilderV10}; + + let topic = get_subject(&cloud_event); + // Use the protobuf `id` field (the standard CE id) rather than the + // EventMesh-specific UNIQUE_ID extension, so cross-language consumers + // can dedup and correlate correctly. + let ce_id = if cloud_event.id.is_empty() { + get_unique_id(&cloud_event) + } else { + cloud_event.id.clone() + }; + let source = if cloud_event.source.is_empty() { + DEFAULT_SOURCE.to_string() + } else { + cloud_event.source.clone() + }; + let ty = if cloud_event.r#type.is_empty() { + ProtocolKey::CLOUD_EVENTS_PROTOCOL_NAME.to_string() + } else { + cloud_event.r#type.clone() + }; + let content_type = cloud_event + .attributes + .get(ProtocolKey::DATA_CONTENT_TYPE) + .map(attr_as_str) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DataContentType::JSON.to_string()); + + let dataschema = cloud_event + .attributes + .get(ProtocolKey::DATA_SCHEMA) + .map(attr_as_str) + .filter(|s| !s.is_empty()); + + let mut builder = EventBuilderV10::new().id(ce_id).source(source).ty(ty); + + // Preserve the native data variant. ProtoData becomes the complete encoded + // `google.protobuf.Any` (including `type_url`), rather than lossy-decoding + // only its value bytes as UTF-8. + let data = match &cloud_event.data { + Some(PbData::TextData(text)) if is_json_content_type(&content_type) => { + Some(Data::Json(serde_json::from_str(text)?)) + } + Some(PbData::TextData(text)) => Some(Data::String(text.clone())), + Some(PbData::BinaryData(bytes)) => Some(Data::Binary(bytes.clone())), + Some(PbData::ProtoData(any)) => Some(Data::Binary(any.encode_to_vec())), + None => None, + }; + if let Some(data) = data { + builder = match &dataschema { + Some(ds) => builder.data_with_schema(content_type.as_str(), ds.as_str(), data), + None => builder.data(content_type.as_str(), data), + }; + } + + if !topic.is_empty() { + builder = builder.subject(topic); + } + + // Extract the standard CE `time` attribute instead of skipping it. + if let Some(v) = cloud_event.attributes.get(ProtocolKey::TIME) { + match &v.attr { + Some(PbAttr::CeTimestamp(ts)) => { + if let Some(dt) = chrono::DateTime::from_timestamp(ts.seconds, ts.nanos as u32) { + builder = builder.time(dt); + } + } + Some(PbAttr::CeString(s)) => { + builder = builder.time(s.clone()); + } + _ => {} + } + } + + for (k, v) in cloud_event.attributes { + if matches!( + k.as_str(), + ProtocolKey::GRPC_RESPONSE_CODE + | ProtocolKey::GRPC_RESPONSE_MESSAGE + | ProtocolKey::TIME + | ProtocolKey::DATA_SCHEMA + | ProtocolKey::DATA_CONTENT_TYPE + ) { + continue; + } + builder = match v.attr { + Some(PbAttr::CeBoolean(value)) => builder.extension(k.as_str(), value), + Some(PbAttr::CeInteger(value)) => builder.extension(k.as_str(), i64::from(value)), + Some(PbAttr::CeString(value)) + | Some(PbAttr::CeUri(value)) + | Some(PbAttr::CeUriRef(value)) => builder.extension(k.as_str(), value), + Some(PbAttr::CeBytes(value)) => { + let value = String::from_utf8(value).map_err(|_| { + EventMeshError::InvalidMessage(format!( + "CloudEvent byte extension {k:?} cannot be represented by the native \ + CloudEvents extension model" + )) + })?; + builder.extension(k.as_str(), value) + } + Some(PbAttr::CeTimestamp(value)) => { + builder.extension(k.as_str(), format!("{}.{}", value.seconds, value.nanos)) + } + None => builder, + }; + } + builder.build().map_err(|e| EventMeshError::Protocol { + transport: "grpc", + message: format!("cloudevents build error: {e}"), + }) +} + +#[cfg(feature = "cloud_events")] +fn is_json_content_type(content_type: &str) -> bool { + let media_type = content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() + .to_ascii_lowercase(); + media_type == "application/json" || media_type == "text/json" || media_type.ends_with("+json") +} + +/// Build a heartbeat CloudEvent. +pub(crate) fn build_heartbeat( + config: &GrpcConfig, + consumer_group: &str, + items: &[(String, String)], +) -> Result { + let mut attrs = common_attributes(config, EventMeshProtocolType::EventMeshMessage); + attrs.insert(ProtocolKey::CONSUMERGROUP.into(), attr_str(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::{Credentials, Endpoint, GrpcConfig, Identity}; + + const PRODUCER_GROUP: &str = "pg"; + const CONSUMER_GROUP: &str = "cg"; + + fn cfg() -> GrpcConfig { + GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205).unwrap()) + .with_identity(Identity::default().with_env("env").with_idc("idc")) + } + + #[test] + fn new_config_fields_and_role_groups_reach_wire_attributes() { + let config = cfg() + .with_identity( + Identity::default() + .with_env("prod") + .with_idc("east") + .with_system("checkout") + .with_process_id("42") + .with_ip("192.0.2.10"), + ) + .with_credentials( + Credentials::new() + .with_basic("alice", "secret") + .with_token("token"), + ); + let publish = from_event_mesh_message( + &EventMeshMessage::new("orders", "created").unwrap(), + &config, + PRODUCER_GROUP, + ) + .unwrap(); + let subscriptions = [Subscription::new("orders")]; + let subscribe = build_subscription_event( + &config, + CONSUMER_GROUP, + EventMeshProtocolType::EventMeshMessage, + None, + &subscriptions, + ) + .unwrap(); + + for (key, expected) in [ + (ProtocolKey::ENV, "prod"), + (ProtocolKey::IDC, "east"), + (ProtocolKey::SYS, "checkout"), + (ProtocolKey::PID, "42"), + (ProtocolKey::IP, "192.0.2.10"), + (ProtocolKey::USERNAME, "alice"), + (ProtocolKey::PASSWD, "secret"), + ("token", "token"), + ] { + assert_eq!( + publish.attributes.get(key).map(attr_as_str).as_deref(), + Some(expected) + ); + } + assert_eq!( + publish + .attributes + .get(ProtocolKey::PRODUCERGROUP) + .map(attr_as_str) + .as_deref(), + Some(PRODUCER_GROUP) + ); + assert_eq!( + subscribe + .attributes + .get(ProtocolKey::CONSUMERGROUP) + .map(attr_as_str) + .as_deref(), + Some(CONSUMER_GROUP) + ); + } + + #[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() + .unwrap(); + let ce = from_event_mesh_message(&msg, &cfg, PRODUCER_GROUP).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).unwrap(); + assert_eq!(back.topic(), "test-topic"); + assert_eq!(back.content(), "hello"); + } + + #[test] + fn decodes_java_compatible_whitespace_content_and_unbounded_ttl() { + let cfg = cfg(); + let msg = EventMeshMessage::builder() + .topic("test-topic") + .content(" \t") + .ttl_millis(2_147_483_648) + .build() + .unwrap(); + let wire = from_event_mesh_message(&msg, &cfg, PRODUCER_GROUP).unwrap(); + let decoded = to_event_mesh_message(&wire).unwrap(); + assert_eq!(decoded.content(), " \t"); + assert_eq!(decoded.get_prop(ProtocolKey::TTL), None); + assert_eq!(decoded.ttl_millis(), Some(2_147_483_648)); + + let msg = EventMeshMessage::builder() + .topic("test-topic") + .content("payload") + .ttl_millis(4000) + .build() + .unwrap(); + let mut wire = from_event_mesh_message(&msg, &cfg, PRODUCER_GROUP).unwrap(); + wire.attributes + .insert(ProtocolKey::TTL.into(), attr_str("java-specific")); + assert!(to_event_mesh_message(&wire).is_err()); + } + + #[test] + fn builds_subscription_event_with_url() { + let cfg = cfg(); + let items = vec![Subscription::new("t")]; + let ce = build_subscription_event( + &cfg, + CONSUMER_GROUP, + EventMeshProtocolType::EventMeshMessage, + Some("http://x/y"), + &items, + ) + .unwrap(); + assert_eq!( + ce.attributes.get("url").map(attr_as_str).unwrap(), + "http://x/y" + ); + assert_eq!( + ce.attributes.get("consumergroup").map(attr_as_str).unwrap(), + "cg" + ); + // The topic list is carried as JSON in text_data. + assert!(get_text_data(&ce).contains("\"topic\":\"t\"")); + } + + #[test] + fn parses_response_code() { + let mut ce = PbCloudEvent::default(); + ce.attributes + .insert(ProtocolKey::GRPC_RESPONSE_CODE.into(), attr_str("0")); + ce.attributes + .insert(ProtocolKey::GRPC_RESPONSE_MESSAGE.into(), attr_str("ok")); + let resp = to_response(&ce); + assert!(resp.is_success()); + assert_eq!(resp.message.as_deref(), Some("ok")); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloudevent_roundtrip_preserves_fields() { + use cloudevents::{event::ExtensionValue, AttributesReader, EventBuilder, EventBuilderV10}; + + let original = EventBuilderV10::new() + .id("my-ce-id") + .source("http://example.com/src") + .ty("com.example.event") + .time("2023-07-13T12:00:00Z") + .data_with_schema( + "application/json", + "http://example.com/schema", + r#"{"k":"v"}"#.to_string(), + ) + .extension("bool-ext", true) + .extension("int-ext", 42i64) + .extension("str-ext", "hello") + .build() + .unwrap(); + + let cfg = cfg(); + let pb = from_cloudevent(&original, &cfg, PRODUCER_GROUP).unwrap(); + + // id is preserved, not replaced with a random UUID. + assert_eq!(pb.id, "my-ce-id"); + + // time is preserved as CeTimestamp. + match pb + .attributes + .get(ProtocolKey::TIME) + .and_then(|v| v.attr.as_ref()) + { + Some(PbAttr::CeTimestamp(_)) => {} + other => panic!("expected CeTimestamp for time, got {other:?}"), + } + + // dataschema is preserved as CeUri. + match pb + .attributes + .get(ProtocolKey::DATA_SCHEMA) + .and_then(|v| v.attr.as_ref()) + { + Some(PbAttr::CeUri(s)) => assert_eq!(s, "http://example.com/schema"), + other => panic!("expected CeUri for dataschema, got {other:?}"), + } + + // Typed extensions preserve their wire types. + match pb.attributes.get("bool-ext").and_then(|v| v.attr.as_ref()) { + Some(PbAttr::CeBoolean(true)) => {} + other => panic!("expected CeBoolean(true) for bool-ext, got {other:?}"), + } + match pb.attributes.get("int-ext").and_then(|v| v.attr.as_ref()) { + Some(PbAttr::CeInteger(42)) => {} + other => panic!("expected CeInteger(42) for int-ext, got {other:?}"), + } + + // Round-trip back to a native CE. + let back = to_cloudevent(pb).unwrap(); + assert_eq!(back.id(), "my-ce-id"); + assert_eq!(back.ty(), "com.example.event"); + assert!(back.time().is_some()); + assert!(back.dataschema().is_some()); + assert!(matches!( + back.extension("bool-ext"), + Some(ExtensionValue::Boolean(true)) + )); + assert!(matches!( + back.extension("int-ext"), + Some(ExtensionValue::Integer(42)) + )); + assert!(matches!( + back.data(), + Some(cloudevents::Data::Json(value)) if value == &serde_json::json!({"k": "v"}) + )); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn rejects_cloudevent_integer_extensions_outside_int32_range() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("id") + .source("/") + .ty("test") + .extension("too-large", i64::from(i32::MAX) + 1) + .build() + .unwrap(); + + assert!(matches!( + from_cloudevent(&event, &cfg(), PRODUCER_GROUP), + Err(EventMeshError::InvalidMessage(message)) + if message.contains("too-large") && message.contains("int32") + )); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn to_cloudevent_leaves_missing_data_absent() { + let pb = PbCloudEvent { + id: "empty-id".into(), + source: "/".into(), + spec_version: "1.0".into(), + r#type: "com.example.empty".into(), + ..Default::default() + }; + + assert!(to_cloudevent(pb).unwrap().data().is_none()); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn to_cloudevent_preserves_complete_protobuf_any_as_binary() { + let any = PbAny { + type_url: "type.googleapis.com/example.Payload".into(), + value: vec![0xff, 0x00, 0x80, 0x01], + }; + let expected = any.encode_to_vec(); + let mut pb = PbCloudEvent { + id: "proto-id".into(), + source: "/".into(), + spec_version: "1.0".into(), + r#type: "com.example.proto".into(), + data: Some(PbData::ProtoData(any)), + ..Default::default() + }; + pb.attributes.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str(DataContentType::PROTOBUF), + ); + + match to_cloudevent(pb).unwrap().data() { + Some(cloudevents::Data::Binary(bytes)) => assert_eq!(bytes, &expected), + other => panic!("expected encoded protobuf Any bytes, got {other:?}"), + } + } + + #[cfg(feature = "cloud_events")] + #[test] + fn to_cloudevent_preserves_binary_data() { + use cloudevents::AttributesReader; + let mut pb = PbCloudEvent { + id: "bin-id".into(), + source: "/".into(), + spec_version: "1.0".into(), + r#type: "com.example.binary".into(), + ..Default::default() + }; + pb.attributes.insert( + ProtocolKey::DATA_CONTENT_TYPE.into(), + attr_str("application/octet-stream"), + ); + pb.data = Some(PbData::BinaryData(vec![0, 1, 2, 3])); + + let ce = to_cloudevent(pb).unwrap(); + assert_eq!(ce.ty(), "com.example.binary"); + match ce.data() { + Some(cloudevents::Data::Binary(b)) => assert_eq!(b, &[0, 1, 2, 3]), + other => panic!("expected binary data, got {other:?}"), + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/consumer.rs new file mode 100644 index 0000000000..4006450ff4 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/consumer.rs @@ -0,0 +1,1168 @@ +// 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 [`MessageHandler`]. +//! The stream, receive loop, and heartbeat all run as background tasks. +//! - [`GrpcWebhookConsumer`] — a lightweight RPC-only client that registers +//! webhook URLs with the runtime (the runtime POSTs delivered messages to +//! the URL over HTTP). No listener, no receive loop. +//! +//! Both types support [`subscribe_webhook`], [`unsubscribe_stream`] / +//! [`unsubscribe_webhook`], and [`wait_for_shutdown`]. +//! +//! [`subscribe_webhook`]: GrpcStreamConsumer::subscribe_webhook +//! [`unsubscribe_stream`]: GrpcStreamConsumer::unsubscribe_stream +//! [`unsubscribe_webhook`]: GrpcStreamConsumer::unsubscribe_webhook + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::task::{JoinHandle, JoinSet}; +use tokio_util::sync::CancellationToken; +use tonic::codegen::tokio_stream::StreamExt; +use tracing::{debug, warn}; + +use crate::common::constants::SDK_STREAM_URL; +use crate::common::protocol_key::ProtocolKey; +use crate::config::{ConsumerOptions, GrpcConfig, GrpcConsumerOptions}; +use crate::error::{EventMeshError, Result}; +use crate::message::Message; +#[cfg(test)] +use crate::model::EventMeshMessage; +use crate::model::{EventMeshProtocolType, PublishResponse}; +use crate::subscription::Subscription; +use crate::transport::grpc::client::ChannelClient; +use crate::transport::grpc::codec; +use crate::transport::grpc::heartbeat::{self, StreamTx}; +use crate::transport::task::BackgroundTask; +use crate::MessageHandler; + +const DEFAULT_REPLY_PRODUCER_GROUP: &str = "DefaultProducerGroup"; + +// --------------------------------------------------------------------------- +// 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: Subscription, + pub(crate) url: String, +} + +fn decode_message(event: &crate::proto_gen::PbCloudEvent) -> Result { + let protocol_type = event + .attributes + .get(ProtocolKey::PROTOCOL_TYPE) + .map(crate::proto_gen::attr_as_str) + .unwrap_or_default(); + + match protocol_type.as_str() { + protocol if protocol == EventMeshProtocolType::CloudEvents.as_str() => { + #[cfg(feature = "cloud_events")] + return codec::to_cloudevent(event.clone()).map(Message::CloudEvent); + + #[cfg(not(feature = "cloud_events"))] + return Err(EventMeshError::Unsupported( + "received a CloudEvent without the 'cloud_events' feature enabled".into(), + )); + } + "" => {} + protocol if protocol == EventMeshProtocolType::EventMeshMessage.as_str() => {} + protocol => { + return Err(EventMeshError::Protocol { + transport: "grpc", + message: format!("unsupported protocoltype {protocol:?}"), + }); + } + } + + Ok(Message::EventMesh(codec::to_event_mesh_message(event)?)) +} + +fn encode_message( + message: &Message, + config: &GrpcConfig, + producer_group: &str, +) -> Result { + match message { + Message::EventMesh(message) => { + codec::from_event_mesh_message(message, config, producer_group) + } + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => codec::from_cloudevent(event, config, producer_group), + } +} + +// --------------------------------------------------------------------------- +// 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 `request_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() => {} + } + }); + } +} + +// --------------------------------------------------------------------------- +// 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 [`request_shutdown`](Self::request_shutdown) / +/// [`wait_for_shutdown`](Self::wait_for_shutdown). +/// +/// [`GrpcConsumerOptions::with_max_concurrent_handlers`] bounds the number of +/// messages dispatched to the listener at once. The default is one, preserving +/// the Java SDK's serial / in-order-reply semantics. Larger values allow +/// concurrent handling and can reorder replies; each reply remains +/// self-correlating through its request attributes. +/// +/// 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::{Endpoint, GrpcConfig, GrpcConsumerOptions}, +/// # GrpcChannel, GrpcStreamConsumer, Message, MessageHandler, Subscription, +/// # }; +/// # struct MyListener; +/// # impl MessageHandler for MyListener { +/// # async fn handle(&self, _: Message) -> eventmesh::Result> { Ok(None) } +/// # } +/// # #[tokio::main] +/// # async fn main() -> eventmesh::Result<()> { +/// let channel = GrpcChannel::connect( +/// GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?), +/// ).await?; +/// let consumer = GrpcStreamConsumer::open( +/// channel, +/// GrpcConsumerOptions::new("consumer-group"), +/// [Subscription::new("t")], +/// MyListener, +/// ).await?; +/// consumer.join().await?; +/// # Ok(()) +/// # } +/// ``` +pub struct GrpcStreamConsumer { + client: ChannelClient, + config: GrpcConfig, + options: GrpcConsumerOptions, + 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 [`request_shutdown`](Self::request_shutdown) or drop. + /// + /// Both current-thread and multi-thread Tokio runtimes are supported. + /// The channel's owning runtime must keep running to drive the stream, + /// heartbeat, and handler tasks. Stream establishment waits at most + /// 15 seconds for the server's response headers. + pub async fn subscribe_stream( + client: ChannelClient, + config: GrpcConfig, + options: GrpcConsumerOptions, + listener: L, + items: Vec, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + options.validate()?; + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "subscription items must not be empty".into(), + )); + } + + 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, + options.consumer().group(), + EventMeshProtocolType::EventMeshMessage, + None, + &items, + )?; + + // Eagerly open the stream. + let (reply_tx, stream) = client.subscribe_stream(event).await?; + let reply_tx = Arc::new(reply_tx); + + // Register the stream sender so heartbeat resubscribe can re-use it. + { + *stream_tx.lock().await = Some((*reply_tx).clone()); + } + + // Record the initial subscription. + { + let mut guard = subscriptions.lock().await; + for item in &items { + guard.insert( + (item.topic.clone(), SDK_STREAM_URL.to_string()), + SubscriptionEntry { + item: item.clone(), + url: SDK_STREAM_URL.to_string(), + }, + ); + } + } + + // Spawn heartbeat. + let heartbeat_handle = heartbeat::spawn( + client.clone(), + config.clone(), + options.consumer().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(), + options.max_concurrent_handlers(), + stream_tx.clone(), + shutdown.clone(), + ); + + Ok(Self { + client, + config, + options, + subscriptions, + _listener: std::marker::PhantomData, + shutdown, + heartbeat_handle: Mutex::new(BackgroundTask::new(heartbeat_handle)), + stream_tx, + driver_handle: Mutex::new(BackgroundTask::new(driver_handle)), + }) + } + + /// Subscribe to additional topics over the already-open stream. + /// + /// The subscription CloudEvent is sent through the stream's request + /// channel. Returns an error if the stream is no longer active, is shutting + /// down, or remains backpressured beyond the configured timeout. + pub async fn subscribe(&self, items: Vec) -> Result<()> { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "subscription items must not be empty".into(), + )); + } + let event = codec::build_subscription_event( + &self.config, + self.options.consumer().group(), + EventMeshProtocolType::EventMeshMessage, + None, + &items, + )?; + match heartbeat::stream_sender(&self.stream_tx).await { + Some(tx) => { + // The state lock is released before awaiting bounded-channel + // capacity. This keeps stream teardown and heartbeat replay + // from being blocked by a backpressured caller subscription. + match heartbeat::await_with_timeout_or_shutdown( + &self.shutdown, + self.config.request_timeout(), + tx.reserve(), + ) + .await + { + heartbeat::OperationOutcome::Completed(Ok(permit)) => { + permit.send(event); + let mut sub_guard = self.subscriptions.lock().await; + for item in &items { + sub_guard.insert( + (item.topic.clone(), SDK_STREAM_URL.to_string()), + SubscriptionEntry { + item: item.clone(), + url: SDK_STREAM_URL.to_string(), + }, + ); + } + Ok(()) + } + heartbeat::OperationOutcome::Completed(Err(e)) => { + Err(EventMeshError::ChannelClosed(format!("subscribe: {e}"))) + } + heartbeat::OperationOutcome::TimedOut => { + Err(EventMeshError::Timeout(self.config.request_timeout())) + } + heartbeat::OperationOutcome::Cancelled => Err(EventMeshError::ChannelClosed( + "stream is shutting down".into(), + )), + } + } + None => Err(EventMeshError::ChannelClosed("stream is not active".into())), + } + } + + /// Unsubscribe stream-mode topics (registered via `subscribe_stream` or + /// `subscribe`). + /// + /// This is an independent unary RPC — it is **not** sent over the open + /// stream. The server matches stream clients by IP + PID, so no URL is + /// needed. + /// + /// Known limitation: the Java Runtime closes the shared emitter even for + /// partial unsubscribe. Remaining topics are not replayed on a new stream; + /// EOF stops the driver and heartbeat. See the public unsubscribe rustdoc. + pub async fn unsubscribe_stream(&self, items: Vec) -> Result { + unsubscribe_stream_rpc( + &self.client, + &self.config, + self.options.consumer(), + &self.subscriptions, + items, + ) + .await + } + + /// Signal the stream driver and heartbeat task to stop. + pub fn request_shutdown(&self) { + self.shutdown.cancel(); + } + + /// Block until the shutdown signal fires or the stream / heartbeat tasks + /// exit on their own, then await their clean exit. + /// + /// If no shutdown signal was provided at construction time, this blocks + /// until the tasks exit naturally (e.g. the server closes the stream). + pub async fn wait_for_shutdown(&self) -> Result<()> { + let mut driver = self.driver_handle.lock().await; + driver.wait().await; + self.shutdown.cancel(); + let mut heartbeat = self.heartbeat_handle.lock().await; + heartbeat.wait().await; + // No awaits after consuming either result: cancellation while waiting + // for heartbeat cleanup must not discard a driver failure. + let driver_result = driver + .take_result() + .unwrap_or(Ok(Ok(()))) + .map_err(|error| { + EventMeshError::ChannelClosed(format!( + "gRPC consumer driver task panicked: {error}" + )) + })?; + let heartbeat_result = heartbeat.take_result().unwrap_or(Ok(())).map_err(|error| { + EventMeshError::ChannelClosed(format!("gRPC consumer heartbeat task panicked: {error}")) + }); + driver_result.and(heartbeat_result) + } +} + +impl Drop for GrpcStreamConsumer { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +// --------------------------------------------------------------------------- +// 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::{ConsumerOptions, Endpoint, GrpcConfig}, +/// # GrpcChannel, GrpcWebhookConsumer, Subscription, +/// # }; +/// # #[tokio::main] +/// # async fn main() -> eventmesh::Result<()> { +/// let channel = GrpcChannel::connect( +/// GrpcConfig::new(Endpoint::new("127.0.0.1", 10_205)?), +/// ).await?; +/// let consumer = GrpcWebhookConsumer::new( +/// channel, +/// ConsumerOptions::new("consumer-group"), +/// ).await?; +/// consumer.subscribe( +/// [Subscription::new("t")], +/// "http://127.0.0.1:8080/cb", +/// ).await?; +/// consumer.join().await?; +/// # Ok(()) +/// # } +/// ``` +pub struct GrpcWebhookConsumer { + client: ChannelClient, + config: GrpcConfig, + options: ConsumerOptions, + 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 [`request_shutdown`](Self::request_shutdown) or drop. + pub async fn new( + client: ChannelClient, + config: GrpcConfig, + options: ConsumerOptions, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + options.validate()?; + 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(), + options.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, + options, + subscriptions, + shutdown, + heartbeat_handle: Mutex::new(BackgroundTask::new(heartbeat_handle)), + }) + } + + #[cfg(test)] + pub(crate) fn client(&self) -> &ChannelClient { + &self.client + } + + /// 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.options, + &self.subscriptions, + items, + url, + ) + .await + } + + /// Unsubscribe webhook topics. + /// + /// `url` must be the same webhook URL passed to `subscribe_webhook`. + /// The server matches webhook clients by URL — omitting or mismatching + /// it leaves a ghost subscription that continues to receive pushes. + pub async fn unsubscribe_webhook( + &self, + items: Vec, + url: impl Into, + ) -> Result { + unsubscribe_webhook_rpc( + &self.client, + &self.config, + &self.options, + &self.subscriptions, + items, + url, + ) + .await + } + + /// Signal the heartbeat task to stop. + pub fn request_shutdown(&self) { + self.shutdown.cancel(); + } + + /// Block until the shutdown signal fires or the heartbeat task exits. + pub async fn wait_for_shutdown(&self) -> Result<()> { + let mut task = self.heartbeat_handle.lock().await; + task.wait().await; + self.shutdown.cancel(); + task.take_result().unwrap_or(Ok(())).map_err(|error| { + EventMeshError::ChannelClosed(format!("gRPC webhook heartbeat task panicked: {error}")) + }) + } +} + +impl Drop for GrpcWebhookConsumer { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +// --------------------------------------------------------------------------- +// 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: &ChannelClient, + config: &GrpcConfig, + consumer: &ConsumerOptions, + 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, + consumer.group(), + EventMeshProtocolType::EventMeshMessage, + Some(&url), + &items, + )?; + let resp = timed(config.request_timeout(), client.subscribe_webhook(event)).await?; + let response = codec::to_response(&resp); + if response.is_success() { + let mut guard = subscriptions.lock().await; + for item in items { + guard.insert( + (item.topic.clone(), url.clone()), + SubscriptionEntry { + item, + url: url.clone(), + }, + ); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "subscribe failed".into()), + }) + } +} + +async fn unsubscribe_stream_rpc( + client: &ChannelClient, + config: &GrpcConfig, + consumer: &ConsumerOptions, + subscriptions: &Arc>>, + items: Vec, +) -> Result { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "unsubscribe items must not be empty".into(), + )); + } + + // Stream subscriptions: the server matches stream clients by ip+pid, + // not by URL, so url=None is correct here. + let event = codec::build_subscription_event( + config, + consumer.group(), + EventMeshProtocolType::EventMeshMessage, + None, + &items, + )?; + let resp = timed(config.request_timeout(), client.unsubscribe(event)).await?; + let response = codec::to_response(&resp); + if response.is_success() { + // Only the requested topics are removed locally, although the Java + // Runtime closes their shared stream. Remaining entries do not imply + // active delivery; no stream recreation/replay is implemented here. + let mut guard = subscriptions.lock().await; + for item in &items { + guard.remove(&(item.topic.clone(), SDK_STREAM_URL.to_string())); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }) + } +} + +async fn unsubscribe_webhook_rpc( + client: &ChannelClient, + config: &GrpcConfig, + consumer: &ConsumerOptions, + subscriptions: &Arc>>, + items: Vec, + url: impl Into, +) -> Result { + let url = url.into(); + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "unsubscribe items must not be empty".into(), + )); + } + + // Webhook subscriptions: the server matches webhook clients by URL. + // The URL must match the one used at subscribe time, otherwise the + // WebhookTopicConfig entry is not removed and pushes continue. + let url_ref = if url.is_empty() { + None + } else { + Some(url.as_str()) + }; + let event = codec::build_subscription_event( + config, + consumer.group(), + EventMeshProtocolType::EventMeshMessage, + url_ref, + &items, + )?; + let resp = timed(config.request_timeout(), client.unsubscribe(event)).await?; + let response = codec::to_response(&resp); + if response.is_success() { + let mut guard = subscriptions.lock().await; + for item in &items { + guard.remove(&(item.topic.clone(), url.clone())); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }) + } +} + +// --------------------------------------------------------------------------- +// Stream receive-loop driver (spawned, not public) +// --------------------------------------------------------------------------- + +/// Spawn the stream receive loop as a background task. +/// +/// Dispatches delivered messages to the listener **concurrently** (up to +/// the configured maximum number of handlers in flight at once) and sends +/// back replies as each handler completes. Concurrency is bounded by a +/// `Semaphore`: when all permits are held by in-flight handlers, the loop +/// stops pulling from the gRPC stream, which engages gRPC flow control and +/// pauses the server — this is the backpressure path. +/// +/// With a concurrency bound greater than one, replies are sent in +/// handler-completion order rather than message-arrival order. Each reply +/// carries the original request's attributes (see [`build_reply`]) so the +/// broker can correlate it independently of ordering. The default bound of one +/// preserves strict serial / in-order-reply semantics. +/// +/// On shutdown (`shutdown` token cancelled or the stream ends) the loop stops +/// accepting new messages and then **drains** all in-flight handlers to +/// completion (mirroring axum's graceful-shutdown behaviour) before clearing +/// `stream_tx` and returning. `Drop` of the consumer aborts the driver task, +/// which drops the `JoinSet` and aborts any remaining in-flight handlers. +fn spawn_stream_driver( + mut stream: tonic::Streaming, + reply_tx: Arc>, + listener: Arc, + config: GrpcConfig, + max_concurrent_handlers: usize, + stream_tx: StreamTx, + shutdown: CancellationToken, +) -> JoinHandle> +where + L: MessageHandler, +{ + tokio::spawn(async move { + let semaphore = Arc::new(Semaphore::new(max_concurrent_handlers)); + let mut join_set: JoinSet> = JoinSet::new(); + let mut terminal_error = None; + + loop { + tokio::select! { + msg = stream.next() => match msg { + None => { + debug!("subscribe stream ended"); + // Cancel the token so wait_for_shutdown() unblocks + // instead of waiting forever for an external signal. + shutdown.cancel(); + break; + } + Some(Err(status)) => { + warn!("stream receive error: {status}"); + terminal_error = Some(EventMeshError::from(status)); + shutdown.cancel(); + break; + } + Some(Ok(cloud_event)) => { + if codec::get_seq_num(&cloud_event).is_empty() { + // Known limitation: this also ignores subscription + // rejection statuscode/responsemessage attributes. + // Normal EOF then lets join() return success. The + // Java SDK likewise does not propagate these + // statuses; retained behavior is documented in + // GrpcStreamConsumer::open and ARCHITECTURE.md. + debug!("skipping control frame (no seqnum)"); + continue; + } + let message = match decode_message(&cloud_event) { + Ok(message) => message, + Err(error) => { + terminal_error = Some(error); + shutdown.cancel(); + break; + } + }; + // Acquire a permit (bounded concurrency) but allow + // shutdown to interrupt the wait. The permit lives + // for the duration of the spawned handler task. + let permit = tokio::select! { + p = semaphore.clone().acquire_owned() => match p { + Ok(p) => p, + Err(_) => break, // semaphore closed + }, + _ = shutdown.cancelled() => break, + }; + join_set.spawn(handle_one( + cloud_event, + message, + Arc::clone(&listener), + Arc::clone(&reply_tx), + config.clone(), + permit, + )); + } + }, + // Reap completed tasks so the JoinSet does not grow unbounded. + // Guard against busy-spinning: join_next() on an empty JoinSet + // returns Ready(None) immediately, which would make the select! + // fire on this branch every iteration. The async block keeps the + // future Pending when the set is empty, so the loop only wakes + // when the stream delivers, a task completes, or shutdown fires. + completed = async { + if !join_set.is_empty() { + join_set.join_next().await + } else { + std::future::pending().await + } + } => { + match completed { + Some(Ok(Ok(()))) | None => {} + Some(Ok(Err(error))) => { + terminal_error = Some(error); + shutdown.cancel(); + break; + } + Some(Err(error)) => { + terminal_error = Some(EventMeshError::ChannelClosed(format!( + "gRPC message handler task panicked: {error}" + ))); + shutdown.cancel(); + break; + } + } + } + _ = shutdown.cancelled() => { + debug!("subscribe stream shutting down"); + break; + } + } + } + + // Drain: wait for all in-flight handlers to finish (mirrors axum's + // graceful-shutdown semantics). `Drop` of the consumer aborts the + // driver task instead, cancelling these immediately. + while let Some(completed) = join_set.join_next().await { + if terminal_error.is_none() { + terminal_error = match completed { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error), + Err(error) => Some(EventMeshError::ChannelClosed(format!( + "gRPC message handler task panicked: {error}" + ))), + }; + } + } + + *stream_tx.lock().await = None; + terminal_error.map_or(Ok(()), Err) + }) +} + +/// Run a single message through the listener and send any reply. +/// +/// The `_permit` is held for the lifetime of this future; dropping it (when +/// the future completes or is cancelled) releases the concurrency slot back +/// to the semaphore, allowing the receive loop to pull the next message. +async fn handle_one( + cloud_event: crate::proto_gen::PbCloudEvent, + message: Message, + listener: Arc, + reply_tx: Arc>, + config: GrpcConfig, + _permit: tokio::sync::OwnedSemaphorePermit, +) -> Result<()> { + match listener.handle(message).await? { + Some(reply) => { + let reply_event = build_reply(&reply, &cloud_event, &config)?; + reply_tx + .send(reply_event) + .await + .map_err(|_| EventMeshError::ChannelClosed("gRPC reply channel closed".into()))?; + } + None => { /* async ack: nothing to send back */ } + } + Ok(()) +} + +/// Build a reply CloudEvent (used by the stream receive loop when the listener +/// returns `Some(message)`). +/// +/// Mirrors the Java SDK's `SubStreamHandler.buildReplyMessage`: the incoming +/// request's attributes are carried over into the reply so the broker can +/// correlate the reply with the original request. The reply's own attributes +/// take precedence. +pub(crate) fn build_reply( + reply: &Message, + request: &crate::proto_gen::PbCloudEvent, + config: &GrpcConfig, +) -> Result { + let mut event = encode_message(reply, config, DEFAULT_REPLY_PRODUCER_GROUP)?; + for (key, value) in &request.attributes { + if crate::model::delivery::is_transport_property(key) { + continue; + } + event + .attributes + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + codec::mark_as_reply(&mut event); + Ok(event) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> GrpcConfig { + GrpcConfig::new(crate::config::Endpoint::new("127.0.0.1", 10_205).unwrap()) + } + + #[test] + fn public_message_rejects_unknown_protocol() { + let mut wire = codec::from_event_mesh_message( + &EventMeshMessage::new("orders", "created").unwrap(), + &config(), + "producer", + ) + .unwrap(); + wire.attributes.insert( + ProtocolKey::PROTOCOL_TYPE.into(), + crate::proto_gen::attr_str("openmessage"), + ); + + let error = decode_message(&wire).unwrap_err(); + assert!(matches!( + error, + EventMeshError::Protocol { + transport: "grpc", + .. + } + )); + } + + #[test] + fn native_reply_restores_routing_without_inheriting_sender_credentials() { + let mut request = codec::from_event_mesh_message( + &EventMeshMessage::new("orders", "request").unwrap(), + &config(), + "producer", + ) + .unwrap(); + for (key, value) in [ + ("cluster", "request-cluster"), + ("correlation99id", "request-id"), + ("reply99to99client", "request-client"), + ("req0sys", "request-system"), + ("token", "sender-token"), + ] { + request + .attributes + .insert(key.into(), crate::proto_gen::attr_str(value)); + } + let decoded = decode_message(&request).unwrap().into_event_mesh().unwrap(); + assert!(decoded.properties().is_empty()); + assert_eq!( + decoded + .delivery_context() + .unwrap() + .attribute("correlation99id"), + Some("request-id") + ); + + let reply = build_reply( + &Message::from(EventMeshMessage::new("orders", "reply").unwrap()), + &request, + &config(), + ) + .unwrap(); + for key in ["cluster", "correlation99id", "reply99to99client", "req0sys"] { + assert_eq!(reply.attributes.get(key), request.attributes.get(key)); + } + assert!(!reply.attributes.contains_key("token")); + } + + type TestHandler = fn(Message) -> std::future::Ready>>; + + fn consumer_with_tasks( + driver: JoinHandle>, + heartbeat: JoinHandle<()>, + ) -> GrpcStreamConsumer { + let config = config(); + GrpcStreamConsumer { + client: ChannelClient::connect_lazy(&config).unwrap(), + config, + options: GrpcConsumerOptions::new("consumer"), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + _listener: std::marker::PhantomData, + shutdown: CancellationToken::new(), + heartbeat_handle: Mutex::new(BackgroundTask::new(heartbeat)), + stream_tx: Arc::new(Mutex::new(None)), + driver_handle: Mutex::new(BackgroundTask::new(driver)), + } + } + + #[tokio::test(start_paused = true)] + async fn cancelled_stream_join_preserves_driver_failure() { + let (release, released) = tokio::sync::oneshot::channel::<()>(); + let consumer = consumer_with_tasks( + tokio::spawn(async move { + released.await.unwrap(); + Err(EventMeshError::Server { + code: 17, + message: "driver failure".into(), + }) + }), + tokio::spawn(async {}), + ); + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .is_err() + ); + consumer.request_shutdown(); + } + release.send(()).unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .unwrap(); + assert!(matches!( + result, + Err(EventMeshError::Server { code: 17, .. }) + )); + } + + #[tokio::test(start_paused = true)] + async fn cancelled_stream_join_during_cleanup_preserves_driver_panic() { + let (release, released) = tokio::sync::oneshot::channel::<()>(); + let consumer = consumer_with_tasks( + tokio::spawn(async { + panic!("driver regression"); + }), + tokio::spawn(async move { + released.await.unwrap(); + }), + ); + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .is_err() + ); + assert!(consumer.shutdown.is_cancelled()); + } + release.send(()).unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .unwrap(); + assert!(matches!(result, Err(EventMeshError::ChannelClosed(message)) + if message.contains("driver regression"))); + } + + #[tokio::test(start_paused = true)] + async fn cancelled_webhook_join_preserves_heartbeat_panic() { + let config = config(); + let consumer = GrpcWebhookConsumer::new( + ChannelClient::connect_lazy(&config).unwrap(), + config, + ConsumerOptions::new("consumer"), + None::>, + ) + .await + .unwrap(); + let (release, released) = tokio::sync::oneshot::channel::<()>(); + *consumer.heartbeat_handle.lock().await = BackgroundTask::new(tokio::spawn(async move { + released.await.unwrap(); + panic!("heartbeat regression"); + })); + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .is_err() + ); + consumer.request_shutdown(); + } + release.send(()).unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .unwrap(); + assert!(matches!(result, Err(EventMeshError::ChannelClosed(message)) + if message.contains("heartbeat regression"))); + } + + #[tokio::test] + async fn webhook_shutdown_then_join_preserves_task_panic() { + let config = config(); + let consumer = GrpcWebhookConsumer::new( + ChannelClient::connect_lazy(&config).unwrap(), + config, + ConsumerOptions::new("consumer"), + None::>, + ) + .await + .unwrap(); + *consumer.heartbeat_handle.lock().await = BackgroundTask::new(tokio::spawn(async { + panic!("heartbeat panic"); + })); + + consumer.request_shutdown(); + let error = consumer.wait_for_shutdown().await.unwrap_err(); + assert!(matches!(error, EventMeshError::ChannelClosed(_))); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn public_message_preserves_cloud_event_protocol_and_reply_metadata() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("event-1") + .source("urn:test") + .ty("orders.created") + .subject("orders") + .data("application/json", serde_json::json!({"status": "created"})) + .build() + .expect("build event"); + let original = Message::CloudEvent(event); + let mut request = + encode_message(&original, &config(), "producer").expect("encode CloudEvent request"); + request.attributes.insert( + "correlation-id".into(), + crate::proto_gen::attr_str("request-7"), + ); + + let decoded = decode_message(&request).expect("decode CloudEvent request"); + assert!(matches!(decoded, Message::CloudEvent(_))); + let reply = build_reply(&original, &request, &config()).expect("encode reply"); + assert_eq!( + reply + .attributes + .get(ProtocolKey::PROTOCOL_TYPE) + .map(crate::proto_gen::attr_as_str) + .as_deref(), + Some(EventMeshProtocolType::CloudEvents.as_str()) + ); + assert_eq!( + reply + .attributes + .get("correlation-id") + .map(crate::proto_gen::attr_as_str) + .as_deref(), + Some("request-7") + ); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/heartbeat.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/heartbeat.rs new file mode 100644 index 0000000000..ed00d0789a --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/heartbeat.rs @@ -0,0 +1,336 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Background heartbeat loop for the gRPC consumer. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{mpsc, Mutex}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::common::constants::SDK_STREAM_URL; +use crate::common::status_code::StatusCode; +use crate::config::{ConsumerOptions, GrpcConfig}; +use crate::model::EventMeshProtocolType; +use crate::proto_gen::PbCloudEvent; +use crate::subscription::Subscription; +use crate::transport::grpc::client::ChannelClient; +use crate::transport::grpc::codec; +use crate::transport::grpc::consumer::SubscriptionEntry; + +/// Initial delay before the first heartbeat. +const HEARTBEAT_INITIAL_DELAY: Duration = Duration::from_secs(10); +/// Interval between heartbeats. +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); + +/// Type alias for the shared stream sender used to re-send stream subscriptions +/// during resubscribe. `None` when no stream is currently active. +pub(crate) type StreamTx = Arc>>>; + +/// Outcome of an operation bounded by a timeout and consumer shutdown. +pub(crate) enum OperationOutcome { + /// The operation completed before either bound was reached. + Completed(T), + /// The configured timeout elapsed first. + TimedOut, + /// Consumer shutdown was requested first. + Cancelled, +} + +/// Await an operation until it completes, its deadline expires, or shutdown +/// is requested. Shutdown wins when it is already ready so callers never +/// start more work after the consumer begins closing. +pub(crate) async fn await_with_timeout_or_shutdown( + shutdown: &CancellationToken, + timeout: Duration, + operation: impl Future, +) -> OperationOutcome { + tokio::select! { + biased; + _ = shutdown.cancelled() => OperationOutcome::Cancelled, + result = tokio::time::timeout(timeout, operation) => match result { + Ok(value) => OperationOutcome::Completed(value), + Err(_) => OperationOutcome::TimedOut, + }, + } +} + +/// Clone the active stream sender while holding the state lock only long +/// enough to read it. Sending through a bounded channel can await indefinitely +/// under backpressure, so it must always happen after this lock is released. +pub(crate) async fn stream_sender(stream_tx: &StreamTx) -> Option> { + stream_tx.lock().await.clone() +} + +/// Spawn the heartbeat loop. Reads the consumer's current `(topic, url)` +/// subscriptions each tick and reports them to the broker. The loop exits +/// promptly when `shutdown` is cancelled, so dropping / shutting down the +/// consumer no longer leaks a permanently-running task. +/// +/// Scheduling mirrors the Java SDK's `scheduleAtFixedRate`: the first tick +/// fires after `HEARTBEAT_INITIAL_DELAY`, subsequent ticks align to a fixed +/// grid of `HEARTBEAT_INTERVAL`. The default `Burst` missed-tick behavior +/// matches Java's "catch up by one (non-concurrent)" semantics — if a tick +/// overruns, the next fires immediately rather than shifting the grid. +/// +/// When the server returns `CLIENT_RESUBSCRIBE`, the loop automatically +/// re-registers all active subscriptions: webhook subscriptions are re-sent via +/// the `subscribe` RPC, stream subscriptions are re-sent over `stream_tx`. +/// +/// Returns the task's [`JoinHandle`] so the owner can await clean exit. +pub(crate) fn spawn( + client: ChannelClient, + config: GrpcConfig, + consumer: ConsumerOptions, + subscriptions: Arc>>, + stream_tx: StreamTx, + shutdown: CancellationToken, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval_at( + tokio::time::Instant::now() + HEARTBEAT_INITIAL_DELAY, + HEARTBEAT_INTERVAL, + ); + // Default `MissedTickBehavior::Burst` mirrors Java's + // `scheduleAtFixedRate`: an overrun is followed by an immediate + // catch-up tick rather than a delayed one. + loop { + tokio::select! { + _ = interval.tick() => {} + _ = shutdown.cancelled() => return, + } + let items: Vec<(String, String)> = subscriptions + .lock() + .await + .iter() + .map(|((topic, url), _entry)| (topic.clone(), url.clone())) + .collect(); + if items.is_empty() { + debug!("heartbeat tick: no subscriptions yet"); + } else if let Ok(event) = codec::build_heartbeat(&config, consumer.group(), &items) { + // Bound the RPC and let shutdown interrupt it, so a network + // black-hole cannot keep the heartbeat task alive indefinitely. + let outcome = await_with_timeout_or_shutdown( + &shutdown, + config.request_timeout(), + client.heartbeat(event), + ) + .await; + match outcome { + OperationOutcome::Completed(Ok(resp)) => { + let response = codec::to_response(&resp); + if response.code == Some(StatusCode::CLIENT_RESUBSCRIBE as i64) { + warn!("server requested resubscribe (CLIENT_RESUBSCRIBE)"); + resubscribe( + &client, + &config, + &consumer, + &subscriptions, + &stream_tx, + &shutdown, + ) + .await; + if shutdown.is_cancelled() { + return; + } + } + debug!("heartbeat ok: {} items", items.len()); + } + OperationOutcome::Completed(Err(e)) => warn!("heartbeat failed: {e}"), + OperationOutcome::TimedOut => { + warn!("heartbeat timed out after {:?}", config.request_timeout()) + } + OperationOutcome::Cancelled => return, + } + } + } + }) +} + +/// Re-register all active subscriptions after the server signals +/// `CLIENT_RESUBSCRIBE`. +/// +/// Subscriptions are grouped by URL. Webhook groups (url != `SDK_STREAM_URL`) +/// are re-registered via the `subscribe` unary RPC. Stream groups +/// (url == `SDK_STREAM_URL`) are re-sent as a subscription CloudEvent through +/// the active stream sender. If no stream is currently open, a warning is +/// logged and the stream subscriptions are skipped (the user must re-call +/// `subscribe_stream`). +async fn resubscribe( + client: &ChannelClient, + config: &GrpcConfig, + consumer: &ConsumerOptions, + subscriptions: &Arc>>, + stream_tx: &StreamTx, + shutdown: &CancellationToken, +) { + // Collect and group subscriptions by URL. We hold the lock only briefly. + let groups: HashMap> = { + let guard = subscriptions.lock().await; + if guard.is_empty() { + return; + } + let mut groups: HashMap> = HashMap::new(); + for entry in guard.values() { + groups + .entry(entry.url.clone()) + .or_default() + .push(entry.item.clone()); + } + groups + }; + + info!("resubscribing {} group(s)", groups.len()); + + for (url, items) in groups { + let is_stream = url == SDK_STREAM_URL; + let event = match codec::build_subscription_event( + config, + consumer.group(), + EventMeshProtocolType::EventMeshMessage, + if is_stream { None } else { Some(&url) }, + &items, + ) { + Ok(e) => e, + Err(e) => { + warn!("resubscribe: failed to build subscription event for url={url}: {e}"); + continue; + } + }; + + if is_stream { + match stream_sender(stream_tx).await { + Some(tx) => { + // Reserve capacity before moving `event` into the channel. + // A timeout or shutdown therefore cancels only the wait + // for capacity. Crucially, the StreamTx mutex was released + // by `stream_sender` before this potentially long wait. + match await_with_timeout_or_shutdown( + shutdown, + config.request_timeout(), + tx.reserve(), + ) + .await + { + OperationOutcome::Completed(Ok(permit)) => { + permit.send(event); + debug!("resubscribe: re-sent {} stream subscriptions", items.len()); + } + OperationOutcome::Completed(Err(_)) => warn!( + "resubscribe: stream channel closed; \ + stream subscriptions will not be re-sent" + ), + OperationOutcome::TimedOut => warn!( + "resubscribe: stream channel stayed backpressured \ + for {:?}; stream subscriptions will not be re-sent", + config.request_timeout() + ), + OperationOutcome::Cancelled => return, + } + } + None => warn!( + "resubscribe: no active stream; \ + stream subscriptions will not be re-sent" + ), + } + } else { + match await_with_timeout_or_shutdown( + shutdown, + config.request_timeout(), + client.subscribe_webhook(event), + ) + .await + { + OperationOutcome::Completed(Ok(_)) => { + debug!("resubscribe: webhook re-registered for url={url}") + } + OperationOutcome::Completed(Err(e)) => { + warn!("resubscribe: webhook re-register failed for url={url}: {e}") + } + OperationOutcome::TimedOut => warn!( + "resubscribe: webhook re-register timed out for url={url} after {:?}", + config.request_timeout() + ), + OperationOutcome::Cancelled => return, + } + } + } +} + +#[cfg(test)] +mod tests { + use std::future::pending; + + use super::*; + + #[tokio::test] + async fn operation_wait_stops_on_shutdown() { + let shutdown = CancellationToken::new(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task_shutdown = shutdown.clone(); + let task = tokio::spawn(async move { + await_with_timeout_or_shutdown(&task_shutdown, Duration::from_secs(60), async move { + let _ = started_tx.send(()); + pending::<()>().await + }) + .await + }); + + started_rx.await.expect("operation should start"); + shutdown.cancel(); + + assert!(matches!( + task.await.expect("task should not panic"), + OperationOutcome::Cancelled + )); + } + + #[tokio::test(start_paused = true)] + async fn operation_wait_times_out() { + let shutdown = CancellationToken::new(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + await_with_timeout_or_shutdown(&shutdown, Duration::from_secs(1), async move { + let _ = started_tx.send(()); + pending::<()>().await + }) + .await + }); + + started_rx.await.expect("operation should start"); + tokio::time::advance(Duration::from_secs(1)).await; + + assert!(matches!( + task.await.expect("task should not panic"), + OperationOutcome::TimedOut + )); + } + + #[tokio::test] + async fn stream_sender_returns_a_snapshot_without_retaining_the_lock() { + let (tx, _rx) = mpsc::channel(1); + let stream_tx = Arc::new(Mutex::new(Some(tx))); + + assert!(stream_sender(&stream_tx).await.is_some()); + assert!(stream_tx.try_lock().is_ok()); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/mod.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/mod.rs new file mode 100644 index 0000000000..abe646aa74 --- /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 publishing, batch publishing, and request/reply. +//! - [`GrpcStreamConsumer`] opens a bidirectional stream and dispatches +//! delivered messages to a [`crate::MessageHandler`]. +//! - [`GrpcWebhookConsumer`] is a lightweight RPC-only client for webhook +//! subscriptions. +//! +//! Wire format is CloudEvents-protobuf; [`crate::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::ChannelClient; +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..0d0ada56eb --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/grpc/producer.rs @@ -0,0 +1,261 @@ +// 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::config::{GrpcConfig, ProducerOptions}; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, PublishResponse}; +use crate::transport::grpc::client::ChannelClient; +use crate::transport::grpc::codec; + +/// gRPC-based producer. +pub struct GrpcProducer { + client: ChannelClient, + config: GrpcConfig, + options: ProducerOptions, +} + +impl GrpcProducer { + /// Create a producer over an existing lazily connected gRPC client. + pub(crate) fn new( + client: ChannelClient, + config: GrpcConfig, + options: ProducerOptions, + ) -> Result { + options.validate()?; + Ok(Self { + client, + config, + options, + }) + } + + #[cfg(test)] + pub(crate) fn client(&self) -> &ChannelClient { + &self.client + } + + /// Publish a batch of native EventMesh messages. + pub(crate) async fn publish_message_batch( + &self, + messages: Vec, + ) -> Result { + let mut events = Vec::with_capacity(messages.len()); + for message in messages { + events.push(match message { + crate::message::Message::EventMesh(message) => { + message.validate_for_grpc_publish()?; + codec::from_event_mesh_message(&message, &self.config, self.options.group())? + } + #[cfg(feature = "cloud_events")] + crate::message::Message::CloudEvent(_) => { + return Err(EventMeshError::Unsupported( + "CloudEvents must use the CloudEvents batch path".into(), + )); + } + }); + } + let response = codec::to_response( + &self + .client + .batch_publish( + crate::proto_gen::PbCloudEventBatch { events }, + self.config.request_timeout(), + ) + .await?, + ); + ensure_success(response, "batch publish failed") + } +} + +#[cfg(feature = "cloud_events")] +impl GrpcProducer { + /// 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, self.options.group())?; + let resp = self + .client + .publish(ce, self.config.request_timeout()) + .await?; + let response = codec::to_response(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| "publish failed".into()), + }); + } + debug!("published CloudEvent id={:?}", event.id()); + Ok(response) + } + + /// Publish several native CloudEvents in a single gRPC batch RPC. + pub async fn publish_cloud_event_batch( + &self, + events: Vec, + ) -> Result { + if events.is_empty() { + return Err(EventMeshError::InvalidArgument( + "batch publish requires at least one CloudEvent".into(), + )); + } + let mut wire_events = Vec::with_capacity(events.len()); + for event in &events { + wire_events.push(codec::from_cloudevent( + event, + &self.config, + self.options.group(), + )?); + } + let resp = self + .client + .batch_publish( + crate::proto_gen::PbCloudEventBatch { + events: wire_events, + }, + self.config.request_timeout(), + ) + .await?; + let response = codec::to_response(&resp); + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "CloudEvents batch publish failed".into()), + }); + } + Ok(response) + } + + /// Send a native CloudEvent and wait for a native CloudEvent reply. + /// + /// `timeout` is applied as a gRPC deadline (see + /// [`ChannelClient::request_reply`]); expiry surfaces as + /// [`Error::Timeout`](crate::Error::Timeout). + pub async fn request_reply_cloud_event( + &self, + event: cloudevents::Event, + timeout: Duration, + ) -> Result { + let event = codec::from_cloudevent(&event, &self.config, self.options.group())?; + let response = self.client.request_reply(event, timeout).await?; + ensure_request_reply_success( + codec::to_response(&response), + "CloudEvents request/reply failed", + )?; + codec::to_cloudevent(response) + } +} + +fn ensure_success(response: PublishResponse, fallback: &str) -> Result { + if response.is_success() { + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response.message.unwrap_or_else(|| fallback.into()), + }) + } +} + +/// Successful request/reply RPCs return the business CloudEvent directly and +/// do not attach a `statuscode`. Error replies carry a non-zero status. +fn ensure_request_reply_success(response: PublishResponse, fallback: &str) -> Result<()> { + match response.code { + None | Some(0) => Ok(()), + Some(code) => Err(EventMeshError::Server { + code: code as i32, + message: response.message.unwrap_or_else(|| fallback.into()), + }), + } +} + +impl GrpcProducer { + /// Publish a native EventMesh message and wait for acknowledgement. + pub(crate) async fn publish(&self, message: EventMeshMessage) -> Result { + message.validate_for_grpc_publish()?; + let event = codec::from_event_mesh_message(&message, &self.config, self.options.group())?; + let resp = self + .client + .publish(event, self.config.request_timeout()) + .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) + } + + /// Send a native EventMesh request and await its reply. + pub(crate) async fn request_reply( + &self, + message: EventMeshMessage, + timeout: Duration, + ) -> Result { + message.validate_for_grpc_publish()?; + let event = codec::from_event_mesh_message(&message, &self.config, self.options.group())?; + let resp = self.client.request_reply(event, timeout).await?; + ensure_request_reply_success(codec::to_response(&resp), "request/reply failed")?; + codec::to_event_mesh_message(&resp) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn publish_validation_rejects_missing_topic_or_empty_content() { + assert!(EventMeshMessage::new("", "body").is_err()); + assert!(EventMeshMessage::new("topic", "") + .unwrap() + .validate_for_grpc_publish() + .is_err()); + assert!(EventMeshMessage::new("topic", " ") + .unwrap() + .validate_for_grpc_publish() + .is_ok()); + } + + #[test] + fn request_reply_accepts_statusless_business_reply() { + assert!( + ensure_request_reply_success(PublishResponse::new(None, None, None), "failed").is_ok() + ); + } + + #[test] + fn request_reply_rejects_explicit_error_status() { + let error = ensure_request_reply_success( + PublishResponse::new(Some(17), Some("broker rejected".into()), None), + "failed", + ) + .unwrap_err(); + assert!(matches!(error, EventMeshError::Server { code: 17, .. })); + } +} 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..425553f857 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/client.rs @@ -0,0 +1,327 @@ +// 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::{ConsumerOptions, HttpConfig, ProducerOptions}; +use crate::error::{EventMeshError, Result}; + +/// Default connection pool size (mirrors the Java SDK). +const DEFAULT_POOL_SIZE: usize = 30; +/// Default idle connection eviction (seconds). +const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 10; + +/// The resolved transport parameters shared by every HTTP role. +#[derive(Clone)] +pub(crate) struct HttpRole { + config: Arc, + selector: Arc, + /// Producer group stamped on publish bodies; empty for consumers. + producer_group: String, + /// Consumer group stamped on subscribe/heartbeat bodies; empty for + /// producers. + consumer_group: String, +} + +impl HttpRole { + /// Resolve a producer role's transport parameters. + pub(crate) fn producer(config: HttpConfig, options: &ProducerOptions) -> Result { + Self::build(config, options.group().to_string(), None) + } + + /// Resolve a consumer role's transport parameters. + pub(crate) fn consumer(config: HttpConfig, options: &ConsumerOptions) -> Result { + Self::build(config, String::new(), Some(options.group().to_string())) + } + + fn build( + config: HttpConfig, + producer_group: impl Into, + consumer_group: Option, + ) -> Result { + let selector = LoadBalanceSelector::new( + config + .endpoints() + .endpoints() + .iter() + .map(|endpoint| ServerNode { + host: endpoint.authority_host(), + port: endpoint.port(), + weight: endpoint.weight() as i32, + }) + .collect(), + config.load_balance().to_wire(), + )?; + Ok(Self { + config: Arc::new(config), + selector: Arc::new(selector), + producer_group: producer_group.into(), + consumer_group: consumer_group.unwrap_or_default(), + }) + } + + /// The public configuration. + pub(crate) fn config(&self) -> &HttpConfig { + &self.config + } + + /// The producer group of this role, if any. + pub(crate) fn producer_group(&self) -> &str { + &self.producer_group + } + + /// The consumer group of this role, if any. + pub(crate) fn consumer_group(&self) -> &str { + &self.consumer_group + } + + /// The request timeout for unary operations. + pub(crate) fn timeout(&self) -> Duration { + self.config.request_timeout() + } + + /// Pick the next server node via the configured load-balance strategy. + pub(crate) fn select_node(&self) -> &ServerNode { + self.selector.select() + } +} + +/// 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, + role: HttpRole, +} + +impl EventMeshHttpClient { + /// Validate the endpoint set and request client construction without + /// issuing network I/O. + pub(crate) fn validate(config: &HttpConfig) -> Result { + let client = Self::new(HttpRole::build(config.clone(), String::new(), None)?)?; + Ok(client) + } + + /// Build the request client for a resolved role. + pub(crate) fn new(role: HttpRole) -> Result { + let config = &role.config; + let mut builder = Client::builder() + .pool_max_idle_per_host(DEFAULT_POOL_SIZE) + .pool_idle_timeout(Some(Duration::from_secs(DEFAULT_IDLE_TIMEOUT_SECS))) + .tcp_nodelay(true); + + // EventMesh nodes are explicit SDK endpoints. Default to the Java + // SDK's direct connection behavior, while allowing applications to + // opt into reqwest's HTTP_PROXY/HTTPS_PROXY/NO_PROXY handling. + if !config.proxy_from_env() { + builder = builder.no_proxy(); + } + + if config.tls_enabled() { + builder = builder.https_only(true); + } + + let inner = builder + .build() + .map_err(|e| EventMeshError::Config(format!("reqwest client build error: {e}")))?; + + Ok(Self { inner, role }) + } + + /// Pick the next server node via the configured load-balance strategy. + pub fn select_node(&self) -> &ServerNode { + self.role.select_node() + } + + /// 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.role.config.tls_enabled() { + "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| { + if e.is_timeout() { + EventMeshError::Timeout(timeout) + } else { + EventMeshError::Http { + status: 0, + message: format!("request failed: {e}"), + } + } + })?; + + let status = resp.status().as_u16(); + let text = resp.text().await.map_err(|e| { + if e.is_timeout() { + EventMeshError::Timeout(timeout) + } else { + 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) + } + + /// The resolved role parameters. + pub(crate) fn role(&self) -> &HttpRole { + &self.role + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use axum::{extract::State, routing::post, Router}; + use tokio::net::TcpListener; + use tokio::sync::oneshot; + + use super::*; + use crate::config::{Endpoint, EndpointSet}; + + async fn start_node( + name: &'static str, + hits: Arc>>, + ) -> (u16, oneshot::Sender<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let app = Router::new() + .route( + "/", + post(move |State(hits): State>>>| async move { + hits.lock().await.push(name); + r#"{"retCode":0}"# + }), + ) + .with_state(hits); + tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + (port, shutdown_tx) + } + + #[tokio::test] + async fn weighted_round_robin_sends_requests_to_each_http_node() { + let hits = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let (first_port, first_shutdown) = start_node("first", Arc::clone(&hits)).await; + let (second_port, second_shutdown) = start_node("second", Arc::clone(&hits)).await; + let endpoints = EndpointSet::new([ + Endpoint::new("127.0.0.1", first_port) + .unwrap() + .with_weight(1) + .unwrap(), + Endpoint::new("127.0.0.1", second_port) + .unwrap() + .with_weight(1) + .unwrap(), + ]) + .unwrap(); + let config = HttpConfig::new(endpoints) + .with_load_balance(crate::config::LoadBalance::WeightedRoundRobin); + let client = EventMeshHttpClient::new( + HttpRole::producer(config, &ProducerOptions::new("g")).unwrap(), + ) + .unwrap(); + + for _ in 0..4 { + assert_eq!( + client + .post_form("/", &[], &[], Duration::from_secs(2)) + .await + .unwrap(), + r#"{"retCode":0}"# + ); + } + assert_eq!(*hits.lock().await, ["first", "second", "first", "second"]); + let _ = first_shutdown.send(()); + let _ = second_shutdown.send(()); + } + + #[tokio::test] + async fn request_timeout_uses_the_transport_independent_error_variant() { + 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 _connection = listener.accept().await.unwrap(); + std::future::pending::<()>().await; + }); + let endpoints = EndpointSet::new([Endpoint::new("127.0.0.1", port).unwrap()]).unwrap(); + let client = EventMeshHttpClient::new( + HttpRole::producer(HttpConfig::new(endpoints), &ProducerOptions::new("g")).unwrap(), + ) + .unwrap(); + let timeout = Duration::from_millis(25); + + let error = client.post_form("/", &[], &[], timeout).await.unwrap_err(); + + assert!(matches!(error, EventMeshError::Timeout(value) if value == timeout)); + server.abort(); + } +} 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..dcc072f3f9 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/codec.rs @@ -0,0 +1,876 @@ +// 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_message`] — decode it with the request +//! headers into a [`Message`], preserving its EventMesh or CloudEvents dialect. +//! - [`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). +//! +//! See the `http_consumer_custom` example for a complete, runnable version. + +use std::collections::HashMap; + +use http::HeaderMap; +use serde::{Deserialize, Serialize}; + +use crate::common::status_code::RequestCode; +use crate::common::util::RandomStringUtils; +#[cfg(test)] +use crate::common::ProtocolKey; +use crate::common::DEFAULT_MESSAGE_TTL; +use crate::config::{Credentials, Identity}; +use crate::error::{EventMeshError, Result}; +use crate::message::Message; +use crate::model::{EventMeshMessage, EventMeshProtocolType, PublishResponse}; +use crate::subscription::Subscription; + +/// 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 — use a path such as [`uri::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 = "/"; + /// Heartbeat — no dedicated path handler; any non-matching path works. + pub const HEARTBEAT: &str = "/eventmesh/heartbeat"; +} + +/// The JSON reply body returned by the EventMesh runtime for publish / +/// subscribe / heartbeat operations. +/// +/// Mirrors `org.apache.eventmesh.common.protocol.http.body.Body`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct EventMeshRetObj { + #[serde(rename = "retCode")] + pub ret_code: i64, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "retMsg")] + pub ret_msg: Option, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "resTime")] + pub res_time: Option, +} + +impl 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 { + /// EventMesh client acknowledgement code. + #[serde(rename = "retCode")] + pub ret_code: i32, + /// Optional acknowledgement description. + #[serde(skip_serializing_if = "Option::is_none", rename = "retMsg")] + pub ret_msg: Option, +} + +impl WebhookReply { + /// Return a successful-delivery acknowledgement. + pub fn ok() -> Self { + Self { + ret_code: crate::common::status_code::ClientRetCode::Ok as i32, + ret_msg: Some("OK".into()), + } + } + + /// Ask EventMesh to retry delivery with an explanatory message. + 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, + /// Optional business sequence number. + #[serde(default)] + pub bizseqno: Option, + /// Optional application-level unique ID. + #[serde(default, rename = "uniqueId")] + pub unique_id: Option, + /// Optional runtime-generated random number. + #[serde(default, rename = "randomNo")] + pub random_no: Option, + /// Optional destination topic. + #[serde(default)] + pub topic: Option, + /// JSON-encoded `Map` of extension attributes. + #[serde(default, rename = "extFields")] + pub extfields: Option, +} + +impl PushMessageRequestBody { + /// Decode a webhook delivery into the public [`Message`] envelope. + /// + /// Uses the `protocoltype` HTTP header, falling back to `extFields` when + /// the header is absent. If neither declares a protocol, the delivery is + /// treated as a native EventMesh message. This is the same decoder used + /// by the built-in webhook server. + /// + /// CloudEvents are preserved when the `cloud_events` feature is enabled; + /// otherwise a CloudEvent delivery returns [`crate::Error::Unsupported`]. + /// An invalid `protocoltype` header, malformed `extFields`, conflicting + /// protocol sources, and unknown protocols return [`crate::Error::Protocol`]. Invalid + /// CloudEvents JSON returns [`crate::Error::Codec`]. + /// + /// ``` + /// use eventmesh::http::codec::parse_push_body; + /// use http::HeaderMap; + /// + /// let headers = HeaderMap::new(); + /// let push = parse_push_body("topic=orders&content=created")?; + /// let message = push.to_message(&headers)?; + /// assert_eq!(message.as_event_mesh().unwrap().content(), "created"); + /// # Ok::<(), eventmesh::Error>(()) + /// ``` + pub fn to_message(&self, headers: &HeaderMap) -> Result { + let header_protocol_type = headers + .get("protocoltype") + .map(|value| { + value.to_str().map_err(|error| EventMeshError::Protocol { + transport: "http", + message: format!("invalid protocoltype header: {error}"), + }) + }) + .transpose()?; + let extension_protocol_type = self + .extfields + .as_deref() + .filter(|fields| !fields.trim().is_empty()) + .map(|fields| { + serde_json::from_str::>(fields).map_err(|error| { + EventMeshError::Protocol { + transport: "http", + message: format!("failed to parse extFields JSON: {error}"), + } + }) + }) + .transpose()? + .and_then(|fields| fields.get("protocoltype").cloned()); + + if let (Some(header), Some(extension)) = + (header_protocol_type, extension_protocol_type.as_deref()) + { + if header != extension { + return Err(EventMeshError::Protocol { + transport: "http", + message: format!( + "conflicting protocoltype values: header={header:?}, \ + extFields={extension:?}" + ), + }); + } + } + + // Runtime HTTP pushes created from an HttpCommand do not carry the + // original `protocoltype` as an HTTP header. They do retain all + // CloudEvent extensions in the form-level `extFields`, including + // `protocoltype`, so consult that field before applying the legacy + // native-message default. + let protocol_type = header_protocol_type + .or(extension_protocol_type.as_deref()) + .unwrap_or(EventMeshProtocolType::EventMeshMessage.as_str()); + + if protocol_type == EventMeshProtocolType::CloudEvents.as_str() { + #[cfg(feature = "cloud_events")] + { + return serde_json::from_str(&self.content) + .map(Message::CloudEvent) + .map_err(EventMeshError::Codec); + } + + #[cfg(not(feature = "cloud_events"))] + return Err(EventMeshError::Unsupported( + "received a CloudEvent without the 'cloud_events' feature enabled".into(), + )); + } + + if protocol_type != EventMeshProtocolType::EventMeshMessage.as_str() { + return Err(EventMeshError::Protocol { + transport: "http", + message: format!("unsupported protocoltype {protocol_type:?}"), + }); + } + + let mut message = self.to_event_mesh_message()?; + if let Some(context) = message.delivery_context.as_mut() { + for (key, value) in headers { + if let Ok(value) = value.to_str() { + context.insert_missing(key.as_str(), value.to_string()); + } + } + } + Ok(Message::EventMesh(message)) + } + + /// Decode the pushed body into an [`EventMeshMessage`]. + /// + /// This explicitly selects the native dialect. Use [`Self::to_message`] + /// to detect the dialect from the request headers and body metadata. + /// + /// The `content` field is **always** treated as the business payload — + /// the Runtime puts the original user payload there, not a serialized + /// `EventMeshMessage`. Message metadata (`topic`, `bizseqno`, + /// `uniqueId`, `extFields`) is taken from the form-level fields. Wire TTL + /// is extracted into the dedicated TTL field and excluded from properties; + /// a present value that cannot be parsed as i64 is rejected. + pub fn to_event_mesh_message(&self) -> Result { + let topic = self + .topic + .clone() + .ok_or_else(|| EventMeshError::InvalidMessage("topic is required".into()))?; + let mut props = HashMap::new(); + if let Some(ext) = &self.extfields { + let trimmed = ext.trim(); + if !trimmed.is_empty() { + props = serde_json::from_str(trimmed).map_err(|e| EventMeshError::Protocol { + transport: "http", + message: format!("failed to parse extFields JSON: {e}"), + })?; + } + } + + let mut builder = EventMeshMessage::builder() + .topic(topic) + .content(self.content.clone()); + if let Some(value) = &self.bizseqno { + builder = builder.biz_seq_no(value.clone()); + } + if let Some(value) = &self.unique_id { + builder = builder.unique_id(value.clone()); + } + crate::transport::decode_native_message(builder.build()?, props) + } +} + +// ---------- 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: &Identity, + credentials: &Credentials, +) -> Vec<(&'static str, String)> { + let mut headers = vec![ + ("code", code.to_string()), + ("env", identity.env().to_string()), + ("idc", identity.idc().to_string()), + ("sys", identity.system().to_string()), + ("pid", identity.process_id().to_string()), + ("ip", identity.ip().to_string()), + ("username", credentials.username().to_string()), + ("passwd", credentials.password().to_string()), + ("language", identity.language().to_string()), + ("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) = credentials.token() { + headers.push(("token", token.to_string())); + } + 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, producer_group: &str) -> Vec<(String, String)> { + let mut fields: Vec<(String, String)> = Vec::new(); + fields.push(("producergroup".into(), producer_group.to_string())); + fields.push(("topic".into(), msg.topic.clone())); + fields.push(("content".into(), msg.content.clone())); + // 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` 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()) + .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)); + // Only business extensions and explicitly mapped payload metadata enter + // extFields. Java merges this map after the current request's headers. + let mut extensions: HashMap<&str, &str> = msg + .props + .iter() + .filter(|(key, _)| !crate::model::delivery::is_reserved_property(key)) + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + if let Some(content_type) = msg.data_content_type() { + extensions.insert("datacontenttype", content_type); + } + if !extensions.is_empty() { + fields.push(( + "extFields".into(), + serde_json::to_string(&extensions).unwrap_or_default(), + )); + } + fields +} + +/// Encode subscribe body fields. +pub fn encode_subscribe( + items: &[Subscription], + url: &str, + consumer_group: &str, +) -> Vec<(String, String)> { + vec![ + ("consumerGroup".into(), consumer_group.to_string()), + ( + "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, + consumer_group: &str, +) -> Vec<(String, String)> { + vec![ + ("consumerGroup".into(), consumer_group.to_string()), + ( + "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)], consumer_group: &str) -> 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(), consumer_group.to_string()), + ("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()) +} + +/// Form-encode a list of `(key, value)` pairs into a URL-encoded body string. +#[cfg(test)] +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 +} + +pub fn subscribe_code() -> i32 { + RequestCode::SUBSCRIBE +} + +pub fn unsubscribe_code() -> i32 { + RequestCode::UNSUBSCRIBE +} + +pub fn heartbeat_code() -> i32 { + RequestCode::HEARTBEAT +} + +/// Decode a webhook push body (form-urlencoded) into fields. +pub fn parse_push_body(body: &str) -> Result { + serde_urlencoded::from_str(body).map_err(|e| EventMeshError::Protocol { + transport: "http", + message: format!("form decode error: {e}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Credentials, Identity}; + + fn identity() -> Identity { + Identity::default() + } + + fn credentials() -> Credentials { + Credentials::new() + } + + #[test] + fn encode_publish_round_trip() { + let msg = EventMeshMessage::builder() + .topic("test-topic") + .content("hello") + .biz_seq_no("seq-1") + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + 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 msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + 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 msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .biz_seq_no("my-seq") + .unique_id("my-uid") + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + 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 msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + 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 msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .prop("key1", "val1") + .prop("key2", "val2") + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + let map: HashMap = fields.into_iter().collect(); + let ext = map.get("extFields").expect("extFields should be present"); + let props: HashMap = serde_json::from_str(ext).unwrap(); + assert_eq!(props.get("key1"), Some(&"val1".to_string())); + assert_eq!(props.get("key2"), Some(&"val2".to_string())); + } + + #[test] + fn encode_publish_filters_reserved_keys_from_ext_fields() { + let mut msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .ttl_millis(7_000) + .biz_seq_no("my-seq") + .unique_id("my-uid") + .prop("key1", "val1") + .build() + .unwrap(); + // Exercise the encoder guard against malformed crate-private state. + msg.props.insert("ttl".into(), "99000".into()); + msg.props.insert("bizseqno".into(), "stale-seq".into()); + msg.props.insert("uniqueid".into(), "stale-uid".into()); + msg.props.insert("topic".into(), "stale-topic".into()); + msg.props.insert("content".into(), "stale-content".into()); + msg.props + .insert("producergroup".into(), "stale-group".into()); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + let map: HashMap = fields.into_iter().collect(); + let ext = map.get("extFields").expect("extFields should be present"); + let props: HashMap = serde_json::from_str(ext).unwrap(); + // Non-reserved keys survive. + assert_eq!(props.get("key1"), Some(&"val1".to_string())); + // Reserved keys are filtered out — they are already emitted as typed + // form fields and must not reverse-overwrite via extFields. + assert!(!props.contains_key("ttl")); + assert!(!props.contains_key("bizseqno")); + assert!(!props.contains_key("uniqueid")); + assert!(!props.contains_key("topic")); + assert!(!props.contains_key("content")); + assert!(!props.contains_key("producergroup")); + } + + #[test] + fn encode_publish_omits_ext_fields_when_all_props_filtered() { + let mut msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .build() + .unwrap(); + // Exercise the encoder guard against malformed crate-private state. + msg.props.insert("ttl".into(), "99000".into()); + msg.props.insert("bizseqno".into(), "stale".into()); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + // All props were reserved keys → no extFields field should be emitted. + assert!(!fields.iter().any(|(k, _)| k == "extFields")); + } + + #[test] + fn encode_publish_omits_ext_fields_when_empty() { + let msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + 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 msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + 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 msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .ttl_millis(30_000) + .build() + .unwrap(); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + let map: HashMap = fields.into_iter().collect(); + assert_eq!(map.get("ttl"), Some(&"30000".to_string())); + } + + #[test] + fn encode_publish_ignores_ttl_prop_when_field_unset() { + // Generic properties never configure native-message TTL. + let mut msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .build() + .unwrap(); + // Exercise the encoder guard against malformed crate-private state. + msg.props.insert(ProtocolKey::TTL.into(), "99000".into()); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + let map: HashMap = fields.into_iter().collect(); + assert_eq!(map.get("ttl"), Some(&DEFAULT_MESSAGE_TTL.to_string())); + } + + #[test] + fn encode_publish_typed_ttl_takes_precedence_over_prop() { + let mut msg = EventMeshMessage::builder() + .topic("t") + .content("c") + .ttl_millis(7_000) + .build() + .unwrap(); + // Exercise the encoder guard against malformed crate-private state. + msg.props.insert(ProtocolKey::TTL.into(), "99000".into()); + let fields = encode_publish(&msg, "DefaultProducerGroup"); + 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 headers = build_headers( + RequestCode::MSG_SEND_ASYNC, + EventMeshProtocolType::EventMeshMessage, + &identity(), + &Credentials::new().with_token("my-jwt"), + ); + 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 headers = build_headers( + RequestCode::MSG_SEND_ASYNC, + EventMeshProtocolType::EventMeshMessage, + &identity(), + &credentials(), + ); + assert!(!headers.iter().any(|(k, _)| *k == "token")); + } + + #[test] + fn parse_response_success() { + let body = r#"{"retCode":0,"retMsg":"success","resTime":42}"#; + let resp = parse_response(body).unwrap(); + assert!(resp.is_success()); + assert_eq!(resp.time, Some(42)); + } + + #[test] + fn parse_response_missing_ret_code_is_error() { + let body = r#"{"retMsg":"oops"}"#; + assert!(parse_response(body).is_err()); + } + + #[test] + fn parse_response_empty_object_is_error() { + assert!(parse_response("{}").is_err()); + } + + #[test] + fn parse_response_non_numeric_ret_code_is_error() { + let body = r#"{"retCode":"abc"}"#; + assert!(parse_response(body).is_err()); + } + + #[test] + fn parse_push_body_form_urlencoded() { + let body = "content=hello&topic=test-topic&bizseqno=seq1"; + let parsed = parse_push_body(body).unwrap(); + assert_eq!(parsed.content, "hello"); + assert_eq!(parsed.topic.as_deref(), Some("test-topic")); + } + + #[test] + fn push_body_to_message_with_json_content() { + // The Runtime puts the *business payload* in `content`, not a + // serialized EventMeshMessage. A JSON payload that happens to + // contain a `create_time` field must NOT be misinterpreted as a + // full EventMeshMessage — it must be preserved verbatim and the + // form-level metadata (topic, bizseqno, extFields) must be applied. + let business_json = r#"{"create_time":123,"order_id":"x"}"#; + let body = form_encode(&[ + ("content".to_string(), business_json.to_string()), + ("topic".to_string(), "test-topic".to_string()), + ("bizseqno".to_string(), "seq-1".to_string()), + ]); + let parsed = parse_push_body(&body).unwrap(); + let msg = parsed.to_event_mesh_message().unwrap(); + assert_eq!(msg.content(), business_json); + assert_eq!(msg.topic(), "test-topic"); + assert_eq!(msg.biz_seq_no.as_deref(), Some("seq-1")); + } + + #[test] + fn push_body_preserves_empty_content_and_transport_specific_ttl() { + let body = form_encode(&[ + ("content".to_string(), String::new()), + ("topic".to_string(), "test-topic".to_string()), + ( + "extFields".to_string(), + r#"{"ttl":"2147483648","custom":"value"}"#.to_string(), + ), + ]); + let msg = parse_push_body(&body) + .and_then(|body| body.to_event_mesh_message()) + .unwrap(); + assert_eq!(msg.content(), ""); + assert_eq!(msg.ttl_millis(), Some(2_147_483_648)); + assert_eq!(msg.get_prop(ProtocolKey::TTL), None); + assert_eq!(msg.get_prop("custom"), Some("value")); + } + + #[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()), + ("topic".to_string(), "orders".to_string()), + ("extFields".to_string(), props_json.to_string()), + ]); + let parsed = parse_push_body(&body).unwrap(); + assert_eq!(parsed.extfields.as_deref(), Some(props_json)); + let msg = parsed.to_event_mesh_message().unwrap(); + assert_eq!(msg.get_prop("prop1"), Some("val1")); + assert_eq!(msg.get_prop("prop2"), Some("val2")); + } + + #[test] + fn push_body_without_ext_fields() { + let body = "content=hello&topic=t"; + let parsed = parse_push_body(body).unwrap(); + assert!(parsed.extfields.is_none()); + let msg = parsed.to_event_mesh_message().unwrap(); + assert!(msg.props.is_empty()); + } + + #[test] + fn push_body_invalid_ext_fields_returns_error() { + let body = form_encode(&[ + ("content".to_string(), "hello".to_string()), + ("extFields".to_string(), "not valid json".to_string()), + ]); + let parsed = parse_push_body(&body).unwrap(); + assert!(parsed.to_event_mesh_message().is_err()); + } + + #[test] + fn form_encode_special_chars() { + let fields = vec![("key".to_string(), "val ue".to_string())]; + let encoded = form_encode(&fields); + // serde_urlencoded encodes spaces as '+'. + assert!(encoded.contains("key=val+ue")); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/consumer.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/consumer.rs new file mode 100644 index 0000000000..6512f4c519 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/consumer.rs @@ -0,0 +1,433 @@ +// 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::{ConsumerOptions, HttpConfig}; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshProtocolType, PublishResponse}; +use crate::subscription::{DeliveryType, Subscription}; +use crate::transport::http::client::{EventMeshHttpClient, HttpRole}; +use crate::transport::http::codec::{self, uri}; +use crate::transport::task::BackgroundTask; + +/// 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 { + item: Subscription, + 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::request_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 [`request_shutdown`](Self::request_shutdown) or drop. + pub fn new( + config: HttpConfig, + options: &ConsumerOptions, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + let runtime = tokio::runtime::Handle::try_current().map_err(|_| { + EventMeshError::Config( + "HTTP consumer construction requires an active Tokio runtime".into(), + ) + })?; + let client = EventMeshHttpClient::new(HttpRole::consumer(config, options)?)?; + let subscriptions = Arc::new(Mutex::new(HashMap::new())); + let shutdown = CancellationToken::new(); + + // Signal watcher. + if let Some(signal) = shutdown_signal { + let token = shutdown.clone(); + runtime.spawn(async move { + tokio::select! { + _ = signal => token.cancel(), + _ = token.cancelled() => {} + } + }); + } + + let heartbeat_handle = spawn_heartbeat( + &runtime, + client.clone(), + Arc::clone(&subscriptions), + shutdown.clone(), + ); + + Ok(Self { + client, + subscriptions, + shutdown, + heartbeat_handle: Mutex::new(BackgroundTask::new(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.delivery_type == DeliveryType::Sync) { + return Err(EventMeshError::InvalidArgument( + "HTTP transport does not support SYNC (request/reply) subscriptions; \ + use the gRPC transport for request/reply" + .into(), + )); + } + let role = self.client.role(); + let config = role.config(); + let body = codec::encode_subscribe(&items, &url, role.consumer_group()); + let code = codec::subscribe_code(); + let headers = codec::build_headers( + code, + EventMeshProtocolType::EventMeshMessage, + config.identity(), + config.credentials(), + ); + let timeout = role.timeout(); + let text = self + .client + .post_form(uri::ROOT, &body, &headers, timeout) + .await?; + let response = codec::parse_response(&text)?; + if response.is_success() { + let mut guard = self.subscriptions.lock().await; + for item in items { + guard.insert( + (item.topic.clone(), url.clone()), + SubscriptionEntry { + item, + url: url.clone(), + }, + ); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "subscribe failed".into()), + }) + } + } + + /// Signal the heartbeat task to stop. + pub fn request_shutdown(&self) { + self.shutdown.cancel(); + } + + /// 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) -> Result<()> { + let mut task = self.heartbeat_handle.lock().await; + task.wait().await; + self.shutdown.cancel(); + task.take_result().unwrap_or(Ok(())).map_err(|error| { + EventMeshError::ChannelClosed(format!("HTTP heartbeat task panicked: {error}")) + }) + } +} + +impl HttpConsumer { + /// Unsubscribe topics from one webhook URL. + pub async fn unsubscribe( + &self, + items: Vec, + url: impl Into, + ) -> Result { + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "unsubscribe items must not be empty".into(), + )); + } + let url = url.into(); + let topics: Vec = items.iter().map(|item| item.topic.clone()).collect(); + { + let guard = self.subscriptions.lock().await; + if let Some(topic) = topics + .iter() + .find(|topic| !guard.contains_key(&(topic.to_string(), url.clone()))) + { + return Err(EventMeshError::InvalidArgument(format!( + "topic {topic:?} is not subscribed to webhook URL {url:?}" + ))); + } + } + let role = self.client.role(); + let config = role.config(); + let code = codec::unsubscribe_code(); + let headers = codec::build_headers( + code, + EventMeshProtocolType::EventMeshMessage, + config.identity(), + config.credentials(), + ); + let timeout = role.timeout(); + let body = codec::encode_unsubscribe(&topics, &url, role.consumer_group()); + 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, url.clone())); + } + Ok(response) + } else { + Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }) + } + } + + /// Remove every locally tracked webhook registration. + /// + /// Registrations are grouped by callback URL because the HTTP protocol + /// requires the original URL when unsubscribing. + pub(crate) async fn unsubscribe_all(&self) -> Result<()> { + let registrations = { + let guard = self.subscriptions.lock().await; + let mut grouped: HashMap> = HashMap::new(); + for entry in guard.values() { + grouped + .entry(entry.url.clone()) + .or_default() + .push(entry.item.clone()); + } + grouped + }; + + let mut first_error = None; + for (url, items) in registrations { + if let Err(error) = self.unsubscribe(items, url).await { + if first_error.is_none() { + first_error = Some(error); + } + } + } + first_error.map_or(Ok(()), Err) + } +} + +impl Drop for HttpConsumer { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +/// Spawn the heartbeat loop. Reads the consumer's subscriptions each tick and +/// reports them to the broker. +fn spawn_heartbeat( + runtime: &tokio::runtime::Handle, + client: EventMeshHttpClient, + subscriptions: Arc>>, + shutdown: CancellationToken, +) -> JoinHandle<()> { + runtime.spawn(async move { + tokio::select! { + _ = tokio::time::sleep(HEARTBEAT_INITIAL_DELAY) => {} + _ = shutdown.cancelled() => return, + } + loop { + let items: Vec<(String, String)> = subscriptions + .lock() + .await + .iter() + .map(|((topic, url), _entry)| (topic.clone(), url.clone())) + .collect(); + if !items.is_empty() { + let role = client.role(); + let config = role.config(); + let body = codec::encode_heartbeat(&items, role.consumer_group()); + let code = codec::heartbeat_code(); + let headers = codec::build_headers( + code, + EventMeshProtocolType::EventMeshMessage, + config.identity(), + config.credentials(), + ); + let timeout = role.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::*; + + fn make_consumer() -> HttpConsumer { + let endpoints = + crate::config::EndpointSet::new([ + crate::config::Endpoint::new("127.0.0.1", 10_105).unwrap() + ]) + .unwrap(); + HttpConsumer::new( + HttpConfig::new(endpoints), + &ConsumerOptions::new("test-group"), + None::>, + ) + .unwrap() + } + + #[tokio::test(start_paused = true)] + async fn cancelled_join_preserves_http_heartbeat_panic() { + let consumer = make_consumer(); + let (release, released) = tokio::sync::oneshot::channel::<()>(); + *consumer.heartbeat_handle.lock().await = BackgroundTask::new(tokio::spawn(async move { + released.await.unwrap(); + panic!("heartbeat regression"); + })); + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .is_err() + ); + consumer.request_shutdown(); + } + release.send(()).unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), consumer.wait_for_shutdown()) + .await + .unwrap(); + assert!(matches!(result, Err(EventMeshError::ChannelClosed(message)) + if message.contains("heartbeat regression"))); + } + + #[tokio::test] + async fn subscribe_webhook_rejects_sync_only() { + let consumer = make_consumer(); + let item = Subscription::new("sync-topic").with_delivery_type(DeliveryType::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![ + Subscription::new("async-topic"), + Subscription::new("sync-topic").with_delivery_type(DeliveryType::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..5312f3dc05 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/mod.rs @@ -0,0 +1,56 @@ +// 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 [`HttpProducer`] 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 protocol-level HTTP consumer registers a webhook URL with the runtime +//! and sends heartbeats. The public façade normally combines it with a bound, +//! background axum server; applications that own their endpoint can use the +//! registration API and these lower-level codec helpers. +//! +//! 1. **Built-in server** — [`WebhookServer`] can bind before its URL is +//! registered, eliminating the callback startup race. +//! 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::to_message`], +//! [`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..735ade260b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/producer.rs @@ -0,0 +1,231 @@ +// 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 tracing::debug; + +use crate::config::{HttpConfig, ProducerOptions}; +use crate::error::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, EventMeshProtocolType, PublishResponse}; +use crate::transport::http::client::{EventMeshHttpClient, HttpRole}; +use crate::transport::http::codec::{self, uri}; + +/// HTTP-based producer. +/// +/// Implements publishing over the EventMesh HTTP protocol. +pub struct HttpProducer { + client: EventMeshHttpClient, +} + +impl HttpProducer { + /// Create a producer from the public config and producer options. + pub fn new(config: HttpConfig, options: &ProducerOptions) -> Result { + let client = EventMeshHttpClient::new(HttpRole::producer(config, options)?)?; + Ok(Self { client }) + } + + /// Publish a native CloudEvent (JSON-serialized) behind the `cloud_events` + /// feature. The CloudEvent's `subject` is used as the topic. + #[cfg(feature = "cloud_events")] + pub async fn publish_cloud_event( + &self, + mut event: cloudevents::Event, + ) -> Result { + use cloudevents::AttributesReader; + + // The runtime's CloudEvents HTTP resolver reads bizseqno/uniqueid from + // CloudEvent extension attributes — NOT from the form fields generated by + // encode_publish — and rejects the request with EVENTMESH_PROTOCOL_BODY_ERR + // when either is missing. Populate them before serializing. + ensure_ce_extension(&mut event, "bizseqno"); + ensure_ce_extension(&mut event, "uniqueid"); + ensure_ce_ttl(&mut event); + + let topic = event + .subject() + .ok_or_else(|| { + EventMeshError::InvalidMessage("CloudEvent subject (topic) is required".into()) + })? + .to_string(); + let json = serde_json::to_string(&event)?; + let msg = EventMeshMessage::builder() + .topic(topic) + .content(json) + .build()?; + self.publish_with_protocol(msg, EventMeshProtocolType::CloudEvents) + .await + } + + /// Internal publish with a specific protocol type. + async fn publish_with_protocol( + &self, + message: EventMeshMessage, + protocol_type: EventMeshProtocolType, + ) -> Result { + message.validate_for_publish()?; + let role = self.client.role(); + let config = role.config(); + let body = codec::encode_publish(&message, role.producer_group()); + let code = codec::publish_code(); + let headers = + codec::build_headers(code, protocol_type, config.identity(), config.credentials()); + let timeout = role.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) + } + + /// Publish a native EventMesh message and wait for acknowledgement. + pub(crate) async fn publish(&self, message: EventMeshMessage) -> Result { + self.publish_with_protocol(message, EventMeshProtocolType::EventMeshMessage) + .await + } +} + +/// Ensure a CloudEvent extension attribute is present and non-blank, +/// generating a random 30-digit numeric value if the caller did not supply one. +/// +/// The runtime's CloudEvents HTTP resolver does not bridge `bizseqno` or +/// `uniqueid` from the request form fields into CloudEvent extensions (only +/// `producergroup` is bridged), so they must be embedded inside the JSON +/// content before serialization. +#[cfg(feature = "cloud_events")] +fn ensure_ce_extension(event: &mut cloudevents::Event, key: &str) { + use crate::common::util::RandomStringUtils; + + let missing = match event.extension(key) { + None => true, + Some(v) => v.to_string().trim().is_empty(), + }; + if missing { + event.set_extension(key, RandomStringUtils::generate_num(30)); + } +} + +/// Ensure a CloudEvent contains the TTL extension required by the HTTP sync +/// request processor. Unlike the form field emitted by `encode_publish`, the +/// CloudEvents resolver reads TTL only from the serialized event. +#[cfg(feature = "cloud_events")] +fn ensure_ce_ttl(event: &mut cloudevents::Event) { + use crate::common::DEFAULT_MESSAGE_TTL; + + let missing = match event.extension("ttl") { + None => true, + Some(value) => value.to_string().trim().is_empty(), + }; + if missing { + event.set_extension("ttl", DEFAULT_MESSAGE_TTL.to_string()); + } +} + +#[cfg(all(test, feature = "cloud_events"))] +mod tests { + use super::*; + + fn make_event() -> cloudevents::Event { + use cloudevents::{EventBuilder, EventBuilderV10}; + EventBuilderV10::new() + .id("test-id") + .source("urn:test") + .ty("test-type") + .subject("test-topic") + .data("text/plain", "hello") + .build() + .unwrap() + } + + #[test] + fn ensure_ce_extension_generates_when_missing() { + let mut event = make_event(); + assert!(event.extension("bizseqno").is_none()); + ensure_ce_extension(&mut event, "bizseqno"); + let v = event + .extension("bizseqno") + .expect("extension should be set"); + let s = v.to_string(); + assert!(!s.is_empty()); + assert!(s.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn ensure_ce_extension_preserves_existing_value() { + let mut event = make_event(); + event.set_extension("bizseqno", "caller-supplied-seq".to_string()); + ensure_ce_extension(&mut event, "bizseqno"); + assert_eq!( + event.extension("bizseqno").unwrap().to_string(), + "caller-supplied-seq" + ); + } + + #[test] + fn ensure_ce_extension_overwrites_blank_value() { + let mut event = make_event(); + event.set_extension("uniqueid", "".to_string()); + ensure_ce_extension(&mut event, "uniqueid"); + let v = event.extension("uniqueid").unwrap().to_string(); + assert!(!v.is_empty()); + } + + #[test] + fn ensure_ce_ttl_defaults_when_missing_and_preserves_caller_value() { + let mut event = make_event(); + ensure_ce_ttl(&mut event); + assert_eq!( + event.extension("ttl").unwrap().to_string(), + crate::common::DEFAULT_MESSAGE_TTL.to_string() + ); + + event.set_extension("ttl", "9000".to_string()); + ensure_ce_ttl(&mut event); + assert_eq!(event.extension("ttl").unwrap().to_string(), "9000"); + } + + #[test] + fn publish_cloud_event_serializes_extensions() { + // Verify that after ensure_ce_extension runs, the serialized JSON + // contains the extension attributes — this is what the runtime reads. + let mut event = make_event(); + ensure_ce_extension(&mut event, "bizseqno"); + ensure_ce_extension(&mut event, "uniqueid"); + ensure_ce_ttl(&mut event); + let json = serde_json::to_string(&event).unwrap(); + assert!( + json.contains("bizseqno"), + "serialized CloudEvent must contain bizseqno extension: {json}" + ); + assert!( + json.contains("uniqueid"), + "serialized CloudEvent must contain uniqueid extension: {json}" + ); + assert!( + json.contains("\"ttl\":\"4000\""), + "serialized CloudEvent must contain ttl extension: {json}" + ); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/server.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/server.rs new file mode 100644 index 0000000000..b4ca27abc5 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/server.rs @@ -0,0 +1,173 @@ +// 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). +//! +//! Receives EventMesh pushes and dispatches them to [`MessageHandler`]. The +//! public [`crate::http::HttpClient::consumer`] API manages this server with +//! its subscriptions, or applications can bind [`crate::webhook::WebhookServer`] +//! separately. See `examples/http/consumer_server.rs` for the managed lifecycle. + +use std::future::{Future, IntoFuture}; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; + +use axum::{routing::post, Router}; +use tracing::info; + +use crate::error::{EventMeshError, Result}; +use crate::transport::http::webhook::{WebhookHandler, WebhookState}; +use crate::MessageHandler; + +/// Default path the webhook server listens on. +pub const DEFAULT_WEBHOOK_PATH: &str = "/eventmesh/callback"; + +/// A built-in axum-based webhook server. +/// +/// Bind with [`WebhookServer::bind`], 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, + listener: Option, + path: String, + advertise_url: Option, + shutdown: Option + Send + 'static>>>, +} + +impl WebhookServer { + /// Bind `addr` before returning, so callers can safely register the webhook + /// URL without a connection-refused window. + pub async fn bind(addr: SocketAddr, listener: Arc) -> Result + where + L: MessageHandler, + { + Self::bind_with_path(addr, listener, DEFAULT_WEBHOOK_PATH).await + } + + /// Like [`WebhookServer::bind`] but with a custom webhook path. + pub async fn bind_with_path(addr: SocketAddr, listener: Arc, path: &str) -> Result + where + L: MessageHandler, + { + let socket = tokio::net::TcpListener::bind(addr) + .await + .map_err(EventMeshError::Io)?; + let bound_addr = socket.local_addr().map_err(EventMeshError::Io)?; + let mut server = Self::with_path(addr, listener, path); + server.addr = bound_addr; + server.listener = Some(socket); + Ok(server) + } + + /// Assemble the router before attaching the bound callback socket. + fn with_path(addr: SocketAddr, listener: Arc, path: &str) -> Self + where + L: MessageHandler, + { + let state = WebhookState::new(listener); + let router = Router::new() + .route(path, post(WebhookHandler::handle)) + .with_state(state); + Self { + router, + addr, + listener: None, + 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 + } + + /// 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, + listener, + path, + advertise_url: _, + shutdown, + } = self; + + Box::pin(async move { + let listener = match listener { + Some(listener) => listener, + None => tokio::net::TcpListener::bind(addr) + .await + .map_err(EventMeshError::Io)?, + }; + let bound_addr = listener.local_addr().map_err(EventMeshError::Io)?; + info!("webhook server listening on http://{bound_addr}{path}"); + + let serve = axum::serve(listener, router); + let result = if let Some(signal) = shutdown { + serve.with_graceful_shutdown(signal).await + } else { + serve.await + }; + result.map_err(|e| EventMeshError::Protocol { + transport: "http", + message: format!("webhook server error: {e}"), + })?; + Ok(()) + }) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/webhook.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/http/webhook.rs new file mode 100644 index 0000000000..a43c9d73ce --- /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 +//! [`MessageHandler`] 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::transport::http::codec::{parse_push_body, WebhookReply}; +use crate::MessageHandler; + +/// 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_message(&headers) { + Ok(m) => m, + Err(e) => { + error!("webhook message decode error: {e}"); + return Json(WebhookReply::retry("message decode error")).into_response(); + } + }; + + debug!("webhook received a message"); + + match state.listener.handle(msg).await { + Ok(Some(reply)) => { + // The listener produced a reply, but the HTTP webhook transport + // cannot deliver it: the runtime's protocol adaptor does not + // support REPLY_MESSAGE (code 301) on the CloudEvents path, so + // there is no wire path to route the reply back to the original + // requester. SYNC subscriptions are rejected at subscribe time; + // this warning is a defensive backstop for messages pushed from + // a non-Rust consumer or a legacy subscription. + warn!( + "listener produced a reply (type={}) but the HTTP webhook \ + transport cannot deliver replies; use the gRPC transport for \ + request/reply", + std::any::type_name_of_val(&reply) + ); + Json(WebhookReply::ok()).into_response() + } + Ok(None) => Json(WebhookReply::ok()).into_response(), + Err(error) => { + warn!(%error, "webhook handler failed; requesting redelivery"); + Json(WebhookReply::retry("handler failed")).into_response() + } + } + } +} 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..92e84de4b0 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/mod.rs @@ -0,0 +1,404 @@ +// 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. + +//! Private transport implementations and wire codecs. +//! +//! Public clients delegate to concrete producer and consumer types. Each +//! transport exposes only the operations supported by its wire protocol. + +pub(crate) mod task; + +#[cfg(feature = "grpc")] +pub mod grpc; + +#[cfg(feature = "http")] +pub mod http; + +#[cfg(feature = "tcp")] +pub mod tcp; + +/// Decode native wire attributes into their owning fields. Business properties +/// are the remainder; protocol/routing context can never be published as props. +pub(crate) fn decode_native_message( + mut message: crate::EventMeshMessage, + mut attributes: std::collections::HashMap, +) -> crate::Result { + message.ttl = take_wire_ttl(&mut attributes)?; + let sequence = attributes + .remove("bizseqno") + .or(attributes.remove("seqnum")); + let unique_id = attributes.remove("uniqueid"); + if message.biz_seq_no.is_none() { + message.biz_seq_no = sequence; + } + if message.unique_id.is_none() { + message.unique_id = unique_id; + } + message.data_content_type = attributes.remove("datacontenttype"); + // Topic and content already came from each protocol's authoritative fields. + for key in ["topic", "subject", "content"] { + attributes.remove(key); + } + message.delivery_context = Some(Box::new(crate::DeliveryContext::take_from(&mut attributes))); + message.props = attributes; + Ok(message) +} + +/// Extract native-message TTL from wire attributes without duplicating it in +/// the business model's extension properties. Inbound values need only fit i64; +/// publishing applies the outbound range limits separately. +pub(crate) fn take_wire_ttl( + attributes: &mut std::collections::HashMap, +) -> crate::Result> { + attributes + .remove(crate::common::ProtocolKey::TTL) + .map(|value| { + value.parse().map_err(|_| { + crate::Error::InvalidMessage( + "wire ttl must be an integer number of milliseconds fitting in i64".into(), + ) + }) + }) + .transpose() +} + +#[cfg(all(test, feature = "grpc", feature = "http", feature = "tcp"))] +mod forwarding_tests { + use super::{grpc, http, tcp}; + use crate::config::{Credentials, Endpoint, GrpcConfig, Identity}; + use crate::model::EventMeshProtocolType; + use crate::proto_gen::attr_as_str; + use crate::EventMeshMessage; + use std::collections::HashMap; + + #[test] + fn forwarding_rebuilds_transport_metadata_without_changing_the_received_message() { + let source_config = GrpcConfig::new(Endpoint::new("127.0.0.1", 10205).unwrap()) + .with_identity(Identity::default().with_system("source-system")) + .with_credentials( + Credentials::new() + .with_basic("source-user", "source-password") + .with_token("source-token"), + ); + let original = EventMeshMessage::builder() + .topic("orders") + .content("payload") + .biz_seq_no("business-id") + .unique_id("unique-id") + .ttl_millis(7000) + .prop("custom", "business-value") + .prop("tag", "order-created") + .build() + .unwrap(); + let wire = grpc::codec::from_event_mesh_message(&original, &source_config, "source-group") + .unwrap(); + let received = grpc::codec::to_event_mesh_message(&wire).unwrap(); + let before = received.clone(); + assert_eq!(received.properties(), original.properties()); + assert_eq!(received.data_content_type(), Some("text/plain")); + let debug = format!("{received:?}"); + assert!(!debug.contains("source-password")); + assert!(!debug.contains("source-token")); + assert_eq!(received.get_prop("protocoldesc"), None); + assert_eq!( + received.delivery_context().unwrap().protocol_description(), + Some("grpc-cloud-event") + ); + + let identity = Identity::default().with_system("destination-system"); + let credentials = Credentials::new().with_basic("destination-user", "destination-password"); + let mut http_attributes: HashMap = http::codec::build_headers( + http::codec::publish_code(), + EventMeshProtocolType::EventMeshMessage, + &identity, + &credentials, + ) + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(); + let http_fields: HashMap<_, _> = + http::codec::encode_publish(&received, "destination-group") + .into_iter() + .collect(); + // Java's resolver applies extFields after the request's own metadata. + let extensions: HashMap = + serde_json::from_str(&http_fields["extFields"]).unwrap(); + http_attributes.extend(extensions); + assert_eq!(http_attributes["protocoldesc"], "http"); + assert_eq!(http_attributes["protocoltype"], "eventmeshmessage"); + assert_eq!(http_attributes["sys"], "destination-system"); + assert_eq!(http_attributes["username"], "destination-user"); + assert_eq!(http_attributes["passwd"], "destination-password"); + assert!(!http_attributes.contains_key("token")); + assert_eq!(http_fields["ttl"], "7000"); + assert_eq!(http_fields["bizseqno"], "business-id"); + assert_eq!(http_fields["uniqueid"], "unique-id"); + assert_eq!(http_fields["producergroup"], "destination-group"); + assert_eq!(http_attributes["custom"], "business-value"); + + let tcp_forwarded = tcp::message::build_message_package( + &received, + tcp::frame::Command::AsyncMessageToServer, + ) + .unwrap(); + let tcp::frame::PackageBody::Text(body) = tcp_forwarded.body else { + panic!("TCP text body") + }; + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(body["properties"].get("protocoldesc").is_none()); + assert!(body["properties"].get("passwd").is_none()); + assert_eq!(body["properties"]["custom"], "business-value"); + assert_eq!(body["properties"]["ttl"], "7000"); + assert_eq!( + tcp_forwarded.header.get_string_property("protocoldesc"), + Some("tcp") + ); + assert_eq!( + tcp_forwarded.header.get_string_property("uniqueid"), + Some("unique-id") + ); + + let destination_config = GrpcConfig::new(Endpoint::new("127.0.0.1", 10205).unwrap()) + .with_identity(identity) + .with_credentials(credentials); + let forwarded = grpc::codec::from_event_mesh_message( + &received, + &destination_config, + "destination-group", + ) + .unwrap(); + assert_eq!( + attr_as_str(&forwarded.attributes["sys"]), + "destination-system" + ); + assert!(!forwarded.attributes.contains_key("token")); + assert_eq!( + attr_as_str(&forwarded.attributes["custom"]), + "business-value" + ); + assert_eq!(received, before); + } + + #[test] + fn tcp_forwarding_preserves_business_and_reply_properties_but_not_transport_overrides() { + use tcp::frame::{Command, PackageBody}; + + for source in ["http", "grpc-cloud-event", "tcp"] { + let json = serde_json::json!({ + "topic": "orders", "body": "payload", + "properties": { + "protocoldesc": source, "protocoltype": "eventmeshmessage", "protocolversion": "0.3", + "env": "old-env", "sys": "old-system", "token": "old-token", "code": "999", "version": "old-version", + "ttl": "7000", "custom": "business-value", "tag": "order-created", + "seqnum": "request-sequence", "req0sys": "request-system", "req0group": "request-group", + "cluster": "reply-cluster", "correlation99id": "broker-correlation", "reply99to99client": "request-client" + } + }); + let received = + tcp::message::parse_message(&PackageBody::Text(json.to_string())).unwrap(); + for command in [ + Command::AsyncMessageToServer, + Command::BroadcastMessageToServer, + Command::RequestToServer, + Command::ResponseToServer, + ] { + let forwarded = tcp::message::build_message_package(&received, command).unwrap(); + let PackageBody::Text(body) = forwarded.body else { + panic!("TCP text body") + }; + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + let properties = body["properties"].as_object().unwrap(); + for key in [ + "protocoldesc", + "protocoltype", + "protocolversion", + "env", + "sys", + "token", + "code", + "version", + ] { + assert!( + !properties.contains_key(key), + "{source} -> TCP {command:?}: stale {key}" + ); + } + assert_eq!( + forwarded.header.get_string_property("protocoldesc"), + Some("tcp") + ); + assert_eq!( + forwarded.header.get_string_property("protocoltype"), + Some("eventmeshmessage") + ); + assert_eq!( + forwarded.header.get_string_property("protocolversion"), + Some("1.0") + ); + assert_eq!(properties["ttl"], "7000"); + assert_eq!(properties["custom"], "business-value"); + assert_eq!(properties["tag"], "order-created"); + assert_eq!(properties["seqnum"], "request-sequence"); + if command == Command::ResponseToServer { + assert_eq!(properties["req0sys"], "request-system"); + assert_eq!(properties["req0group"], "request-group"); + assert_eq!(properties["cluster"], "reply-cluster"); + assert_eq!(properties["correlation99id"], "broker-correlation"); + assert_eq!(properties["reply99to99client"], "request-client"); + } else { + assert!(!properties.contains_key("req0sys")); + assert!(!properties.contains_key("req0group")); + assert!(!properties.contains_key("cluster")); + assert!(!properties.contains_key("correlation99id")); + assert!(!properties.contains_key("reply99to99client")); + } + } + } + } +} + +#[cfg(all(test, feature = "grpc", feature = "http", feature = "tcp"))] +mod ttl_tests { + use super::{grpc, http, tcp}; + use crate::config::{Endpoint, GrpcConfig}; + use crate::proto_gen::{attr_as_str, attr_str}; + use crate::{EventMeshMessage, Result}; + use std::collections::HashMap; + + fn config() -> GrpcConfig { + GrpcConfig::new(Endpoint::new("127.0.0.1", 10205).unwrap()) + } + + fn decode_java_message(protocol: &str, ttl: Option<&str>) -> Result { + let mut properties = HashMap::from([("custom".to_string(), "value".to_string())]); + if let Some(ttl) = ttl { + properties.insert("ttl".into(), ttl.into()); + } + match protocol { + "http" => { + let form = http::codec::form_encode(&[ + ("topic".into(), "orders".into()), + ("content".into(), "payload".into()), + ( + "extFields".into(), + serde_json::to_string(&properties).unwrap(), + ), + ]); + http::codec::parse_push_body(&form)?.to_event_mesh_message() + } + "grpc" => { + let message = EventMeshMessage::new("orders", "payload")?; + let mut wire = grpc::codec::from_event_mesh_message(&message, &config(), "group")?; + wire.attributes.remove("ttl"); + for (key, value) in properties { + wire.attributes.insert(key, attr_str(value)); + } + grpc::codec::to_event_mesh_message(&wire) + } + "tcp" => { + let json = serde_json::json!({ + "topic": "orders", "body": "payload", "properties": properties + }); + tcp::message::parse_message(&tcp::frame::PackageBody::Text(json.to_string())) + .ok_or_else(|| crate::Error::InvalidMessage("invalid TCP message".into())) + } + _ => unreachable!(), + } + } + + fn assert_outbound_ttl(message: &EventMeshMessage) { + let expected = message.ttl_millis().unwrap_or(4000).to_string(); + let http_fields: HashMap<_, _> = http::codec::encode_publish(message, "group") + .into_iter() + .collect(); + assert_eq!(http_fields.get("ttl"), Some(&expected)); + let extensions: HashMap = + serde_json::from_str(http_fields.get("extFields").unwrap()).unwrap(); + assert!(!extensions.contains_key("ttl")); + let grpc_wire = grpc::codec::from_event_mesh_message(message, &config(), "group").unwrap(); + assert_eq!(attr_as_str(&grpc_wire.attributes["ttl"]), expected); + + let tcp_wire = + tcp::message::build_message_package(message, tcp::frame::Command::AsyncMessageToServer) + .unwrap(); + let expected_tcp = message.ttl_millis().map(|ttl| ttl.to_string()); + assert_eq!( + tcp_wire.header.get_string_property("ttl"), + expected_tcp.as_deref() + ); + let tcp::frame::PackageBody::Text(body) = &tcp_wire.body else { + panic!("TCP body") + }; + let body: serde_json::Value = serde_json::from_str(body).unwrap(); + assert_eq!(body["properties"]["ttl"].as_str(), expected_tcp.as_deref()); + let decoded = tcp::message::parse_message(&tcp_wire.body).unwrap(); + assert_eq!(decoded.ttl_millis(), message.ttl_millis()); + assert_eq!(decoded.get_prop("ttl"), None); + } + + #[test] + fn native_ttl_is_decoded_into_one_field_on_every_transport() { + for protocol in ["http", "grpc", "tcp"] { + for ttl in [ + None, + Some("4000"), + Some("0"), + Some("-1"), + Some("2147483648"), + Some("9223372036854775807"), + ] { + let message = decode_java_message(protocol, ttl).unwrap(); + assert_eq!( + message.ttl_millis(), + ttl.map(|value| value.parse().unwrap()), + "{protocol}" + ); + assert_eq!(message.get_prop("ttl"), None, "{protocol}"); + assert_eq!(message.get_prop("custom"), Some("value")); + } + for ttl in ["", "invalid", "9223372036854775808"] { + assert!( + decode_java_message(protocol, Some(ttl)).is_err(), + "{protocol}: {ttl}" + ); + } + // A Java message received on any transport can be forwarded using + // each native encoder without losing its dedicated TTL. + assert_outbound_ttl(&decode_java_message(protocol, Some("7000")).unwrap()); + } + } + + #[test] + fn native_encoders_ignore_generic_ttl_properties() { + for ttl in [None, Some(7000)] { + let mut builder = EventMeshMessage::builder() + .topic("orders") + .content("payload") + .prop("custom", "value"); + if let Some(ttl) = ttl { + builder = builder.ttl_millis(ttl); + } + let mut message = builder.build().unwrap(); + // Defense in depth if crate-private code supplies a reserved key. + message.props.insert("ttl".into(), "99000".into()); + assert_outbound_ttl(&message); + // Encoding borrows the model; it does not rewrite user properties. + assert_eq!(message.get_prop("ttl"), Some("99000")); + assert_eq!(message.ttl_millis(), ttl); + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/task.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/task.rs new file mode 100644 index 0000000000..c23e20c048 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/task.rs @@ -0,0 +1,62 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Owned task state retained across cancelled lifecycle waits. + +use tokio::task::{JoinError, JoinHandle}; + +pub(crate) struct BackgroundTask { + handle: Option>, + result: Option>, +} + +impl BackgroundTask { + pub(crate) fn new(handle: JoinHandle) -> Self { + Self { + handle: Some(handle), + result: None, + } + } + + /// Borrow the handle while waiting and retain its output until all cleanup + /// is complete. Cancelling this future never detaches the task or loses a + /// completed result, and a completed handle is never polled twice. + pub(crate) async fn wait(&mut self) { + if let Some(handle) = self.handle.as_mut() { + self.result = Some(handle.await); + self.handle = None; + } + } + + /// Consume the result only after the last cancellation point in the caller. + pub(crate) fn take_result(&mut self) -> Option> { + self.result.take() + } + + #[cfg(feature = "http")] + pub(crate) fn is_finished(&self) -> bool { + self.handle.as_ref().is_none_or(JoinHandle::is_finished) + } +} + +impl Drop for BackgroundTask { + fn drop(&mut self) { + if let Some(handle) = &self.handle { + handle.abort(); + } + } +} 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..55aa476bfd --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection.rs @@ -0,0 +1,1001 @@ +// 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 driver-owned, `seq`-keyed pending map of `oneshot` channels. +//! +//! ## Reconnect +//! +//! When automatic reconnect is enabled (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::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use crate::config::ReconnectPolicy; +use crate::error::{EventMeshError, Result}; + +use super::frame::Package; +use super::message; + +mod dispatcher; +mod driver; +mod supervisor; + +use dispatcher::{OutboundCommand, PendingCancellation, PendingKey}; +use supervisor::{ConnectionState, ConnectionSupervisor}; + +/// 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; + +/// 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 reconnecting 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>>, + /// Non-blocking cancellation path to the driver-owned pending map. + pending_cancel_tx: mpsc::UnboundedSender, + /// Serializes enqueue commitment with connection teardown. + state: Arc>, + /// Whether unmatched `RESPONSE_TO_CLIENT` frames should be made available + /// to a publisher-side business handler. + deliver_orphan_responses: Arc, + /// Shutdown signal shared with the background task. + cancel: CancellationToken, + /// Maximum time fire-and-forget sends may wait for outbound queue + /// capacity. Request-response operations use their own per-call deadline. + outbound_timeout: Duration, + /// 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. + #[cfg(test)] + 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. + /// + /// `connect_timeout` bounds the socket connection and `control_timeout` + /// bounds the HELLO response, mirroring Java's separate 1-second Netty + /// connect timeout and 20-second protocol request timeout. + /// + /// 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, + connect_timeout: Duration, + control_timeout: Duration, + reconnect: ReconnectPolicy, + ) -> Result { + // Initial connect is inline so the caller gets immediate feedback. + // Subsequent reconnects happen in the background task. + let framed = ConnectionSupervisor::establish( + addr, + port, + user_agent, + connect_timeout, + control_timeout, + ) + .await?; + + let (outbound_tx, outbound_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (pending_cancel_tx, pending_cancel_rx) = mpsc::unbounded_channel(); + let (inbound_tx, inbound_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (reconnect_tx, reconnect_rx) = mpsc::channel(RECONNECT_CHANNEL_CAPACITY); + let state = Arc::new(Mutex::new(ConnectionState { + generation: 0, + active: true, + })); + let deliver_orphan_responses = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let alive = Arc::new(AtomicBool::new(true)); + + let join = tokio::spawn(ConnectionSupervisor::run( + addr.to_string(), + port, + user_agent.clone(), + heartbeat_interval, + connect_timeout, + control_timeout, + reconnect, + framed, + outbound_rx, + inbound_tx, + reconnect_tx, + pending_cancel_rx, + Arc::clone(&state), + Arc::clone(&deliver_orphan_responses), + 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_tx, + state, + deliver_orphan_responses, + cancel, + outbound_timeout: control_timeout, + #[cfg(test)] + alive, + join: Mutex::new(Some(join)), + }) + } + + fn deadline_after(timeout: Duration) -> Result { + tokio::time::Instant::now() + .checked_add(timeout) + .ok_or_else(|| EventMeshError::InvalidArgument("TCP timeout is too large".into())) + } + + /// 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 { + let deadline = Self::deadline_after(timeout)?; + // 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(); + + // Reserve capacity first, then commit the pending registration and + // enqueue while holding the lifecycle lock. Teardown takes this same + // lock before invalidating the generation and draining the queue, so a + // sender that slept on a full channel cannot enqueue onto the next + // socket after teardown has completed. + let generation = self.active_generation().await?; + let permit = self.reserve_outbound(deadline, timeout).await?; + let pending_key = (generation, seq); + { + let state = self.state.lock().await; + if self.cancel.is_cancelled() || !state.active || state.generation != generation { + return Err(Self::inactive_error()); + } + if tokio::time::Instant::now() >= deadline { + return Err(EventMeshError::Timeout(timeout)); + } + permit.send(OutboundCommand::Request { + package: pkg, + key: pending_key.clone(), + response_tx: tx, + }); + } + + let mut pending_cancellation = + PendingCancellation::new(pending_key, self.pending_cancel_tx.clone()); + + // Wait for the response using the same deadline that bounded queue + // reservation, so backpressure consumes the caller's timeout budget + // instead of starting a fresh timer after enqueue. + let result = tokio::select! { + biased; + _ = self.cancel.cancelled() => Err(Self::inactive_error()), + _ = tokio::time::sleep_until(deadline) => Err(EventMeshError::Timeout(timeout)), + response = rx => match response { + Ok(response) => Ok(response), + Err(_) => Err(EventMeshError::ChannelClosed( + "connection task exited while waiting for response".into(), + )), + }, + }; + if result.is_ok() { + // The driver removes the entry before delivering the response. + pending_cancellation.disarm(); + } + result + } + + async fn reserve_outbound( + &self, + deadline: tokio::time::Instant, + timeout: Duration, + ) -> Result> { + tokio::select! { + biased; + _ = self.cancel.cancelled() => Err(Self::inactive_error()), + _ = tokio::time::sleep_until(deadline) => Err(EventMeshError::Timeout(timeout)), + permit = self.outbound_tx.reserve() => permit.map_err(|_| { + EventMeshError::ChannelClosed("connection send loop exited".into()) + }), + } + } + + /// Enqueue a package without waiting for its socket write or a reply. + /// + /// Corresponds to Java `TcpClient.send()`. + pub async fn send(&self, pkg: Package) -> Result<()> { + let timeout = self.outbound_timeout; + let deadline = Self::deadline_after(timeout)?; + self.enqueue_send(OutboundCommand::Send(pkg), deadline, timeout) + .await + } + + /// Wait for the package to be flushed to the local socket, without + /// waiting for a server ACK. Queueing and write completion share one + /// timeout budget. A timeout during a write leaves delivery uncertain. + pub async fn send_and_flush(&self, pkg: Package) -> Result<()> { + let timeout = self.outbound_timeout; + let deadline = Self::deadline_after(timeout)?; + let (completion_tx, completion_rx) = oneshot::channel(); + self.enqueue_send( + OutboundCommand::SendAndFlush { + package: pkg, + completion_tx, + }, + deadline, + timeout, + ) + .await?; + + tokio::select! { + biased; + _ = self.cancel.cancelled() => Err(Self::inactive_error()), + _ = tokio::time::sleep_until(deadline) => Err(EventMeshError::Timeout(timeout)), + result = completion_rx => result.map_err(|_| EventMeshError::ChannelClosed( + "connection task exited before completing the socket write".into(), + ))?, + } + } + + async fn enqueue_send( + &self, + command: OutboundCommand, + deadline: tokio::time::Instant, + timeout: Duration, + ) -> Result<()> { + let generation = self.active_generation().await?; + let permit = self.reserve_outbound(deadline, timeout).await?; + let state = self.state.lock().await; + if self.cancel.is_cancelled() || !state.active || state.generation != generation { + return Err(Self::inactive_error()); + } + if tokio::time::Instant::now() >= deadline { + return Err(EventMeshError::Timeout(timeout)); + } + permit.send(command); + Ok(()) + } + + async fn active_generation(&self) -> Result { + let state = self.state.lock().await; + if state.active { + Ok(state.generation) + } else { + Err(Self::inactive_error()) + } + } + + fn inactive_error() -> EventMeshError { + EventMeshError::ChannelClosed("connection is not active (reconnecting or shut down)".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() + } + + /// Deliver unmatched server `RESPONSE_TO_CLIENT` frames to the inbound + /// receiver. This is used by TCP publisher-side business handlers. + pub fn enable_orphan_response_delivery(&self) { + self.deliver_orphan_responses.store(true, Ordering::Release); + } + + /// 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. + #[cfg(test)] + 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. Never wait for outbound capacity here: a full + // queue is precisely when cancellation is needed to unblock callers + // and an in-progress socket write. + let _ = self + .outbound_tx + .try_send(OutboundCommand::Send(message::goodbye())); + self.cancel.cancel(); + let mut task = self.join.lock().await; + if let Some(join) = task.as_mut() { + let _ = join.await; + task.take(); + } + } +} + +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::sync::Arc; + use std::time::Duration; + + use super::*; + use crate::config::{Endpoint, ProducerOptions, ReconnectPolicy, TcpConfig}; + use crate::model::EventMeshMessage; + use crate::transport::tcp::codec::TcpCodec; + use crate::transport::tcp::frame::{Command, Header, Package, PackageBody}; + + use futures::SinkExt; + use tokio::net::TcpListener; + use tokio_stream::StreamExt; + use tokio_util::codec::Framed; + + fn blocked_test_connection_with_timeout( + outbound_timeout: Duration, + ) -> (Arc, mpsc::Receiver) { + let (outbound_tx, outbound_rx) = mpsc::channel(1); + outbound_tx + .try_send(OutboundCommand::Send(Package::new(Header::new( + Command::AsyncMessageToServer, + "occupied", + )))) + .unwrap(); + let (_inbound_tx, inbound_rx) = mpsc::channel(1); + let (_reconnect_tx, reconnect_rx) = mpsc::channel(1); + let (pending_cancel_tx, _pending_cancel_rx) = mpsc::unbounded_channel(); + + let connection = TcpConnection { + outbound_tx, + inbound_rx: Mutex::new(Some(inbound_rx)), + reconnect_rx: Mutex::new(Some(reconnect_rx)), + pending_cancel_tx, + state: Arc::new(Mutex::new(ConnectionState { + generation: 0, + active: true, + })), + deliver_orphan_responses: Arc::new(AtomicBool::new(false)), + cancel: CancellationToken::new(), + outbound_timeout, + alive: Arc::new(AtomicBool::new(true)), + join: Mutex::new(None), + }; + (Arc::new(connection), outbound_rx) + } + + fn blocked_test_connection() -> (Arc, mpsc::Receiver) { + blocked_test_connection_with_timeout(Duration::from_secs(5)) + } + + #[tokio::test(start_paused = true)] + async fn cancelled_shutdown_keeps_waiting_for_connection_task() { + let (mut connection, _outbound) = blocked_test_connection(); + let (release, released) = oneshot::channel::<()>(); + *Arc::get_mut(&mut connection).unwrap().join.get_mut() = Some(tokio::spawn(async move { + released.await.unwrap(); + })); + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_secs(1), connection.shutdown()) + .await + .is_err() + ); + } + release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(1), connection.shutdown()) + .await + .unwrap(); + } + + async fn simulate_teardown( + conn: &TcpConnection, + outbound_rx: &mut mpsc::Receiver, + ) { + let mut state = conn.state.lock().await; + state.active = false; + state.generation = state.generation.wrapping_add(1); + conn.alive.store(false, Ordering::Release); + while outbound_rx.try_recv().is_ok() {} + } + + #[tokio::test] + async fn blocked_send_cannot_enqueue_after_teardown() { + let (conn, mut outbound_rx) = blocked_test_connection(); + let sender = { + let conn = Arc::clone(&conn); + tokio::spawn(async move { + conn.send(Package::new(Header::new( + Command::AsyncMessageToServer, + "stale", + ))) + .await + }) + }; + tokio::task::yield_now().await; + + simulate_teardown(&conn, &mut outbound_rx).await; + + assert!(matches!( + sender.await.unwrap(), + Err(EventMeshError::ChannelClosed(_)) + )); + assert!(matches!( + outbound_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn blocked_io_cannot_register_or_enqueue_after_teardown() { + let (conn, mut outbound_rx) = blocked_test_connection(); + let sender = { + let conn = Arc::clone(&conn); + tokio::spawn(async move { + conn.io( + Package::new(Header::new(Command::RequestToServer, "stale")), + Duration::from_secs(5), + ) + .await + }) + }; + tokio::task::yield_now().await; + + simulate_teardown(&conn, &mut outbound_rx).await; + + assert!(matches!( + sender.await.unwrap(), + Err(EventMeshError::ChannelClosed(_)) + )); + assert!(matches!( + outbound_rx.try_recv(), + Err(mpsc::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn blocked_io_uses_one_timeout_for_queue_capacity_and_response() { + let (conn, _outbound_rx) = blocked_test_connection(); + let timeout = Duration::from_millis(20); + + let result = tokio::time::timeout( + Duration::from_secs(1), + conn.io( + Package::new(Header::new(Command::RequestToServer, "blocked")), + timeout, + ), + ) + .await + .expect("queue wait must respect the request timeout"); + + assert!(matches!( + result, + Err(EventMeshError::Timeout(value)) if value == timeout + )); + } + + #[tokio::test] + async fn queue_wait_consumes_the_response_timeout_budget() { + let (conn, mut outbound_rx) = blocked_test_connection(); + let request_timeout = Duration::from_millis(200); + let request = { + let conn = Arc::clone(&conn); + tokio::spawn(async move { + conn.io( + Package::new(Header::new(Command::RequestToServer, "delayed")), + request_timeout, + ) + .await + }) + }; + + tokio::time::sleep(Duration::from_millis(120)).await; + let occupied = outbound_rx.recv().await.expect("occupied queue entry"); + assert!(matches!( + occupied, + OutboundCommand::Send(pkg) if pkg.header.seq.as_deref() == Some("occupied") + )); + + let result = tokio::time::timeout(Duration::from_millis(150), request) + .await + .expect("response wait must use only the original deadline's remaining time") + .expect("request task must not panic"); + assert!(matches!( + result, + Err(EventMeshError::Timeout(value)) if value == request_timeout + )); + } + + #[tokio::test] + async fn blocked_fire_and_forget_send_uses_the_outbound_timeout() { + let timeout = Duration::from_millis(20); + let (conn, _outbound_rx) = blocked_test_connection_with_timeout(timeout); + + let result = tokio::time::timeout( + Duration::from_secs(1), + conn.send(Package::new(Header::new( + Command::AsyncMessageToServer, + "blocked", + ))), + ) + .await + .expect("queue wait must respect the outbound timeout"); + + assert!(matches!( + result, + Err(EventMeshError::Timeout(value)) if value == timeout + )); + } + + #[tokio::test(start_paused = true)] + async fn send_and_flush_shares_one_timeout_for_queueing_and_completion() { + let timeout = Duration::from_millis(100); + let (conn, mut outbound_rx) = blocked_test_connection_with_timeout(timeout); + let sender = tokio::spawn(async move { + conn.send_and_flush(message::package(Command::BroadcastMessageToServer)) + .await + }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_millis(60)).await; + let _occupied = outbound_rx.recv().await.unwrap(); + // Retain the command without completing its write, so both queueing + // and completion consume the same caller-side timeout budget. + let _broadcast = outbound_rx.recv().await.unwrap(); + assert!( + !sender.is_finished(), + "enqueueing alone must not complete the send" + ); + tokio::time::advance(Duration::from_millis(40)).await; + let result = tokio::time::timeout(Duration::from_millis(1), sender) + .await + .expect("completion must use the original deadline") + .unwrap(); + assert!(matches!(result, Err(EventMeshError::Timeout(value)) if value == timeout)); + } + + #[tokio::test] + async fn shutdown_wakes_a_sender_waiting_for_write_completion() { + let (conn, mut outbound_rx) = blocked_test_connection(); + let _occupied = outbound_rx.recv().await.unwrap(); + let sender = { + let conn = Arc::clone(&conn); + tokio::spawn(async move { + conn.send_and_flush(message::package(Command::BroadcastMessageToServer)) + .await + }) + }; + let _broadcast = outbound_rx.recv().await.unwrap(); + + tokio::time::timeout(Duration::from_secs(1), conn.shutdown()) + .await + .expect("waiting for write completion must not hold the lifecycle lock"); + let result = tokio::time::timeout(Duration::from_secs(1), sender) + .await + .expect("shutdown must interrupt the completion wait") + .unwrap(); + assert!(matches!(result, Err(EventMeshError::ChannelClosed(_)))); + } + + #[tokio::test] + async fn cancellation_wakes_a_sender_waiting_for_queue_capacity() { + let (conn, _outbound_rx) = blocked_test_connection(); + let sender = { + let conn = Arc::clone(&conn); + tokio::spawn(async move { + conn.send(Package::new(Header::new( + Command::AsyncMessageToServer, + "blocked", + ))) + .await + }) + }; + tokio::task::yield_now().await; + + conn.cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), sender) + .await + .expect("cancellation must wake the blocked sender") + .expect("sender task must not panic"); + + assert!(matches!(result, Err(EventMeshError::ChannelClosed(_)))); + } + + #[tokio::test] + async fn shutdown_does_not_wait_for_a_full_outbound_queue() { + let (conn, _outbound_rx) = blocked_test_connection(); + + tokio::time::timeout(Duration::from_secs(1), conn.shutdown()) + .await + .expect("shutdown must not wait for outbound queue capacity"); + + assert!(conn.cancel.is_cancelled()); + } + + #[tokio::test] + async fn hello_response_wait_uses_the_control_timeout() { + 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(); + std::future::pending::<()>().await; + }); + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_connect_timeout(Duration::from_secs(1)) + .with_control_timeout(Duration::from_millis(20)) + .with_heartbeat_interval(Duration::from_secs(60)) + .with_reconnect(ReconnectPolicy::default().with_enabled(false)); + let user_agent = super::super::frame::UserAgent::from_role( + config.identity(), + config.credentials(), + "g", + config.endpoint().port(), + "pub", + ); + + let result = TcpConnection::connect( + &config.endpoint().authority_host(), + config.endpoint().port(), + &user_agent, + config.heartbeat_interval(), + config.connect_timeout(), + config.control_timeout(), + config.reconnect().clone(), + ) + .await; + assert!(matches!( + result, + Err(EventMeshError::Timeout(timeout)) if timeout == Duration::from_millis(20) + )); + server.abort(); + } + + /// Dropping the entire `io()` future must notify the driver to remove its + /// pending entry. A late RESPONSE_TO_CLIENT is therefore orphaned and must + /// not be ACKed as though a caller were still waiting for it. + #[tokio::test] + async fn externally_cancelled_io_removes_driver_pending_entry() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (request_seen_tx, request_seen_rx) = oneshot::channel(); + let (send_late_response_tx, send_late_response_rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new(Command::HelloResponse, "hello"))) + .await + .unwrap(); + + let request = framed.next().await.unwrap().unwrap(); + assert_eq!(request.header.cmd, Command::RequestToServer); + let seq = request.header.seq.unwrap_or_default(); + let _ = request_seen_tx.send(()); + let _ = send_late_response_rx.await; + + // Give the driver a chance to receive the cancellation command + // emitted synchronously when the request future was dropped. + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(20)).await; + framed + .send(Package::new(Header::new(Command::ResponseToClient, seq))) + .await + .unwrap(); + + // A leaked pending entry would make the client ACK this response. + // An orphan response is dropped by a default producer connection. + tokio::time::timeout(Duration::from_millis(100), framed.next()) + .await + .is_err() + }); + + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(1)) + .with_heartbeat_interval(Duration::from_secs(60)) + .with_reconnect(ReconnectPolicy::default().with_enabled(false)); + let user_agent = super::super::frame::UserAgent::from_role( + config.identity(), + config.credentials(), + "g", + config.endpoint().port(), + "pub", + ); + let conn = Arc::new( + TcpConnection::connect( + &config.endpoint().authority_host(), + config.endpoint().port(), + &user_agent, + config.heartbeat_interval(), + config.connect_timeout(), + config.control_timeout(), + config.reconnect().clone(), + ) + .await + .expect("connect"), + ); + + let request = { + let conn = Arc::clone(&conn); + tokio::spawn(async move { + conn.io( + Package::new(Header::new(Command::RequestToServer, "cancel-me")), + Duration::from_secs(30), + ) + .await + }) + }; + request_seen_rx + .await + .expect("server did not receive request"); + request.abort(); + let _ = request.await; + let _ = send_late_response_tx.send(()); + + assert!(server.await.expect("server task failed")); + conn.shutdown().await; + } + + /// 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 = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)) + .with_reconnect(ReconnectPolicy::default().with_enabled(false)); + + let producer = + crate::transport::tcp::TcpProducer::connect(config, &ProducerOptions::new("g")) + .await + .expect("connect"); + + let msg = EventMeshMessage::builder() + .topic("t") + .content("ping") + .build() + .unwrap(); + let reply = producer + .request_reply(msg, Duration::from_secs(3)) + .await + .expect("request_reply"); + assert_eq!(reply.topic(), "reply"); + assert_eq!(reply.content(), "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 = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)) + .with_reconnect( + ReconnectPolicy::default() + .with_enabled(true) + .with_initial_backoff(Duration::from_millis(100)) + .with_max_backoff(Duration::from_millis(500)), + ); + + let user_agent = super::super::frame::UserAgent::from_role( + config.identity(), + config.credentials(), + "g", + config.endpoint().port(), + "pub", + ); + let conn = TcpConnection::connect( + &config.endpoint().authority_host(), + config.endpoint().port(), + &user_agent, + config.heartbeat_interval(), + config.connect_timeout(), + config.control_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/connection/dispatcher.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/dispatcher.rs new file mode 100644 index 0000000000..6adeebe2dc --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/dispatcher.rs @@ -0,0 +1,114 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Request-response correlation for the TCP transport. + +use std::collections::HashMap; + +use tokio::sync::{mpsc, oneshot}; + +use crate::error::Result; + +use super::super::frame::Package; + +pub(super) type PendingKey = (u64, String); + +pub(super) enum OutboundCommand { + Send(Package), + SendAndFlush { + package: Package, + completion_tx: oneshot::Sender>, + }, + Request { + package: Package, + key: PendingKey, + response_tx: oneshot::Sender, + }, +} + +/// Owns all pending requests. It is driven exclusively by `SocketDriver`, so +/// request correlation never requires a shared map or an asynchronous lock. +pub(super) struct RpcDispatcher { + pending: HashMap>, +} + +impl RpcDispatcher { + pub(super) fn new() -> Self { + Self { + pending: HashMap::new(), + } + } + + /// Register a request immediately before the driver writes it. A caller + /// cancelled while its command was queued is detected without touching the + /// socket. + pub(super) fn register( + &mut self, + key: PendingKey, + response_tx: oneshot::Sender, + ) -> bool { + if response_tx.is_closed() { + return false; + } + self.pending.insert(key, response_tx); + true + } + + pub(super) fn take_response( + &mut self, + generation: u64, + seq: String, + ) -> Option> { + self.pending.remove(&(generation, seq)) + } + + pub(super) fn cancel(&mut self, key: &PendingKey) { + self.pending.remove(key); + } + + pub(super) fn connection_lost(&mut self) { + self.pending.clear(); + } +} + +/// Emits a non-blocking cancellation command when an `io()` future is +/// dropped. The dispatcher remains the only owner of the pending map. +pub(super) struct PendingCancellation { + key: Option, + cancel_tx: mpsc::UnboundedSender, +} + +impl PendingCancellation { + pub(super) fn new(key: PendingKey, cancel_tx: mpsc::UnboundedSender) -> Self { + Self { + key: Some(key), + cancel_tx, + } + } + + pub(super) fn disarm(&mut self) { + self.key = None; + } +} + +impl Drop for PendingCancellation { + fn drop(&mut self) { + if let Some(key) = self.key.take() { + let _ = self.cancel_tx.send(key); + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/driver.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/driver.rs new file mode 100644 index 0000000000..da5d69be1e --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/driver.rs @@ -0,0 +1,256 @@ +// 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. + +//! Read, write, heartbeat, and push routing for one connected socket. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use futures::SinkExt; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio_stream::StreamExt; +use tokio_util::codec::Framed; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::error::EventMeshError; + +use super::super::codec::TcpCodec; +use super::super::frame::{Command, Package}; +use super::super::message; +use super::dispatcher::{OutboundCommand, PendingKey, RpcDispatcher}; + +#[derive(Debug)] +pub(super) enum IoExitReason { + Cancelled, + AllSendersDropped, + IoError, + ServerClosed, + SlowConsumer, +} + +/// Runs all I/O for exactly one established TCP socket. +pub(super) struct SocketDriver; + +impl SocketDriver { + #[allow(clippy::too_many_arguments)] + pub(super) async fn run( + framed: &mut Framed, + outbound_rx: &mut mpsc::Receiver, + pending_cancel_rx: &mut mpsc::UnboundedReceiver, + inbound_tx: &mpsc::Sender, + dispatcher: &mut RpcDispatcher, + deliver_orphan_responses: &Arc, + generation: u64, + heartbeat_interval: Duration, + write_timeout: Duration, + cancel: &CancellationToken, + ) -> IoExitReason { + use tokio::time::MissedTickBehavior; + + let mut heartbeat = tokio::time::interval(heartbeat_interval); + heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay); + heartbeat.tick().await; + + loop { + tokio::select! { + biased; + + _ = cancel.cancelled() => { + debug!("connection task cancelled"); + return IoExitReason::Cancelled; + } + + key = pending_cancel_rx.recv() => { + if let Some(key) = key { + dispatcher.cancel(&key); + } + } + + command = outbound_rx.recv() => { + match command { + Some(OutboundCommand::Send(package)) => { + if let Err((reason, _)) = Self::write_frame( + framed, + package, + write_timeout, + cancel, + ).await { + return reason; + } + } + Some(OutboundCommand::SendAndFlush { package, completion_tx }) => { + // Do not write a queued broadcast whose caller was + // cancelled or timed out before the driver reached it. + if completion_tx.is_closed() { + continue; + } + match Self::write_frame(framed, package, write_timeout, cancel).await { + Ok(()) => { + let _ = completion_tx.send(Ok(())); + } + Err((reason, error)) => { + let _ = completion_tx.send(Err(error)); + return reason; + } + } + } + Some(OutboundCommand::Request { package, key, response_tx }) => { + if !dispatcher.register(key.clone(), response_tx) { + continue; + } + if let Err((reason, _)) = Self::write_frame( + framed, + package, + write_timeout, + cancel, + ).await { + dispatcher.cancel(&key); + return reason; + } + } + None => { + debug!("all senders dropped, stopping connection task"); + return IoExitReason::AllSendersDropped; + } + } + } + + result = framed.next() => { + match result { + Some(Ok(package)) => { + if let Some(reason) = Self::handle_inbound( + framed, + inbound_tx, + dispatcher, + deliver_orphan_responses, + generation, + write_timeout, + cancel, + package, + ).await { + return reason; + } + } + Some(Err(error)) => { + warn!(%error, "read error, connection lost"); + return IoExitReason::IoError; + } + None => { + info!("connection closed by server"); + return IoExitReason::ServerClosed; + } + } + } + + _ = heartbeat.tick() => { + if let Err((reason, _)) = Self::write_frame( + framed, + message::heartbeat(), + write_timeout, + cancel, + ).await { + return reason; + } + debug!("heartbeat sent"); + } + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn handle_inbound( + framed: &mut Framed, + inbound_tx: &mpsc::Sender, + dispatcher: &mut RpcDispatcher, + deliver_orphan_responses: &Arc, + generation: u64, + write_timeout: Duration, + cancel: &CancellationToken, + package: Package, + ) -> Option { + if package.header.cmd == Command::HeartbeatResponse { + debug!("heartbeat response received"); + return None; + } + + let seq = package.header.seq.clone().unwrap_or_default(); + if let Some(response_tx) = dispatcher.take_response(generation, seq) { + if package.header.cmd == Command::ResponseToClient { + let ack = message::response_to_client_ack(&package); + if let Err((reason, _)) = + Self::write_frame(framed, ack, write_timeout, cancel).await + { + let _ = response_tx.send(package); + return Some(reason); + } + } + let _ = response_tx.send(package); + return None; + } + + if package.header.cmd == Command::ResponseToClient + && !deliver_orphan_responses.load(Ordering::Acquire) + { + debug!("dropping orphan RESPONSE_TO_CLIENT"); + return None; + } + + match inbound_tx.try_send(package) { + Ok(()) => None, + Err(mpsc::error::TrySendError::Full(_)) => { + warn!( + "inbound channel full — disconnecting to trigger server redelivery of \ + unacked messages" + ); + Some(IoExitReason::SlowConsumer) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!("inbound channel closed (consumer dropped)"); + None + } + } + } + + async fn write_frame( + framed: &mut Framed, + package: Package, + timeout: Duration, + cancel: &CancellationToken, + ) -> std::result::Result<(), (IoExitReason, EventMeshError)> { + tokio::select! { + biased; + _ = cancel.cancelled() => Err(( + IoExitReason::Cancelled, + EventMeshError::ChannelClosed("TCP write cancelled by shutdown".into()), + )), + result = tokio::time::timeout(timeout, framed.send(package)) => match result { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => { + warn!(%error, "TCP write failed; connection lost"); + Err((IoExitReason::IoError, error)) + } + Err(_) => { + warn!(?timeout, "TCP write timed out; connection lost"); + Err((IoExitReason::IoError, EventMeshError::Timeout(timeout))) + } + }, + } + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/supervisor.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/supervisor.rs new file mode 100644 index 0000000000..676484d54f --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/connection/supervisor.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. + +//! Connection establishment, HELLO handshake, and reconnect supervision. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use futures::SinkExt; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, Mutex}; +use tokio_stream::StreamExt; +use tokio_util::codec::Framed; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::config::ReconnectPolicy; +use crate::error::{EventMeshError, Result}; + +use super::super::codec::TcpCodec; +use super::super::frame::{Command, Package, UserAgent}; +use super::super::message; +use super::dispatcher::{OutboundCommand, PendingKey, RpcDispatcher}; +use super::driver::{IoExitReason, SocketDriver}; + +#[derive(Debug)] +pub(super) struct ConnectionState { + pub(super) generation: u64, + pub(super) active: bool, +} + +pub(super) struct ConnectionSupervisor; + +impl ConnectionSupervisor { + pub(super) async fn establish( + addr: &str, + port: u16, + user_agent: &UserAgent, + connect_timeout: Duration, + control_timeout: Duration, + ) -> Result> { + let peer = format!("{addr}:{port}"); + debug!(%peer, "connecting TCP"); + let stream = tokio::time::timeout(connect_timeout, TcpStream::connect(&peer)) + .await + .map_err(|_| EventMeshError::Timeout(connect_timeout))??; + stream.set_nodelay(true).ok(); + + let mut framed = Framed::new(stream, TcpCodec::new()); + debug!("sending HELLO"); + let deadline = Self::deadline_after(control_timeout)?; + tokio::time::timeout_at(deadline, framed.send(message::hello(user_agent))) + .await + .map_err(|_| EventMeshError::Timeout(control_timeout))??; + + match tokio::time::timeout_at(deadline, framed.next()).await { + Err(_) => Err(EventMeshError::Timeout(control_timeout)), + Ok(None) => Err(EventMeshError::Tcp("connection closed during HELLO".into())), + Ok(Some(Err(error))) => Err(error), + Ok(Some(Ok(response))) if response.header.cmd == Command::HelloResponse => { + if response.header.code != 0 { + return Err(EventMeshError::Server { + code: response.header.code, + message: response + .header + .desc + .unwrap_or_else(|| "HELLO rejected".into()), + }); + } + debug!(code = response.header.code, "HELLO ok"); + Ok(framed) + } + Ok(Some(Ok(response))) => Err(EventMeshError::Tcp(format!( + "unexpected response to HELLO: {:?}", + response.header.cmd + ))), + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) async fn run( + addr: String, + port: u16, + user_agent: UserAgent, + heartbeat_interval: Duration, + connect_timeout: Duration, + control_timeout: Duration, + reconnect: ReconnectPolicy, + mut framed: Framed, + mut outbound_rx: mpsc::Receiver, + inbound_tx: mpsc::Sender, + reconnect_tx: mpsc::Sender<()>, + mut pending_cancel_rx: mpsc::UnboundedReceiver, + state: Arc>, + deliver_orphan_responses: Arc, + cancel: CancellationToken, + alive: Arc, + ) { + let mut dispatcher = RpcDispatcher::new(); + loop { + let generation = state.lock().await.generation; + let reason = SocketDriver::run( + &mut framed, + &mut outbound_rx, + &mut pending_cancel_rx, + &inbound_tx, + &mut dispatcher, + &deliver_orphan_responses, + generation, + heartbeat_interval, + control_timeout, + &cancel, + ) + .await; + + { + let mut state = state.lock().await; + state.active = false; + state.generation = state.generation.wrapping_add(1); + alive.store(false, Ordering::Release); + dispatcher.connection_lost(); + while outbound_rx.try_recv().is_ok() {} + } + + match reason { + IoExitReason::Cancelled | IoExitReason::AllSendersDropped => { + debug!(?reason, "connection task exiting"); + return; + } + IoExitReason::IoError | IoExitReason::ServerClosed | IoExitReason::SlowConsumer => { + } + } + + if !reconnect.enabled() || cancel.is_cancelled() { + debug!("reconnect disabled or cancelled, exiting"); + return; + } + + let mut backoff = reconnect.initial_backoff(); + let mut attempt = 0usize; + 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, "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()); + + let result = tokio::select! { + biased; + _ = cancel.cancelled() => { + debug!("cancelled during reconnect attempt"); + return; + } + result = Self::establish( + &addr, + port, + &user_agent, + connect_timeout, + control_timeout, + ) => result, + }; + + match result { + Ok(new_framed) => { + info!(attempt, peer = %format!("{addr}:{port}"), "TCP reconnected"); + state.lock().await.active = true; + alive.store(true, Ordering::Release); + let _ = reconnect_tx.try_send(()); + framed = new_framed; + break; + } + Err(error) => warn!(attempt, %error, "reconnect attempt failed"), + } + } + } + } + + fn deadline_after(timeout: Duration) -> Result { + tokio::time::Instant::now() + .checked_add(timeout) + .ok_or_else(|| EventMeshError::InvalidArgument("TCP timeout is too large".into())) + } +} 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..6507323522 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/consumer.rs @@ -0,0 +1,1571 @@ +// 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()`. +//! +//! See [`crate::tcp`] for the public client and consumer API. + +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::{ConsumerOptions, TcpConfig}; +use crate::error::{EventMeshError, Result}; +use crate::message::Message; +#[cfg(test)] +use crate::model::EventMeshMessage; +use crate::model::PublishResponse; +use crate::subscription::Subscription; +use crate::transport::task::BackgroundTask; +use crate::transport::tcp::connection::TcpConnection; +use crate::transport::tcp::frame::{Command, Package, PackageBody, RedirectInfo, UserAgent}; +use crate::transport::tcp::message; +use crate::MessageHandler; + +pub(crate) fn decode_message(pkg: &Package) -> Option { + if message::is_cloudevents(pkg) { + #[cfg(feature = "cloud_events")] + return message::parse_cloud_event(&pkg.body).map(Message::CloudEvent); + #[cfg(not(feature = "cloud_events"))] + return None; + } + + if !message::is_event_mesh_message(pkg) { + return None; + } + + let mut message = message::parse_message(&pkg.body)?; + if let Some(context) = message.delivery_context.as_mut() { + for (key, value) in &pkg.header.properties { + context.insert_missing( + key, + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()), + ); + } + } + Some(Message::EventMesh(message)) +} + +pub(crate) fn encode_reply(message: &Message) -> Result { + match message { + Message::EventMesh(message) => { + message::build_message_package(message, Command::ResponseToServer) + } + #[cfg(feature = "cloud_events")] + Message::CloudEvent(event) => { + message::build_cloud_event_package(event, Command::ResponseToServer) + } + } +} + +fn inherit_request_metadata(reply_message: &mut Message, request: &Message) { + #[cfg(feature = "cloud_events")] + if let (Message::CloudEvent(reply), Message::CloudEvent(request)) = + (&mut *reply_message, request) + { + for (key, value) in request.iter_extensions() { + if reply.extension(key).is_none() { + reply.set_extension(key, value.clone()); + } + } + return; + } + + // Normalize non-CloudEvent replies to EventMeshMessage before merging + // request metadata. + #[cfg(feature = "cloud_events")] + let reply = match reply_message.clone() { + Message::EventMesh(message) => Ok(message), + Message::CloudEvent(event) => message::cloud_event_to_message(&event), + }; + #[cfg(feature = "cloud_events")] + let request = match request.clone() { + Message::EventMesh(message) => Ok(message), + Message::CloudEvent(event) => message::cloud_event_to_message(&event), + }; + #[cfg(feature = "cloud_events")] + let (Ok(mut reply), Ok(request)) = (reply, request) else { + return; + }; + #[cfg(not(feature = "cloud_events"))] + let (mut reply, request) = match (reply_message.clone(), request.clone()) { + (Message::EventMesh(reply), Message::EventMesh(request)) => (reply, request), + }; + if reply.ttl.is_none() { + reply.ttl = request.ttl; + } + if reply.biz_seq_no.is_none() { + reply.biz_seq_no = request.biz_seq_no.clone(); + } + if reply.unique_id.is_none() { + reply.unique_id = request.unique_id.clone(); + } + // A reply always belongs to this request, even if the application returned + // a message originally received from another delivery. + reply.delivery_context = request.delivery_context.clone(); + for (key, value) in &request.props { + reply + .props + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + *reply_message = Message::EventMesh(reply); +} + +/// Why the TCP consumer's receive loop stopped. +/// +/// Returned by [`TcpConsumer::wait_for_shutdown`] so the caller can react to +/// server-driven events like redirect. +#[derive(Debug, Clone)] +pub enum ShutdownReason { + /// The shutdown token was cancelled — either by the user-supplied + /// shutdown signal, an explicit `shutdown()` call, or the driver itself + /// after a clean exit. + Cancelled, + /// The server sent `REDIRECT_TO_CLIENT` with a target address. The caller + /// should connect to the advertised EventMesh node. + /// + /// (The Java SDK ignores this frame entirely — it falls into the `default` + /// branch of the handler switch and is logged as a warning. The Rust SDK + /// surfaces it so the caller can act on it.) + Redirect(RedirectInfo), + /// The inbound channel closed (the connection was lost and not + /// re-established, or the consumer was dropped). + ChannelClosed, + /// The receive-loop driver task exited abnormally (panic or error). + Error(String), +} + +/// Result of dispatching a single inbound package. +enum InboundResult { + /// Keep the receive loop running. + Continue, + /// Stop the loop and report a redirect target to the caller. + Redirect(RedirectInfo), + /// Stop the loop (parse failure, unknown command, etc.). + Stop, +} + +/// TCP-based consumer, generic over the user's [`MessageHandler`] 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 [`request_shutdown`](Self::request_shutdown) / [`wait_for_shutdown`](Self::wait_for_shutdown). +pub struct TcpConsumer { + conn: Arc, + config: TcpConfig, + _listener: std::marker::PhantomData>, + shutdown: CancellationToken, + subscriptions: Arc>>, + driver_handle: Mutex>>, + /// Filled by the driver before it exits, so `wait_for_shutdown` can + /// return a structured [`ShutdownReason`]. + shutdown_reason: Arc>>, +} + +impl TcpConsumer +where + L: MessageHandler, +{ + /// 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 + /// [`request_shutdown`](Self::request_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: TcpConfig, + options: &ConsumerOptions, + listener: L, + shutdown_signal: Option + Send + 'static>, + ) -> Result { + let user_agent = UserAgent::from_role( + config.identity(), + config.credentials(), + options.group(), + config.endpoint().port(), + "sub", + ); + let conn = TcpConnection::connect( + &config.endpoint().authority_host(), + config.endpoint().port(), + &user_agent, + config.heartbeat_interval(), + config.connect_timeout(), + config.control_timeout(), + config.reconnect().clone(), + ) + .await?; + let conn = Arc::new(conn); + + let shutdown = CancellationToken::new(); + let subscriptions = Arc::new(Mutex::new(Vec::new())); + let shutdown_reason = Arc::new(Mutex::new(None)); + let listener = Arc::new(listener); + + // Signal watcher. + if let Some(signal) = shutdown_signal { + let token = shutdown.clone(); + tokio::spawn(async move { + tokio::select! { + _ = signal => token.cancel(), + _ = token.cancelled() => {} + } + }); + } + + // Send LISTEN_REQUEST and verify it succeeds. + let listen_pkg = message::listen(); + let listen_resp = conn.io(listen_pkg, config.control_timeout()).await?; + let listen_status = message::response_from_pkg(&listen_resp); + if !listen_status.is_success() { + return Err(EventMeshError::Server { + code: listen_status.code.unwrap_or(-1) as i32, + message: listen_status + .message + .unwrap_or_else(|| "listen failed".into()), + }); + } + debug!("LISTEN ok, entering receive loop"); + + // Take the inbound receiver (only available once). + let inbound_rx = conn + .take_inbound_rx() + .await + .ok_or_else(|| EventMeshError::Tcp("inbound receiver already taken".into()))?; + + // Take the reconnect-event receiver. + let reconnect_rx = conn.take_reconnect_rx().await; + + // Spawn the receive-loop driver. + let driver_handle = spawn_driver( + Arc::clone(&conn), + inbound_rx, + reconnect_rx, + Arc::clone(&listener), + config.clone(), + Arc::clone(&subscriptions), + shutdown.clone(), + Arc::clone(&shutdown_reason), + ); + + Ok(Self { + conn, + config, + _listener: std::marker::PhantomData, + shutdown, + subscriptions, + driver_handle: Mutex::new(BackgroundTask::new(driver_handle)), + shutdown_reason, + }) + } + + /// Subscribe to additional topics. Sends a `SUBSCRIBE_REQUEST` for each + /// item via `conn.io()` and records it locally after the server confirms. + /// + /// This can be called at any time after construction — the connection is + /// already open and the receive loop is running. + pub async fn subscribe(&self, items: &[Subscription]) -> 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.control_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.control_timeout()) + .await?; + let response = message::response_from_pkg(&resp); + + if !response.is_success() { + return Err(EventMeshError::Server { + code: response.code.unwrap_or(-1) as i32, + message: response + .message + .unwrap_or_else(|| "unsubscribe failed".into()), + }); + } + + let mut subs = self.subscriptions.lock().await; + let current: Vec = subs.iter().map(|s| s.topic.clone()).collect(); + let passed_all = + items.len() == current.len() && items.iter().all(|i| current.contains(&i.topic)); + if !passed_all { + warn!( + passed = ?items.iter().map(|i| i.topic.clone()).collect::>(), + current = ?current, + "TCP unsubscribe drops ALL topics on the server (not just \ + the ones passed); clearing local state to match" + ); + } + subs.clear(); + Ok(response) + } + + /// Unsubscribe every topic on this TCP session. + pub async fn unsubscribe_all(&self) -> Result { + let items = self.subscriptions.lock().await.clone(); + if items.is_empty() { + return Err(EventMeshError::InvalidArgument( + "TCP consumer has no active subscriptions".into(), + )); + } + self.unsubscribe(items).await + } + + /// Signal the receive-loop driver to stop. + pub fn request_shutdown(&self) { + self.shutdown.cancel(); + } + + /// Signal shutdown, close the connection, and await the driver task. + #[cfg(test)] + pub async fn shutdown(&self) { + self.request_shutdown(); + self.conn.shutdown().await; + self.wait_for_driver().await; + } + + async fn wait_for_driver(&self) { + let mut driver = self.driver_handle.lock().await; + driver.wait().await; + self.shutdown.cancel(); + // Acquire the state lock before consuming the task result, so a + // cancelled wait cannot lose an error while acquiring this lock. + let mut reason = self.shutdown_reason.lock().await; + match driver.take_result() { + Some(Ok(Err(error))) => { + *reason = Some(ShutdownReason::Error(error.to_string())); + } + Some(Err(error)) => { + *reason = Some(ShutdownReason::Error(format!( + "receive-loop driver task panicked: {error}" + ))); + } + _ => {} + } + } + + /// Block until the shutdown signal fires or the receive loop exits on its + /// own (e.g. server redirect or I/O error), then return a + /// [`ShutdownReason`] indicating why the driver stopped. + /// + /// If the driver task panics, the `JoinHandle` resolves with + /// `Err(JoinError)` and this method returns `ShutdownReason::Error` — it + /// does **not** hang forever waiting for the cancellation token (which the + /// panicked driver would never fire). + /// + /// If no shutdown signal was provided at construction time, this blocks + /// until the driver exits naturally. + pub async fn wait_for_shutdown(&self) -> ShutdownReason { + self.wait_for_driver().await; + self.conn.shutdown().await; + + self.shutdown_reason + .lock() + .await + .take() + .unwrap_or(ShutdownReason::Cancelled) + } +} + +impl Drop for TcpConsumer { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +// --------------------------------------------------------------------------- +// 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: TcpConfig, + subscriptions: Arc>>, + shutdown: CancellationToken, + shutdown_reason: Arc>>, +) -> JoinHandle> +where + L: MessageHandler, +{ + tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = shutdown.cancelled() => { + debug!("receive loop shutting down"); + *shutdown_reason.lock().await = Some(ShutdownReason::Cancelled); + break; + } + + // Reconnect event: the connection task has re-established + // the TCP session. Replay all subscriptions + LISTEN. + event = async { + match reconnect_rx.as_mut() { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + if event.is_none() { + info!("reconnect channel closed, exiting receive loop"); + *shutdown_reason.lock().await = Some(ShutdownReason::ChannelClosed); + break; + } + info!("connection reconnected, replaying subscriptions"); + let subs_snapshot = subscriptions.lock().await.clone(); + let mut replay_error = None; + for item in &subs_snapshot { + let sub_pkg = + message::subscribe(&item.topic, std::slice::from_ref(item)); + match conn.io(sub_pkg, config.control_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" + ); + replay_error = Some(format!( + "re-subscribe after reconnect rejected for topic {:?}: \ + server code {:?}", + item.topic, r.code + )); + break; + } + } + Err(e) => { + warn!( + topic = ?item.topic, + error = %e, + "re-subscribe after reconnect error" + ); + replay_error = Some(format!( + "re-subscribe after reconnect failed for topic {:?}: {e}", + item.topic + )); + break; + } + } + } + + if let Some(error) = replay_error { + *shutdown_reason.lock().await = Some(ShutdownReason::Error(error)); + break; + } + + match conn.io(message::listen(), config.control_timeout()).await { + Ok(resp) => { + let r = message::response_from_pkg(&resp); + if !r.is_success() { + warn!(code = r.code, "re-LISTEN after reconnect rejected"); + *shutdown_reason.lock().await = Some(ShutdownReason::Error( + format!( + "re-LISTEN after reconnect rejected: server code {:?}", + r.code + ), + )); + break; + } + debug!("re-LISTEN ok after reconnect"); + } + Err(e) => { + warn!(error = %e, "re-LISTEN after reconnect error"); + *shutdown_reason.lock().await = Some(ShutdownReason::Error(format!( + "re-LISTEN after reconnect failed: {e}" + ))); + break; + } + } + } + + // Inbound message from the server. + pkg = inbound_rx.recv() => { + match pkg { + Some(pkg) => { + match handle_inbound(&pkg, &conn, &*listener).await { + InboundResult::Continue => {} + InboundResult::Redirect(ri) => { + info!( + ip = %ri.ip, + port = ri.port, + "receive loop stopping after REDIRECT_TO_CLIENT" + ); + *shutdown_reason.lock().await = + Some(ShutdownReason::Redirect(ri)); + break; + } + InboundResult::Stop => { + info!("receive loop stopping (disconnect or parse failure)"); + // The consumer keeps an Arc to the connection after the + // driver exits. Close it here so an unacknowledged delivery + // is released for server-side redelivery instead of leaving + // the I/O task and socket alive until the caller joins. + conn.shutdown().await; + *shutdown_reason.lock().await = + Some(ShutdownReason::ChannelClosed); + break; + } + } + } + None => { + info!("inbound channel closed, exiting receive loop"); + *shutdown_reason.lock().await = Some(ShutdownReason::ChannelClosed); + break; + } + } + } + } + } + + conn.shutdown().await; + // Signal wait_for_shutdown that we've exited. + shutdown.cancel(); + Ok(()) + }) +} + +/// Dispatch an inbound package: parse the message, invoke the listener, send +/// any reply, then send the matching ACK. +/// +/// Returns [`InboundResult::Continue`] to keep the receive loop running, +/// [`InboundResult::Redirect`] to stop and report a redirect target, or +/// [`InboundResult::Stop`] to stop the loop (which triggers reconnection +/// and server-side redelivery). +/// +/// If the message cannot be parsed, **no ACK is sent** and the function +/// returns [`InboundResult::Stop`] so the connection is torn down — mirroring +/// the Java SDK, where a parse exception propagates to `exceptionCaught` and +/// closes the channel. A listener failure or a reply encoding / enqueue +/// failure follows the same no-ACK path so the server can redeliver the +/// request. An unwinding listener panic is logged and leaves only that delivery +/// without a reply or ACK; the receive loop continues with the next message. +async fn handle_inbound(pkg: &Package, conn: &TcpConnection, listener: &L) -> InboundResult +where + L: MessageHandler, +{ + let ack_cmd = match pkg.header.cmd { + Command::RequestToClient => Some(Command::RequestToClientAck), + Command::AsyncMessageToClient => Some(Command::AsyncMessageToClientAck), + Command::BroadcastMessageToClient => Some(Command::BroadcastMessageToClientAck), + Command::ServerGoodbyeRequest => { + info!("server goodbye received, sending SERVER_GOODBYE_RESPONSE"); + let resp = message::ack(Command::ServerGoodbyeResponse, pkg); + if let Err(e) = conn.send(resp).await { + warn!(error = %e, "failed to send SERVER_GOODBYE_RESPONSE"); + } + return InboundResult::Continue; + } + Command::RedirectToClient => { + match pkg.body { + PackageBody::RedirectInfo(ref ri) => { + info!( + ip = %ri.ip, + port = ri.port, + "received REDIRECT_TO_CLIENT; stopping receive loop so the \ + caller can reconnect to the advertised EventMesh node" + ); + return InboundResult::Redirect(ri.clone()); + } + PackageBody::Text(ref s) => warn!( + body = %s, + "REDIRECT_TO_CLIENT body did not deserialize into RedirectInfo; \ + stopping receive loop" + ), + ref other => warn!( + body = ?other, + "unexpected body shape for REDIRECT_TO_CLIENT; stopping receive loop" + ), + } + return InboundResult::Stop; + } + cmd => { + warn!(?cmd, "unexpected inbound command, ignoring"); + return InboundResult::Continue; + } + }; + + let msg = match decode_message(pkg) { + Some(msg) => msg, + None => { + warn!("failed to parse inbound message body; disconnecting without ACK"); + return InboundResult::Stop; + } + }; + + debug!("dispatching to listener"); + let request = (pkg.header.cmd == Command::RequestToClient).then(|| msg.clone()); + // Include both construction and polling of the handler future in the panic + // boundary. No SDK state is mutated inside it; application state remains + // the handler's responsibility when it is called again after a panic. + let handled = AssertUnwindSafe(async { listener.handle(msg).await }) + .catch_unwind() + .await; + let reply = match handled { + Ok(Ok(reply)) => reply, + Ok(Err(error)) => { + warn!(%error, "listener failed; disconnecting without ACK"); + return InboundResult::Stop; + } + Err(panic) => { + let reason = panic + .downcast_ref::<&str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or("non-string panic payload"); + warn!( + seq = ?pkg.header.seq, + reason, + "TCP handler panicked; continuing without replying or ACKing this delivery" + ); + return InboundResult::Continue; + } + }; + if let Some(mut reply) = reply { + if let Some(request) = request.as_ref() { + inherit_request_metadata(&mut reply, request); + } + let reply_pkg = match encode_reply(&reply) { + Ok(reply_pkg) => reply_pkg, + Err(error) => { + warn!(%error, "failed to serialize reply; disconnecting without ACK"); + return InboundResult::Stop; + } + }; + if let Err(error) = conn.send(reply_pkg).await { + warn!(%error, "failed to send reply; disconnecting without ACK"); + return InboundResult::Stop; + } + } + + if let Some(cmd) = ack_cmd { + let ack_pkg = message::ack(cmd, pkg); + if let Err(e) = conn.send(ack_pkg).await { + warn!(error = %e, "failed to send ACK"); + } + } + + InboundResult::Continue +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use super::*; + use crate::config::{ConsumerOptions, Endpoint, ReconnectPolicy, TcpConfig}; + use crate::subscription::{DeliveryType, Subscription}; + 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 MessageHandler for NoopListener { + async fn handle(&self, _: Message) -> Result> { + Ok(None) + } + } + + #[tokio::test] + async fn cancelled_join_preserves_tcp_driver_failure() { + 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()); + for (request, response) in [ + (Command::HelloRequest, Command::HelloResponse), + (Command::ListenRequest, Command::ListenResponse), + ] { + let package = framed.next().await.unwrap().unwrap(); + assert_eq!(package.header.cmd, request); + framed + .send(Package::new(Header::new( + response, + package.header.seq.unwrap(), + ))) + .await + .unwrap(); + } + while framed.next().await.is_some() {} + }); + let consumer = TcpConsumer::connect( + TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_reconnect(ReconnectPolicy::default().with_enabled(false)), + &ConsumerOptions::new("consumer"), + NoopListener, + None::>, + ) + .await + .unwrap(); + let (release, released) = tokio::sync::oneshot::channel::<()>(); + *consumer.driver_handle.lock().await = BackgroundTask::new(tokio::spawn(async move { + released.await.unwrap(); + Err(EventMeshError::Tcp("driver regression".into())) + })); + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_millis(20), consumer.wait_for_shutdown()) + .await + .is_err() + ); + consumer.request_shutdown(); + } + release.send(()).unwrap(); + let reason = tokio::time::timeout(Duration::from_secs(3), consumer.wait_for_shutdown()) + .await + .unwrap(); + assert!(matches!(reason, ShutdownReason::Error(message) + if message.contains("driver regression"))); + tokio::time::timeout(Duration::from_secs(3), server) + .await + .unwrap() + .unwrap(); + } + + /// A listener failure must tear down the TCP session without ACKing the + /// delivery so the server can redeliver it on a subsequent connection. + struct FailingListener; + impl MessageHandler for FailingListener { + async fn handle(&self, _: Message) -> Result> { + Err(EventMeshError::Tcp("listener failure".into())) + } + } + + struct PanicsOnce { + calls: AtomicUsize, + panic_before_future: bool, + } + + impl MessageHandler for PanicsOnce { + fn handle(&self, _: Message) -> impl Future>> + Send { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if call == 0 && self.panic_before_future { + panic!("handler panicked before returning its future"); + } + async move { + tokio::task::yield_now().await; + if call == 0 { + std::panic::panic_any(String::from("handler panicked after yielding")); + } + Ok(None) + } + } + } + + async fn assert_handler_panic_isolated(panic_before_future: bool) { + 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()); + for (request, response) in [ + (Command::HelloRequest, Command::HelloResponse), + (Command::ListenRequest, Command::ListenResponse), + ] { + let package = framed.next().await.unwrap().unwrap(); + assert_eq!(package.header.cmd, request); + framed + .send(Package::new(Header::new( + response, + package.header.seq.unwrap(), + ))) + .await + .unwrap(); + } + + for (seq, command) in [ + ("first", Command::RequestToClient), + ("second", Command::AsyncMessageToClient), + ] { + let mut package = message::build_message_package( + &EventMeshMessage::new("topic", seq).unwrap(), + command, + ) + .unwrap(); + package.header.seq = Some(seq.into()); + framed.send(package).await.unwrap(); + } + + // No reply, ACK, or disconnect may precede the second delivery's ACK. + let ack = tokio::time::timeout(Duration::from_secs(3), framed.next()) + .await + .expect("the next delivery must be handled after a panic") + .expect("the same connection must remain open") + .unwrap(); + assert_eq!(ack.header.cmd, Command::AsyncMessageToClientAck); + assert_eq!(ack.header.seq.as_deref(), Some("second")); + framed + }); + + let consumer = TcpConsumer::connect( + TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_reconnect(ReconnectPolicy::default().with_enabled(false)) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)), + &ConsumerOptions::new("g"), + PanicsOnce { + calls: AtomicUsize::new(0), + panic_before_future, + }, + None::>, + ) + .await + .unwrap(); + + let _server_connection = server.await.unwrap(); + assert!(consumer.conn.is_active()); + consumer.request_shutdown(); + let reason = tokio::time::timeout(Duration::from_secs(3), consumer.wait_for_shutdown()) + .await + .expect("consumer must still shut down cleanly"); + assert!(matches!(reason, ShutdownReason::Cancelled)); + } + + #[tokio::test] + async fn handler_panic_before_future_does_not_stop_next_delivery() { + assert_handler_panic_isolated(true).await; + } + + #[tokio::test] + async fn handler_panic_during_poll_does_not_stop_next_delivery() { + assert_handler_panic_isolated(false).await; + } + + /// A reply that fails the TCP CloudEvents compatibility check must not ACK + /// the request or leave the session accepting deliveries. + #[cfg(feature = "cloud_events")] + struct ReplyEncodingFailingListener; + + #[cfg(feature = "cloud_events")] + impl MessageHandler for ReplyEncodingFailingListener { + async fn handle(&self, _: Message) -> Result> { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let reply = EventBuilderV10::new() + .id("reply") + .source("urn:test") + .ty("reply") + .subject("topic") + .data("text/plain", "reply") + .build() + .unwrap(); + Ok(Some(Message::CloudEvent(reply))) + } + } + + #[test] + fn public_message_rejects_unknown_protocol() { + let mut package = message::build_message_package( + &EventMeshMessage::new("orders", "created").unwrap(), + Command::AsyncMessageToClient, + ) + .unwrap(); + package.header.set_property("protocoltype", "openmessage"); + + assert!(decode_message(&package).is_none()); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn public_message_preserves_cloud_event_protocol() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("event-1") + .source("urn:test") + .ty("orders.created") + .subject("orders") + .data("application/cloudevents+json", "created") + .build() + .expect("build event"); + let package = + message::build_cloud_event_package(&event, Command::AsyncMessageToClient).unwrap(); + let decoded = decode_message(&package).expect("decode message"); + match decoded { + Message::CloudEvent(decoded) => { + use cloudevents::AttributesReader; + assert_eq!(decoded.id(), "event-1"); + assert_eq!(decoded.subject(), Some("orders")); + assert!(decoded.data().is_some()); + } + other => panic!("expected CloudEvent, got {other:?}"), + } + } + + #[tokio::test] + async fn listener_error_closes_tcp_connection_without_ack() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new( + Command::HelloResponse, + "hello-seq", + ))) + .await + .unwrap(); + + let listen = framed.next().await.unwrap().unwrap(); + assert_eq!(listen.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + listen.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + let delivery = message::build_message_package( + &EventMeshMessage::builder() + .topic("topic") + .content("payload") + .build() + .unwrap(), + Command::AsyncMessageToClient, + ) + .unwrap(); + framed.send(delivery).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(3), async { + loop { + match framed.next().await { + Some(Ok(pkg)) if pkg.header.cmd == Command::ClientGoodbyeRequest => {} + Some(Ok(pkg)) => { + panic!( + "listener failure must not ACK delivery; got {:?}", + pkg.header.cmd + ) + } + Some(Err(_)) | None => break, + } + } + }) + .await + .expect("listener failure should close the TCP connection promptly"); + }); + + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)); + let consumer = TcpConsumer::connect( + config, + &ConsumerOptions::new("g"), + FailingListener, + None::>, + ) + .await + .expect("connect"); + + server.await.unwrap(); + assert!( + !consumer.conn.is_active(), + "listener failure must stop the connection I/O task without requiring join" + ); + } + + #[cfg(feature = "cloud_events")] + #[tokio::test] + async fn reply_encoding_error_closes_tcp_connection_without_ack() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new( + Command::HelloResponse, + "hello-seq", + ))) + .await + .unwrap(); + + let listen = framed.next().await.unwrap().unwrap(); + assert_eq!(listen.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + listen.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + use cloudevents::{EventBuilder, EventBuilderV10}; + let request = EventBuilderV10::new() + .id("request") + .source("urn:test") + .ty("request") + .subject("topic") + .data("application/cloudevents+json", "payload") + .build() + .unwrap(); + let delivery = + message::build_cloud_event_package(&request, Command::RequestToClient).unwrap(); + framed.send(delivery).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(3), async { + loop { + match framed.next().await { + Some(Ok(pkg)) if pkg.header.cmd == Command::ClientGoodbyeRequest => {} + Some(Ok(pkg)) => { + panic!( + "reply encoding failure must not send a reply or ACK; got {:?}", + pkg.header.cmd + ) + } + Some(Err(_)) | None => break, + } + } + }) + .await + .expect("reply encoding failure should close the TCP connection promptly"); + }); + + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)); + let consumer = TcpConsumer::connect( + config, + &ConsumerOptions::new("g"), + ReplyEncodingFailingListener, + None::>, + ) + .await + .expect("connect"); + + server.await.unwrap(); + assert!( + !consumer.conn.is_active(), + "reply encoding failure must stop the connection I/O task without requiring join" + ); + } + + #[test] + fn request_reply_inherits_routing_properties_without_overwriting_reply() { + let request = EventMeshMessage::builder() + .topic("request-topic") + .content("request") + .ttl_millis(4000) + .prop("correlation-id", "request-id") + .build() + .unwrap(); + let reply = EventMeshMessage::builder() + .topic("reply-topic") + .content("reply") + .prop("correlation-id", "reply-id") + .build() + .unwrap(); + + let request = crate::transport::decode_native_message( + request, + std::collections::HashMap::from([ + ("cluster".into(), "remote-cluster".into()), + ("correlation-id".into(), "request-id".into()), + ("ttl".into(), "4000".into()), + ]), + ) + .unwrap(); + let mut reply = Message::from(reply); + inherit_request_metadata(&mut reply, &Message::from(request)); + let pkg = encode_reply(&reply).expect("encode reply"); + let encoded = message::parse_message(&pkg.body).expect("decode reply"); + + assert_eq!(encoded.ttl_millis(), Some(4000)); + assert_eq!(encoded.get_prop("ttl"), None); + assert_eq!(encoded.get_prop("cluster"), None); + assert_eq!( + encoded.delivery_context().unwrap().attribute("cluster"), + Some("remote-cluster") + ); + assert_eq!(encoded.get_prop("correlation-id"), Some("reply-id")); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloud_event_reply_inherits_request_extensions() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let request = EventBuilderV10::new() + .id("request") + .source("urn:test") + .ty("test") + .extension("cluster", "remote-cluster") + .build() + .expect("build request"); + let reply = EventBuilderV10::new() + .id("reply") + .source("urn:test") + .ty("test") + .build() + .expect("build reply"); + + let mut reply = Message::from(reply); + inherit_request_metadata(&mut reply, &Message::from(request)); + assert_eq!( + match reply { + Message::CloudEvent(reply) => reply.extension("cluster").unwrap().to_string(), + other => panic!("expected CloudEvent, got {other:?}"), + }, + "remote-cluster" + ); + } + + /// Loopback test: the runtime's TCP `UnSubscribeProcessor` ignores the + /// request body and drops **all** session topics. After subscribing to A + /// and B and calling `unsubscribe([A])`, the local `subscriptions` map + /// must be empty (not just missing A) so it matches the server. + #[tokio::test] + async fn unsubscribe_clears_all_local_state() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + // 1. HELLO handshake. + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + let hello_resp = Package::new(Header::new(Command::HelloResponse, "hello-seq")); + framed.send(hello_resp).await.unwrap(); + + // 2. Reply to LISTEN_REQUEST. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 3. Reply to each SUBSCRIBE_REQUEST with SubscribeResponse (code 0). + for _ in 0..2 { + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::SubscribeRequest); + let resp = Package::new(Header::new( + Command::SubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + )); + framed.send(resp).await.unwrap(); + } + + // 4. Reply to the UNSUBSCRIBE_REQUEST with UnsubscribeResponse (code 0). + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::UnsubscribeRequest); + let resp = Package::new(Header::new( + Command::UnsubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + )); + framed.send(resp).await.unwrap(); + + // Keep the connection alive until the client drops it. + let _ = framed.close().await; + }); + + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)); + + let consumer = TcpConsumer::connect( + config, + &ConsumerOptions::new("g"), + NoopListener, + None::>, + ) + .await + .expect("connect"); + + // Subscribe to two topics. Each call records into `self.subscriptions`. + let item_a = Subscription::new("A").with_delivery_type(DeliveryType::Sync); + let item_b = Subscription::new("B").with_delivery_type(DeliveryType::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 = Subscription::new("A").with_delivery_type(DeliveryType::Sync); + consumer + .unsubscribe(vec![item_a]) + .await + .expect("unsubscribe A"); + { + let subs = consumer.subscriptions.lock().await; + assert!( + subs.is_empty(), + "local subscriptions must be fully cleared after unsubscribe, got: {:?}", + *subs + ); + } + + consumer.shutdown().await; + let _ = server.await; + } + + /// When the server returns a non-zero code for UNSUBSCRIBE_REQUEST, the + /// SDK must return `Err(Server)` — not `Ok` with a failed response. + /// Local subscription state must be preserved on failure. + #[tokio::test] + async fn unsubscribe_nonzero_returns_err() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + // 1. HELLO handshake. + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new( + Command::HelloResponse, + "hello-seq", + ))) + .await + .unwrap(); + + // 2. Reply to LISTEN_REQUEST. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 3. Reply to SUBSCRIBE_REQUEST with code 0. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::SubscribeRequest); + framed + .send(Package::new(Header::new( + Command::SubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 4. Reply to UNSUBSCRIBE_REQUEST with code 1 (FAIL). + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::UnsubscribeRequest); + let mut resp = Header::new( + Command::UnsubscribeResponse, + req.header.seq.clone().unwrap_or_default(), + ); + resp.code = 1; + resp.desc = Some("group not found".into()); + framed.send(Package::new(resp)).await.unwrap(); + + let _ = framed.close().await; + }); + + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)); + + let consumer = TcpConsumer::connect( + config, + &ConsumerOptions::new("g"), + NoopListener, + None::>, + ) + .await + .expect("connect"); + + let item = Subscription::new("A").with_delivery_type(DeliveryType::Sync); + consumer.subscribe(&[item]).await.expect("subscribe"); + + // Server returns code 1 → must be Err, not Ok. + let item = Subscription::new("A").with_delivery_type(DeliveryType::Sync); + let err = consumer + .unsubscribe(vec![item]) + .await + .expect_err("should fail"); + assert!( + err.to_string().contains("server error"), + "expected Server error, got: {err}" + ); + + // Local state must be preserved on failure. + let subs = consumer.subscriptions.lock().await; + assert_eq!( + subs.len(), + 1, + "subscriptions must not be cleared on failure" + ); + + consumer.shutdown().await; + let _ = server.await; + } + + #[tokio::test] + async fn rejected_reconnect_replay_stops_the_consumer() { + 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 first = Framed::new(stream, TcpCodec::new()); + let hello = first.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + first + .send(Package::new(Header::new( + Command::HelloResponse, + hello.header.seq.unwrap_or_default(), + ))) + .await + .unwrap(); + let listen = first.next().await.unwrap().unwrap(); + assert_eq!(listen.header.cmd, Command::ListenRequest); + first + .send(Package::new(Header::new( + Command::ListenResponse, + listen.header.seq.unwrap_or_default(), + ))) + .await + .unwrap(); + let subscribe = first.next().await.unwrap().unwrap(); + assert_eq!(subscribe.header.cmd, Command::SubscribeRequest); + first + .send(Package::new(Header::new( + Command::SubscribeResponse, + subscribe.header.seq.unwrap_or_default(), + ))) + .await + .unwrap(); + drop(first); + + let (stream, _) = listener.accept().await.unwrap(); + let mut second = Framed::new(stream, TcpCodec::new()); + let hello = second.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + second + .send(Package::new(Header::new( + Command::HelloResponse, + hello.header.seq.unwrap_or_default(), + ))) + .await + .unwrap(); + let replay = second.next().await.unwrap().unwrap(); + assert_eq!(replay.header.cmd, Command::SubscribeRequest); + let mut rejection = Header::new( + Command::SubscribeResponse, + replay.header.seq.unwrap_or_default(), + ); + rejection.code = 17; + rejection.desc = Some("replay rejected".into()); + second.send(Package::new(rejection)).await.unwrap(); + + let _ = tokio::time::timeout(Duration::from_secs(3), second.next()).await; + }); + + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)) + .with_reconnect( + ReconnectPolicy::default() + .with_enabled(true) + .with_initial_backoff(Duration::from_millis(20)) + .with_max_backoff(Duration::from_millis(50)), + ); + let consumer = TcpConsumer::connect( + config, + &ConsumerOptions::new("g"), + NoopListener, + None::>, + ) + .await + .expect("connect"); + consumer + .subscribe(&[Subscription::new("orders")]) + .await + .expect("initial subscribe"); + + let reason = tokio::time::timeout(Duration::from_secs(5), consumer.wait_for_shutdown()) + .await + .expect("rejected replay must stop the consumer"); + assert!(matches!( + reason, + ShutdownReason::Error(message) if message.contains("re-subscribe") + )); + server.await.unwrap(); + } + + /// The server sends `REDIRECT_TO_CLIENT` with an `ip`/`port` body. + /// The receive loop must stop promptly and `wait_for_shutdown` must + /// return `ShutdownReason::Redirect` carrying the advertised address. + #[tokio::test] + async fn redirect_to_client_returns_redirect_reason() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut framed = Framed::new(stream, TcpCodec::new()); + + // 1. HELLO handshake. + let hello = framed.next().await.unwrap().unwrap(); + assert_eq!(hello.header.cmd, Command::HelloRequest); + framed + .send(Package::new(Header::new( + Command::HelloResponse, + "hello-seq", + ))) + .await + .unwrap(); + + // 2. Reply to LISTEN_REQUEST. + let req = framed.next().await.unwrap().unwrap(); + assert_eq!(req.header.cmd, Command::ListenRequest); + framed + .send(Package::new(Header::new( + Command::ListenResponse, + req.header.seq.clone().unwrap_or_default(), + ))) + .await + .unwrap(); + + // 3. Send REDIRECT_TO_CLIENT. + let redirect = Package::new(Header::new(Command::RedirectToClient, "redirect-seq")) + .with_body(PackageBody::RedirectInfo(RedirectInfo { + ip: "10.0.0.9".into(), + port: 10000, + })); + framed.send(redirect).await.unwrap(); + + let _ = framed.close().await; + }); + + let config = TcpConfig::new(Endpoint::new("127.0.0.1", port).unwrap()) + .with_control_timeout(Duration::from_secs(3)) + .with_heartbeat_interval(Duration::from_secs(60)); + + let consumer = TcpConsumer::connect( + config, + &ConsumerOptions::new("g"), + NoopListener, + None::>, + ) + .await + .expect("connect"); + + // The redirect frame should make the driver exit on its own. + // wait_for_shutdown must return promptly with the redirect reason. + let reason = + tokio::time::timeout(Duration::from_secs(10), consumer.wait_for_shutdown()).await; + assert!( + reason.is_ok(), + "REDIRECT_TO_CLIENT should stop the receive loop promptly" + ); + + match reason.unwrap() { + ShutdownReason::Redirect(ri) => { + assert_eq!(ri.ip, "10.0.0.9", "redirect ip must match"); + assert_eq!(ri.port, 10000, "redirect port must match"); + } + other => panic!("expected ShutdownReason::Redirect, got {other:?}"), + } + + let _ = server.await; + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/frame.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/frame.rs new file mode 100644 index 0000000000..c8328b4bd3 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/frame.rs @@ -0,0 +1,679 @@ +// 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::subscription::Subscription as TopicSubscription; + +// --------------------------------------------------------------------------- +// 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 { + /// The wire name — the exact Java `Command` enum constant name in + /// SCREAMING_SNAKE_CASE, which is how the Java runtime (Jackson default) + /// serializes the `cmd` field on the wire. + pub fn name(self) -> &'static str { + match self { + Self::HeartbeatRequest => "HEARTBEAT_REQUEST", + Self::HeartbeatResponse => "HEARTBEAT_RESPONSE", + Self::HelloRequest => "HELLO_REQUEST", + Self::HelloResponse => "HELLO_RESPONSE", + Self::ClientGoodbyeRequest => "CLIENT_GOODBYE_REQUEST", + Self::ClientGoodbyeResponse => "CLIENT_GOODBYE_RESPONSE", + Self::ServerGoodbyeRequest => "SERVER_GOODBYE_REQUEST", + Self::ServerGoodbyeResponse => "SERVER_GOODBYE_RESPONSE", + Self::SubscribeRequest => "SUBSCRIBE_REQUEST", + Self::SubscribeResponse => "SUBSCRIBE_RESPONSE", + Self::UnsubscribeRequest => "UNSUBSCRIBE_REQUEST", + Self::UnsubscribeResponse => "UNSUBSCRIBE_RESPONSE", + Self::ListenRequest => "LISTEN_REQUEST", + Self::ListenResponse => "LISTEN_RESPONSE", + Self::RequestToServer => "REQUEST_TO_SERVER", + Self::RequestToClient => "REQUEST_TO_CLIENT", + Self::RequestToClientAck => "REQUEST_TO_CLIENT_ACK", + Self::ResponseToServer => "RESPONSE_TO_SERVER", + Self::ResponseToClient => "RESPONSE_TO_CLIENT", + Self::ResponseToClientAck => "RESPONSE_TO_CLIENT_ACK", + Self::AsyncMessageToServer => "ASYNC_MESSAGE_TO_SERVER", + Self::AsyncMessageToServerAck => "ASYNC_MESSAGE_TO_SERVER_ACK", + Self::AsyncMessageToClient => "ASYNC_MESSAGE_TO_CLIENT", + Self::AsyncMessageToClientAck => "ASYNC_MESSAGE_TO_CLIENT_ACK", + Self::BroadcastMessageToServer => "BROADCAST_MESSAGE_TO_SERVER", + Self::BroadcastMessageToServerAck => "BROADCAST_MESSAGE_TO_SERVER_ACK", + Self::BroadcastMessageToClient => "BROADCAST_MESSAGE_TO_CLIENT", + Self::BroadcastMessageToClientAck => "BROADCAST_MESSAGE_TO_CLIENT_ACK", + Self::SysLogToLogServer => "SYS_LOG_TO_LOGSERVER", + Self::TraceLogToLogServer => "TRACE_LOG_TO_LOGSERVER", + Self::RedirectToClient => "REDIRECT_TO_CLIENT", + Self::RegisterRequest => "REGISTER_REQUEST", + Self::RegisterResponse => "REGISTER_RESPONSE", + Self::UnregisterRequest => "UNREGISTER_REQUEST", + Self::UnregisterResponse => "UNREGISTER_RESPONSE", + Self::RecommendRequest => "RECOMMEND_REQUEST", + Self::RecommendResponse => "RECOMMEND_RESPONSE", + } + } + + /// Reverse lookup of [`Command::name`]. + pub fn from_name(name: &str) -> Option { + Some(match name { + "HEARTBEAT_REQUEST" => Self::HeartbeatRequest, + "HEARTBEAT_RESPONSE" => Self::HeartbeatResponse, + "HELLO_REQUEST" => Self::HelloRequest, + "HELLO_RESPONSE" => Self::HelloResponse, + "CLIENT_GOODBYE_REQUEST" => Self::ClientGoodbyeRequest, + "CLIENT_GOODBYE_RESPONSE" => Self::ClientGoodbyeResponse, + "SERVER_GOODBYE_REQUEST" => Self::ServerGoodbyeRequest, + "SERVER_GOODBYE_RESPONSE" => Self::ServerGoodbyeResponse, + "SUBSCRIBE_REQUEST" => Self::SubscribeRequest, + "SUBSCRIBE_RESPONSE" => Self::SubscribeResponse, + "UNSUBSCRIBE_REQUEST" => Self::UnsubscribeRequest, + "UNSUBSCRIBE_RESPONSE" => Self::UnsubscribeResponse, + "LISTEN_REQUEST" => Self::ListenRequest, + "LISTEN_RESPONSE" => Self::ListenResponse, + "REQUEST_TO_SERVER" => Self::RequestToServer, + "REQUEST_TO_CLIENT" => Self::RequestToClient, + "REQUEST_TO_CLIENT_ACK" => Self::RequestToClientAck, + "RESPONSE_TO_SERVER" => Self::ResponseToServer, + "RESPONSE_TO_CLIENT" => Self::ResponseToClient, + "RESPONSE_TO_CLIENT_ACK" => Self::ResponseToClientAck, + "ASYNC_MESSAGE_TO_SERVER" => Self::AsyncMessageToServer, + "ASYNC_MESSAGE_TO_SERVER_ACK" => Self::AsyncMessageToServerAck, + "ASYNC_MESSAGE_TO_CLIENT" => Self::AsyncMessageToClient, + "ASYNC_MESSAGE_TO_CLIENT_ACK" => Self::AsyncMessageToClientAck, + "BROADCAST_MESSAGE_TO_SERVER" => Self::BroadcastMessageToServer, + "BROADCAST_MESSAGE_TO_SERVER_ACK" => Self::BroadcastMessageToServerAck, + "BROADCAST_MESSAGE_TO_CLIENT" => Self::BroadcastMessageToClient, + "BROADCAST_MESSAGE_TO_CLIENT_ACK" => Self::BroadcastMessageToClientAck, + "SYS_LOG_TO_LOGSERVER" => Self::SysLogToLogServer, + "TRACE_LOG_TO_LOGSERVER" => Self::TraceLogToLogServer, + "REDIRECT_TO_CLIENT" => Self::RedirectToClient, + "REGISTER_REQUEST" => Self::RegisterRequest, + "REGISTER_RESPONSE" => Self::RegisterResponse, + "UNREGISTER_REQUEST" => Self::UnregisterRequest, + "UNREGISTER_RESPONSE" => Self::UnregisterResponse, + "RECOMMEND_REQUEST" => Self::RecommendRequest, + "RECOMMEND_RESPONSE" => Self::RecommendResponse, + _ => return None, + }) + } +} + +impl TryFrom for Command { + type Error = EventMeshError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0 => Self::HeartbeatRequest, + 1 => Self::HeartbeatResponse, + 2 => Self::HelloRequest, + 3 => Self::HelloResponse, + 4 => Self::ClientGoodbyeRequest, + 5 => Self::ClientGoodbyeResponse, + 6 => Self::ServerGoodbyeRequest, + 7 => Self::ServerGoodbyeResponse, + 8 => Self::SubscribeRequest, + 9 => Self::SubscribeResponse, + 10 => Self::UnsubscribeRequest, + 11 => Self::UnsubscribeResponse, + 12 => Self::ListenRequest, + 13 => Self::ListenResponse, + 14 => Self::RequestToServer, + 15 => Self::RequestToClient, + 16 => Self::RequestToClientAck, + 17 => Self::ResponseToServer, + 18 => Self::ResponseToClient, + 19 => Self::ResponseToClientAck, + 20 => Self::AsyncMessageToServer, + 21 => Self::AsyncMessageToServerAck, + 22 => Self::AsyncMessageToClient, + 23 => Self::AsyncMessageToClientAck, + 24 => Self::BroadcastMessageToServer, + 25 => Self::BroadcastMessageToServerAck, + 26 => Self::BroadcastMessageToClient, + 27 => Self::BroadcastMessageToClientAck, + 28 => Self::SysLogToLogServer, + 29 => Self::TraceLogToLogServer, + 30 => Self::RedirectToClient, + 31 => Self::RegisterRequest, + 32 => Self::RegisterResponse, + 33 => Self::UnregisterRequest, + 34 => Self::UnregisterResponse, + 35 => Self::RecommendRequest, + 36 => Self::RecommendResponse, + other => return Err(EventMeshError::Tcp(format!("unknown command: {other}"))), + }) + } +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- + +/// Frame header — JSON-serialized on the wire. +/// +/// The `cmd` field is serialized/deserialized as the Java `Command` enum +/// constant name string (e.g. `"HELLO_RESPONSE"`) to match the Java server's +/// `Header` JSON (where `Command` is serialized by Jackson's default enum +/// handling). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Header { + /// Command type (serialized as the Java enum constant name). + #[serde(with = "command_serde")] + pub cmd: Command, + /// Status code (0 = success). + pub code: i32, + /// Optional description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub desc: Option, + /// Correlation key (random 10-char string generated per request). + /// + /// Optional on the wire: the Java runtime sends some server-initiated + /// frames (`SERVER_GOODBYE_REQUEST`, `REDIRECT_TO_CLIENT`) with `seq = + /// null`, which `JsonUtils` omits. Treating the field as `Option` + /// lets us decode those valid frames instead of rejecting them for a + /// missing required field before `handle_inbound` can ACK them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, + /// Arbitrary key-value properties (e.g. `protocol_type`). + #[serde(default)] + pub properties: HashMap, +} + +impl Header { + /// Create a new header with the given command and a correlation seq. + /// + /// `seq` is stored as `Some(seq)` — client-originated frames always carry + /// a seq so the server can correlate the reply. Server-initiated frames + /// with no seq are only ever *received* (built directly on the wire by the + /// Java runtime), so there is no need to construct a `None`-seq header here. + pub fn new(cmd: Command, seq: impl Into) -> Self { + Self { + cmd, + code: 0, + desc: None, + seq: Some(seq.into()), + properties: HashMap::new(), + } + } + + /// Set a string property. + pub fn set_property(&mut self, key: impl Into, value: impl Into) -> &mut Self { + self.properties + .insert(key.into(), serde_json::Value::String(value.into())); + self + } + + /// Get a string property. + pub fn get_string_property(&self, key: &str) -> Option<&str> { + self.properties.get(key).and_then(|v| v.as_str()) + } +} + +/// Serde module for the `cmd` field. +/// +/// The Java runtime serializes `Command` as its enum constant **name string** +/// (e.g. `"HELLO_RESPONSE"`) via Jackson's default enum handling, so we must do +/// the same when sending. On decode we additionally accept the numeric form for +/// robustness (Jackson falls back to ordinals when given an integer token, so +/// either form may legitimately appear on the wire). +mod command_serde { + use serde::{de, Deserialize, Deserializer, Serializer}; + + use super::Command; + + pub fn serialize(cmd: &Command, s: S) -> Result { + s.serialize_str(cmd.name()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + match v { + serde_json::Value::String(name) => Command::from_name(&name) + .ok_or_else(|| de::Error::custom(format!("unknown command name: {name}"))), + serde_json::Value::Number(n) => { + let value = n.as_i64().ok_or_else(|| { + de::Error::custom(format!("command number out of range: {n}")) + })?; + // Validate the value fits in a `u8` before the narrowing cast; + // `value as u8` would silently wrap for values >= 256 (e.g. + // 256 -> 0 -> HeartbeatRequest) and misinterpret the frame. + u8::try_from(value) + .map_err(|_| de::Error::custom(format!("command number out of range: {value}"))) + .and_then(|b| Command::try_from(b).map_err(de::Error::custom)) + } + other => Err(de::Error::custom(format!( + "expected string or number for `cmd`, got {other}" + ))), + } + } +} + +// --------------------------------------------------------------------------- +// UserAgent +// --------------------------------------------------------------------------- + +/// Client identity sent in the HELLO body (mirrors Java `UserAgent.java`). +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct UserAgent { + #[serde(default)] + pub env: String, + #[serde(default)] + pub subsystem: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub pid: i32, + #[serde(default)] + pub host: String, + #[serde(default)] + pub port: i32, + #[serde(default)] + pub version: String, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub idc: String, + #[serde(default)] + pub group: String, + #[serde(default)] + pub purpose: String, + #[serde(default)] + pub unack: i32, +} + +impl UserAgent { + /// Build a `UserAgent` from the public identity configuration and the + /// role's group, tagged with `purpose` ("pub" or "sub"). + /// + /// `host` is the **client's** local IP (from the identity), 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_role( + identity: &crate::config::Identity, + credentials: &crate::config::Credentials, + group: &str, + port: u16, + purpose: &str, + ) -> Self { + Self { + env: identity.env().to_string(), + subsystem: identity.system().to_string(), + path: String::new(), + pid: identity.process_id().parse().unwrap_or(0), + host: identity.ip().to_string(), + port: port as i32, + version: "1.0".to_string(), + username: credentials.username().to_string(), + password: credentials.password().to_string(), + token: credentials.token().unwrap_or_default().to_string(), + idc: identity.idc().to_string(), + group: group.to_string(), + purpose: purpose.to_string(), + unack: 0, + } + } +} + +impl std::fmt::Debug for UserAgent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UserAgent") + .field("env", &self.env) + .field("subsystem", &self.subsystem) + .field("path", &self.path) + .field("pid", &self.pid) + .field("host", &self.host) + .field("port", &self.port) + .field("version", &self.version) + .field("username", &self.username) + .field("password", &"***") + .field("token", &"***") + .field("idc", &self.idc) + .field("group", &self.group) + .field("purpose", &self.purpose) + .field("unack", &self.unack) + .finish() + } +} + +// --------------------------------------------------------------------------- +// Subscription body +// --------------------------------------------------------------------------- + +/// Body for `SUBSCRIBE_REQUEST` / `UNSUBSCRIBE_REQUEST`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Subscription { + /// Matches Java field name `topicList` (camelCase). + #[serde(rename = "topicList")] + pub topic_list: Vec, +} + +impl Subscription { + pub fn new(topics: Vec) -> Self { + Self { topic_list: topics } + } +} + +/// Body for `REDIRECT_TO_CLIENT`. +/// +/// Mirrors `org.apache.eventmesh.common.protocol.tcp.RedirectInfo`, whose +/// fields are `ip` (String) and `port` (int). The runtime emits this in +/// `EventMeshTcp2Client.redirectClient2NewEventMesh` to tell the client which +/// EventMesh node to reconnect to during a rebalance. The previous shape only +/// had a defaulted `redirect_to`, which serde silently discarded the target +/// address for, making any redirect handling impossible. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedirectInfo { + #[serde(default)] + pub ip: String, + #[serde(default)] + pub port: u16, +} + +// --------------------------------------------------------------------------- +// Package +// --------------------------------------------------------------------------- + +/// Type-erased body of a [`Package`]. +/// +/// On the wire, most bodies are JSON. The codec dispatches on the header's +/// `Command` to decide which Rust type to deserialize into (mirrors the Java +/// `Codec.deserializeBody` switch). +#[derive(Debug, Clone, Default)] +pub enum PackageBody { + /// No body (heartbeat, goodbye, listen, ...). + #[default] + Empty, + /// HELLO / RECOMMEND body. + UserAgent(Box), + /// SUBSCRIBE / UNSUBSCRIBE body. + Subscription(Subscription), + /// REDIRECT_TO_CLIENT body. + RedirectInfo(RedirectInfo), + /// A raw JSON string — deferred to the protocol layer (most message / + /// ACK commands). Mirrors the Java "return bodyJsonString" default. + Text(String), + /// Raw bytes — used for CloudEvents bodies (serialized by the caller). + Bytes(Vec), +} + +/// The wire envelope — a [`Header`] plus an optional [`PackageBody`]. +/// +/// Mirrors Java `Package.java`. +#[derive(Debug, Clone)] +pub struct Package { + pub header: Header, + pub body: PackageBody, +} + +impl Package { + pub fn new(header: Header) -> Self { + Self { + header, + body: PackageBody::Empty, + } + } + + pub fn with_body(mut self, body: PackageBody) -> Self { + self.body = body; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Java runtime (Jackson default) serializes `Command` as its enum + /// constant *name string*, not an ordinal. We must emit the same on the wire. + #[test] + fn command_serializes_as_java_enum_name_string() { + let header = Header::new(Command::HelloResponse, "seq-1"); + let json = serde_json::to_string(&header).unwrap(); + assert!( + json.contains("\"cmd\":\"HELLO_RESPONSE\""), + "expected cmd serialized as the Java enum name string, got: {json}" + ); + assert!( + !json.contains("\"cmd\":3"), + "cmd must NOT be serialized as a number, got: {json}" + ); + } + + /// The Java server's HELLO response arrives as `{"cmd":"HELLO_RESPONSE",...}`; + /// the Rust client must be able to decode it. + #[test] + fn decodes_java_wire_format_string_cmd() { + let json = r#"{"cmd":"HELLO_RESPONSE","code":0,"seq":"seq-1"}"#; + let header: Header = serde_json::from_str(json).unwrap(); + assert_eq!(header.cmd, Command::HelloResponse); + } + + /// Ordinal/integer form is also accepted on decode (Jackson falls back to + /// ordinal matching for integer tokens, so this is a legitimate wire shape). + #[test] + fn decodes_numeric_cmd_form() { + let json = r#"{"cmd":18,"code":0,"seq":"seq-1"}"#; + let header: Header = serde_json::from_str(json).unwrap(); + assert_eq!(header.cmd, Command::ResponseToClient); + } + + /// Values >= 256 must be rejected rather than silently wrapped by a + /// narrowing `as u8` cast (e.g. 256 -> 0 -> HeartbeatRequest). + #[test] + fn rejects_out_of_range_numeric_cmd() { + for bad in [256, 1_000, 65_536] { + let json = format!(r#"{{"cmd":{bad},"code":0,"seq":"seq-1"}}"#); + let err = serde_json::from_str::
(&json).unwrap_err(); + assert!( + err.to_string().contains("out of range"), + "value {bad} rejected with unexpected message: {err}" + ); + } + } + + /// Negative numbers are not valid command ordinals. + #[test] + fn rejects_negative_numeric_cmd() { + let json = r#"{"cmd":-1,"code":0,"seq":"seq-1"}"#; + assert!(serde_json::from_str::
(json).is_err()); + } + + /// `name()` / `from_name()` must be exact inverses and cover every variant. + #[test] + fn name_round_trip_all_variants() { + for &cmd in &[ + Command::HeartbeatRequest, + Command::HelloResponse, + Command::SysLogToLogServer, + Command::TraceLogToLogServer, + Command::RecommendResponse, + ] { + let name = cmd.name(); + assert_eq!(Command::from_name(name), Some(cmd), "{name}"); + } + } + + /// Server-initiated frames (`SERVER_GOODBYE_REQUEST`, `REDIRECT_TO_CLIENT`) + /// are built by the Java runtime with `seq = null`, which Jackson omits on + /// the wire. We must accept those frames rather than rejecting them for a + /// missing required field before `handle_inbound` can send the + /// `SERVER_GOODBYE_RESPONSE`. + #[test] + fn accepts_missing_seq_for_server_initiated_frames() { + for cmd_name in ["SERVER_GOODBYE_REQUEST", "REDIRECT_TO_CLIENT"] { + let json = format!(r#"{{"cmd":"{cmd_name}","code":0}}"#); + let header: Header = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("failed to decode {cmd_name} frame without seq: {e}")); + assert_eq!(header.cmd.name(), cmd_name); + assert_eq!(header.seq, None, "{cmd_name} seq should be absent"); + } + } + + /// A header with a seq still round-trips it as `Some`. + #[test] + fn present_seq_decodes_as_some_and_is_omitted_when_none() { + let with_seq = r#"{"cmd":"HELLO_RESPONSE","code":0,"seq":"seq-1"}"#; + let header: Header = serde_json::from_str(with_seq).unwrap(); + assert_eq!(header.seq.as_deref(), Some("seq-1")); + // Serializing a None-seq header must omit the field (matches Java + // JsonUtils, which skips nulls). + let none_seq = Header { + cmd: Command::ServerGoodbyeRequest, + code: 0, + desc: None, + seq: None, + properties: HashMap::new(), + }; + let json = serde_json::to_string(&none_seq).unwrap(); + assert!( + !json.contains("seq"), + "None seq should be omitted, got: {json}" + ); + } + + /// `RedirectInfo` must carry `ip`/`port` (the Java + /// `org.apache.eventmesh.common.protocol.tcp.RedirectInfo` wire shape), not + /// a synthetic `redirect_to`. The runtime serializes it via Jackson with + /// these exact field names; any other shape would make serde silently drop + /// the redirect target on decode. + #[test] + fn redirect_info_round_trips_java_wire_shape() { + let java_json = r#"{"ip":"10.0.0.5","port":10000}"#; + let ri: RedirectInfo = serde_json::from_str(java_json).expect("decode RedirectInfo"); + assert_eq!(ri.ip, "10.0.0.5"); + assert_eq!(ri.port, 10000); + + // Re-serialize and ensure the field names match the Java wire format. + let out = serde_json::to_string(&ri).unwrap(); + assert!( + out.contains("\"ip\":\"10.0.0.5\""), + "expected ip field on the wire, got: {out}" + ); + assert!( + out.contains("\"port\":10000"), + "expected port field on the wire, got: {out}" + ); + assert!( + !out.contains("redirect_to"), + "must NOT emit a redirect_to field, got: {out}" + ); + } + + /// Missing `ip`/`port` default (mirrors Jackson populating `null`/`0` for + /// an absent field rather than rejecting the frame). + #[test] + fn redirect_info_defaults_missing_fields() { + let ri: RedirectInfo = serde_json::from_str("{}").expect("decode empty RedirectInfo"); + assert_eq!(ri.ip, ""); + assert_eq!(ri.port, 0); + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/message.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/message.rs new file mode 100644 index 0000000000..a9f7dea1f0 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/message.rs @@ -0,0 +1,750 @@ +// 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::{EventMeshError, Result}; +use crate::model::{EventMeshMessage, PublishResponse}; +use crate::subscription::Subscription as TopicSubscription; + +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"; +#[cfg(feature = "cloud_events")] +const REQUIRED_CE_DATA_CONTENT_TYPE: &str = "application/cloudevents+json"; + +/// 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 { + let mut properties: HashMap<_, _> = msg + .props + .iter() + .filter(|(key, _)| !crate::model::delivery::is_reserved_property(key)) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + if let Some(ttl) = msg.ttl { + properties.insert("ttl".into(), ttl.to_string()); + } + if let Some(sequence) = &msg.biz_seq_no { + properties.insert("seqnum".into(), sequence.clone()); + } + if let Some(unique_id) = &msg.unique_id { + properties.insert("uniqueid".into(), unique_id.clone()); + } + let mut headers = HashMap::new(); + if let Some(content_type) = &msg.data_content_type { + headers.insert("datacontenttype".into(), content_type.clone()); + } + Self { + topic: Some(msg.topic.clone()), + properties, + headers, + body: Some(msg.content.clone()), + } + } +} + +impl TryFrom for EventMeshMessage { + type Error = crate::Error; + + fn try_from(wire: TcpWireMessage) -> Result { + // Collect wire attributes before separating business fields, business + // properties, and the read-only delivery context. + let mut props = wire.properties; + for (k, v) in wire.headers { + props.entry(k).or_insert(v); + } + let builder = EventMeshMessage::builder() + .topic( + wire.topic + .ok_or_else(|| EventMeshError::InvalidMessage("topic is required".into()))?, + ) + .content( + wire.body + .ok_or_else(|| EventMeshError::InvalidMessage("content is required".into()))?, + ); + crate::transport::decode_native_message(builder.build()?, props) + } +} + +/// 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: &[TopicSubscription]) -> 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: &[TopicSubscription]) -> Package { + let sub = Subscription::new(items.to_vec()); + package(Command::UnsubscribeRequest).with_body(PackageBody::Subscription(sub)) +} + +// --------------------------------------------------------------------------- +// ACK builders +// --------------------------------------------------------------------------- + +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 mut wire = TcpWireMessage::from(msg); + if cmd == Command::ResponseToServer { + if let Some(context) = msg.delivery_context() { + for (key, value) in context.reply_attributes() { + wire.properties.insert(key.clone(), value.clone()); + } + } + } + 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()?; + EventMeshMessage::try_from(wire).ok() + } + _ => None, + } +} + +// --------------------------------------------------------------------------- +// CloudEvents wire support +// --------------------------------------------------------------------------- + +/// Whether the given header properties declare a CloudEvents body +/// (`protocoltype == "cloudevents"`). Used by the consumer to decide whether +/// to parse the body as a CloudEvent JSON or a TCP-wire `EventMeshMessage`. +pub fn is_cloudevents(pkg: &Package) -> bool { + pkg.header.get_string_property(PROTOCOL_TYPE_KEY) == Some(CLOUD_EVENTS_PROTOCOL) +} + +/// Whether a package is a native EventMesh message. A missing discriminator is +/// accepted for compatibility with older runtime deliveries; an explicit +/// unknown discriminator is rejected by the consumer. +pub(crate) fn is_event_mesh_message(pkg: &Package) -> bool { + matches!( + pkg.header.get_string_property(PROTOCOL_TYPE_KEY), + None | Some(EM_MESSAGE_PROTOCOL) + ) +} + +/// Wrap a native [`cloudevents::Event`] into a [`Package`] with the given +/// command, using the CloudEvents JSON wire format +/// (`application/cloudevents+json`). +/// +/// Sets `protocoltype=cloudevents`, `protocolversion=`, +/// `protocoldesc=tcp` so the Java runtime's codec writes the body bytes +/// verbatim instead of re-serializing via Jackson. +/// +/// # `datacontenttype` requirement +/// +/// The CloudEvent's `datacontenttype` **must** be set to +/// `application/cloudevents+json`. The Java runtime's +/// `CloudEventsProtocolAdaptor.fromCloudEvent` (downlink path) uses +/// `datacontenttype` to resolve the CloudEvents `EventFormat` serializer +/// via `EventFormatProvider.resolveFormat(dataContentType)`. The only +/// registered format is `application/cloudevents+json`; any other value +/// (e.g. `application/json`, `text/plain`) causes `resolveFormat()` to +/// return null, so runtime delivery fails before the TCP consumer receives the +/// message. The Java TCP SDK also makes the same lookup on the upload path and +/// fails before sending when given another value. +/// +/// This is a known EventMesh TCP compatibility constraint. Java examples meet +/// it by explicitly setting `datacontenttype = application/cloudevents+json`; +/// the Java SDK does not rewrite the value automatically. +/// +/// 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; + + validate_cloud_event(event)?; + 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) +} + +#[cfg(feature = "cloud_events")] +fn validate_cloud_event(event: &cloudevents::Event) -> Result<()> { + use cloudevents::AttributesReader; + + match event.datacontenttype() { + Some(REQUIRED_CE_DATA_CONTENT_TYPE) => Ok(()), + Some(other) => Err(EventMeshError::InvalidMessage(format!( + "TCP transport requires datacontenttype = \"{REQUIRED_CE_DATA_CONTENT_TYPE}\", \ + got \"{other}\" — the EventMesh Java TCP codec cannot serialize other values" + ))), + None => Err(EventMeshError::InvalidMessage(format!( + "TCP transport requires datacontenttype = \"{REQUIRED_CE_DATA_CONTENT_TYPE}\", \ + but none is set — the EventMesh Java TCP codec requires it as a serializer selector" + ))), + } +} + +/// 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`] when merging request/reply +/// metadata across message dialects. +/// +/// - `subject` → `topic` +/// - `data` → `content` (string values are kept as-is; JSON values are +/// stringified; binary values are lossily converted to UTF-8) +/// - `ttl` extension → the dedicated TTL field +/// - Message IDs/content type → dedicated business fields +/// - Known protocol/routing extensions → read-only delivery context +/// - Remaining extensions → business properties +/// +/// This mirrors the gRPC codec's `to_event_mesh_message`. +#[cfg(feature = "cloud_events")] +pub fn cloud_event_to_message(event: &cloudevents::Event) -> Result { + 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()); + } + + let builder = + EventMeshMessage::builder() + .topic(topic.ok_or_else(|| { + EventMeshError::InvalidMessage("CloudEvent subject (topic) is required".into()) + })?) + .content(content.ok_or_else(|| { + EventMeshError::InvalidMessage("CloudEvent data is required".into()) + })?); + if let Some(content_type) = event.datacontenttype() { + props.insert("datacontenttype".into(), content_type.into()); + } + crate::transport::decode_native_message(builder.build()?, props) +} + +/// 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.clone(); + let mut builder = EventBuilderV10::new() + .id(msg + .unique_id + .clone() + .unwrap_or_else(crate::common::RandomStringUtils::generate_uuid)) + .source(source) + .ty("org.apache.eventmesh"); + + builder = builder.subject(&msg.topic); + builder = builder.data( + msg.data_content_type().unwrap_or("text/plain"), + msg.content.clone(), + ); + for (k, v) in &msg.props { + if !crate::model::delivery::is_reserved_property(k) { + builder = builder.extension(k.as_str(), v.as_str()); + } + } + if let Some(ttl) = msg.ttl { + builder = builder.extension("ttl", ttl.to_string()); + } + if let Some(sequence) = msg.biz_seq_no() { + builder = builder.extension("seqnum", sequence); + } + if let Some(unique_id) = msg.unique_id() { + builder = builder.extension("uniqueid", unique_id); + } + if let Some(context) = msg.delivery_context() { + for (key, value) in context.reply_attributes() { + builder = builder.extension(key.as_str(), value.as_str()); + } + } + builder + .build() + .map_err(|e| crate::error::EventMeshError::Protocol { + transport: "tcp", + message: format!("cloudevents build error: {e}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_seq_length() { + let s = random_seq(); + assert_eq!(s.len(), SEQ_LEN); + assert!(s.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn ack_preserves_seq() { + let pkg = package(Command::AsyncMessageToClient); + let ack_pkg = ack(Command::AsyncMessageToClientAck, &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() + .unwrap(); + 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") + .ttl_millis(4000) + .build() + .unwrap(); + 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(), "t"); + assert_eq!(msg.content(), "payload"); + assert_eq!(msg.get_prop("k"), Some("v")); + } + + #[test] + fn parse_message_preserves_cross_transport_empty_body_and_ttl() { + let server_json = r#"{"topic":"t","properties":{"ttl":"2147483648"},"body":""}"#; + let message = parse_message(&PackageBody::Text(server_json.into())).expect("parse"); + assert_eq!(message.content(), ""); + assert_eq!(message.get_prop("ttl"), None); + assert_eq!(message.ttl_millis(), Some(2_147_483_648)); + } + + #[test] + fn parse_message_preserves_wire_headers() { + // The Java runtime puts protocol-level metadata (e.g. + // datacontenttype) in the wire `headers` field. These must not be + // silently discarded when deserializing into EventMeshMessage. + let server_json = r#"{"topic":"t","headers":{"datacontenttype":"application/json"},"properties":{"k":"v"},"body":"payload"}"#; + let msg = parse_message(&PackageBody::Text(server_json.into())).expect("parse"); + assert_eq!(msg.content(), "payload"); + assert_eq!(msg.get_prop("k"), Some("v")); + assert_eq!( + msg.data_content_type(), + Some("application/json"), + "wire content type must populate its dedicated field" + ); + } + + #[test] + fn wire_format_round_trip() { + let original = EventMeshMessage::builder() + .topic("round-trip") + .content("payload") + .prop("key", "val") + .build() + .unwrap(); + let pkg = + build_message_package(&original, Command::AsyncMessageToServer).expect("build pkg"); + let parsed = parse_message(&pkg.body).expect("parse"); + assert_eq!(parsed.topic, original.topic); + assert_eq!(parsed.content, original.content); + assert_eq!(parsed.props, original.props); + } + + #[test] + fn is_cloudevents_detects_protocol() { + let em_pkg = package(Command::AsyncMessageToServer); + assert!(!is_cloudevents(&em_pkg)); + + let mut ce_pkg = package(Command::AsyncMessageToServer); + ce_pkg + .header + .set_property(PROTOCOL_TYPE_KEY, CLOUD_EVENTS_PROTOCOL); + assert!(is_cloudevents(&ce_pkg)); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloudevents_build_sets_protocol_headers() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("ce-1") + .source("https://example.com") + .ty("com.example.test") + .subject("ce-topic") + .data( + "application/cloudevents+json", + serde_json::json!({"hello": "world"}), + ) + .build() + .expect("valid event"); + + let pkg = + build_cloud_event_package(&event, Command::AsyncMessageToServer).expect("build pkg"); + assert_eq!( + pkg.header.get_string_property(PROTOCOL_TYPE_KEY), + Some(CLOUD_EVENTS_PROTOCOL) + ); + assert_eq!( + pkg.header.get_string_property(PROTOCOL_DESC_KEY), + Some(PROTOCOL_DESC_TCP) + ); + assert_eq!( + pkg.header.get_string_property(PROTOCOL_VERSION_KEY), + Some("1.0") + ); + assert_eq!(pkg.header.cmd, Command::AsyncMessageToServer); + // Body must be Bytes (raw JSON, not re-encoded). + assert!(matches!(pkg.body, PackageBody::Bytes(_))); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn cloudevents_round_trip() { + use cloudevents::{AttributesReader, EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("ce-rt-1") + .source("https://example.com") + .ty("com.example.test") + .subject("ce-round-trip") + .data(REQUIRED_CE_DATA_CONTENT_TYPE, "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 shared_cloudevent_builder_rejects_incompatible_content_type() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("ce-invalid") + .source("https://example.com") + .ty("com.example.test") + .data("application/json", serde_json::json!({"hello": "world"})) + .build() + .unwrap(); + + assert!(matches!( + build_cloud_event_package(&event, Command::ResponseToServer), + Err(EventMeshError::InvalidMessage(message)) + if message.contains(REQUIRED_CE_DATA_CONTENT_TYPE) + )); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn shared_cloudevent_builder_rejects_missing_content_type() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("ce-invalid") + .source("https://example.com") + .ty("com.example.test") + .build() + .unwrap(); + + assert!(matches!( + build_cloud_event_package(&event, Command::ResponseToServer), + Err(EventMeshError::InvalidMessage(_)) + )); + } + + #[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).unwrap(); + assert_eq!(msg.topic(), "conv-topic"); + assert_eq!(msg.content(), "conv-content"); + assert_eq!(msg.get_prop("ttl"), None); + assert_eq!(msg.ttl_millis(), Some(5000)); + let roundtrip = message_to_cloud_event(&msg).unwrap(); + assert_eq!(roundtrip.extension("ttl").unwrap().to_string(), "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..1fa094c2aa --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/mod.rs @@ -0,0 +1,63 @@ +// 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`). +//! +//! Producers provide publishing, request/reply, and broadcast operations. +//! Consumers decode deliveries into [`crate::Message`] and dispatch them to +//! [`crate::MessageHandler`]. See [`crate::tcp`] for the public client API. +//! +//! # 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`]. Consumers preserve inbound +//! CloudEvents as `Message::CloudEvent` values. +//! +//! **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::{ShutdownReason, 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..a47535dd8e --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/transport/tcp/producer.rs @@ -0,0 +1,241 @@ +// 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::sync::Arc; +use std::time::Duration; + +use tracing::debug; + +use crate::config::{ProducerOptions, TcpConfig}; +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; + +/// TCP-based producer. +/// +/// Created via [`TcpProducer::connect`], which opens a TCP connection, performs +/// the HELLO handshake (role = pub), and starts the background heartbeat. +pub struct TcpProducer { + conn: Arc, + request_timeout: std::time::Duration, +} + +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: TcpConfig, options: &ProducerOptions) -> Result { + let user_agent = UserAgent::from_role( + config.identity(), + config.credentials(), + options.group(), + config.endpoint().port(), + "pub", + ); + let conn = Arc::new( + TcpConnection::connect( + &config.endpoint().authority_host(), + config.endpoint().port(), + &user_agent, + config.heartbeat_interval(), + config.connect_timeout(), + config.control_timeout(), + config.reconnect().clone(), + ) + .await?, + ); + let request_timeout = config.request_timeout(); + + Ok(Self { + conn, + request_timeout, + }) + } + + /// Broadcast a message, waiting for its local socket write but no server + /// ACK. Uses `BROADCAST_MESSAGE_TO_SERVER`, matching Java `broadcast`. + pub async fn broadcast(&self, msg: EventMeshMessage) -> Result<()> { + msg.validate_for_tcp_publish()?; + let pkg = message::build_message_package(&msg, Command::BroadcastMessageToServer)?; + self.conn.send_and_flush(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`. + /// Both the Java SDK's upload path and the Java runtime's TCP downlink path + /// use this value to resolve the serializer for the whole CloudEvent. + /// Other values (e.g. `application/json`, `text/plain`) therefore fail in + /// the Java SDK before sending or fail during runtime delivery. This SDK + /// returns [`EventMeshError::InvalidMessage`] before performing network + /// I/O instead. + /// + /// ```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.request_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, waiting for its local socket write but + /// no server ACK (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_and_flush(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 + } + + /// Clone the shared connection for a background publisher-side handler. + pub fn shared_connection(&self) -> Arc { + Arc::clone(&self.conn) + } + + /// Graceful shutdown. + pub async fn shutdown(&self) { + self.conn.shutdown().await; + } + + /// Publish a message and wait for the broker ACK. + /// Uses `ASYNC_MESSAGE_TO_SERVER` + `io()` (mirrors the Java SDK). + pub(crate) async fn publish(&self, message: EventMeshMessage) -> Result { + message.validate_for_tcp_publish()?; + let pkg = super::message::build_message_package(&message, Command::AsyncMessageToServer)?; + debug!(topic = ?message.topic, "publishing via TCP"); + + let resp = self.conn.io(pkg, self.request_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) + } + + /// Synchronous request/reply. Uses `REQUEST_TO_SERVER` + `io()` and waits + /// for the `RESPONSE_TO_CLIENT` push from the server. + pub(crate) async fn request_reply( + &self, + message: EventMeshMessage, + timeout: Duration, + ) -> Result { + message.validate_for_tcp_publish()?; + 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")) + }) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/src/webhook.rs b/eventmesh-sdks/eventmesh-sdk-rust/src/webhook.rs new file mode 100644 index 0000000000..e07af65134 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/src/webhook.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. + +//! Semantic HTTP webhook acknowledgement helpers. + +/// Network settings for an SDK-managed HTTP webhook consumer. +#[cfg(feature = "http")] +#[derive(Debug, Clone)] +pub struct WebhookOptions { + bind_addr: std::net::SocketAddr, + advertise_url: Option, +} + +#[cfg(feature = "http")] +impl WebhookOptions { + /// Listen on `bind_addr` and derive the callback URL from the bound address. + pub fn new(bind_addr: std::net::SocketAddr) -> Self { + Self { + bind_addr, + advertise_url: None, + } + } + + /// Override the URL registered with EventMesh. + /// + /// This is normally required when binding to `0.0.0.0` or when EventMesh + /// runs in a container or on another host. + pub fn with_advertise_url(mut self, url: impl Into) -> Self { + self.advertise_url = Some(url.into()); + self + } + + pub(crate) const fn bind_addr(&self) -> std::net::SocketAddr { + self.bind_addr + } + + pub(crate) fn advertise_url(&self) -> Option<&str> { + self.advertise_url.as_deref() + } + + pub(crate) fn validate(&self) -> crate::Result<()> { + if let Some(url) = self.advertise_url() { + validate_webhook_url(url)?; + } else if self.bind_addr.ip().is_unspecified() { + return Err(crate::Error::Config( + "an advertise URL is required when the webhook binds to an unspecified address" + .into(), + )); + } + Ok(()) + } +} + +#[cfg(any(feature = "grpc", feature = "http"))] +pub(crate) fn validate_webhook_url(url: &str) -> crate::Result<()> { + let uri = url + .parse::() + .map_err(|error| crate::Error::InvalidArgument(format!("invalid webhook URL: {error}")))?; + if !matches!(uri.scheme_str(), Some("http" | "https")) || uri.authority().is_none() { + return Err(crate::Error::InvalidArgument( + "webhook URL must be an absolute http:// or https:// URL".into(), + )); + } + if matches!(uri.host(), Some("0.0.0.0" | "::" | "[::]")) { + return Err(crate::Error::InvalidArgument( + "webhook URL must use an address reachable by EventMesh".into(), + )); + } + Ok(()) +} + +/// Built-in axum webhook server. +#[cfg(feature = "http")] +pub struct WebhookServer { + inner: crate::transport::http::WebhookServer, + _handler: std::marker::PhantomData, +} + +#[cfg(feature = "http")] +impl WebhookServer { + /// Bind before returning, guaranteeing that [`url`](Self::url) is ready to + /// register with EventMesh. + pub async fn bind(addr: std::net::SocketAddr, handler: H) -> crate::Result { + let inner = + crate::transport::http::WebhookServer::bind(addr, std::sync::Arc::new(handler)).await?; + Ok(Self { + inner, + _handler: std::marker::PhantomData, + }) + } + + /// Return the URL that should be registered with EventMesh. + pub fn url(&self) -> String { + self.inner.url() + } + + /// Override the externally visible webhook URL. + pub fn with_advertise_url(mut self, url: impl Into) -> Self { + self.inner = self.inner.with_advertise_url(url); + self + } + + /// Configure a graceful shutdown signal. + pub fn with_graceful_shutdown( + mut self, + signal: impl std::future::Future + Send + 'static, + ) -> Self { + self.inner = self.inner.with_graceful_shutdown(signal); + self + } +} + +#[cfg(feature = "http")] +impl std::future::IntoFuture for WebhookServer { + type Output = crate::Result<()>; + type IntoFuture = + std::pin::Pin> + Send>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { self.inner.await }) + } +} + +#[cfg(all(test, feature = "http"))] +mod tests { + use super::*; + + #[test] + fn webhook_urls_must_be_absolute_http_urls() { + assert!(validate_webhook_url("http://127.0.0.1:8080/callback").is_ok()); + assert!(validate_webhook_url("https://example.com/callback").is_ok()); + assert!(validate_webhook_url("/callback").is_err()); + assert!(validate_webhook_url("ftp://example.com/callback").is_err()); + assert!(validate_webhook_url("http://0.0.0.0:8080/callback").is_err()); + assert!(validate_webhook_url("http://[::]:8080/callback").is_err()); + assert!(validate_webhook_url(" ").is_err()); + } + + #[test] + fn unspecified_bind_address_requires_an_advertise_url() { + let options = WebhookOptions::new("0.0.0.0:8080".parse().unwrap()); + assert!(options.validate().is_err()); + assert!(options + .with_advertise_url("http://127.0.0.1:8080/eventmesh/callback") + .validate() + .is_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..5188e8dea8 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/codec_test.rs @@ -0,0 +1,270 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use eventmesh::{ + config::{Endpoint, EndpointSet}, + message::{EventMeshMessage, Message, MessageKind}, + subscription::{DeliveryMode, DeliveryType, Subscription}, +}; + +#[cfg(feature = "http")] +use eventmesh::http::codec::{parse_push_body, PushMessageRequestBody, WebhookReply}; + +#[test] +fn message_kind_is_explicit() { + let message = Message::from(EventMeshMessage::new("orders", "created").unwrap()); + assert_eq!(message.kind(), MessageKind::EventMesh); +} + +#[test] +fn message_construction_requires_fields_and_builder_is_public() { + assert!(EventMeshMessage::new("", "created").is_err()); + let transport_specific_ttl = EventMeshMessage::builder() + .topic("orders") + .content("") + .ttl_millis(0) + .build() + .unwrap(); + assert_eq!(transport_specific_ttl.content(), ""); + assert_eq!(transport_specific_ttl.ttl_millis(), Some(0)); + + let message = EventMeshMessage::builder() + .topic("orders") + .content("created") + .unique_id("event-1") + .ttl_millis(1_000) + .build() + .unwrap(); + assert_eq!(message.topic(), "orders"); + assert_eq!(message.content(), "created"); + assert_eq!(message.unique_id(), Some("event-1")); + assert_eq!(message.ttl_millis(), Some(1_000)); +} + +#[test] +fn subscriptions_have_rust_style_defaults_and_setters() { + let subscription = Subscription::new("orders") + .with_delivery_mode(DeliveryMode::Broadcast) + .with_delivery_type(DeliveryType::Async); + assert_eq!(subscription.topic, "orders"); + assert_eq!(subscription.delivery_mode, DeliveryMode::Broadcast); +} + +#[test] +fn endpoint_sets_require_members() { + assert!(EndpointSet::new(Vec::new()).is_err()); + assert_eq!( + Endpoint::new("::1", 10_205).unwrap().authority(), + "[::1]:10205" + ); +} + +#[cfg(feature = "http")] +#[test] +fn custom_webhook_codec_is_public() { + let parsed: PushMessageRequestBody = + parse_push_body("content=hello&topic=orders&bizseqno=seq-1&uniqueId=id-1") + .expect("decode webhook body"); + for protocol in [None, Some("eventmeshmessage")] { + let mut headers = http::HeaderMap::new(); + if let Some(protocol) = protocol { + headers.insert("protocoltype", protocol.parse().unwrap()); + } + let message = parsed.to_message(&headers).unwrap(); + let message = message.as_event_mesh().unwrap(); + assert_eq!(message.topic(), "orders"); + assert_eq!(message.content(), "hello"); + assert_eq!(message.biz_seq_no(), Some("seq-1")); + assert_eq!(message.unique_id(), Some("id-1")); + } + assert_eq!(WebhookReply::ok().ret_code, 1); +} + +#[cfg(feature = "http")] +mod webhook_messages { + use super::*; + use eventmesh::Error; + use http::{HeaderMap, HeaderValue}; + + fn push(content: &str, protocol: Option<&str>) -> PushMessageRequestBody { + let extfields = protocol + .map(|protocol| serde_json::json!({"protocoltype": protocol}).to_string()) + .unwrap_or_default(); + let body = serde_urlencoded::to_string([ + ("topic", "orders"), + ("content", content), + ("extFields", &extfields), + ]) + .unwrap(); + parse_push_body(&body).unwrap() + } + + #[test] + fn rejects_unknown_protocol_in_headers_or_extensions() { + let mut headers = HeaderMap::new(); + headers.insert("protocoltype", "openmessage".parse().unwrap()); + for (body, headers) in [ + (push("created", None), headers), + (push("created", Some("openmessage")), HeaderMap::new()), + ] { + assert!(matches!( + body.to_message(&headers), + Err(Error::Protocol { + transport: "http", + .. + }) + )); + } + } + + #[test] + fn rejects_conflicting_protocol_sources() { + let mut headers = HeaderMap::new(); + headers.insert("protocoltype", "eventmeshmessage".parse().unwrap()); + assert!(matches!( + push("created", Some("cloudevents")).to_message(&headers), + Err(Error::Protocol { + transport: "http", + .. + }) + )); + } + + #[test] + fn rejects_malformed_protocol_metadata() { + let mut headers = HeaderMap::new(); + headers.insert("protocoltype", HeaderValue::from_bytes(&[0xff]).unwrap()); + assert!(matches!( + push("created", None).to_message(&headers), + Err(Error::Protocol { + transport: "http", + .. + }) + )); + + let mut body = push("created", None); + body.extfields = Some("invalid JSON".into()); + assert!(matches!( + body.to_message(&HeaderMap::new()), + Err(Error::Protocol { + transport: "http", + .. + }) + )); + } + + #[cfg(feature = "cloud_events")] + #[test] + fn preserves_cloud_events_from_either_or_both_protocol_sources() { + use cloudevents::{EventBuilder, EventBuilderV10}; + + let event = EventBuilderV10::new() + .id("event-1") + .source("urn:test") + .ty("orders.created") + .subject("orders") + .data( + "application/json", + serde_json::json!({"text": "订单 + & ="}), + ) + .extension("custom", "value") + .build() + .unwrap(); + let content = serde_json::to_string(&event).unwrap(); + for (header, extension) in [(true, false), (false, true), (true, true)] { + let mut headers = HeaderMap::new(); + if header { + headers.insert("protocoltype", "cloudevents".parse().unwrap()); + } + let body = push(&content, extension.then_some("cloudevents")); + assert_eq!( + body.to_message(&headers).unwrap(), + Message::CloudEvent(event.clone()) + ); + } + } + + #[cfg(feature = "cloud_events")] + #[test] + fn rejects_malformed_cloud_event_payload() { + assert!(matches!( + push("invalid JSON", Some("cloudevents")).to_message(&HeaderMap::new()), + Err(Error::Codec(_)) + )); + } + + #[cfg(not(feature = "cloud_events"))] + #[test] + fn rejects_cloud_events_when_feature_is_disabled() { + let mut headers = HeaderMap::new(); + headers.insert("protocoltype", "cloudevents".parse().unwrap()); + for (body, headers) in [ + (push("{}", None), headers), + (push("{}", Some("cloudevents")), HeaderMap::new()), + ] { + assert!(matches!( + body.to_message(&headers), + Err(Error::Unsupported(_)) + )); + } + } +} + +#[cfg(feature = "http")] +#[test] +fn native_webhook_exposes_read_only_context_and_typed_fields() { + let ext = serde_json::json!({ + "protocoltype": "eventmeshmessage", "protocoldesc": "http", "protocolversion": "1.0", + "sys": "source-system", "cluster": "source-cluster", "correlation99id": "correlation", + "ttl": "7000", "datacontenttype": "application/json", "custom": "business-value", + "seqnum": "old-sequence", "uniqueid": "old-id" + }) + .to_string(); + let body = serde_urlencoded::to_string([ + ("topic", "orders"), + ("content", "{}"), + ("bizseqno", "form-sequence"), + ("uniqueId", "form-id"), + ("extFields", ext.as_str()), + ]) + .unwrap(); + let mut headers = http::HeaderMap::new(); + headers.insert("language", "JAVA".parse().unwrap()); + let message = parse_push_body(&body) + .unwrap() + .to_message(&headers) + .unwrap(); + let mut message = message.into_event_mesh().unwrap(); + assert_eq!(message.properties().len(), 1); + assert_eq!(message.get_prop("custom"), Some("business-value")); + assert_eq!(message.biz_seq_no(), Some("form-sequence")); + assert_eq!(message.unique_id(), Some("form-id")); + assert_eq!(message.ttl_millis(), Some(7000)); + assert_eq!(message.data_content_type(), Some("application/json")); + let context: &eventmesh::DeliveryContext = message.delivery_context().unwrap(); + assert_eq!(context.protocol_type(), Some("eventmeshmessage")); + assert_eq!(context.protocol_version(), Some("1.0")); + assert_eq!(context.protocol_description(), Some("http")); + assert_eq!(context.attribute("sys"), Some("source-system")); + assert_eq!(context.attribute("language"), Some("JAVA")); + assert_eq!(context.attribute("cluster"), Some("source-cluster")); + assert_eq!(context.attribute("correlation99id"), Some("correlation")); + let context = context.clone(); + assert!(message.set_prop("protocoldesc", "tcp").is_err()); + message.set_prop("custom", "updated").unwrap(); + assert_eq!(message.delivery_context(), Some(&context)); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_cloud_events.rs new file mode 100644 index 0000000000..b39bf1ed90 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_cloud_events.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. + +//! E2e: gRPC CloudEvents publishing and stream delivery. + +use std::time::Duration; + +use cloudevents::{AttributesReader, Event, EventBuilder, EventBuilderV10}; +use eventmesh::{ + grpc::GrpcStreamConsumer, message::Message, subscription::Subscription, MessageHandler, Result, +}; +use tokio::sync::mpsc; + +use crate::harness::{ + ensure_topic, grpc_channel, grpc_consumer_options, grpc_producer, let_stream_settle, + unique_topic, +}; +use crate::require_runtime; + +struct CloudEventListener(mpsc::UnboundedSender); + +impl MessageHandler for CloudEventListener { + async fn handle(&self, message: Message) -> Result> { + if let Message::CloudEvent(event) = message { + let _ = self.0.send(event); + } + Ok(None) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn grpc_publish_cloud_event() { + require_runtime!(); + let topic = unique_topic("grpc-ce-pub"); + ensure_topic(&topic).await; + let (tx, mut receiver) = mpsc::unbounded_channel(); + let consumer = GrpcStreamConsumer::open( + grpc_channel().await, + grpc_consumer_options(), + [Subscription::new(&topic)], + CloudEventListener(tx), + ) + .await + .expect("open gRPC CloudEvent consumer"); + let_stream_settle().await; + + let event = EventBuilderV10::new() + .id("grpc-ce-e2e-1") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.rust.grpc") + .subject(&topic) + .data( + "application/json", + r#"{"msg":"hello from gRPC CloudEvents"}"#, + ) + .build() + .expect("valid CloudEvent"); + let receipt = grpc_producer() + .await + .publish(Message::from(event)) + .await + .expect("publish gRPC CloudEvent"); + assert_eq!(receipt.code, 0); + + let received = tokio::time::timeout(Duration::from_secs(20), receiver.recv()) + .await + .expect("timed out waiting for gRPC CloudEvent delivery") + .expect("CloudEvent handler channel closed"); + assert_eq!(received.subject(), Some(topic.as_str())); + assert!(serde_json::to_string(&received) + .expect("serialize received CloudEvent") + .contains("hello from gRPC CloudEvents")); + consumer.shutdown(); + consumer.join().await.expect("join gRPC consumer"); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_concurrent_dispatch.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_concurrent_dispatch.rs new file mode 100644 index 0000000000..604353811c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_concurrent_dispatch.rs @@ -0,0 +1,109 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: bounded concurrent gRPC handler dispatch through the v2 facade. + +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::time::Duration; + +use eventmesh::{ + config::GrpcConsumerOptions, + grpc::GrpcStreamConsumer, + message::{EventMeshMessage, Message}, + subscription::Subscription, + MessageHandler, Result, +}; +use tokio::sync::mpsc; + +use crate::harness::{ensure_topic, grpc_channel, grpc_producer, let_stream_settle, unique_topic}; +use crate::require_runtime; + +const HANDLER_DELAY: Duration = Duration::from_millis(500); +const COUNT: usize = 5; +const MAX_CONCURRENT: usize = 2; + +struct SlowHandler { + active: Arc, + max_active: Arc, + completed: mpsc::UnboundedSender<()>, +} + +impl MessageHandler for SlowHandler { + async fn handle(&self, _message: Message) -> Result> { + let active = self.active.fetch_add(1, Ordering::SeqCst) + 1; + self.max_active.fetch_max(active, Ordering::SeqCst); + tokio::time::sleep(HANDLER_DELAY).await; + self.active.fetch_sub(1, Ordering::SeqCst); + let _ = self.completed.send(()); + Ok(None) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_dispatch_overlaps_handlers() { + require_runtime!(); + let topic = unique_topic("concurrent"); + ensure_topic(&topic).await; + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let (completed, mut completions) = mpsc::unbounded_channel(); + let consumer = GrpcStreamConsumer::open( + grpc_channel().await, + GrpcConsumerOptions::new(unique_topic("concurrent-group")) + .with_max_concurrent_handlers(MAX_CONCURRENT), + [Subscription::new(&topic)], + SlowHandler { + active: Arc::clone(&active), + max_active: Arc::clone(&max_active), + completed, + }, + ) + .await + .expect("open gRPC consumer"); + let_stream_settle().await; + + let producer = grpc_producer().await; + for index in 0..COUNT { + producer + .publish(Message::from( + EventMeshMessage::new(&topic, format!("m{index}")).unwrap(), + )) + .await + .expect("publish"); + } + + for _ in 0..COUNT { + tokio::time::timeout(Duration::from_secs(15), completions.recv()) + .await + .expect("timed out waiting for handler completion") + .expect("handler completion channel closed"); + } + let observed = max_active.load(Ordering::SeqCst); + assert!( + observed > 1, + "expected overlapping handlers, observed maximum was {observed}" + ); + assert!( + observed <= MAX_CONCURRENT, + "handler limit was {MAX_CONCURRENT}, observed {observed}" + ); + consumer.shutdown(); + consumer.join().await.expect("join gRPC consumer"); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_webhook.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_webhook.rs new file mode 100644 index 0000000000..ec89cbdc25 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/grpc_webhook.rs @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: gRPC webhook registration and HTTP callback delivery. + +use std::time::{Duration, Instant}; + +use eventmesh::{ + grpc::GrpcWebhookConsumer, + message::{EventMeshMessage, Message}, + subscription::Subscription, +}; + +use crate::harness::{ + consumer_options, ensure_topic, grpc_channel, grpc_producer, let_stream_settle, + start_webhook_server, unique_topic, +}; +use crate::require_runtime; + +#[tokio::test(flavor = "multi_thread")] +async fn grpc_webhook_consumer_receives_delivery() { + require_runtime!(); + let topic = unique_topic("grpc-webhook"); + ensure_topic(&topic).await; + + // The server is deliberately not registered through the HTTP client: this + // leaves gRPC as the only component that owns the runtime subscription. + let (webhook_server, mut receiver) = start_webhook_server().await; + let webhook = GrpcWebhookConsumer::new(grpc_channel().await, consumer_options()) + .await + .expect("build gRPC webhook consumer"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + match webhook + .subscribe([Subscription::new(&topic)], webhook_server.webhook_url()) + .await + { + Ok(()) => break, + Err(error) if Instant::now() < deadline => { + tracing::debug!(%error, "gRPC webhook registration is waiting for runtime routes"); + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(error) => panic!("register gRPC webhook: {error}"), + } + } + let_stream_settle().await; + + // Keep this test on the gRPC-origin path. The Runtime cannot currently + // adapt an HTTP-origin CloudEvent into its gRPC push representation. + grpc_producer() + .await + .publish(Message::from( + EventMeshMessage::new(&topic, "delivered-via-grpc-webhook").unwrap(), + )) + .await + .expect("publish to gRPC webhook"); + let delivered = tokio::time::timeout(Duration::from_secs(15), receiver.recv()) + .await + .expect("timed out waiting for gRPC webhook callback") + .expect("webhook handler channel closed"); + assert_eq!(delivered.content(), "delivered-via-grpc-webhook"); + webhook.shutdown(); + webhook.join().await.expect("join gRPC webhook consumer"); +} 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..143923cec8 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/harness.rs @@ -0,0 +1,493 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Shared v2 e2e helpers. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use eventmesh::{ + config::{ + ConsumerOptions, Credentials, Endpoint, EndpointSet, GrpcConfig, GrpcConsumerOptions, + HttpConfig, Identity, ProducerOptions, TcpConfig, + }, + grpc::{GrpcChannel, GrpcProducer, GrpcStreamConsumer}, + http::{HttpClient, HttpConsumer}, + message::{EventMeshMessage, Message}, + subscription::Subscription, + tcp::{TcpClient, TcpConsumer, TcpProducer}, + webhook::{WebhookOptions, WebhookServer}, + MessageHandler, Result, +}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use tracing::{debug, warn}; + +use crate::runtime::{ + ensure_runtime, webhook_host, ADMIN_PORT, GRPC_PORT, HOST, HTTP_PORT, TCP_PORT, +}; + +static SEQ: AtomicU64 = AtomicU64::new(0); +static IDENTITY_SEQ: AtomicU64 = AtomicU64::new(1); +static TCP_E2E_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// Serializes TCP e2e cases against the shared EventMesh/RocketMQ runtime. +/// +/// Unique topics isolate message data, but concurrent TCP subscriptions still +/// compete in the runtime's asynchronous route refresh and RocketMQ rebalance +/// cycles. Keep the guard alive for the complete test case. +pub(crate) async fn serialize_tcp_e2e() -> tokio::sync::MutexGuard<'static, ()> { + TCP_E2E_LOCK.lock().await +} + +pub(crate) fn unique_topic(scope: &str) -> String { + let n = SEQ.fetch_add(1, Ordering::Relaxed); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("e2e-{scope}-{ts}-{n}") +} + +fn identity() -> Identity { + let identity_id = IDENTITY_SEQ.fetch_add(1, Ordering::Relaxed); + let third_octet = (identity_id / 254) % 256; + let fourth_octet = identity_id % 254 + 1; + Identity::default() + .with_env("env") + .with_idc("idc") + .with_system("sys") + // 198.18.0.0/15 is reserved for benchmarking. The address is only a + // logical gRPC subscriber identity and is never used as a route. + .with_ip(format!("198.18.{third_octet}.{fourth_octet}")) +} + +fn credentials() -> Credentials { + Credentials::new().with_basic("eventmesh", "eventmesh") +} + +pub(crate) fn producer_options() -> ProducerOptions { + ProducerOptions::new(unique_topic("producer-group")) +} + +pub(crate) fn consumer_options() -> ConsumerOptions { + ConsumerOptions::new(unique_topic("consumer-group")) +} + +pub(crate) fn grpc_consumer_options() -> GrpcConsumerOptions { + GrpcConsumerOptions::new(unique_topic("consumer-group")) +} + +pub(crate) async fn grpc_channel() -> GrpcChannel { + let endpoint = Endpoint::new(HOST, GRPC_PORT).expect("valid gRPC endpoint"); + GrpcChannel::connect( + GrpcConfig::new(endpoint) + .with_identity(identity()) + .with_credentials(credentials()), + ) + .await + .expect("build gRPC client") +} + +pub(crate) async fn grpc_producer() -> GrpcProducer { + GrpcProducer::new(grpc_channel().await, producer_options()).expect("build gRPC producer") +} + +pub(crate) fn http_client() -> HttpClient { + let endpoint = Endpoint::new(HOST, HTTP_PORT).expect("valid HTTP endpoint"); + HttpClient::new( + HttpConfig::new(EndpointSet::new([endpoint]).expect("non-empty endpoints")) + .with_identity(identity()) + .with_credentials(credentials()), + ) + .expect("build HTTP client") +} + +pub(crate) fn http_producer() -> eventmesh::http::HttpProducer { + http_client() + .producer(producer_options()) + .expect("build HTTP producer") +} + +pub(crate) fn tcp_client() -> TcpClient { + tcp_client_with_system("sys") +} + +pub(crate) fn tcp_client_with_system(system: &str) -> TcpClient { + let endpoint = Endpoint::new(HOST, TCP_PORT).expect("valid TCP endpoint"); + TcpClient::new( + TcpConfig::new(endpoint) + .with_identity(identity().with_system(system)) + .with_credentials(credentials()), + ) + .expect("build TCP client") +} + +pub(crate) async fn tcp_producer() -> TcpProducer { + tcp_client() + .producer(producer_options()) + .await + .expect("build TCP producer") +} + +pub(crate) async fn ensure_topic(topic: &str) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + let url = format!("http://{HOST}:{ADMIN_PORT}/topic"); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client"); + + for attempt in 0..5u8 { + let result = client.post(&url).form(&[("name", topic)]).send().await; + match result { + Ok(response) if response.status().is_success() || response.status().as_u16() == 409 => { + break; + } + Ok(response) => debug!(%topic, status = %response.status(), "ensure_topic response"), + Err(error) => warn!(%topic, attempt, "ensure_topic error: {error}"), + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + // RocketMQ's admin call updates brokers before the new route is visible + // through NameServer. A consumer started in that gap logs "topic not + // exist" and may not rebalance again until after a short E2E timeout. + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if let Ok(response) = client.get(&url).send().await { + if let Ok(topics) = response.json::>().await { + if topics + .iter() + .any(|entry| entry.get("name").and_then(|name| name.as_str()) == Some(topic)) + { + return; + } + } + } + assert!( + Instant::now() < deadline, + "topic {topic:?} was not visible through EventMesh admin within 15s" + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +/// Ask the Runtime admin API to gracefully disconnect only TCP sessions whose +/// HELLO `subsystem` matches `subsystem`. This exercises the same server-side +/// disconnect path used by operational client management without restarting +/// the shared Runtime container. +pub(crate) async fn reject_tcp_subsystem(subsystem: &str) { + let url = format!("http://{HOST}:{ADMIN_PORT}/clientManage/rejectClientBySubSystem"); + let response = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client") + .get(url) + .query(&[("subsystem", subsystem)]) + .send() + .await + .expect("reject TCP subsystem through Runtime admin API"); + assert!( + response.status().is_success(), + "reject TCP subsystem status" + ); + let body = response.text().await.expect("reject TCP subsystem body"); + assert!( + body.contains("success!") && !body.contains("no session had been closed"), + "Runtime did not find the test TCP sessions: {body}" + ); +} + +pub(crate) async fn let_stream_settle() { + tokio::time::sleep(Duration::from_millis(800)).await; +} + +/// The TCP Runtime acknowledges `SUBSCRIBE_REQUEST` before its RocketMQ push +/// consumer has refreshed routes and sent the new subscription heartbeat. +/// Route refresh and rebalance run on separate scheduled intervals, so allow +/// enough time for both before publishing a message under test. +pub(crate) async fn let_tcp_subscription_settle() { + tokio::time::sleep(Duration::from_secs(45)).await; +} + +/// Poll the Runtime's protocol-specific client inventory until `group` is +/// present or absent. Unique E2E consumer groups make this an unambiguous +/// server-side subscription assertion even though the current admin response +/// does not include the topic. +pub(crate) async fn wait_for_client_group(protocol: &str, group: &str, expected: bool) { + let path = match protocol { + "http" => "/client/http", + "grpc" => "/client/grpc", + other => panic!("unsupported admin client protocol {other:?}"), + }; + let url = format!("http://{HOST}:{ADMIN_PORT}{path}"); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client"); + let deadline = Instant::now() + Duration::from_secs(15); + let mut last_groups = Vec::new(); + + loop { + if let Ok(response) = client.get(&url).send().await { + if response.status().is_success() { + if let Ok(entries) = response.json::>().await { + last_groups = entries + .iter() + .filter_map(|entry| entry.get("group").and_then(serde_json::Value::as_str)) + .map(str::to_owned) + .collect(); + let present = last_groups.iter().any(|candidate| candidate == group); + if present == expected { + return; + } + } + } + } + assert!( + Instant::now() < deadline, + "Runtime admin {path} did not report consumer group {group:?} as {} within 15s; \ + last groups: {last_groups:?}", + if expected { "present" } else { "absent" } + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +/// Poll the Runtime's TCP topic listener inventory until the unique E2E topic +/// has (or has no) active listening sessions. +pub(crate) async fn wait_for_tcp_topic_listener(topic: &str, expected: bool) { + let url = format!("http://{HOST}:{ADMIN_PORT}/clientManage/showListenClientByTopic"); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .expect("reqwest client"); + let deadline = Instant::now() + Duration::from_secs(15); + let mut last_body = String::new(); + + loop { + if let Ok(response) = client.get(&url).query(&[("topic", topic)]).send().await { + if response.status().is_success() { + if let Ok(body) = response.text().await { + let present = !body.trim().is_empty(); + last_body = body; + if present == expected { + return; + } + } + } + } + assert!( + Instant::now() < deadline, + "Runtime admin TCP listener query did not report topic {topic:?} as {} within 15s; \ + last response: {last_body:?}", + if expected { "present" } else { "absent" } + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +pub(crate) async fn warm_topic( + topic: &str, +) -> ( + GrpcStreamConsumer, + mpsc::UnboundedReceiver, +) { + warm_topic_as(topic, unique_topic("consumer-group")).await +} + +pub(crate) async fn warm_topic_as( + topic: &str, + consumer_group: String, +) -> ( + GrpcStreamConsumer, + mpsc::UnboundedReceiver, +) { + let (listener, receiver) = CollectingListener::new(); + let consumer = GrpcStreamConsumer::open( + grpc_channel().await, + GrpcConsumerOptions::new(consumer_group), + [Subscription::new(topic)], + listener, + ) + .await + .expect("open gRPC stream consumer"); + let_stream_settle().await; + (consumer, receiver) +} + +pub(crate) fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind port probe"); + let port = listener.local_addr().expect("probe local address").port(); + drop(listener); + port +} + +async fn wait_for_listen(address: SocketAddr, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if Instant::now() >= deadline { + panic!("webhook server at {address} did not start within {timeout:?}"); + } + if TcpStream::connect(address).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// A standalone SDK webhook server for transports that own their registration +/// lifecycle (notably gRPC webhook consumers). +pub(crate) struct WebhookServerHandle { + webhook_url: String, + server_task: JoinHandle<()>, + shutdown_tx: Option>, +} + +impl WebhookServerHandle { + pub(crate) fn webhook_url(&self) -> &str { + &self.webhook_url + } +} + +impl Drop for WebhookServerHandle { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + self.server_task.abort(); + } +} + +pub(crate) async fn start_webhook_server() -> ( + WebhookServerHandle, + mpsc::UnboundedReceiver, +) { + let (listener, receiver) = CollectingListener::new(); + let port = free_port(); + let bind_address: SocketAddr = format!("0.0.0.0:{port}").parse().expect("webhook address"); + let url = format!("http://{}:{port}/eventmesh/callback", webhook_host()); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let server = WebhookServer::bind(bind_address, listener) + .await + .expect("bind webhook server") + .with_advertise_url(url.clone()) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(async move { + if let Err(error) = server.await { + warn!(%error, "webhook server exited with error"); + } + }); + wait_for_listen(bind_address, Duration::from_secs(5)).await; + ( + WebhookServerHandle { + webhook_url: url, + server_task, + shutdown_tx: Some(shutdown_tx), + }, + receiver, + ) +} + +pub(crate) async fn http_warm_topic( + topic: &str, +) -> (HttpConsumer, mpsc::UnboundedReceiver) { + http_warm_topic_as(topic, unique_topic("consumer-group")).await +} + +pub(crate) async fn http_warm_topic_as( + topic: &str, + consumer_group: String, +) -> (HttpConsumer, mpsc::UnboundedReceiver) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + let (listener, receiver) = CollectingListener::new(); + let port = free_port(); + let bind_address: SocketAddr = format!("0.0.0.0:{port}").parse().expect("webhook address"); + let url = format!("http://{}:{port}/eventmesh/callback", webhook_host()); + let consumer = http_client() + .consumer( + ConsumerOptions::new(consumer_group), + WebhookOptions::new(bind_address).with_advertise_url(url), + [Subscription::new(topic)], + listener, + ) + .await + .expect("open HTTP consumer"); + let_stream_settle().await; + + (consumer, receiver) +} + +pub(crate) async fn tcp_warm_topic( + topic: &str, +) -> ( + TcpConsumer, + mpsc::UnboundedReceiver, +) { + assert!(ensure_runtime(), "ensure_runtime() must be called first"); + let (listener, receiver) = CollectingListener::new(); + let consumer = tcp_client() + .consumer(consumer_options(), listener) + .await + .expect("open TCP consumer"); + consumer + .subscribe(Subscription::new(topic)) + .await + .expect("subscribe TCP consumer"); + let_tcp_subscription_settle().await; + (consumer, receiver) +} + +pub(crate) struct CollectingListener { + tx: mpsc::UnboundedSender, +} + +impl CollectingListener { + pub(crate) fn new() -> (Self, mpsc::UnboundedReceiver) { + let (tx, receiver) = mpsc::unbounded_channel(); + (Self { tx }, receiver) + } +} + +impl MessageHandler for CollectingListener { + async fn handle(&self, message: Message) -> Result> { + let message = message.into_event_mesh()?; + let _ = self.tx.send(message); + Ok(None) + } +} + +pub(crate) struct ReplyingListener { + pub(crate) reply_content: String, +} + +impl MessageHandler for ReplyingListener { + async fn handle(&self, message: Message) -> Result> { + let request = message.into_event_mesh()?; + Ok(Some( + EventMeshMessage::new(request.topic(), self.reply_content.clone())?.into(), + )) + } +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_cloud_events.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_cloud_events.rs new file mode 100644 index 0000000000..ccb1cd4881 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_cloud_events.rs @@ -0,0 +1,97 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: HTTP CloudEvents publishing, consumed by a real gRPC stream. + +use std::{net::SocketAddr, time::Duration}; + +use cloudevents::{AttributesReader, Event, EventBuilder, EventBuilderV10}; +use eventmesh::{ + message::Message, subscription::Subscription, webhook::WebhookOptions, MessageHandler, Result, +}; +use tokio::sync::mpsc; + +use crate::harness::{ + consumer_options, ensure_topic, free_port, http_client, http_producer, let_stream_settle, + unique_topic, +}; +use crate::require_runtime; +use crate::runtime::webhook_host; + +struct CloudEventListener(mpsc::UnboundedSender); + +impl MessageHandler for CloudEventListener { + async fn handle(&self, message: Message) -> Result> { + let event = match message { + Message::CloudEvent(event) => event, + Message::EventMesh(message) => { + panic!("expected HTTP CloudEvent to preserve its dialect, got {message:?}") + } + _ => panic!("expected HTTP CloudEvent"), + }; + let _ = self.0.send(event); + Ok(None) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_publish_cloud_event() { + require_runtime!(); + let topic = unique_topic("http-ce-pub"); + ensure_topic(&topic).await; + let (tx, mut receiver) = mpsc::unbounded_channel(); + let port = free_port(); + let bind_address: SocketAddr = format!("0.0.0.0:{port}").parse().expect("webhook address"); + let advertise_url = format!("http://{}:{port}/eventmesh/callback", webhook_host()); + let consumer = http_client() + .consumer( + consumer_options(), + WebhookOptions::new(bind_address).with_advertise_url(advertise_url), + [Subscription::new(&topic)], + CloudEventListener(tx), + ) + .await + .expect("open HTTP CloudEvent consumer"); + let_stream_settle().await; + + let event = EventBuilderV10::new() + .id("http-ce-e2e-1") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.rust.http") + .subject(&topic) + .data( + "application/json", + r#"{"msg":"hello from HTTP CloudEvents"}"#, + ) + .build() + .expect("valid CloudEvent"); + let receipt = http_producer() + .publish(Message::from(event)) + .await + .expect("publish HTTP CloudEvent"); + assert_eq!(receipt.code, 0); + + let received = tokio::time::timeout(Duration::from_secs(20), receiver.recv()) + .await + .expect("timed out waiting for HTTP CloudEvent delivery") + .expect("CloudEvent handler channel closed"); + assert_eq!(received.subject(), Some(topic.as_str())); + assert!(serde_json::to_string(&received) + .expect("serialize received CloudEvent") + .contains("hello from HTTP CloudEvents")); + consumer.close().await.expect("close HTTP consumer"); +} 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..3f2e09bdbc --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_publish.rs @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: HTTP producer operations through the v2 facade. + +use eventmesh::message::{EventMeshMessage, Message}; + +use crate::harness::{ensure_topic, http_producer, http_warm_topic, unique_topic}; +use crate::require_runtime; +use std::time::Duration; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(15), receiver.recv()) + .await + .expect("timed out waiting for HTTP delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_publish_single() { + require_runtime!(); + let topic = unique_topic("http-pub-single"); + ensure_topic(&topic).await; + let (_handle, mut receiver) = http_warm_topic(&topic).await; + + let receipt = http_producer() + .publish(Message::from( + EventMeshMessage::new(&topic, "hello from rust http e2e").unwrap(), + )) + .await + .expect("HTTP publish"); + assert_eq!(receipt.code, 0, "HTTP publish should succeed: {receipt:?}"); + assert_eq!( + receive(&mut receiver).await.content(), + "hello from rust http e2e" + ); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_subscribe.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_subscribe.rs new file mode 100644 index 0000000000..74ed200a0d --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/http_subscribe.rs @@ -0,0 +1,95 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: HTTP webhook subscriptions through the v2 facade. + +use std::time::Duration; + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::Subscription, +}; + +use crate::harness::{ + ensure_topic, http_producer, http_warm_topic, http_warm_topic_as, unique_topic, + wait_for_client_group, +}; +use crate::require_runtime; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(15), receiver.recv()) + .await + .expect("timed out waiting for webhook delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_subscribe_and_receive() { + require_runtime!(); + let topic = unique_topic("http-sub-recv"); + ensure_topic(&topic).await; + let (_handle, mut receiver) = http_warm_topic(&topic).await; + + http_producer() + .publish(Message::from( + EventMeshMessage::new(&topic, "delivered-via-http").unwrap(), + )) + .await + .expect("HTTP publish"); + assert_eq!(receive(&mut receiver).await.content(), "delivered-via-http"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_unsubscribe_stops_delivery() { + require_runtime!(); + let topic = unique_topic("http-sub-unsub"); + let consumer_group = unique_topic("consumer-group"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = http_warm_topic_as(&topic, consumer_group.clone()).await; + let producer = http_producer(); + wait_for_client_group("http", &consumer_group, true).await; + + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "before-http-unsub").unwrap(), + )) + .await + .expect("HTTP publish before unsubscribe"); + let _ = receive(&mut receiver).await; + + consumer + .unsubscribe(Subscription::new(&topic)) + .await + .expect("HTTP unsubscribe"); + wait_for_client_group("http", &consumer_group, false).await; + + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "after-http-unsub").unwrap(), + )) + .await + .expect("HTTP publish after unsubscribe"); + assert!( + matches!( + tokio::time::timeout(Duration::from_secs(3), receiver.recv()).await, + Err(_) | Ok(None) + ), + "webhook delivery leaked after unsubscribe" + ); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/interop.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/interop.rs new file mode 100644 index 0000000000..5b5c0eb62c --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/interop.rs @@ -0,0 +1,225 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Cross-SDK gRPC, HTTP, and TCP interoperability with a Java SDK peer. + +use std::{path::PathBuf, process::Command, sync::OnceLock, time::Duration}; + +use eventmesh::message::{EventMeshMessage, Message}; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, BufReader, Lines}, + process::Command as TokioCommand, +}; + +use crate::{ + harness::{ + ensure_topic, grpc_producer, http_producer, http_warm_topic, let_stream_settle, + serialize_tcp_e2e, tcp_producer, tcp_warm_topic, unique_topic, wait_for_tcp_topic_listener, + warm_topic, + }, + require_runtime, + runtime::webhook_host, +}; + +static PEER_JAR: OnceLock = OnceLock::new(); + +fn java_peer_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("interop/java-peer") +} + +fn peer_jar() -> &'static PathBuf { + PEER_JAR.get_or_init(|| { + let project = java_peer_dir(); + let output = Command::new("mvn") + .current_dir(&project) + .args(["--quiet", "--batch-mode", "-DskipTests", "package"]) + .output() + .expect("run Maven for Java interop peer"); + assert!( + output.status.success(), + "build Java interop peer: {}", + String::from_utf8_lossy(&output.stderr) + ); + let jar = project.join("target/eventmesh-java-interop-peer.jar"); + assert!( + jar.is_file(), + "Java interop peer jar was not created at {}", + jar.display() + ); + jar + }) +} + +async fn peer(operation: &str, topic: &str, argument: Option<&str>) -> tokio::process::Child { + let mut command = TokioCommand::new("java"); + command.args([ + "-jar", + peer_jar().to_str().expect("utf-8 Java peer jar path"), + operation, + "127.0.0.1", + topic, + ]); + if let Some(argument) = argument { + command.arg(argument); + } + command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .expect("start Java SDK peer") +} + +async fn wait_for_line(lines: &mut Lines>, expected: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let line = tokio::time::timeout(remaining, lines.next_line()) + .await + .expect("Java peer output timeout") + .expect("read Java peer output") + .expect("Java peer exited before expected output"); + if line == expected { + return; + } + } +} + +async fn assert_peer_success(child: &mut tokio::process::Child) { + assert!(child.wait().await.expect("wait Java peer").success()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn grpc_rust_publishes_to_java_consumer() { + require_runtime!(); + let topic = unique_topic("interop-grpc-rust-java"); + ensure_topic(&topic).await; + let mut child = peer("grpc-consume", &topic, None).await; + let mut lines = BufReader::new(child.stdout.take().expect("peer stdout")).lines(); + wait_for_line(&mut lines, "INTEROP_READY").await; + grpc_producer() + .await + .publish(Message::from( + EventMeshMessage::new(&topic, "from-rust-grpc").unwrap(), + )) + .await + .expect("Rust gRPC publish"); + wait_for_line( + &mut lines, + &format!("INTEROP_RECEIVED={topic}\tfrom-rust-grpc"), + ) + .await; + assert_peer_success(&mut child).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn grpc_java_publishes_to_rust_consumer() { + require_runtime!(); + let topic = unique_topic("interop-grpc-java-rust"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + let mut child = peer("grpc-publish", &topic, Some("from-java-grpc")).await; + assert_peer_success(&mut child).await; + let received = tokio::time::timeout(Duration::from_secs(20), receiver.recv()) + .await + .expect("Rust gRPC receive timeout") + .expect("Rust gRPC listener closed"); + assert_eq!(received.content(), "from-java-grpc"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_rust_publishes_to_java_consumer() { + require_runtime!(); + let topic = unique_topic("interop-http-rust-java"); + ensure_topic(&topic).await; + let callback_host = webhook_host(); + let mut child = peer("http-consume", &topic, Some(&callback_host)).await; + let mut lines = BufReader::new(child.stdout.take().expect("peer stdout")).lines(); + wait_for_line(&mut lines, "INTEROP_READY").await; + let_stream_settle().await; + http_producer() + .publish(Message::from( + EventMeshMessage::new(&topic, "from-rust-http").unwrap(), + )) + .await + .expect("Rust HTTP publish"); + wait_for_line( + &mut lines, + &format!("INTEROP_RECEIVED={topic}\tfrom-rust-http"), + ) + .await; + assert_peer_success(&mut child).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_java_publishes_to_rust_consumer() { + require_runtime!(); + let topic = unique_topic("interop-http-java-rust"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = http_warm_topic(&topic).await; + let mut child = peer("http-publish", &topic, Some("from-java-http")).await; + assert_peer_success(&mut child).await; + let received = tokio::time::timeout(Duration::from_secs(20), receiver.recv()) + .await + .expect("Rust HTTP receive timeout") + .expect("Rust HTTP listener closed"); + assert_eq!(received.content(), "from-java-http"); + consumer.close().await.expect("close Rust HTTP consumer"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_rust_publishes_to_java_consumer() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("interop-tcp-rust-java"); + ensure_topic(&topic).await; + let mut child = peer("tcp-consume", &topic, None).await; + let mut lines = BufReader::new(child.stdout.take().expect("peer stdout")).lines(); + wait_for_line(&mut lines, "INTEROP_READY").await; + wait_for_tcp_topic_listener(&topic, true).await; + let producer = tcp_producer().await; + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "from-rust-tcp").unwrap(), + )) + .await + .expect("Rust TCP publish"); + wait_for_line( + &mut lines, + &format!("INTEROP_RECEIVED={topic}\tfrom-rust-tcp"), + ) + .await; + assert_peer_success(&mut child).await; + producer.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_java_publishes_to_rust_consumer() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("interop-tcp-java-rust"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = tcp_warm_topic(&topic).await; + let mut child = peer("tcp-publish", &topic, Some("from-java-tcp")).await; + assert_peer_success(&mut child).await; + let received = tokio::time::timeout(Duration::from_secs(20), receiver.recv()) + .await + .expect("Rust TCP receive timeout") + .expect("Rust TCP listener closed"); + assert_eq!(received.content(), "from-java-tcp"); + consumer.shutdown(); + consumer.join().await.expect("join Rust TCP consumer"); +} 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..46d3330584 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/main.rs @@ -0,0 +1,77 @@ +// 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 fail by default so a missing runtime cannot produce a false green. +//! +//! For local compile/smoke checks where skipping is intentional, set +//! `EVENTMESH_E2E_ALLOW_SKIP=1`. Release CI must never set this escape hatch. + +#![cfg(feature = "e2e")] + +mod grpc_cloud_events; +mod grpc_concurrent_dispatch; +mod grpc_webhook; +mod harness; +mod http_cloud_events; +mod http_publish; +mod http_subscribe; +#[cfg(feature = "interop_e2e")] +mod interop; +mod publish; +mod request_reply; +mod runtime; +mod subscribe; +mod tcp_cloud_events; +mod tcp_publish; +mod tcp_reconnect; +mod tcp_request_reply; +mod tcp_subscribe; + +/// Guard clause for e2e tests: ensures a runtime is available before +/// proceeding. +/// +/// A missing runtime is a failure by default. `EVENTMESH_E2E_ALLOW_SKIP=1` is +/// an explicit local-only escape hatch for environments that only want to +/// compile the e2e suite. +macro_rules! require_runtime { + () => { + if !crate::runtime::ensure_runtime() { + if !crate::runtime::allow_skip() { + panic!("EventMesh runtime is not available; set EVENTMESH_E2E_ALLOW_SKIP=1 only when skipping is intentional"); + } + return; + } + }; +} +pub(crate) use require_runtime; diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/publish.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/publish.rs new file mode 100644 index 0000000000..2ce400c73b --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/publish.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. + +//! E2e: gRPC producer operations through the v2 facade. + +use eventmesh::message::{EventMeshMessage, Message}; + +use crate::harness::{ensure_topic, grpc_producer, unique_topic, warm_topic}; +use crate::require_runtime; +use std::time::Duration; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for gRPC delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn publish_single() { + require_runtime!(); + let topic = unique_topic("pub-single"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + let receipt = grpc_producer() + .await + .publish(Message::from( + EventMeshMessage::new(&topic, "hello from rust e2e").unwrap(), + )) + .await + .expect("publish"); + assert_eq!(receipt.code, 0, "publish should succeed: {receipt:?}"); + assert_eq!( + receive(&mut receiver).await.content(), + "hello from rust e2e" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn publish_batch() { + require_runtime!(); + let topic = unique_topic("pub-batch"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + let messages = (0..3) + .map(|index| { + Message::from(EventMeshMessage::new(&topic, format!("batch message #{index}")).unwrap()) + }) + .collect(); + let receipt = grpc_producer() + .await + .publish_batch(messages) + .await + .expect("batch publish"); + assert_eq!(receipt.code, 0, "batch publish should succeed: {receipt:?}"); + let mut contents = Vec::new(); + for _ in 0..3 { + contents.push(receive(&mut receiver).await.content().to_owned()); + } + contents.sort(); + assert_eq!( + contents, + ["batch message #0", "batch message #1", "batch message #2"] + ); +} 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..9d9ce15d71 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/request_reply.rs @@ -0,0 +1,151 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: gRPC synchronous request/reply through the v2 facade. + +use std::time::{Duration, Instant}; + +use eventmesh::{ + grpc::GrpcStreamConsumer, + message::{EventMeshMessage, Message}, + subscription::{DeliveryType, Subscription}, + Error, MessageHandler, Result, +}; + +use crate::harness::{ + ensure_topic, grpc_channel, grpc_consumer_options, grpc_producer, let_stream_settle, + unique_topic, ReplyingListener, +}; +use crate::require_runtime; + +#[tokio::test(flavor = "multi_thread")] +async fn request_reply_roundtrip() { + require_runtime!(); + let topic = unique_topic("req-reply"); + ensure_topic(&topic).await; + let consumer = GrpcStreamConsumer::open( + grpc_channel().await, + grpc_consumer_options(), + [Subscription::new(&topic).with_delivery_type(DeliveryType::Sync)], + ReplyingListener { + reply_content: "pong".into(), + }, + ) + .await + .expect("open request/reply consumer"); + let_stream_settle().await; + + let reply = grpc_producer() + .await + .request_reply(Message::from( + EventMeshMessage::new(&topic, "ping").unwrap(), + )) + .await + .expect("gRPC request/reply"); + match reply { + Message::EventMesh(message) => assert_eq!(message.content(), "pong"), + #[cfg(feature = "cloud_events")] + other => panic!("expected native reply, got {other:?}"), + } + consumer.shutdown(); + consumer.join().await.expect("join gRPC consumer"); +} + +/// A replying listener that sleeps before answering, so callers hit their +/// deadline while the reply is still in flight. +struct SlowReplyingListener { + delay: Duration, +} + +impl MessageHandler for SlowReplyingListener { + async fn handle(&self, message: Message) -> Result> { + let request = message.into_event_mesh()?; + tokio::time::sleep(self.delay).await; + Ok(Some( + EventMeshMessage::new(request.topic(), "late-pong")?.into(), + )) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn request_reply_deadline_times_out() { + require_runtime!(); + let topic = unique_topic("req-reply-timeout"); + ensure_topic(&topic).await; + // Concurrent handlers so the follow-up request is not queued behind the + // still-sleeping first one. + let consumer = GrpcStreamConsumer::open( + grpc_channel().await, + grpc_consumer_options().with_max_concurrent_handlers(4), + [Subscription::new(&topic).with_delivery_type(DeliveryType::Sync)], + SlowReplyingListener { + delay: Duration::from_secs(5), + }, + ) + .await + .expect("open slow-reply consumer"); + let_stream_settle().await; + + let producer = grpc_producer().await; + let timeout = Duration::from_millis(1500); + let started = Instant::now(); + let error = producer + .request_reply_with_timeout(Message::from(request_message(&topic)), timeout) + .await + .expect_err("request/reply must hit the deadline"); + let elapsed = started.elapsed(); + assert!( + matches!(error, Error::Timeout(actual) if actual == timeout), + "expected Error::Timeout({timeout:?}), got {error:?}" + ); + assert!( + elapsed >= timeout, + "deadline cannot fire before {timeout:?}, fired at {elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(4), + "deadline fired suspiciously late: {elapsed:?}" + ); + + // The cancelled HTTP/2 stream must not poison the shared channel: a + // follow-up request on the same producer still gets its (late) reply. + let reply = producer + .request_reply_with_timeout( + Message::from(request_message(&topic)), + Duration::from_secs(15), + ) + .await + .expect("request/reply after a timed-out call"); + match reply { + Message::EventMesh(message) => assert_eq!(message.content(), "late-pong"), + #[cfg(feature = "cloud_events")] + other => panic!("expected native reply, got {other:?}"), + } + consumer.shutdown(); + consumer.join().await.expect("join gRPC consumer"); +} + +/// The server-side reply wait is bounded by the message TTL (the SDK defaults +/// it to 4s), so the late replies in the deadline test need a longer one. +fn request_message(topic: &str) -> EventMeshMessage { + EventMeshMessage::builder() + .topic(topic) + .content("ping") + .ttl_millis(15_000) + .build() + .expect("build request message") +} 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..48ddf2e1cd --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/runtime.rs @@ -0,0 +1,299 @@ +// 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 fail unless skipping was explicitly allowed. + 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 an +/// unavailable notice) when neither Docker nor a live server is available. The +/// caller decides whether that is a failure or an explicitly allowed skip. +/// +/// 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; let the caller enforce strict/skip policy. + false + } + } +} + +/// Whether an unavailable runtime may be treated as an intentional skip. +/// +/// This is an opt-in local escape hatch. Release verification is strict by +/// default and must not set this variable. +pub(crate) fn allow_skip() -> bool { + std::env::var_os("EVENTMESH_E2E_ALLOW_SKIP") + .map(|v| v == "1" || v == "true") + .unwrap_or(false) +} + +/// The hostname an EventMesh runtime should use to POST webhook callbacks to a +/// server running in this test process. +/// +/// The runtime almost always lives in a container — either the harness started +/// it via `docker compose` (`Mode::Started`), or the user pre-started the very +/// same compose file and we reuse it (`Mode::External`). In both cases the +/// container cannot reach the test process via `127.0.0.1` (that resolves to +/// the container's own loopback), so we advertise `host.docker.internal`, +/// which both profiles in `docker-compose.yml` map to the host gateway. +/// +/// Override with the `EVENTMESH_E2E_WEBHOOK_HOST` env var for non-containerized +/// setups (e.g. a runtime running directly on the host via `bin/start.sh`, in +/// which case `127.0.0.1` is correct) or a server on another host. +pub(crate) fn webhook_host() -> String { + if let Ok(h) = std::env::var("EVENTMESH_E2E_WEBHOOK_HOST") { + return h; + } + match MODE.get() { + // Both compose profiles map host.docker.internal -> host-gateway, so + // callbacks from the container reach the test process on the host. + // This deliberately covers Mode::External too: the common "external" + // case is a user who pre-started this crate's compose file, where the + // runtime is still containerized and 127.0.0.1 would route callbacks + // to the container's loopback and silently time out the webhook tests. + // For a genuinely non-containerized local runtime, set + // EVENTMESH_E2E_WEBHOOK_HOST=127.0.0.1. + Some(&Mode::Started | &Mode::External) => "host.docker.internal".to_string(), + _ => "127.0.0.1".to_string(), + } +} + +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] unavailable: 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}; runtime unavailable"); + Mode::Unavailable + } + Err(e) => { + eprintln!("[e2e] failed to invoke `docker compose`: {e}; runtime unavailable"); + 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..e7ac87f706 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/subscribe.rs @@ -0,0 +1,121 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: gRPC stream subscriptions through the v2 facade. + +use std::time::Duration; + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::Subscription, +}; + +use crate::harness::{ + ensure_topic, grpc_producer, unique_topic, wait_for_client_group, warm_topic, warm_topic_as, +}; +use crate::require_runtime; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn subscribe_and_receive() { + require_runtime!(); + let topic = unique_topic("sub-recv"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + grpc_producer() + .await + .publish(Message::from( + EventMeshMessage::new(&topic, "delivered-payload").unwrap(), + )) + .await + .expect("publish"); + let received = receive(&mut receiver).await; + assert_eq!(received.content(), "delivered-payload"); + assert_eq!(received.topic(), topic.as_str()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn subscribe_batch_receive() { + require_runtime!(); + let topic = unique_topic("sub-batch"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = warm_topic(&topic).await; + + let messages = (0..3) + .map(|index| Message::from(EventMeshMessage::new(&topic, format!("m{index}")).unwrap())) + .collect(); + grpc_producer() + .await + .publish_batch(messages) + .await + .expect("batch publish"); + + let mut contents = Vec::new(); + for _ in 0..3 { + contents.push(receive(&mut receiver).await.content().to_owned()); + } + contents.sort(); + assert_eq!(contents, ["m0", "m1", "m2"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn unsubscribe_stops_delivery() { + require_runtime!(); + let topic = unique_topic("sub-unsub"); + let consumer_group = unique_topic("consumer-group"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = warm_topic_as(&topic, consumer_group.clone()).await; + let producer = grpc_producer().await; + wait_for_client_group("grpc", &consumer_group, true).await; + + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "before-unsub").unwrap(), + )) + .await + .expect("publish before unsubscribe"); + let _ = receive(&mut receiver).await; + + consumer + .unsubscribe(Subscription::new(&topic)) + .await + .expect("unsubscribe"); + wait_for_client_group("grpc", &consumer_group, false).await; + + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "after-unsub").unwrap(), + )) + .await + .expect("publish after unsubscribe"); + assert!( + matches!( + tokio::time::timeout(Duration::from_secs(3), receiver.recv()).await, + Err(_) | Ok(None) + ), + "delivery leaked after unsubscribe" + ); +} 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..2d0a20bd93 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_cloud_events.rs @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: TCP CloudEvents publishing through the v2 message enum. + +use std::time::Duration; + +use cloudevents::{AttributesReader, Event, EventBuilder, EventBuilderV10}; +use eventmesh::{message::Message, MessageHandler, Result}; +use tokio::sync::mpsc; + +use crate::harness::{ + consumer_options, ensure_topic, let_tcp_subscription_settle, serialize_tcp_e2e, tcp_client, + tcp_producer, unique_topic, +}; +use crate::require_runtime; + +struct CloudEventListener { + tx: mpsc::UnboundedSender, +} + +impl MessageHandler for CloudEventListener { + async fn handle(&self, message: Message) -> Result> { + if let Message::CloudEvent(event) = message { + let _ = self.tx.send(event); + } + Ok(None) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_publish_cloud_event() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("tcp-ce-pub"); + ensure_topic(&topic).await; + let (tx, mut receiver) = mpsc::unbounded_channel(); + let consumer = tcp_client() + .consumer(consumer_options(), CloudEventListener { tx }) + .await + .expect("open TCP CloudEvent consumer"); + consumer + .subscribe(eventmesh::subscription::Subscription::new(&topic)) + .await + .expect("subscribe TCP CloudEvent consumer"); + let_tcp_subscription_settle().await; + let producer = tcp_producer().await; + + let event = EventBuilderV10::new() + .id("tcp-ce-e2e-1") + .source("https://eventmesh.apache.org/rust-sdk") + .ty("com.example.someevent") + .subject(&topic) + .data( + "application/cloudevents+json", + r#"{"msg":"hello from rust tcp cloudevents e2e"}"#, + ) + .build() + .expect("valid CloudEvent"); + let receipt = producer + .publish(Message::from(event)) + .await + .expect("publish CloudEvent"); + assert_eq!(receipt.code, 0); + + let received = tokio::time::timeout(Duration::from_secs(35), receiver.recv()) + .await + .expect("timed out waiting for CloudEvent delivery") + .expect("handler channel closed"); + assert_eq!(received.subject(), Some(topic.as_str())); + assert!(serde_json::to_string(&received) + .expect("serialize received CloudEvent") + .contains("hello from rust tcp cloudevents e2e")); + + producer.shutdown().await; + consumer.shutdown(); + consumer.join().await.expect("join TCP consumer"); +} 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..aa7fb58f43 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_publish.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. + +//! E2e: TCP producer operations through the v2 facade. + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::{DeliveryMode, Subscription}, +}; + +use crate::harness::{ + consumer_options, ensure_topic, let_tcp_subscription_settle, serialize_tcp_e2e, tcp_client, + tcp_producer, tcp_warm_topic, unique_topic, CollectingListener, +}; +use crate::require_runtime; +use std::time::Duration; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for TCP delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_publish_single() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("tcp-pub-single"); + ensure_topic(&topic).await; + let (_consumer, mut receiver) = tcp_warm_topic(&topic).await; + + let producer = tcp_producer().await; + let receipt = producer + .publish(Message::from( + EventMeshMessage::new(&topic, "hello from rust TCP e2e").unwrap(), + )) + .await + .expect("TCP publish"); + assert_eq!(receipt.code, 0, "TCP publish should succeed: {receipt:?}"); + assert_eq!( + receive(&mut receiver).await.content(), + "hello from rust TCP e2e" + ); + producer.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_broadcast() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("tcp-broadcast"); + ensure_topic(&topic).await; + let producer = tcp_producer().await; + let receipt = producer + .publish(Message::from( + EventMeshMessage::new(&topic, "warm TCP broadcast topic").unwrap(), + )) + .await + .expect("warm TCP broadcast topic"); + assert_eq!(receipt.code, 0, "warm publish should succeed: {receipt:?}"); + + let (listener, mut receiver) = CollectingListener::new(); + let consumer = tcp_client() + .consumer(consumer_options(), listener) + .await + .expect("open TCP broadcast consumer"); + consumer + .subscribe(Subscription::new(&topic).with_delivery_mode(DeliveryMode::Broadcast)) + .await + .expect("subscribe TCP broadcast consumer"); + let_tcp_subscription_settle().await; + + producer + .broadcast(Message::from( + EventMeshMessage::new(&topic, "broadcast from rust TCP e2e").unwrap(), + )) + .await + .expect("TCP broadcast"); + assert_eq!( + tokio::time::timeout(Duration::from_secs(35), receiver.recv()) + .await + .expect("timed out waiting for TCP broadcast delivery") + .expect("broadcast handler channel closed") + .content(), + "broadcast from rust TCP e2e" + ); + producer.shutdown().await; + consumer.shutdown(); + consumer.join().await.expect("join TCP consumer"); +} diff --git a/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_reconnect.rs b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_reconnect.rs new file mode 100644 index 0000000000..115c7c13e0 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_reconnect.rs @@ -0,0 +1,91 @@ +// 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: disconnect isolated TCP sessions through the Runtime admin API and +//! verify reconnect, subscription replay, and publish. + +use std::time::{Duration, Instant}; + +use eventmesh::message::{EventMeshMessage, Message}; + +use crate::harness::{ + consumer_options, ensure_topic, let_tcp_subscription_settle, producer_options, + reject_tcp_subsystem, serialize_tcp_e2e, tcp_client_with_system, unique_topic, + CollectingListener, +}; +use crate::require_runtime; + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_reconnect_replays_subscription_after_server_disconnect() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + + let topic = unique_topic("tcp-real-reconnect"); + let subsystem = unique_topic("tcp-reconnect-subsystem"); + ensure_topic(&topic).await; + let client = tcp_client_with_system(&subsystem); + let (listener, mut receiver) = CollectingListener::new(); + let consumer = client + .consumer(consumer_options(), listener) + .await + .expect("open reconnect test consumer"); + consumer + .subscribe(eventmesh::Subscription::new(&topic)) + .await + .expect("subscribe reconnect test consumer"); + let producer = client + .producer(producer_options()) + .await + .expect("open reconnect test producer"); + let_tcp_subscription_settle().await; + + reject_tcp_subsystem(&subsystem).await; + // Runtime sends SERVER_GOODBYE_REQUEST first and closes the session with a + // 30-second safety timer after the client ACK. Wait past that existing + // server behavior, then allow reconnect + subscription replay + broker + // rebalance to settle. + tokio::time::sleep(Duration::from_secs(32)).await; + let_tcp_subscription_settle().await; + + let deadline = Instant::now() + Duration::from_secs(60); + loop { + match producer + .publish(Message::from( + EventMeshMessage::new(&topic, "after-real-reconnect").unwrap(), + )) + .await + { + Ok(receipt) => { + assert_eq!(receipt.code, 0); + break; + } + Err(error) if Instant::now() < deadline => { + tracing::debug!(%error, "TCP producer still reconnecting"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + Err(error) => panic!("TCP producer did not recover after runtime restart: {error}"), + } + } + let delivered = tokio::time::timeout(Duration::from_secs(35), receiver.recv()) + .await + .expect("timed out waiting for replayed TCP subscription") + .expect("TCP handler channel closed"); + assert_eq!(delivered.content(), "after-real-reconnect"); + producer.shutdown().await; + consumer.shutdown(); + consumer.join().await.expect("join TCP consumer"); +} 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..edd5997256 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_request_reply.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 synchronous request/reply through the v2 facade. + +use eventmesh::{ + message::{EventMeshMessage, Message}, + subscription::{DeliveryType, Subscription}, +}; + +use crate::harness::{ + consumer_options, ensure_topic, let_tcp_subscription_settle, serialize_tcp_e2e, tcp_client, + tcp_producer, unique_topic, ReplyingListener, +}; +use crate::require_runtime; + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_request_reply_roundtrip() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("tcp-req-reply"); + ensure_topic(&topic).await; + let consumer = tcp_client() + .consumer( + consumer_options(), + ReplyingListener { + reply_content: "pong".into(), + }, + ) + .await + .expect("open TCP request/reply consumer"); + consumer + .subscribe(Subscription::new(&topic).with_delivery_type(DeliveryType::Sync)) + .await + .expect("subscribe TCP request/reply consumer"); + let_tcp_subscription_settle().await; + + let producer = tcp_producer().await; + let reply = producer + .request_reply(Message::from( + EventMeshMessage::new(&topic, "ping").unwrap(), + )) + .await + .expect("TCP request/reply"); + producer.shutdown().await; + consumer.shutdown(); + consumer.join().await.expect("join TCP consumer"); + + match reply { + Message::EventMesh(message) => assert_eq!(message.content(), "pong"), + #[cfg(feature = "cloud_events")] + other => panic!("expected native reply, got {other:?}"), + } +} 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..40a633c6c2 --- /dev/null +++ b/eventmesh-sdks/eventmesh-sdk-rust/tests/e2e/tcp_subscribe.rs @@ -0,0 +1,101 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! E2e: TCP subscriptions through the v2 facade. + +use std::time::Duration; + +use eventmesh::message::{EventMeshMessage, Message}; + +use crate::harness::{ + ensure_topic, serialize_tcp_e2e, tcp_producer, tcp_warm_topic, unique_topic, + wait_for_tcp_topic_listener, +}; +use crate::require_runtime; + +async fn receive( + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> EventMeshMessage { + tokio::time::timeout(Duration::from_secs(10), receiver.recv()) + .await + .expect("timed out waiting for TCP delivery") + .expect("handler channel closed") +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_subscribe_and_receive() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("tcp-sub-recv"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = tcp_warm_topic(&topic).await; + let producer = tcp_producer().await; + wait_for_tcp_topic_listener(&topic, true).await; + + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "delivered-via-tcp").unwrap(), + )) + .await + .expect("TCP publish"); + let received = receive(&mut receiver).await; + assert_eq!(received.content(), "delivered-via-tcp"); + assert_eq!(received.topic(), topic.as_str()); + + producer.shutdown().await; + consumer.shutdown(); + consumer.join().await.expect("join TCP consumer"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tcp_unsubscribe_stops_delivery() { + let _tcp_e2e_guard = serialize_tcp_e2e().await; + require_runtime!(); + let topic = unique_topic("tcp-sub-unsub"); + ensure_topic(&topic).await; + let (consumer, mut receiver) = tcp_warm_topic(&topic).await; + let producer = tcp_producer().await; + wait_for_tcp_topic_listener(&topic, true).await; + + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "before-unsub").unwrap(), + )) + .await + .expect("publish before unsubscribe"); + let _ = receive(&mut receiver).await; + consumer.unsubscribe_all().await.expect("TCP unsubscribe"); + wait_for_tcp_topic_listener(&topic, false).await; + + producer + .publish(Message::from( + EventMeshMessage::new(&topic, "after-unsub").unwrap(), + )) + .await + .expect("TCP publish after unsubscribe"); + assert!( + matches!( + tokio::time::timeout(Duration::from_secs(3), receiver.recv()).await, + Err(_) | Ok(None) + ), + "TCP delivery leaked after unsubscribe" + ); + + producer.shutdown().await; + consumer.shutdown(); + consumer.join().await.expect("join TCP consumer"); +} 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())) - ); -}