This is an automated email from the ASF dual-hosted git repository. spetz pushed a commit to branch iggy_sdk_raw_request in repository https://gitbox.apache.org/repos/asf/iggy.git
commit 3f153a743ad877826da269c59fdbc5680e4a1ae7 Author: spetz <[email protected]> AuthorDate: Fri May 29 12:30:14 2026 +0200 feat(sdk): support sending raw requests for custom commands --- Cargo.lock | 6 +- Cargo.toml | 6 +- core/binary_protocol/Cargo.toml | 2 +- core/common/Cargo.toml | 2 +- core/common/src/lib.rs | 1 + .../src/http/mod.rs => common/src/types/http.rs} | 35 +++++---- core/common/src/types/mod.rs | 1 + core/integration/tests/sdk/mod.rs | 1 + core/integration/tests/sdk/raw.rs | 90 ++++++++++++++++++++++ core/sdk/Cargo.toml | 2 +- core/sdk/src/clients/client.rs | 46 ++++++++++- core/sdk/src/http/http_client.rs | 30 +++++++- core/sdk/src/http/http_transport.rs | 12 ++- core/sdk/src/http/mod.rs | 2 +- core/sdk/src/prelude.rs | 10 +-- foreign/python/Cargo.toml | 4 +- 16 files changed, 216 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a44aa177..602eeb4ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6441,7 +6441,7 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "iggy" -version = "0.10.1-edge.1" +version = "0.10.1-edge.2" dependencies = [ "async-broadcast", "async-dropper", @@ -6651,7 +6651,7 @@ dependencies = [ [[package]] name = "iggy_binary_protocol" -version = "0.10.1-edge.1" +version = "0.10.1-edge.2" dependencies = [ "aligned-vec", "bytemuck", @@ -6663,7 +6663,7 @@ dependencies = [ [[package]] name = "iggy_common" -version = "0.10.1-edge.1" +version = "0.10.1-edge.2" dependencies = [ "aes-gcm", "async-broadcast", diff --git a/Cargo.toml b/Cargo.toml index 172193865..506e164d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -178,10 +178,10 @@ hwlocality = "1.0.0-alpha.12" iceberg = "0.9.1" iceberg-catalog-rest = "0.9.1" iceberg-storage-opendal = "0.9.1" -iggy = { path = "core/sdk", version = "0.10.1-edge.1" } +iggy = { path = "core/sdk", version = "0.10.1-edge.2" } iggy-cli = { path = "core/cli", version = "0.13.1-edge.1" } -iggy_binary_protocol = { path = "core/binary_protocol", version = "0.10.1-edge.1" } -iggy_common = { path = "core/common", version = "0.10.1-edge.1" } +iggy_binary_protocol = { path = "core/binary_protocol", version = "0.10.1-edge.2" } +iggy_common = { path = "core/common", version = "0.10.1-edge.2" } iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.3.1-edge.1" } indexmap = "2.14.0" integration = { path = "core/integration" } diff --git a/core/binary_protocol/Cargo.toml b/core/binary_protocol/Cargo.toml index a7e963534..7b28fd618 100644 --- a/core/binary_protocol/Cargo.toml +++ b/core/binary_protocol/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy_binary_protocol" -version = "0.10.1-edge.1" +version = "0.10.1-edge.2" description = "Wire protocol types and codec for the Iggy binary protocol. Shared between server and SDK." edition = "2024" license = "Apache-2.0" diff --git a/core/common/Cargo.toml b/core/common/Cargo.toml index 37cbfc1c6..2d5b106c2 100644 --- a/core/common/Cargo.toml +++ b/core/common/Cargo.toml @@ -16,7 +16,7 @@ # under the License. [package] name = "iggy_common" -version = "0.10.1-edge.1" +version = "0.10.1-edge.2" description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second." edition = "2024" license = "Apache-2.0" diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs index dd504e9f5..5c064be44 100644 --- a/core/common/src/lib.rs +++ b/core/common/src/lib.rs @@ -93,6 +93,7 @@ pub use types::consumer::consumer_offset_info::*; pub use types::consumer::consumer_offsets::*; pub use types::diagnostic::diagnostic_event::DiagnosticEvent; pub use types::either::Either; +pub use types::http::HttpMethod; pub use types::identifier::*; pub use types::message::*; pub use types::partition::*; diff --git a/core/sdk/src/http/mod.rs b/core/common/src/types/http.rs similarity index 57% copy from core/sdk/src/http/mod.rs copy to core/common/src/types/http.rs index 3a12b5e84..54f3339d5 100644 --- a/core/sdk/src/http/mod.rs +++ b/core/common/src/types/http.rs @@ -16,16 +16,25 @@ * under the License. */ -pub mod cluster; -pub mod consumer_groups; -pub mod consumer_offsets; -pub mod http_client; -mod http_transport; -pub mod messages; -pub mod partitions; -pub mod personal_access_tokens; -pub mod segments; -pub mod streams; -pub mod system; -pub mod topics; -pub mod users; +use strum::{Display, EnumString, IntoStaticStr}; + +/// HTTP request method, kept dependency-free so `iggy_common` carries no +/// concrete HTTP client type. +#[derive(Clone, Copy, Debug, Default, Display, PartialEq, Eq, EnumString, IntoStaticStr)] +pub enum HttpMethod { + #[default] + #[strum(serialize = "GET")] + Get, + #[strum(serialize = "POST")] + Post, + #[strum(serialize = "PUT")] + Put, + #[strum(serialize = "PATCH")] + Patch, + #[strum(serialize = "DELETE")] + Delete, + #[strum(serialize = "HEAD")] + Head, + #[strum(serialize = "OPTIONS")] + Options, +} diff --git a/core/common/src/types/mod.rs b/core/common/src/types/mod.rs index b0bace663..f1a2f37af 100644 --- a/core/common/src/types/mod.rs +++ b/core/common/src/types/mod.rs @@ -24,6 +24,7 @@ pub(crate) mod configuration; pub(crate) mod consumer; pub(crate) mod diagnostic; pub(crate) mod either; +pub(crate) mod http; pub(crate) mod identifier; pub(crate) mod message; pub(crate) mod partition; diff --git a/core/integration/tests/sdk/mod.rs b/core/integration/tests/sdk/mod.rs index 09a6a8697..1d5633f67 100644 --- a/core/integration/tests/sdk/mod.rs +++ b/core/integration/tests/sdk/mod.rs @@ -19,3 +19,4 @@ mod hello_world; #[cfg(not(feature = "vsr"))] mod producer; +mod raw; diff --git a/core/integration/tests/sdk/raw.rs b/core/integration/tests/sdk/raw.rs new file mode 100644 index 000000000..d533b865f --- /dev/null +++ b/core/integration/tests/sdk/raw.rs @@ -0,0 +1,90 @@ +/* Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 iggy::prelude::*; +use iggy_binary_protocol::WireEncode; +use iggy_binary_protocol::codes::{GET_STATS_CODE, PING_CODE}; +use iggy_binary_protocol::requests::system::{GetStatsRequest, PingRequest}; +use integration::iggy_harness; + +#[cfg(not(feature = "vsr"))] +#[iggy_harness(test_client_transport = [Tcp, Quic, Http, WebSocket])] +async fn given_authenticated_client_when_sending_raw_request_should_round_trip( + harness: &TestHarness, +) { + let client = harness.root_client().await.unwrap(); + assert_raw_round_trip(&client).await; +} + +#[cfg(feature = "vsr")] +#[iggy_harness(test_client_transport = [Tcp, WebSocket])] +async fn given_authenticated_client_when_sending_raw_request_should_round_trip( + harness: &TestHarness, +) { + let client = harness.new_client().await.unwrap(); + client + .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD) + .await + .unwrap(); + assert_raw_round_trip(&client).await; +} + +/// Each transport answers its own raw escape hatch and rejects the other's +/// with [`IggyError::FeatureUnavailable`]: the binary `(code, payload)` frame +/// has no HTTP analogue and the `(method, path)` request has no binary one. +async fn assert_raw_round_trip(client: &IggyClient) { + match client.get_connection_info().await.protocol { + TransportProtocol::Http => { + client + .send_http_request(HttpMethod::Get, "/ping", None) + .await + .expect("HTTP ping request should succeed"); + + let stats = client + .send_http_request(HttpMethod::Get, "/stats", None) + .await + .expect("authenticated HTTP request should return a body"); + assert!(!stats.is_empty()); + + let error = client + .send_binary_request(PING_CODE, PingRequest.to_bytes()) + .await + .expect_err("binary command must be unavailable on HTTP"); + assert_eq!(error, IggyError::FeatureUnavailable); + } + _ => { + let response = client + .send_binary_request(PING_CODE, PingRequest.to_bytes()) + .await + .expect("binary ping request should succeed"); + assert!(response.is_empty()); + + let stats = client + .send_binary_request(GET_STATS_CODE, GetStatsRequest.to_bytes()) + .await + .expect("authenticated binary command should return a body"); + assert!(!stats.is_empty()); + + let error = client + .send_http_request(HttpMethod::Get, "/ping", None) + .await + .expect_err("HTTP request must be unavailable on binary transports"); + assert_eq!(error, IggyError::FeatureUnavailable); + } + } +} diff --git a/core/sdk/Cargo.toml b/core/sdk/Cargo.toml index 3aae027cb..560d1f5b0 100644 --- a/core/sdk/Cargo.toml +++ b/core/sdk/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy" -version = "0.10.1-edge.1" +version = "0.10.1-edge.2" description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second." edition = "2024" license = "Apache-2.0" diff --git a/core/sdk/src/clients/client.rs b/core/sdk/src/clients/client.rs index 6ed641e69..03f520ef2 100644 --- a/core/sdk/src/clients/client.rs +++ b/core/sdk/src/clients/client.rs @@ -20,6 +20,7 @@ use crate::client_wrappers::client_wrapper::ClientWrapper; use crate::client_wrappers::connection_info::ConnectionInfo; use crate::clients::client_builder::IggyClientBuilder; use crate::http::http_client::HttpClient; +use crate::http::http_transport::HttpTransport; use crate::prelude::EncryptorKind; use crate::prelude::IggyConsumerBuilder; use crate::prelude::IggyError; @@ -29,9 +30,10 @@ use crate::tcp::tcp_client::TcpClient; use crate::websocket::websocket_client::WebSocketClient; use async_broadcast::Receiver; use async_trait::async_trait; +use bytes::Bytes; use iggy_common::Consumer; use iggy_common::locking::{IggyRwLock, IggyRwLockFn}; -use iggy_common::{Client, SystemClient}; +use iggy_common::{BinaryTransport, Client, HttpMethod, SystemClient}; use iggy_common::{ConnectionStringUtils, DiagnosticEvent, Partitioner, TransportProtocol}; use std::fmt::Debug; use std::sync::Arc; @@ -182,6 +184,31 @@ impl IggyClient { pub async fn get_connection_info(&self) -> ConnectionInfo { self.client.read().await.get_connection_info().await } + + /// Send a raw binary command (`code` + serialized `payload`) and return the + /// raw response. Binary transports only; HTTP yields `FeatureUnavailable`. + pub async fn send_binary_request(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { + match &*self.client.read().await { + ClientWrapper::Tcp(client) => client.send_raw_with_response(code, payload).await, + ClientWrapper::Quic(client) => client.send_raw_with_response(code, payload).await, + ClientWrapper::WebSocket(client) => client.send_raw_with_response(code, payload).await, + ClientWrapper::Http(_) | ClientWrapper::Iggy(_) => Err(IggyError::FeatureUnavailable), + } + } + + /// Invoke an arbitrary HTTP endpoint and return the raw response body. HTTP + /// transport only; binary transports yield `FeatureUnavailable`. + pub async fn send_http_request( + &self, + method: HttpMethod, + path: &str, + body: Option<Bytes>, + ) -> Result<Bytes, IggyError> { + match &*self.client.read().await { + ClientWrapper::Http(client) => client.send_http_request(method, path, body).await, + _ => Err(IggyError::FeatureUnavailable), + } + } } #[async_trait] @@ -406,4 +433,21 @@ mod tests { let client = IggyClient::from_connection_string(&value); assert!(client.is_ok()); } + + #[tokio::test] + async fn should_reject_http_request_on_binary_transport() { + let client = IggyClient::default(); + let result = client + .send_http_request(HttpMethod::Get, "/ping", None) + .await; + assert!(matches!(result, Err(IggyError::FeatureUnavailable))); + } + + #[tokio::test] + async fn should_reject_binary_request_on_http_transport() { + let client = + IggyClient::from_connection_string("iggy+http://user:[email protected]:1234").unwrap(); + let result = client.send_binary_request(0, Bytes::new()).await; + assert!(matches!(result, Err(IggyError::FeatureUnavailable))); + } } diff --git a/core/sdk/src/http/http_client.rs b/core/sdk/src/http/http_client.rs index 40f2d26d7..d7edcdee7 100644 --- a/core/sdk/src/http/http_client.rs +++ b/core/sdk/src/http/http_client.rs @@ -20,12 +20,13 @@ use crate::http::http_transport::HttpTransport; use crate::prelude::{Client, HttpClientConfig, IggyDuration, IggyError}; use async_broadcast::{Receiver, Sender, broadcast}; use async_trait::async_trait; +use bytes::Bytes; use iggy_common::locking::{IggyRwLock, IggyRwLockFn}; use iggy_common::{ ConnectionString, ConnectionStringUtils, DiagnosticEvent, HttpConnectionStringOptions, - IdentityInfo, TransportProtocol, validate_api_url, + HttpMethod, IdentityInfo, TransportProtocol, validate_api_url, }; -use reqwest::{Response, StatusCode, Url}; +use reqwest::{Method, Response, StatusCode, Url}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; use reqwest_tracing::{SpanBackendWithUrl, TracingMiddleware}; @@ -202,6 +203,31 @@ impl HttpTransport for HttpClient { Self::handle_response(response).await } + async fn send_http_request( + &self, + method: HttpMethod, + path: &str, + body: Option<Bytes>, + ) -> Result<Bytes, IggyError> { + let method = Method::from_bytes(<&str>::from(method).as_bytes()) + .map_err(|_| IggyError::InvalidHttpRequest)?; + let url = self.get_url(path)?; + let token = self.access_token.read().await; + let mut request = self.client.request(method, url).bearer_auth(token.deref()); + if let Some(body) = body { + request = request.body(body); + } + let response = request + .send() + .await + .map_err(|_| IggyError::InvalidHttpRequest)?; + let response = Self::handle_response(response).await?; + response + .bytes() + .await + .map_err(|_| IggyError::InvalidHttpRequest) + } + /// Returns true if the client is authenticated. async fn is_authenticated(&self) -> bool { let token = self.access_token.read().await; diff --git a/core/sdk/src/http/http_transport.rs b/core/sdk/src/http/http_transport.rs index eb49f41af..b651009d6 100644 --- a/core/sdk/src/http/http_transport.rs +++ b/core/sdk/src/http/http_transport.rs @@ -16,7 +16,8 @@ // under the License. use async_trait::async_trait; -use iggy_common::{IdentityInfo, IggyError}; +use bytes::Bytes; +use iggy_common::{HttpMethod, IdentityInfo, IggyError}; use reqwest::{Response, Url}; use serde::Serialize; @@ -59,6 +60,15 @@ pub trait HttpTransport { query: &T, ) -> Result<Response, IggyError>; + /// Invoke an arbitrary HTTP endpoint with the client's bearer auth and + /// return the raw response bytes. + async fn send_http_request( + &self, + method: HttpMethod, + path: &str, + body: Option<Bytes>, + ) -> Result<Bytes, IggyError>; + /// Returns true if the client is authenticated. async fn is_authenticated(&self) -> bool; diff --git a/core/sdk/src/http/mod.rs b/core/sdk/src/http/mod.rs index 3a12b5e84..4a3d29bd8 100644 --- a/core/sdk/src/http/mod.rs +++ b/core/sdk/src/http/mod.rs @@ -20,7 +20,7 @@ pub mod cluster; pub mod consumer_groups; pub mod consumer_offsets; pub mod http_client; -mod http_transport; +pub(crate) mod http_transport; pub mod messages; pub mod partitions; pub mod personal_access_tokens; diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs index 3cb66f07a..840599be0 100644 --- a/core/sdk/src/prelude.rs +++ b/core/sdk/src/prelude.rs @@ -53,11 +53,11 @@ pub use iggy_common::{ ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus, CompressionAlgorithm, Consumer, ConsumerGroupDetails, ConsumerKind, EncryptorKind, GlobalPermissions, HeaderKey, HeaderKind, HeaderValue, HttpClientConfig, - HttpClientConfigBuilder, IdKind, Identifier, IdentityInfo, IggyByteSize, IggyDuration, - IggyError, IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader, IggyMessageHeaderView, - IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize, Partition, Partitioner, - Partitioning, Permissions, PersonalAccessTokenExpiry, PollMessages, PolledMessages, - PollingKind, PollingStrategy, QuicClientConfig, QuicClientConfigBuilder, + HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, IdentityInfo, IggyByteSize, + IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader, + IggyMessageHeaderView, IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize, + Partition, Partitioner, Partitioning, Permissions, PersonalAccessTokenExpiry, PollMessages, + PolledMessages, PollingKind, PollingStrategy, QuicClientConfig, QuicClientConfigBuilder, QuicClientReconnectionConfig, SendMessages, Sizeable, SnapshotCompression, Stats, Stream, StreamDetails, StreamPermissions, SystemSnapshotType, TcpClientConfig, TcpClientConfigBuilder, TcpClientReconnectionConfig, Topic, TopicDetails, TopicPermissions, TransportEndpoints, diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml index 8aa8a1354..8153e114b 100644 --- a/foreign/python/Cargo.toml +++ b/foreign/python/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "apache-iggy" -version = "0.8.1-dev1" +version = "0.8.1-dev2" edition = "2024" authors = ["Iggy Committers <[email protected]>"] license = "Apache-2.0" @@ -37,7 +37,7 @@ doc = false [dependencies] bytes = "1.11.1" futures = "0.3.32" -iggy = { path = "../../core/sdk", version = "0.10.1-edge.1" } +iggy = { path = "../../core/sdk", version = "0.10.1-edge.2" } pyo3 = "0.28.3" pyo3-async-runtimes = { version = "0.28.0", features = [ "attributes",
