You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by hg...@apache.org on 2026/03/18 08:46:43 UTC
(iggy) 01/01: feat(rust): add wire types for all commands and zero-copy message primitives
This is an automated email from the ASF dual-hosted git repository.
hgruszecki pushed a commit to branch refactor-binary-3-all-commands
in repository https://gitbox.apache.org/repos/asf/iggy.git
commit af4eeb6b12fc06df913eab24d99c8f712f37b410
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Mon Mar 16 19:06:56 2026 +0100
feat(rust): add wire types for all commands and zero-copy message primitives
The binary_protocol crate had wire types for streams only. This
adds the remaining 41 commands plus shared primitives and
zero-copy message frame types.
Shared primitives: WireConsumer, WirePartitioning,
WirePollingStrategy, WirePermissions (full hierarchy).
Requests: all 10 categories (consumer_groups, consumer_offsets,
messages, partitions, personal_access_tokens, segments, system,
topics, users) with WireEncode + WireDecode + roundtrip tests.
Responses: all 9 categories (clients, consumer_groups,
consumer_offsets, messages, personal_access_tokens, system,
topics, users) including StatsResponse with CacheMetricsEntry.
Zero-copy message primitives: SendMessagesHeader (metadata-only
decoder), SendMessagesMetadataEncoder (vectored I/O),
RawMessage::encode_header, WireMessageView/Mut (frame views
with typed setters), WireMessageIterator/Mut (frame iterators),
PollMessagesResponseHeader + PollMessagesResponse (borrowed
response with lazy iteration), 8 field offset constants.
---
.typos.toml | 4 +-
core/binary_protocol/src/codec.rs | 52 ++
core/binary_protocol/src/consensus/header.rs | 20 -
core/binary_protocol/src/consensus/message.rs | 2 +-
core/binary_protocol/src/frame.rs | 6 +
core/binary_protocol/src/lib.rs | 15 +-
core/binary_protocol/src/message_layout.rs | 56 ++
core/binary_protocol/src/message_view.rs | 614 +++++++++++++++++
core/binary_protocol/src/primitives/consumer.rs | 133 ++++
.../src/{ => primitives}/identifier.rs | 6 -
.../src/{requests => primitives}/mod.rs | 8 +-
.../binary_protocol/src/primitives/partitioning.rs | 271 ++++++++
core/binary_protocol/src/primitives/permissions.rs | 471 +++++++++++++
.../src/primitives/polling_strategy.rs | 196 ++++++
.../consumer_groups/create_consumer_group.rs | 126 ++++
.../consumer_groups/delete_consumer_group.rs | 110 +++
.../requests/consumer_groups/get_consumer_group.rs | 110 +++
.../get_consumer_groups.rs} | 61 +-
.../consumer_groups/join_consumer_group.rs | 110 +++
.../consumer_groups/leave_consumer_group.rs | 110 +++
.../{responses => requests/consumer_groups}/mod.rs | 34 +-
.../consumer_offsets/delete_consumer_offset.rs | 175 +++++
.../consumer_offsets/get_consumer_offset.rs | 175 +++++
.../src/requests/{ => consumer_offsets}/mod.rs | 8 +-
.../consumer_offsets/store_consumer_offset.rs | 180 +++++
.../src/requests/messages/flush_unsaved_buffer.rs | 155 +++++
.../src/requests/{ => messages}/mod.rs | 10 +-
.../src/requests/messages/poll_messages.rs | 220 ++++++
.../src/requests/messages/send_messages.rs | 750 +++++++++++++++++++++
core/binary_protocol/src/requests/mod.rs | 9 +
.../src/requests/partitions/create_partitions.rs | 118 ++++
.../src/requests/partitions/delete_partitions.rs | 118 ++++
.../src/requests/{ => partitions}/mod.rs | 6 +-
.../create_personal_access_token.rs | 115 ++++
.../delete_personal_access_token.rs} | 46 +-
.../get_personal_access_tokens.rs} | 25 +-
.../login_with_personal_access_token.rs} | 47 +-
.../personal_access_tokens/mod.rs} | 16 +-
.../src/requests/segments/delete_segments.rs | 128 ++++
.../src/requests/{ => segments}/mod.rs | 4 +-
.../src/requests/streams/create_stream.rs | 2 +-
.../src/requests/streams/update_stream.rs | 2 +-
.../create_stream.rs => system/get_client.rs} | 72 +-
.../mod.rs => requests/system/get_clients.rs} | 25 +-
.../system/get_cluster_metadata.rs} | 25 +-
.../mod.rs => requests/system/get_me.rs} | 25 +-
.../mod.rs => requests/system/get_snapshot.rs} | 25 +-
.../mod.rs => requests/system/get_stats.rs} | 25 +-
.../src/{frame.rs => requests/system/mod.rs} | 22 +-
.../{responses/mod.rs => requests/system/ping.rs} | 25 +-
.../src/requests/topics/create_topic.rs | 147 ++++
.../update_stream.rs => topics/delete_topic.rs} | 51 +-
.../update_stream.rs => topics/get_topic.rs} | 53 +-
.../update_stream.rs => topics/get_topics.rs} | 44 +-
.../src/{frame.rs => requests/topics/mod.rs} | 20 +-
.../update_stream.rs => topics/purge_topic.rs} | 39 +-
.../src/requests/topics/update_topic.rs | 150 +++++
.../src/requests/users/change_password.rs | 131 ++++
.../src/requests/users/create_user.rs | 203 ++++++
.../update_stream.rs => users/delete_user.rs} | 48 +-
.../update_stream.rs => users/get_user.rs} | 57 +-
.../mod.rs => requests/users/get_users.rs} | 25 +-
.../src/requests/users/login_user.rs | 220 ++++++
.../mod.rs => requests/users/logout_user.rs} | 25 +-
.../src/{responses => requests/users}/mod.rs | 40 +-
.../src/requests/users/update_permissions.rs | 160 +++++
.../src/requests/users/update_user.rs | 184 +++++
.../src/responses/clients/client_response.rs | 211 ++++++
.../src/responses/clients/get_client.rs | 143 ++++
.../src/responses/clients/get_clients.rs | 98 +++
.../src/{requests => responses/clients}/mod.rs | 8 +-
.../consumer_group_response.rs} | 86 ++-
.../consumer_groups/create_consumer_group.rs} | 3 +-
.../consumer_groups/delete_consumer_group.rs} | 3 +-
.../consumer_groups/get_consumer_group.rs | 210 ++++++
.../consumer_groups/get_consumer_groups.rs | 109 +++
.../consumer_groups/join_consumer_group.rs} | 3 +-
.../consumer_groups/leave_consumer_group.rs} | 3 +-
.../src/responses/{ => consumer_groups}/mod.rs | 37 +-
.../consumer_offsets/delete_consumer_offset.rs} | 3 +-
.../consumer_offsets/get_consumer_offset.rs | 114 ++++
.../consumer_offsets}/mod.rs | 9 +-
.../consumer_offsets/store_consumer_offset.rs} | 3 +-
.../src/{requests => responses/messages}/mod.rs | 4 +-
.../src/responses/messages/poll_messages.rs | 176 +++++
core/binary_protocol/src/responses/mod.rs | 8 +
.../create_personal_access_token.rs} | 51 +-
.../delete_personal_access_token.rs} | 2 +-
.../get_personal_access_tokens.rs | 145 ++++
.../login_with_personal_access_token.rs} | 2 +-
.../responses/{ => personal_access_tokens}/mod.rs | 33 +-
.../src/responses/streams/get_stream.rs | 2 +-
.../src/responses/streams/stream_response.rs | 2 +-
.../src/responses/system/get_stats.rs | 427 ++++++++++++
.../src/{requests => responses/system}/mod.rs | 7 +-
.../{requests/mod.rs => responses/system/ping.rs} | 2 +-
.../mod.rs => responses/topics/create_topic.rs} | 3 +-
.../mod.rs => responses/topics/delete_topic.rs} | 3 +-
.../src/responses/topics/get_topic.rs | 234 +++++++
.../src/responses/topics/get_topics.rs | 115 ++++
.../src/{frame.rs => responses/topics/mod.rs} | 21 +-
.../mod.rs => responses/topics/purge_topic.rs} | 3 +-
.../mod.rs => responses/topics/update_topic.rs} | 3 +-
.../mod.rs => responses/users/change_password.rs} | 2 +-
.../mod.rs => responses/users/create_user.rs} | 2 +-
.../mod.rs => responses/users/delete_user.rs} | 2 +-
.../src/responses/users/get_user.rs | 205 ++++++
.../src/responses/users/get_users.rs | 97 +++
.../src/responses/users/login_user.rs | 75 +++
.../mod.rs => responses/users/logout_user.rs} | 2 +-
.../src/responses/{ => users}/mod.rs | 43 +-
.../users/update_permissions.rs} | 2 +-
.../mod.rs => responses/users/update_user.rs} | 2 +-
.../stream_response.rs => users/user_response.rs} | 86 ++-
114 files changed, 8841 insertions(+), 628 deletions(-)
diff --git a/.typos.toml b/.typos.toml
index dba98da09..7550e6f15 100644
--- a/.typos.toml
+++ b/.typos.toml
@@ -17,6 +17,8 @@
# Whitelist valid technical terms to avoid typos false positives
[default.extend-words]
+# French for coffee, used in UTF-8 test strings (cafe with accent)
+caf = "caf"
# Valid bi-directional map data structure term
bimap = "bimap"
# Valid compression algorithm abbreviation
@@ -29,8 +31,6 @@ bais = "bais"
Strat = "Strat"
# Same as above
strin = "strin"
-# Valid prefix in Unicode escape test strings (e.g. "caf\u{00e9}" = cafe)
-caf = "caf"
# Exclude auto-generated/non-editable files from typos check
[files]
diff --git a/core/binary_protocol/src/codec.rs b/core/binary_protocol/src/codec.rs
index 3aedd9c5e..1ef155847 100644
--- a/core/binary_protocol/src/codec.rs
+++ b/core/binary_protocol/src/codec.rs
@@ -152,6 +152,58 @@ pub fn read_str(buf: &[u8], offset: usize, len: usize) -> Result<String, WireErr
.map_err(|_| WireError::InvalidUtf8 { offset })
}
+/// Helper to read a `u128` LE from `buf` at `offset`.
+///
+/// # Errors
+/// Returns `WireError::UnexpectedEof` if fewer than 16 bytes remain.
+#[allow(clippy::missing_panics_doc)]
+#[inline]
+pub fn read_u128_le(buf: &[u8], offset: usize) -> Result<u128, WireError> {
+ let end = offset
+ .checked_add(16)
+ .ok_or_else(|| WireError::UnexpectedEof {
+ offset,
+ need: 16,
+ have: buf.len().saturating_sub(offset),
+ })?;
+ let slice = buf
+ .get(offset..end)
+ .ok_or_else(|| WireError::UnexpectedEof {
+ offset,
+ need: 16,
+ have: buf.len().saturating_sub(offset),
+ })?;
+ Ok(u128::from_le_bytes(
+ slice.try_into().expect("slice is exactly 16 bytes"),
+ ))
+}
+
+/// Helper to read an `f32` LE from `buf` at `offset`.
+///
+/// # Errors
+/// Returns `WireError::UnexpectedEof` if fewer than 4 bytes remain.
+#[allow(clippy::missing_panics_doc)]
+#[inline]
+pub fn read_f32_le(buf: &[u8], offset: usize) -> Result<f32, WireError> {
+ let end = offset
+ .checked_add(4)
+ .ok_or_else(|| WireError::UnexpectedEof {
+ offset,
+ need: 4,
+ have: buf.len().saturating_sub(offset),
+ })?;
+ let slice = buf
+ .get(offset..end)
+ .ok_or_else(|| WireError::UnexpectedEof {
+ offset,
+ need: 4,
+ have: buf.len().saturating_sub(offset),
+ })?;
+ Ok(f32::from_le_bytes(
+ slice.try_into().expect("slice is exactly 4 bytes"),
+ ))
+}
+
/// Helper to read a byte slice of `len` bytes from `buf` at `offset`.
///
/// # Errors
diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index 648aae6ef..40f66b1e0 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -40,9 +40,7 @@ pub trait ConsensusHeader: Sized + CheckedBitPattern + NoUninit {
fn size(&self) -> u32;
}
-// ---------------------------------------------------------------------------
// GenericHeader - type-erased dispatch
-// ---------------------------------------------------------------------------
/// Type-erased 256-byte header for initial message dispatch.
#[repr(C)]
@@ -84,9 +82,7 @@ impl ConsensusHeader for GenericHeader {
}
}
-// ---------------------------------------------------------------------------
// RequestHeader - client -> primary
-// ---------------------------------------------------------------------------
/// Client -> primary request header. 256 bytes.
#[repr(C)]
@@ -167,9 +163,7 @@ impl ConsensusHeader for RequestHeader {
}
}
-// ---------------------------------------------------------------------------
// ReplyHeader - primary -> client
-// ---------------------------------------------------------------------------
/// Primary -> client reply header. 256 bytes.
#[repr(C)]
@@ -251,9 +245,7 @@ impl ConsensusHeader for ReplyHeader {
}
}
-// ---------------------------------------------------------------------------
// PrepareHeader - primary -> replicas (replication)
-// ---------------------------------------------------------------------------
/// Primary -> replicas: replicate this operation.
#[repr(C)]
@@ -340,9 +332,7 @@ impl ConsensusHeader for PrepareHeader {
}
}
-// ---------------------------------------------------------------------------
// PrepareOkHeader - replica -> primary (acknowledgement)
-// ---------------------------------------------------------------------------
/// Replica -> primary: acknowledge a Prepare.
#[repr(C)]
@@ -427,9 +417,7 @@ impl ConsensusHeader for PrepareOkHeader {
}
}
-// ---------------------------------------------------------------------------
// CommitHeader - primary -> replicas (commit, header-only)
-// ---------------------------------------------------------------------------
/// Primary -> replicas: commit up to this op. Header-only (no body).
#[repr(C)]
@@ -484,9 +472,7 @@ impl ConsensusHeader for CommitHeader {
}
}
-// ---------------------------------------------------------------------------
// StartViewChangeHeader - failure detection (header-only)
-// ---------------------------------------------------------------------------
/// Replica suspects primary failure. Header-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
@@ -540,9 +526,7 @@ impl ConsensusHeader for StartViewChangeHeader {
}
}
-// ---------------------------------------------------------------------------
// DoViewChangeHeader - view change vote (header-only)
-// ---------------------------------------------------------------------------
/// Replica -> primary candidate: vote for view change. Header-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
@@ -614,9 +598,7 @@ impl ConsensusHeader for DoViewChangeHeader {
}
}
-// ---------------------------------------------------------------------------
// StartViewHeader - new view announcement (header-only)
-// ---------------------------------------------------------------------------
/// New primary -> all replicas: start new view. Header-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, CheckedBitPattern, NoUninit)]
@@ -681,9 +663,7 @@ impl ConsensusHeader for StartViewHeader {
}
}
-// ---------------------------------------------------------------------------
// Tests
-// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
diff --git a/core/binary_protocol/src/consensus/message.rs b/core/binary_protocol/src/consensus/message.rs
index ee2ac4be3..d8f56d872 100644
--- a/core/binary_protocol/src/consensus/message.rs
+++ b/core/binary_protocol/src/consensus/message.rs
@@ -49,7 +49,7 @@ impl<H: ConsensusHeader> Message<H> {
});
}
- // TODO: bytemuck::checked::try_from_bytes requires the buffer to be aligned
+ // TODO(hubcio): bytemuck::checked::try_from_bytes requires the buffer to be aligned
// to the target type's alignment. Consensus headers contain u128 fields (16-byte
// alignment), but Bytes from Vec<u8> only guarantees 8-byte alignment. The checked
// variant returns Err on misalignment (no UB), but production code can fail on
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/frame.rs
index b17d0d3f9..77a5f2b3e 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/frame.rs
@@ -15,6 +15,12 @@
// specific language governing permissions and limitations
// under the License.
+// TODO(hubcio): Legacy framing constants for the current binary protocol.
+// Once VSR consensus is integrated, both client-server and
+// replica-replica traffic will use the unified 256-byte
+// consensus header (`consensus::header::HEADER_SIZE`).
+// These constants will be removed at that point.
+
/// Request frame: `[length:4 LE][code:4 LE][payload:N]`
/// `length` = size of code + payload = 4 + N
pub const REQUEST_HEADER_SIZE: usize = 4;
diff --git a/core/binary_protocol/src/lib.rs b/core/binary_protocol/src/lib.rs
index 288d468a5..9610abf7e 100644
--- a/core/binary_protocol/src/lib.rs
+++ b/core/binary_protocol/src/lib.rs
@@ -23,7 +23,7 @@
//!
//! # Design
//!
-//! Protocol types are independent from domain types (`iggy_common`).
+//! Protocol types are independent from domain types.
//! Conversion between wire types and domain types happens at the boundary
//! (SDK client impls, server handlers).
//!
@@ -47,7 +47,9 @@ pub mod codes;
pub mod consensus;
pub mod error;
pub mod frame;
-pub mod identifier;
+pub mod message_layout;
+pub mod message_view;
+pub mod primitives;
pub mod requests;
pub mod responses;
@@ -55,4 +57,11 @@ pub use codec::{WireDecode, WireEncode};
pub use codes::*;
pub use error::WireError;
pub use frame::*;
-pub use identifier::{MAX_WIRE_NAME_LENGTH, WireIdentifier, WireName};
+pub use message_layout::*;
+pub use primitives::consumer::WireConsumer;
+pub use primitives::identifier::{MAX_WIRE_NAME_LENGTH, WireIdentifier, WireName};
+pub use primitives::partitioning::{MAX_MESSAGES_KEY_LENGTH, WirePartitioning};
+pub use primitives::permissions::{
+ WireGlobalPermissions, WirePermissions, WireStreamPermissions, WireTopicPermissions,
+};
+pub use primitives::polling_strategy::WirePollingStrategy;
diff --git a/core/binary_protocol/src/message_layout.rs b/core/binary_protocol/src/message_layout.rs
new file mode 100644
index 000000000..2858ec5e2
--- /dev/null
+++ b/core/binary_protocol/src/message_layout.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.
+
+//! 64-byte message frame layout constants.
+//!
+//! This module is the single source of truth for the on-wire message
+//! header layout shared between encoders, decoders, and zero-copy views.
+//!
+//! ```text
+//! [checksum:8][id:16][offset:8][timestamp:8][origin_timestamp:8]
+//! [user_headers_length:4][payload_length:4][reserved:8]
+//! ```
+
+/// Fixed-size message header on the wire (64 bytes).
+pub const WIRE_MESSAGE_HEADER_SIZE: usize = 64;
+
+/// Fixed-size index entry per message (16 bytes).
+///
+/// Layout: `[0x00000000:4][cumulative_size:u32_le:4][0x0000000000000000:8]`
+pub const WIRE_MESSAGE_INDEX_SIZE: usize = 16;
+
+// Field offsets within the 64-byte message header.
+pub const MSG_CHECKSUM_OFFSET: usize = 0;
+pub const MSG_ID_OFFSET: usize = 8;
+pub const MSG_OFFSET_OFFSET: usize = 24;
+pub const MSG_TIMESTAMP_OFFSET: usize = 32;
+pub const MSG_ORIGIN_TIMESTAMP_OFFSET: usize = 40;
+pub const MSG_USER_HEADERS_LEN_OFFSET: usize = 48;
+pub const MSG_PAYLOAD_LEN_OFFSET: usize = 52;
+pub const MSG_RESERVED_OFFSET: usize = 56;
+
+// Compile-time verification that field offsets are contiguous and sum to the header size.
+const _: () = {
+ assert!(MSG_ID_OFFSET == MSG_CHECKSUM_OFFSET + 8);
+ assert!(MSG_OFFSET_OFFSET == MSG_ID_OFFSET + 16);
+ assert!(MSG_TIMESTAMP_OFFSET == MSG_OFFSET_OFFSET + 8);
+ assert!(MSG_ORIGIN_TIMESTAMP_OFFSET == MSG_TIMESTAMP_OFFSET + 8);
+ assert!(MSG_USER_HEADERS_LEN_OFFSET == MSG_ORIGIN_TIMESTAMP_OFFSET + 8);
+ assert!(MSG_PAYLOAD_LEN_OFFSET == MSG_USER_HEADERS_LEN_OFFSET + 4);
+ assert!(MSG_RESERVED_OFFSET == MSG_PAYLOAD_LEN_OFFSET + 4);
+ assert!(MSG_RESERVED_OFFSET + 8 == WIRE_MESSAGE_HEADER_SIZE);
+};
diff --git a/core/binary_protocol/src/message_view.rs b/core/binary_protocol/src/message_view.rs
new file mode 100644
index 000000000..c291d9a7e
--- /dev/null
+++ b/core/binary_protocol/src/message_view.rs
@@ -0,0 +1,614 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Zero-copy view types for message frames on the wire.
+//!
+//! These types borrow an existing buffer and provide typed access to
+//! message header fields and payload data without copying.
+
+use crate::error::WireError;
+use crate::message_layout::{
+ MSG_CHECKSUM_OFFSET, MSG_ID_OFFSET, MSG_OFFSET_OFFSET, MSG_ORIGIN_TIMESTAMP_OFFSET,
+ MSG_PAYLOAD_LEN_OFFSET, MSG_TIMESTAMP_OFFSET, MSG_USER_HEADERS_LEN_OFFSET,
+ WIRE_MESSAGE_HEADER_SIZE,
+};
+
+// Private helpers for infallible reads on validated buffers
+
+#[inline]
+fn u32_at(buf: &[u8], offset: usize) -> u32 {
+ u32::from_le_bytes(
+ buf[offset..offset + 4]
+ .try_into()
+ .expect("slice is exactly 4 bytes"),
+ )
+}
+
+#[inline]
+fn u64_at(buf: &[u8], offset: usize) -> u64 {
+ u64::from_le_bytes(
+ buf[offset..offset + 8]
+ .try_into()
+ .expect("slice is exactly 8 bytes"),
+ )
+}
+
+#[inline]
+fn u128_at(buf: &[u8], offset: usize) -> u128 {
+ u128::from_le_bytes(
+ buf[offset..offset + 16]
+ .try_into()
+ .expect("slice is exactly 16 bytes"),
+ )
+}
+
+/// Validate a message frame buffer and return `(total_size, payload_len, user_headers_len)`.
+fn validate_frame(buf: &[u8]) -> Result<(usize, usize, usize), WireError> {
+ if buf.len() < WIRE_MESSAGE_HEADER_SIZE {
+ return Err(WireError::UnexpectedEof {
+ offset: 0,
+ need: WIRE_MESSAGE_HEADER_SIZE,
+ have: buf.len(),
+ });
+ }
+
+ let user_headers_len = u32_at(buf, MSG_USER_HEADERS_LEN_OFFSET) as usize;
+ let payload_len = u32_at(buf, MSG_PAYLOAD_LEN_OFFSET) as usize;
+
+ let total = WIRE_MESSAGE_HEADER_SIZE
+ .checked_add(payload_len)
+ .and_then(|s| s.checked_add(user_headers_len))
+ .ok_or_else(|| WireError::Validation("message frame size overflow".to_string()))?;
+
+ if buf.len() < total {
+ return Err(WireError::UnexpectedEof {
+ offset: 0,
+ need: total,
+ have: buf.len(),
+ });
+ }
+
+ Ok((total, payload_len, user_headers_len))
+}
+
+// WireMessageView - immutable zero-copy frame view
+
+/// Borrowed view over a single message frame in a contiguous buffer.
+///
+/// Validates the buffer on construction; all accessors are infallible.
+pub struct WireMessageView<'a> {
+ buf: &'a [u8],
+ payload_len: usize,
+ user_headers_len: usize,
+}
+
+impl<'a> WireMessageView<'a> {
+ /// Create a view over the first message frame in `buf`.
+ ///
+ /// # Errors
+ /// Returns `WireError` if the buffer is shorter than the header (64 bytes)
+ /// or shorter than the full frame indicated by the length fields.
+ pub fn new(buf: &'a [u8]) -> Result<Self, WireError> {
+ let (total, payload_len, user_headers_len) = validate_frame(buf)?;
+ Ok(Self {
+ buf: &buf[..total],
+ payload_len,
+ user_headers_len,
+ })
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn checksum(&self) -> u64 {
+ u64_at(self.buf, MSG_CHECKSUM_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn id(&self) -> u128 {
+ u128_at(self.buf, MSG_ID_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn offset(&self) -> u64 {
+ u64_at(self.buf, MSG_OFFSET_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn timestamp(&self) -> u64 {
+ u64_at(self.buf, MSG_TIMESTAMP_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn origin_timestamp(&self) -> u64 {
+ u64_at(self.buf, MSG_ORIGIN_TIMESTAMP_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub const fn payload_length(&self) -> usize {
+ self.payload_len
+ }
+
+ #[must_use]
+ #[inline]
+ pub const fn user_headers_length(&self) -> usize {
+ self.user_headers_len
+ }
+
+ #[must_use]
+ pub fn payload(&self) -> &'a [u8] {
+ &self.buf[WIRE_MESSAGE_HEADER_SIZE..WIRE_MESSAGE_HEADER_SIZE + self.payload_len]
+ }
+
+ #[must_use]
+ pub fn user_headers(&self) -> &'a [u8] {
+ let start = WIRE_MESSAGE_HEADER_SIZE + self.payload_len;
+ &self.buf[start..start + self.user_headers_len]
+ }
+
+ #[must_use]
+ #[inline]
+ pub const fn total_size(&self) -> usize {
+ self.buf.len()
+ }
+
+ #[must_use]
+ pub const fn as_bytes(&self) -> &'a [u8] {
+ self.buf
+ }
+}
+
+// WireMessageViewMut - mutable zero-copy frame view
+
+/// Mutable view for in-place header patching (offset, timestamp, checksum).
+pub struct WireMessageViewMut<'a> {
+ buf: &'a mut [u8],
+ payload_len: usize,
+ user_headers_len: usize,
+}
+
+impl<'a> WireMessageViewMut<'a> {
+ /// Create a mutable view over the first message frame in `buf`.
+ ///
+ /// # Errors
+ /// Same validation as [`WireMessageView::new`].
+ pub fn new(buf: &'a mut [u8]) -> Result<Self, WireError> {
+ let (total, payload_len, user_headers_len) = validate_frame(buf)?;
+ let (frame, _) = buf.split_at_mut(total);
+ Ok(Self {
+ buf: frame,
+ payload_len,
+ user_headers_len,
+ })
+ }
+
+ // -- Read accessors (same semantics as WireMessageView) --
+
+ #[must_use]
+ #[inline]
+ pub fn checksum(&self) -> u64 {
+ u64_at(self.buf, MSG_CHECKSUM_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn id(&self) -> u128 {
+ u128_at(self.buf, MSG_ID_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn offset(&self) -> u64 {
+ u64_at(self.buf, MSG_OFFSET_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn timestamp(&self) -> u64 {
+ u64_at(self.buf, MSG_TIMESTAMP_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub fn origin_timestamp(&self) -> u64 {
+ u64_at(self.buf, MSG_ORIGIN_TIMESTAMP_OFFSET)
+ }
+
+ #[must_use]
+ #[inline]
+ pub const fn payload_length(&self) -> usize {
+ self.payload_len
+ }
+
+ #[must_use]
+ #[inline]
+ pub const fn user_headers_length(&self) -> usize {
+ self.user_headers_len
+ }
+
+ #[must_use]
+ pub fn payload(&self) -> &[u8] {
+ &self.buf[WIRE_MESSAGE_HEADER_SIZE..WIRE_MESSAGE_HEADER_SIZE + self.payload_len]
+ }
+
+ #[must_use]
+ pub fn user_headers(&self) -> &[u8] {
+ let start = WIRE_MESSAGE_HEADER_SIZE + self.payload_len;
+ &self.buf[start..start + self.user_headers_len]
+ }
+
+ #[must_use]
+ #[inline]
+ pub const fn total_size(&self) -> usize {
+ self.buf.len()
+ }
+
+ // -- Typed setters --
+
+ pub fn set_checksum(&mut self, value: u64) {
+ self.buf[MSG_CHECKSUM_OFFSET..MSG_CHECKSUM_OFFSET + 8]
+ .copy_from_slice(&value.to_le_bytes());
+ }
+
+ pub fn set_id(&mut self, value: u128) {
+ self.buf[MSG_ID_OFFSET..MSG_ID_OFFSET + 16].copy_from_slice(&value.to_le_bytes());
+ }
+
+ pub fn set_offset(&mut self, value: u64) {
+ self.buf[MSG_OFFSET_OFFSET..MSG_OFFSET_OFFSET + 8].copy_from_slice(&value.to_le_bytes());
+ }
+
+ pub fn set_timestamp(&mut self, value: u64) {
+ self.buf[MSG_TIMESTAMP_OFFSET..MSG_TIMESTAMP_OFFSET + 8]
+ .copy_from_slice(&value.to_le_bytes());
+ }
+}
+
+// WireMessageIterator - zero-copy frame iterator
+
+/// Iterates over contiguous message frames, yielding borrowed views.
+pub struct WireMessageIterator<'a> {
+ buf: &'a [u8],
+ pos: usize,
+ remaining: u32,
+}
+
+impl<'a> WireMessageIterator<'a> {
+ #[must_use]
+ pub const fn new(buf: &'a [u8], count: u32) -> Self {
+ Self {
+ buf,
+ pos: 0,
+ remaining: count,
+ }
+ }
+}
+
+impl<'a> Iterator for WireMessageIterator<'a> {
+ type Item = Result<WireMessageView<'a>, WireError>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ if self.remaining == 0 {
+ return None;
+ }
+ match WireMessageView::new(&self.buf[self.pos..]) {
+ Ok(view) => {
+ self.pos += view.total_size();
+ self.remaining -= 1;
+ Some(Ok(view))
+ }
+ Err(e) => {
+ self.remaining = 0;
+ Some(Err(e))
+ }
+ }
+ }
+}
+
+// WireMessageIteratorMut - mutable frame iterator (cursor-based)
+
+/// Mutable iterator over contiguous message frames.
+///
+/// Cannot implement standard `Iterator` because the yielded mutable view's
+/// lifetime is tied to `&mut self`. Uses a cursor-based API instead.
+pub struct WireMessageIteratorMut<'a> {
+ buf: &'a mut [u8],
+ pos: usize,
+ remaining: u32,
+}
+
+impl<'a> WireMessageIteratorMut<'a> {
+ pub const fn new(buf: &'a mut [u8], count: u32) -> Self {
+ Self {
+ buf,
+ pos: 0,
+ remaining: count,
+ }
+ }
+
+ /// Advance to the next frame and return a mutable view over it.
+ ///
+ /// The returned view borrows `self`, so it must be dropped before
+ /// calling this method again.
+ pub fn next_view_mut(&mut self) -> Option<Result<WireMessageViewMut<'_>, WireError>> {
+ if self.remaining == 0 {
+ return None;
+ }
+
+ // Compute frame size via temporary immutable access (borrow ends at block exit).
+ let (total, available) = {
+ let rest = &self.buf[self.pos..];
+ if rest.len() < WIRE_MESSAGE_HEADER_SIZE {
+ self.remaining = 0;
+ return Some(Err(WireError::UnexpectedEof {
+ offset: self.pos,
+ need: WIRE_MESSAGE_HEADER_SIZE,
+ have: rest.len(),
+ }));
+ }
+ let user_headers_len = u32_at(rest, MSG_USER_HEADERS_LEN_OFFSET) as usize;
+ let payload_len = u32_at(rest, MSG_PAYLOAD_LEN_OFFSET) as usize;
+ let Some(total) = WIRE_MESSAGE_HEADER_SIZE
+ .checked_add(payload_len)
+ .and_then(|s| s.checked_add(user_headers_len))
+ else {
+ self.remaining = 0;
+ return Some(Err(WireError::Validation(
+ "message frame size overflow".to_string(),
+ )));
+ };
+ (total, rest.len())
+ };
+
+ if available < total {
+ self.remaining = 0;
+ return Some(Err(WireError::UnexpectedEof {
+ offset: self.pos,
+ need: total,
+ have: available,
+ }));
+ }
+
+ let start = self.pos;
+ self.pos += total;
+ self.remaining -= 1;
+
+ Some(WireMessageViewMut::new(&mut self.buf[start..start + total]))
+ }
+}
+
+// Tests
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::message_layout::{
+ MSG_ID_OFFSET, MSG_ORIGIN_TIMESTAMP_OFFSET, MSG_PAYLOAD_LEN_OFFSET,
+ MSG_USER_HEADERS_LEN_OFFSET, WIRE_MESSAGE_HEADER_SIZE,
+ };
+
+ fn make_frame(payload: &[u8], user_headers: &[u8], id: u128, origin_ts: u64) -> Vec<u8> {
+ let total = WIRE_MESSAGE_HEADER_SIZE + payload.len() + user_headers.len();
+ let mut frame = vec![0u8; total];
+ frame[MSG_ID_OFFSET..MSG_ID_OFFSET + 16].copy_from_slice(&id.to_le_bytes());
+ frame[MSG_ORIGIN_TIMESTAMP_OFFSET..MSG_ORIGIN_TIMESTAMP_OFFSET + 8]
+ .copy_from_slice(&origin_ts.to_le_bytes());
+ #[allow(clippy::cast_possible_truncation)]
+ {
+ frame[MSG_USER_HEADERS_LEN_OFFSET..MSG_USER_HEADERS_LEN_OFFSET + 4]
+ .copy_from_slice(&(user_headers.len() as u32).to_le_bytes());
+ frame[MSG_PAYLOAD_LEN_OFFSET..MSG_PAYLOAD_LEN_OFFSET + 4]
+ .copy_from_slice(&(payload.len() as u32).to_le_bytes());
+ }
+ frame[WIRE_MESSAGE_HEADER_SIZE..WIRE_MESSAGE_HEADER_SIZE + payload.len()]
+ .copy_from_slice(payload);
+ frame[WIRE_MESSAGE_HEADER_SIZE + payload.len()..].copy_from_slice(user_headers);
+ frame
+ }
+
+ // -- WireMessageView --
+
+ #[test]
+ fn view_rejects_short_buffer() {
+ let buf = [0u8; WIRE_MESSAGE_HEADER_SIZE - 1];
+ assert!(WireMessageView::new(&buf).is_err());
+ }
+
+ #[test]
+ fn view_rejects_truncated_payload() {
+ let mut buf = vec![0u8; WIRE_MESSAGE_HEADER_SIZE + 5];
+ buf[MSG_PAYLOAD_LEN_OFFSET..MSG_PAYLOAD_LEN_OFFSET + 4]
+ .copy_from_slice(&10u32.to_le_bytes());
+ assert!(WireMessageView::new(&buf).is_err());
+ }
+
+ #[test]
+ fn view_accessors() {
+ let frame = make_frame(b"hello", b"hdr", 42, 999);
+ let view = WireMessageView::new(&frame).unwrap();
+ assert_eq!(view.checksum(), 0);
+ assert_eq!(view.id(), 42);
+ assert_eq!(view.offset(), 0);
+ assert_eq!(view.timestamp(), 0);
+ assert_eq!(view.origin_timestamp(), 999);
+ assert_eq!(view.payload_length(), 5);
+ assert_eq!(view.user_headers_length(), 3);
+ assert_eq!(view.payload(), b"hello");
+ assert_eq!(view.user_headers(), b"hdr");
+ assert_eq!(view.total_size(), WIRE_MESSAGE_HEADER_SIZE + 5 + 3);
+ assert_eq!(view.as_bytes().len(), view.total_size());
+ }
+
+ #[test]
+ fn view_empty_payload_and_headers() {
+ let frame = make_frame(b"", b"", 1, 0);
+ let view = WireMessageView::new(&frame).unwrap();
+ assert_eq!(view.payload(), b"");
+ assert_eq!(view.user_headers(), b"");
+ assert_eq!(view.total_size(), WIRE_MESSAGE_HEADER_SIZE);
+ }
+
+ #[test]
+ fn view_from_larger_buffer() {
+ let mut buf = make_frame(b"pay", b"", 1, 0);
+ buf.extend_from_slice(b"trailing_garbage");
+ let view = WireMessageView::new(&buf).unwrap();
+ assert_eq!(view.total_size(), WIRE_MESSAGE_HEADER_SIZE + 3);
+ assert_eq!(view.as_bytes().len(), WIRE_MESSAGE_HEADER_SIZE + 3);
+ }
+
+ // -- WireMessageViewMut --
+
+ #[test]
+ fn view_mut_set_get_roundtrip() {
+ let mut frame = make_frame(b"data", b"", 1, 0);
+ let mut view = WireMessageViewMut::new(&mut frame).unwrap();
+
+ view.set_checksum(0xDEAD_BEEF);
+ view.set_id(0x1234);
+ view.set_offset(100);
+ view.set_timestamp(200);
+
+ assert_eq!(view.checksum(), 0xDEAD_BEEF);
+ assert_eq!(view.id(), 0x1234);
+ assert_eq!(view.offset(), 100);
+ assert_eq!(view.timestamp(), 200);
+ assert_eq!(view.origin_timestamp(), 0);
+ assert_eq!(view.payload(), b"data");
+ }
+
+ #[test]
+ fn view_mut_set_does_not_corrupt_adjacent_fields() {
+ let mut frame = make_frame(b"x", b"y", 42, 999);
+ {
+ let mut view = WireMessageViewMut::new(&mut frame).unwrap();
+ view.set_offset(0xFFFF_FFFF_FFFF_FFFF);
+ }
+ let view = WireMessageView::new(&frame).unwrap();
+ assert_eq!(view.id(), 42);
+ assert_eq!(view.offset(), 0xFFFF_FFFF_FFFF_FFFF);
+ assert_eq!(view.timestamp(), 0);
+ assert_eq!(view.origin_timestamp(), 999);
+ assert_eq!(view.payload(), b"x");
+ assert_eq!(view.user_headers(), b"y");
+ }
+
+ // -- WireMessageIterator --
+
+ #[test]
+ fn iterator_zero_count() {
+ let buf = [];
+ let mut iter = WireMessageIterator::new(&buf, 0);
+ assert!(iter.next().is_none());
+ }
+
+ #[test]
+ fn iterator_single_message() {
+ let frame = make_frame(b"hello", b"", 1, 100);
+ let mut iter = WireMessageIterator::new(&frame, 1);
+ let view = iter.next().unwrap().unwrap();
+ assert_eq!(view.id(), 1);
+ assert_eq!(view.payload(), b"hello");
+ assert!(iter.next().is_none());
+ }
+
+ #[test]
+ fn iterator_multiple_messages() {
+ let f1 = make_frame(b"aaa", b"", 1, 100);
+ let f2 = make_frame(b"bbb", b"hh", 2, 200);
+ let f3 = make_frame(b"c", b"", 3, 300);
+ let mut buf = Vec::new();
+ buf.extend_from_slice(&f1);
+ buf.extend_from_slice(&f2);
+ buf.extend_from_slice(&f3);
+
+ let views: Vec<_> = WireMessageIterator::new(&buf, 3)
+ .collect::<Result<Vec<_>, _>>()
+ .unwrap();
+ assert_eq!(views.len(), 3);
+ assert_eq!(views[0].id(), 1);
+ assert_eq!(views[0].payload(), b"aaa");
+ assert_eq!(views[1].id(), 2);
+ assert_eq!(views[1].payload(), b"bbb");
+ assert_eq!(views[1].user_headers(), b"hh");
+ assert_eq!(views[2].id(), 3);
+ assert_eq!(views[2].payload(), b"c");
+ }
+
+ #[test]
+ fn iterator_truncated_mid_frame() {
+ let frame = make_frame(b"hello", b"", 1, 0);
+ let truncated = &frame[..frame.len() / 2];
+ let mut iter = WireMessageIterator::new(truncated, 1);
+ assert!(iter.next().unwrap().is_err());
+ assert!(iter.next().is_none());
+ }
+
+ // -- WireMessageIteratorMut --
+
+ #[test]
+ fn iterator_mut_mutate_then_read() {
+ let f1 = make_frame(b"aaa", b"", 1, 100);
+ let f2 = make_frame(b"bbb", b"", 2, 200);
+ let mut buf = Vec::new();
+ buf.extend_from_slice(&f1);
+ buf.extend_from_slice(&f2);
+
+ let mut iter = WireMessageIteratorMut::new(&mut buf, 2);
+ {
+ let mut view = iter.next_view_mut().unwrap().unwrap();
+ view.set_offset(10);
+ view.set_timestamp(1000);
+ }
+ {
+ let mut view = iter.next_view_mut().unwrap().unwrap();
+ view.set_offset(20);
+ view.set_timestamp(2000);
+ }
+ assert!(iter.next_view_mut().is_none());
+
+ let views: Vec<_> = WireMessageIterator::new(&buf, 2)
+ .collect::<Result<Vec<_>, _>>()
+ .unwrap();
+ assert_eq!(views[0].offset(), 10);
+ assert_eq!(views[0].timestamp(), 1000);
+ assert_eq!(views[0].payload(), b"aaa");
+ assert_eq!(views[1].offset(), 20);
+ assert_eq!(views[1].timestamp(), 2000);
+ assert_eq!(views[1].payload(), b"bbb");
+ }
+
+ #[test]
+ fn iterator_mut_count_matches() {
+ let f1 = make_frame(b"a", b"", 1, 0);
+ let f2 = make_frame(b"b", b"", 2, 0);
+ let mut buf = Vec::new();
+ buf.extend_from_slice(&f1);
+ buf.extend_from_slice(&f2);
+
+ let mut iter = WireMessageIteratorMut::new(&mut buf, 2);
+ let mut count = 0;
+ while let Some(Ok(_)) = iter.next_view_mut() {
+ count += 1;
+ }
+ assert_eq!(count, 2);
+ }
+}
diff --git a/core/binary_protocol/src/primitives/consumer.rs b/core/binary_protocol/src/primitives/consumer.rs
new file mode 100644
index 000000000..255b6a38a
--- /dev/null
+++ b/core/binary_protocol/src/primitives/consumer.rs
@@ -0,0 +1,133 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8};
+use bytes::{BufMut, BytesMut};
+
+const KIND_CONSUMER: u8 = 1;
+const KIND_CONSUMER_GROUP: u8 = 2;
+
+/// Wire consumer type. Identifies either a single consumer or a consumer group.
+///
+/// Wire format: `[kind:1][identifier:variable]`
+/// - kind=1: consumer
+/// - kind=2: consumer group
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireConsumer {
+ pub kind: u8,
+ pub id: WireIdentifier,
+}
+
+impl WireConsumer {
+ #[must_use]
+ pub const fn consumer(id: WireIdentifier) -> Self {
+ Self {
+ kind: KIND_CONSUMER,
+ id,
+ }
+ }
+
+ #[must_use]
+ pub const fn consumer_group(id: WireIdentifier) -> Self {
+ Self {
+ kind: KIND_CONSUMER_GROUP,
+ id,
+ }
+ }
+}
+
+impl WireEncode for WireConsumer {
+ fn encoded_size(&self) -> usize {
+ 1 + self.id.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u8(self.kind);
+ self.id.encode(buf);
+ }
+}
+
+impl WireDecode for WireConsumer {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let kind = read_u8(buf, 0)?;
+ if kind != KIND_CONSUMER && kind != KIND_CONSUMER_GROUP {
+ return Err(WireError::UnknownDiscriminant {
+ type_name: "WireConsumer",
+ value: kind,
+ offset: 0,
+ });
+ }
+ let (id, id_consumed) = WireIdentifier::decode(&buf[1..])?;
+ Ok((Self { kind, id }, 1 + id_consumed))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_consumer() {
+ let c = WireConsumer::consumer(WireIdentifier::numeric(42));
+ let bytes = c.to_bytes();
+ assert_eq!(bytes.len(), 1 + 6);
+ let (decoded, consumed) = WireConsumer::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, c);
+ }
+
+ #[test]
+ fn roundtrip_consumer_group() {
+ let c = WireConsumer::consumer_group(WireIdentifier::numeric(7));
+ let bytes = c.to_bytes();
+ let (decoded, consumed) = WireConsumer::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, c);
+ assert_eq!(decoded.kind, KIND_CONSUMER_GROUP);
+ }
+
+ #[test]
+ fn roundtrip_with_string_identifier() {
+ let c = WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap());
+ let bytes = c.to_bytes();
+ let (decoded, consumed) = WireConsumer::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, c);
+ }
+
+ #[test]
+ fn unknown_kind_rejected() {
+ let mut bytes = BytesMut::new();
+ bytes.put_u8(0xFF);
+ WireIdentifier::numeric(1).encode(&mut bytes);
+ assert!(WireConsumer::decode(&bytes).is_err());
+ }
+
+ #[test]
+ fn truncated_buffer() {
+ let c = WireConsumer::consumer(WireIdentifier::numeric(1));
+ let bytes = c.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ WireConsumer::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/identifier.rs b/core/binary_protocol/src/primitives/identifier.rs
similarity index 96%
rename from core/binary_protocol/src/identifier.rs
rename to core/binary_protocol/src/primitives/identifier.rs
index 79c5c03d7..af25c189c 100644
--- a/core/binary_protocol/src/identifier.rs
+++ b/core/binary_protocol/src/primitives/identifier.rs
@@ -20,9 +20,7 @@ use crate::codec::{WireDecode, WireEncode, read_bytes, read_str, read_u8};
use bytes::{BufMut, BytesMut};
use std::ops::Deref;
-// ---------------------------------------------------------------------------
// WireName
-// ---------------------------------------------------------------------------
/// Maximum byte length for a wire name (fits in a u8 length prefix).
pub const MAX_WIRE_NAME_LENGTH: usize = 255;
@@ -113,9 +111,7 @@ impl WireDecode for WireName {
}
}
-// ---------------------------------------------------------------------------
// WireIdentifier
-// ---------------------------------------------------------------------------
const KIND_NUMERIC: u8 = 1;
const KIND_STRING: u8 = 2;
@@ -243,9 +239,7 @@ impl std::fmt::Display for WireIdentifier {
}
}
-// ---------------------------------------------------------------------------
// Tests
-// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/primitives/mod.rs
similarity index 81%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/primitives/mod.rs
index e7b687f1f..dcbef623e 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/primitives/mod.rs
@@ -15,4 +15,10 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+//! Shared wire primitives reused across request and response types.
+
+pub mod consumer;
+pub mod identifier;
+pub mod partitioning;
+pub mod permissions;
+pub mod polling_strategy;
diff --git a/core/binary_protocol/src/primitives/partitioning.rs b/core/binary_protocol/src/primitives/partitioning.rs
new file mode 100644
index 000000000..4bfbf5f58
--- /dev/null
+++ b/core/binary_protocol/src/primitives/partitioning.rs
@@ -0,0 +1,271 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_bytes, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+const KIND_BALANCED: u8 = 1;
+const KIND_PARTITION_ID: u8 = 2;
+const KIND_MESSAGES_KEY: u8 = 3;
+
+/// Maximum key length for `MessagesKey` partitioning (u8 length prefix on the wire).
+pub const MAX_MESSAGES_KEY_LENGTH: usize = 255;
+
+/// Partitioning strategy for message routing.
+///
+/// Wire format: `[kind:1][length:1][value:0..255]`
+/// - `Balanced`: kind=1, length=0, no value bytes
+/// - `PartitionId`: kind=2, length=4, value=u32 LE
+/// - `MessagesKey`: kind=3, length=1..255, value=raw bytes
+///
+/// Use [`WirePartitioning::messages_key`] to construct a `MessagesKey` variant
+/// with validation. Direct enum construction is possible but skips the length check.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum WirePartitioning {
+ Balanced,
+ PartitionId(u32),
+ MessagesKey(Vec<u8>),
+}
+
+impl WirePartitioning {
+ /// Create a `MessagesKey` partitioning with key length validation.
+ ///
+ /// # Errors
+ /// Returns `WireError::Validation` if `key` is empty or exceeds 255 bytes.
+ pub fn messages_key(key: Vec<u8>) -> Result<Self, WireError> {
+ if key.is_empty() {
+ return Err(WireError::Validation(
+ "messages_key partitioning cannot have empty key".to_string(),
+ ));
+ }
+ if key.len() > MAX_MESSAGES_KEY_LENGTH {
+ return Err(WireError::Validation(format!(
+ "messages_key length {} exceeds maximum {MAX_MESSAGES_KEY_LENGTH}",
+ key.len()
+ )));
+ }
+ Ok(Self::MessagesKey(key))
+ }
+}
+
+impl WireEncode for WirePartitioning {
+ fn encoded_size(&self) -> usize {
+ match self {
+ Self::Balanced => 2,
+ Self::PartitionId(_) => 2 + 4,
+ Self::MessagesKey(key) => 2 + key.len(),
+ }
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ match self {
+ Self::Balanced => {
+ buf.put_u8(KIND_BALANCED);
+ buf.put_u8(0);
+ }
+ Self::PartitionId(id) => {
+ buf.put_u8(KIND_PARTITION_ID);
+ buf.put_u8(4);
+ buf.put_u32_le(*id);
+ }
+ Self::MessagesKey(key) => {
+ debug_assert!(
+ !key.is_empty() && key.len() <= MAX_MESSAGES_KEY_LENGTH,
+ "MessagesKey length {} out of valid range 1..={MAX_MESSAGES_KEY_LENGTH}; \
+ use WirePartitioning::messages_key() for validated construction",
+ key.len()
+ );
+ buf.put_u8(KIND_MESSAGES_KEY);
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u8(key.len() as u8);
+ buf.put_slice(key);
+ }
+ }
+ }
+}
+
+impl WireDecode for WirePartitioning {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let kind = read_u8(buf, 0)?;
+ let length = read_u8(buf, 1)? as usize;
+
+ match kind {
+ KIND_BALANCED => {
+ if length != 0 {
+ return Err(WireError::Validation(format!(
+ "balanced partitioning must have length 0, got {length}"
+ )));
+ }
+ Ok((Self::Balanced, 2))
+ }
+ KIND_PARTITION_ID => {
+ if length != 4 {
+ return Err(WireError::Validation(format!(
+ "partition_id partitioning must have length 4, got {length}"
+ )));
+ }
+ let id = read_u32_le(buf, 2)?;
+ Ok((Self::PartitionId(id), 6))
+ }
+ KIND_MESSAGES_KEY => {
+ if length == 0 {
+ return Err(WireError::Validation(
+ "messages_key partitioning cannot have empty key".to_string(),
+ ));
+ }
+ let key = read_bytes(buf, 2, length)?;
+ Ok((Self::MessagesKey(key.to_vec()), 2 + length))
+ }
+ _ => Err(WireError::UnknownDiscriminant {
+ type_name: "WirePartitioning",
+ value: kind,
+ offset: 0,
+ }),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_balanced() {
+ let p = WirePartitioning::Balanced;
+ let bytes = p.to_bytes();
+ assert_eq!(bytes.len(), 2);
+ let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+ assert_eq!(consumed, 2);
+ assert_eq!(decoded, p);
+ }
+
+ #[test]
+ fn roundtrip_partition_id() {
+ let p = WirePartitioning::PartitionId(42);
+ let bytes = p.to_bytes();
+ assert_eq!(bytes.len(), 6);
+ let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+ assert_eq!(consumed, 6);
+ assert_eq!(decoded, p);
+ }
+
+ #[test]
+ fn roundtrip_messages_key() {
+ let p = WirePartitioning::MessagesKey(b"user-123".to_vec());
+ let bytes = p.to_bytes();
+ assert_eq!(bytes.len(), 2 + 8);
+ let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, p);
+ }
+
+ #[test]
+ fn messages_key_max_length() {
+ let key = vec![0xAB; 255];
+ let p = WirePartitioning::MessagesKey(key.clone());
+ let bytes = p.to_bytes();
+ let (decoded, _) = WirePartitioning::decode(&bytes).unwrap();
+ assert_eq!(decoded, WirePartitioning::MessagesKey(key));
+ }
+
+ #[test]
+ fn messages_key_single_byte() {
+ let p = WirePartitioning::MessagesKey(vec![0x01]);
+ let bytes = p.to_bytes();
+ let (decoded, consumed) = WirePartitioning::decode(&bytes).unwrap();
+ assert_eq!(consumed, 3);
+ assert_eq!(decoded, p);
+ }
+
+ #[test]
+ fn empty_messages_key_rejected() {
+ let buf = [KIND_MESSAGES_KEY, 0x00];
+ assert!(WirePartitioning::decode(&buf).is_err());
+ }
+
+ #[test]
+ fn unknown_kind_rejected() {
+ let buf = [0xFF, 0x00];
+ assert!(WirePartitioning::decode(&buf).is_err());
+ }
+
+ #[test]
+ fn balanced_with_nonzero_length_rejected() {
+ let buf = [KIND_BALANCED, 0x01, 0x00];
+ assert!(WirePartitioning::decode(&buf).is_err());
+ }
+
+ #[test]
+ fn partition_id_with_wrong_length_rejected() {
+ let buf = [KIND_PARTITION_ID, 0x03, 0x00, 0x00, 0x00];
+ assert!(WirePartitioning::decode(&buf).is_err());
+ }
+
+ #[test]
+ fn truncated_buffer() {
+ let p = WirePartitioning::PartitionId(1);
+ let bytes = p.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ WirePartitioning::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn wire_compat_balanced_layout() {
+ let bytes = WirePartitioning::Balanced.to_bytes();
+ assert_eq!(&bytes[..], &[KIND_BALANCED, 0x00]);
+ }
+
+ #[test]
+ fn wire_compat_partition_id_layout() {
+ let bytes = WirePartitioning::PartitionId(1).to_bytes();
+ assert_eq!(
+ &bytes[..],
+ &[KIND_PARTITION_ID, 0x04, 0x01, 0x00, 0x00, 0x00]
+ );
+ }
+
+ #[test]
+ fn messages_key_constructor_validates_empty() {
+ assert!(WirePartitioning::messages_key(vec![]).is_err());
+ }
+
+ #[test]
+ fn messages_key_constructor_validates_too_long() {
+ let key = vec![0xAB; 256];
+ assert!(WirePartitioning::messages_key(key).is_err());
+ }
+
+ #[test]
+ fn messages_key_constructor_accepts_max() {
+ let key = vec![0xAB; 255];
+ let p = WirePartitioning::messages_key(key.clone()).unwrap();
+ assert_eq!(p, WirePartitioning::MessagesKey(key));
+ }
+
+ #[test]
+ fn messages_key_constructor_accepts_single_byte() {
+ let p = WirePartitioning::messages_key(vec![0x01]).unwrap();
+ let bytes = p.to_bytes();
+ let (decoded, _) = WirePartitioning::decode(&bytes).unwrap();
+ assert_eq!(decoded, p);
+ }
+}
diff --git a/core/binary_protocol/src/primitives/permissions.rs b/core/binary_protocol/src/primitives/permissions.rs
new file mode 100644
index 000000000..7a3b42e10
--- /dev/null
+++ b/core/binary_protocol/src/primitives/permissions.rs
@@ -0,0 +1,471 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+const HAS_NEXT: u8 = 1;
+const NO_NEXT: u8 = 0;
+
+fn bool_to_u8(b: bool) -> u8 {
+ u8::from(b)
+}
+
+fn read_bool(buf: &[u8], offset: usize) -> Result<bool, WireError> {
+ Ok(read_u8(buf, offset)? != 0)
+}
+
+/// Full permission set for a user.
+///
+/// Wire format:
+/// ```text
+/// [10 x bool as u8: global permissions]
+/// [has_streams:1]
+/// loop streams:
+/// [stream_id:u32_le][6 x bool: stream perms][has_topics:1]
+/// loop topics:
+/// [topic_id:u32_le][4 x bool: topic perms][has_next_topic:1]
+/// [has_next_stream:1]
+/// ```
+///
+/// Streams and topics are stored as `Vec` sorted by ID for deterministic encoding.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WirePermissions {
+ pub global: WireGlobalPermissions,
+ pub streams: Vec<WireStreamPermissions>,
+}
+
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireGlobalPermissions {
+ pub manage_servers: bool,
+ pub read_servers: bool,
+ pub manage_users: bool,
+ pub read_users: bool,
+ pub manage_streams: bool,
+ pub read_streams: bool,
+ pub manage_topics: bool,
+ pub read_topics: bool,
+ pub poll_messages: bool,
+ pub send_messages: bool,
+}
+
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireStreamPermissions {
+ pub stream_id: u32,
+ pub manage_stream: bool,
+ pub read_stream: bool,
+ pub manage_topics: bool,
+ pub read_topics: bool,
+ pub poll_messages: bool,
+ pub send_messages: bool,
+ pub topics: Vec<WireTopicPermissions>,
+}
+
+#[allow(clippy::struct_excessive_bools)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WireTopicPermissions {
+ pub topic_id: u32,
+ pub manage_topic: bool,
+ pub read_topic: bool,
+ pub poll_messages: bool,
+ pub send_messages: bool,
+}
+
+impl WireGlobalPermissions {
+ fn encode_into(&self, buf: &mut BytesMut) {
+ buf.put_u8(bool_to_u8(self.manage_servers));
+ buf.put_u8(bool_to_u8(self.read_servers));
+ buf.put_u8(bool_to_u8(self.manage_users));
+ buf.put_u8(bool_to_u8(self.read_users));
+ buf.put_u8(bool_to_u8(self.manage_streams));
+ buf.put_u8(bool_to_u8(self.read_streams));
+ buf.put_u8(bool_to_u8(self.manage_topics));
+ buf.put_u8(bool_to_u8(self.read_topics));
+ buf.put_u8(bool_to_u8(self.poll_messages));
+ buf.put_u8(bool_to_u8(self.send_messages));
+ }
+
+ fn decode_at(buf: &[u8], pos: usize) -> Result<(Self, usize), WireError> {
+ let mut p = pos;
+ let manage_servers = read_bool(buf, p)?;
+ p += 1;
+ let read_servers = read_bool(buf, p)?;
+ p += 1;
+ let manage_users = read_bool(buf, p)?;
+ p += 1;
+ let read_users = read_bool(buf, p)?;
+ p += 1;
+ let manage_streams = read_bool(buf, p)?;
+ p += 1;
+ let read_streams = read_bool(buf, p)?;
+ p += 1;
+ let manage_topics = read_bool(buf, p)?;
+ p += 1;
+ let read_topics = read_bool(buf, p)?;
+ p += 1;
+ let poll_messages = read_bool(buf, p)?;
+ p += 1;
+ let send_messages = read_bool(buf, p)?;
+ p += 1;
+
+ Ok((
+ Self {
+ manage_servers,
+ read_servers,
+ manage_users,
+ read_users,
+ manage_streams,
+ read_streams,
+ manage_topics,
+ read_topics,
+ poll_messages,
+ send_messages,
+ },
+ p,
+ ))
+ }
+}
+
+impl WireTopicPermissions {
+ const ENCODED_SIZE: usize = 4 + 4 + 1; // topic_id + 4 bools + has_next_topic
+
+ fn encode_into(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.topic_id);
+ buf.put_u8(bool_to_u8(self.manage_topic));
+ buf.put_u8(bool_to_u8(self.read_topic));
+ buf.put_u8(bool_to_u8(self.poll_messages));
+ buf.put_u8(bool_to_u8(self.send_messages));
+ }
+
+ fn decode_at(buf: &[u8], pos: usize) -> Result<(Self, usize), WireError> {
+ let mut p = pos;
+ let topic_id = read_u32_le(buf, p)?;
+ p += 4;
+ let manage_topic = read_bool(buf, p)?;
+ p += 1;
+ let read_topic = read_bool(buf, p)?;
+ p += 1;
+ let poll_messages = read_bool(buf, p)?;
+ p += 1;
+ let send_messages = read_bool(buf, p)?;
+ p += 1;
+
+ Ok((
+ Self {
+ topic_id,
+ manage_topic,
+ read_topic,
+ poll_messages,
+ send_messages,
+ },
+ p,
+ ))
+ }
+}
+
+impl WireStreamPermissions {
+ #[allow(clippy::missing_const_for_fn)]
+ fn encoded_size(&self) -> usize {
+ // stream_id(4) + 6 bools(6) + has_topics(1) + topics + has_next_stream(1)
+ let topics_size: usize = if self.topics.is_empty() {
+ 0
+ } else {
+ self.topics.len() * WireTopicPermissions::ENCODED_SIZE
+ };
+ 4 + 6 + 1 + topics_size + 1
+ }
+
+ fn encode_into(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.stream_id);
+ buf.put_u8(bool_to_u8(self.manage_stream));
+ buf.put_u8(bool_to_u8(self.read_stream));
+ buf.put_u8(bool_to_u8(self.manage_topics));
+ buf.put_u8(bool_to_u8(self.read_topics));
+ buf.put_u8(bool_to_u8(self.poll_messages));
+ buf.put_u8(bool_to_u8(self.send_messages));
+
+ if self.topics.is_empty() {
+ buf.put_u8(NO_NEXT);
+ } else {
+ buf.put_u8(HAS_NEXT);
+ for (i, topic) in self.topics.iter().enumerate() {
+ topic.encode_into(buf);
+ let is_last = i == self.topics.len() - 1;
+ buf.put_u8(if is_last { NO_NEXT } else { HAS_NEXT });
+ }
+ }
+ }
+
+ fn decode_at(buf: &[u8], pos: usize) -> Result<(Self, usize), WireError> {
+ let mut p = pos;
+ let stream_id = read_u32_le(buf, p)?;
+ p += 4;
+ let manage_stream = read_bool(buf, p)?;
+ p += 1;
+ let read_stream = read_bool(buf, p)?;
+ p += 1;
+ let manage_topics = read_bool(buf, p)?;
+ p += 1;
+ let read_topics = read_bool(buf, p)?;
+ p += 1;
+ let poll_messages = read_bool(buf, p)?;
+ p += 1;
+ let send_messages = read_bool(buf, p)?;
+ p += 1;
+
+ let has_topics = read_bool(buf, p)?;
+ p += 1;
+
+ let mut topics = Vec::new();
+ if has_topics {
+ loop {
+ let (topic, next_p) = WireTopicPermissions::decode_at(buf, p)?;
+ p = next_p;
+ topics.push(topic);
+ let has_next = read_bool(buf, p)?;
+ p += 1;
+ if !has_next {
+ break;
+ }
+ }
+ }
+
+ Ok((
+ Self {
+ stream_id,
+ manage_stream,
+ read_stream,
+ manage_topics,
+ read_topics,
+ poll_messages,
+ send_messages,
+ topics,
+ },
+ p,
+ ))
+ }
+}
+
+impl WireEncode for WirePermissions {
+ fn encoded_size(&self) -> usize {
+ // global(10) + has_streams(1) + streams
+ let streams_size: usize = if self.streams.is_empty() {
+ 0
+ } else {
+ self.streams
+ .iter()
+ .map(WireStreamPermissions::encoded_size)
+ .sum()
+ };
+ 10 + 1 + streams_size
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.global.encode_into(buf);
+
+ if self.streams.is_empty() {
+ buf.put_u8(NO_NEXT);
+ } else {
+ buf.put_u8(HAS_NEXT);
+ for (i, stream) in self.streams.iter().enumerate() {
+ stream.encode_into(buf);
+ let is_last = i == self.streams.len() - 1;
+ buf.put_u8(if is_last { NO_NEXT } else { HAS_NEXT });
+ }
+ }
+ }
+}
+
+impl WireDecode for WirePermissions {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (global, mut p) = WireGlobalPermissions::decode_at(buf, 0)?;
+
+ let has_streams = read_bool(buf, p)?;
+ p += 1;
+
+ let mut streams = Vec::new();
+ if has_streams {
+ loop {
+ let (stream, next_p) = WireStreamPermissions::decode_at(buf, p)?;
+ p = next_p;
+ streams.push(stream);
+ let has_next = read_bool(buf, p)?;
+ p += 1;
+ if !has_next {
+ break;
+ }
+ }
+ }
+
+ Ok((Self { global, streams }, p))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_global(all: bool) -> WireGlobalPermissions {
+ WireGlobalPermissions {
+ manage_servers: all,
+ read_servers: all,
+ manage_users: all,
+ read_users: all,
+ manage_streams: all,
+ read_streams: all,
+ manage_topics: all,
+ read_topics: all,
+ poll_messages: all,
+ send_messages: all,
+ }
+ }
+
+ fn make_topic(id: u32) -> WireTopicPermissions {
+ WireTopicPermissions {
+ topic_id: id,
+ manage_topic: true,
+ read_topic: true,
+ poll_messages: false,
+ send_messages: true,
+ }
+ }
+
+ fn make_stream(id: u32, topics: Vec<WireTopicPermissions>) -> WireStreamPermissions {
+ WireStreamPermissions {
+ stream_id: id,
+ manage_stream: true,
+ read_stream: true,
+ manage_topics: false,
+ read_topics: true,
+ poll_messages: true,
+ send_messages: false,
+ topics,
+ }
+ }
+
+ #[test]
+ fn roundtrip_global_only() {
+ let perms = WirePermissions {
+ global: make_global(true),
+ streams: vec![],
+ };
+ let bytes = perms.to_bytes();
+ assert_eq!(bytes.len(), 11); // 10 global + 1 has_streams=0
+ let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, perms);
+ }
+
+ #[test]
+ fn roundtrip_with_streams_no_topics() {
+ let perms = WirePermissions {
+ global: make_global(false),
+ streams: vec![make_stream(1, vec![]), make_stream(2, vec![])],
+ };
+ let bytes = perms.to_bytes();
+ let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, perms);
+ }
+
+ #[test]
+ fn roundtrip_with_streams_and_topics() {
+ let perms = WirePermissions {
+ global: make_global(true),
+ streams: vec![
+ make_stream(1, vec![make_topic(10), make_topic(20)]),
+ make_stream(2, vec![make_topic(30)]),
+ ],
+ };
+ let bytes = perms.to_bytes();
+ let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, perms);
+ }
+
+ #[test]
+ fn roundtrip_single_stream_single_topic() {
+ let perms = WirePermissions {
+ global: make_global(false),
+ streams: vec![make_stream(42, vec![make_topic(7)])],
+ };
+ let bytes = perms.to_bytes();
+ let (decoded, consumed) = WirePermissions::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, perms);
+ }
+
+ #[test]
+ fn global_permissions_all_false() {
+ let perms = WirePermissions {
+ global: make_global(false),
+ streams: vec![],
+ };
+ let bytes = perms.to_bytes();
+ // First 10 bytes should all be 0
+ for byte in &bytes[..10] {
+ assert_eq!(*byte, 0);
+ }
+ let (decoded, _) = WirePermissions::decode(&bytes).unwrap();
+ assert_eq!(decoded, perms);
+ }
+
+ #[test]
+ fn global_permissions_all_true() {
+ let perms = WirePermissions {
+ global: make_global(true),
+ streams: vec![],
+ };
+ let bytes = perms.to_bytes();
+ for byte in &bytes[..10] {
+ assert_eq!(*byte, 1);
+ }
+ }
+
+ #[test]
+ fn truncated_buffer_global_only() {
+ let perms = WirePermissions {
+ global: make_global(true),
+ streams: vec![],
+ };
+ let bytes = perms.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ WirePermissions::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn truncated_buffer_with_streams() {
+ let perms = WirePermissions {
+ global: make_global(true),
+ streams: vec![make_stream(1, vec![make_topic(10)])],
+ };
+ let bytes = perms.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ WirePermissions::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/primitives/polling_strategy.rs b/core/binary_protocol/src/primitives/polling_strategy.rs
new file mode 100644
index 000000000..eee3a48a7
--- /dev/null
+++ b/core/binary_protocol/src/primitives/polling_strategy.rs
@@ -0,0 +1,196 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u64_le};
+use bytes::{BufMut, BytesMut};
+
+const KIND_OFFSET: u8 = 1;
+const KIND_TIMESTAMP: u8 = 2;
+const KIND_FIRST: u8 = 3;
+const KIND_LAST: u8 = 4;
+const KIND_NEXT: u8 = 5;
+
+/// Polling strategy for consuming messages.
+///
+/// Wire format: `[kind:1][value:8]` = 9 bytes fixed.
+/// - Offset(1): resume from a specific offset
+/// - Timestamp(2): resume from a specific timestamp (microseconds)
+/// - First(3): start from the beginning
+/// - Last(4): start from the end
+/// - Next(5): continue from last stored offset
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WirePollingStrategy {
+ pub kind: u8,
+ pub value: u64,
+}
+
+impl WirePollingStrategy {
+ #[must_use]
+ pub const fn offset(value: u64) -> Self {
+ Self {
+ kind: KIND_OFFSET,
+ value,
+ }
+ }
+
+ #[must_use]
+ pub const fn timestamp(value: u64) -> Self {
+ Self {
+ kind: KIND_TIMESTAMP,
+ value,
+ }
+ }
+
+ #[must_use]
+ pub const fn first() -> Self {
+ Self {
+ kind: KIND_FIRST,
+ value: 0,
+ }
+ }
+
+ #[must_use]
+ pub const fn last() -> Self {
+ Self {
+ kind: KIND_LAST,
+ value: 0,
+ }
+ }
+
+ #[must_use]
+ pub const fn next() -> Self {
+ Self {
+ kind: KIND_NEXT,
+ value: 0,
+ }
+ }
+}
+
+impl WireEncode for WirePollingStrategy {
+ fn encoded_size(&self) -> usize {
+ 9
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u8(self.kind);
+ buf.put_u64_le(self.value);
+ }
+}
+
+impl WireDecode for WirePollingStrategy {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let kind = read_u8(buf, 0)?;
+ match kind {
+ KIND_OFFSET | KIND_TIMESTAMP | KIND_FIRST | KIND_LAST | KIND_NEXT => {}
+ _ => {
+ return Err(WireError::UnknownDiscriminant {
+ type_name: "WirePollingStrategy",
+ value: kind,
+ offset: 0,
+ });
+ }
+ }
+ let value = read_u64_le(buf, 1)?;
+ Ok((Self { kind, value }, 9))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_offset() {
+ let s = WirePollingStrategy::offset(100);
+ let bytes = s.to_bytes();
+ assert_eq!(bytes.len(), 9);
+ let (decoded, consumed) = WirePollingStrategy::decode(&bytes).unwrap();
+ assert_eq!(consumed, 9);
+ assert_eq!(decoded, s);
+ }
+
+ #[test]
+ fn roundtrip_timestamp() {
+ let s = WirePollingStrategy::timestamp(1_700_000_000_000);
+ let bytes = s.to_bytes();
+ let (decoded, consumed) = WirePollingStrategy::decode(&bytes).unwrap();
+ assert_eq!(consumed, 9);
+ assert_eq!(decoded, s);
+ }
+
+ #[test]
+ fn roundtrip_first() {
+ let s = WirePollingStrategy::first();
+ let bytes = s.to_bytes();
+ let (decoded, _) = WirePollingStrategy::decode(&bytes).unwrap();
+ assert_eq!(decoded, s);
+ assert_eq!(decoded.value, 0);
+ }
+
+ #[test]
+ fn roundtrip_last() {
+ let s = WirePollingStrategy::last();
+ let bytes = s.to_bytes();
+ let (decoded, _) = WirePollingStrategy::decode(&bytes).unwrap();
+ assert_eq!(decoded, s);
+ }
+
+ #[test]
+ fn roundtrip_next() {
+ let s = WirePollingStrategy::next();
+ let bytes = s.to_bytes();
+ let (decoded, _) = WirePollingStrategy::decode(&bytes).unwrap();
+ assert_eq!(decoded, s);
+ }
+
+ #[test]
+ fn unknown_kind_rejected() {
+ let mut buf = BytesMut::new();
+ buf.put_u8(0xFF);
+ buf.put_u64_le(0);
+ assert!(WirePollingStrategy::decode(&buf).is_err());
+ }
+
+ #[test]
+ fn kind_zero_rejected() {
+ let mut buf = BytesMut::new();
+ buf.put_u8(0);
+ buf.put_u64_le(0);
+ assert!(WirePollingStrategy::decode(&buf).is_err());
+ }
+
+ #[test]
+ fn truncated_buffer() {
+ let s = WirePollingStrategy::offset(1);
+ let bytes = s.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ WirePollingStrategy::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn wire_compat_layout() {
+ let s = WirePollingStrategy::offset(1);
+ let bytes = s.to_bytes();
+ assert_eq!(bytes[0], KIND_OFFSET);
+ assert_eq!(u64::from_le_bytes(bytes[1..9].try_into().unwrap()), 1);
+ }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/create_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/create_consumer_group.rs
new file mode 100644
index 000000000..b5bb9575f
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/create_consumer_group.rs
@@ -0,0 +1,126 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use crate::primitives::identifier::WireName;
+use bytes::BytesMut;
+
+/// `CreateConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][name_len:1][name:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreateConsumerGroupRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub name: WireName,
+}
+
+impl WireEncode for CreateConsumerGroupRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.name.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ self.name.encode(buf);
+ }
+}
+
+impl WireDecode for CreateConsumerGroupRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (name, n) = WireName::decode(&buf[pos..])?;
+ pos += n;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ name,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = CreateConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ name: WireName::new("my-group").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreateConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_identifiers() {
+ let req = CreateConsumerGroupRequest {
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ name: WireName::new("consumer-group-1").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreateConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = CreateConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ name: WireName::new("grp").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ CreateConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn wire_compat_byte_layout() {
+ let req = CreateConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ name: WireName::new("grp").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ // stream_id: [1,4, 1,0,0,0] + topic_id: [1,4, 2,0,0,0] + name: [3, g,r,p]
+ assert_eq!(
+ &bytes[..],
+ &[1, 4, 1, 0, 0, 0, 1, 4, 2, 0, 0, 0, 3, b'g', b'r', b'p']
+ );
+ }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/delete_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/delete_consumer_group.rs
new file mode 100644
index 000000000..d26e0d3da
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/delete_consumer_group.rs
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `DeleteConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeleteConsumerGroupRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub group_id: WireIdentifier,
+}
+
+impl WireEncode for DeleteConsumerGroupRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ self.group_id.encode(buf);
+ }
+}
+
+impl WireDecode for DeleteConsumerGroupRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ group_id,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = DeleteConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = DeleteConsumerGroupRequest {
+ stream_id: WireIdentifier::named("s").unwrap(),
+ topic_id: WireIdentifier::named("t").unwrap(),
+ group_id: WireIdentifier::named("g").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = DeleteConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ DeleteConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/get_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/get_consumer_group.rs
new file mode 100644
index 000000000..1589b1cc7
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/get_consumer_group.rs
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `GetConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetConsumerGroupRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub group_id: WireIdentifier,
+}
+
+impl WireEncode for GetConsumerGroupRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ self.group_id.encode(buf);
+ }
+}
+
+impl WireDecode for GetConsumerGroupRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ group_id,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_numeric() {
+ let req = GetConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = GetConsumerGroupRequest {
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ group_id: WireIdentifier::named("group-1").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = GetConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ GetConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/consumer_groups/get_consumer_groups.rs
similarity index 52%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/consumer_groups/get_consumer_groups.rs
index a8085f099..2dd65a312 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/consumer_groups/get_consumer_groups.rs
@@ -18,33 +18,42 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetConsumerGroups` request.
+///
+/// Wire format: `[stream_id][topic_id]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct GetConsumerGroupsRequest {
pub stream_id: WireIdentifier,
- pub name: WireName,
+ pub topic_id: WireIdentifier,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetConsumerGroupsRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.stream_id.encoded_size() + self.topic_id.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.topic_id.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetConsumerGroupsRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
- pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ let mut pos = 0;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ },
+ pos,
+ ))
}
}
@@ -53,27 +62,39 @@ mod tests {
use super::*;
#[test]
- fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ fn roundtrip_numeric() {
+ let req = GetConsumerGroupsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetConsumerGroupsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = GetConsumerGroupsRequest {
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = GetConsumerGroupsRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
fn truncated_returns_error() {
- let req = UpdateStreamRequest {
+ let req = GetConsumerGroupsRequest {
stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ topic_id: WireIdentifier::numeric(2),
};
let bytes = req.to_bytes();
for i in 0..bytes.len() {
assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+ GetConsumerGroupsRequest::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
diff --git a/core/binary_protocol/src/requests/consumer_groups/join_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/join_consumer_group.rs
new file mode 100644
index 000000000..75eb12811
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/join_consumer_group.rs
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `JoinConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JoinConsumerGroupRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub group_id: WireIdentifier,
+}
+
+impl WireEncode for JoinConsumerGroupRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ self.group_id.encode(buf);
+ }
+}
+
+impl WireDecode for JoinConsumerGroupRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ group_id,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = JoinConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = JoinConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = JoinConsumerGroupRequest {
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ group_id: WireIdentifier::named("group-1").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = JoinConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = JoinConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ JoinConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/consumer_groups/leave_consumer_group.rs b/core/binary_protocol/src/requests/consumer_groups/leave_consumer_group.rs
new file mode 100644
index 000000000..621922c3e
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_groups/leave_consumer_group.rs
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `LeaveConsumerGroup` request.
+///
+/// Wire format: `[stream_id][topic_id][group_id]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LeaveConsumerGroupRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub group_id: WireIdentifier,
+}
+
+impl WireEncode for LeaveConsumerGroupRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + self.group_id.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ self.group_id.encode(buf);
+ }
+}
+
+impl WireDecode for LeaveConsumerGroupRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (group_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ group_id,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = LeaveConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = LeaveConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = LeaveConsumerGroupRequest {
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ group_id: WireIdentifier::named("group-1").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = LeaveConsumerGroupRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = LeaveConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ group_id: WireIdentifier::numeric(3),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ LeaveConsumerGroupRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/consumer_groups/mod.rs
similarity index 60%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/consumer_groups/mod.rs
index 7b062a665..e82c728e8 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/consumer_groups/mod.rs
@@ -15,26 +15,16 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod create_consumer_group;
+pub mod delete_consumer_group;
+pub mod get_consumer_group;
+pub mod get_consumer_groups;
+pub mod join_consumer_group;
+pub mod leave_consumer_group;
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
- fn encoded_size(&self) -> usize {
- 0
- }
-
- fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
- fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
- Ok((Self, 0))
- }
-}
+pub use create_consumer_group::CreateConsumerGroupRequest;
+pub use delete_consumer_group::DeleteConsumerGroupRequest;
+pub use get_consumer_group::GetConsumerGroupRequest;
+pub use get_consumer_groups::GetConsumerGroupsRequest;
+pub use join_consumer_group::JoinConsumerGroupRequest;
+pub use leave_consumer_group::LeaveConsumerGroupRequest;
diff --git a/core/binary_protocol/src/requests/consumer_offsets/delete_consumer_offset.rs b/core/binary_protocol/src/requests/consumer_offsets/delete_consumer_offset.rs
new file mode 100644
index 000000000..1c3c480fe
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_offsets/delete_consumer_offset.rs
@@ -0,0 +1,175 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::consumer::WireConsumer;
+use bytes::{BufMut, BytesMut};
+
+/// `DeleteConsumerOffset` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeleteConsumerOffsetRequest {
+ pub consumer: WireConsumer,
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partition_id: Option<u32>,
+}
+
+impl WireEncode for DeleteConsumerOffsetRequest {
+ fn encoded_size(&self) -> usize {
+ self.consumer.encoded_size()
+ + self.stream_id.encoded_size()
+ + self.topic_id.encoded_size()
+ + 1
+ + 4
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.consumer.encode(buf);
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ if let Some(pid) = self.partition_id {
+ buf.put_u8(1);
+ buf.put_u32_le(pid);
+ } else {
+ buf.put_u8(0);
+ buf.put_u32_le(0);
+ }
+ }
+}
+
+impl WireDecode for DeleteConsumerOffsetRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+ pos += n;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let partition_flag = read_u8(buf, pos)?;
+ pos += 1;
+ let partition_raw = read_u32_le(buf, pos)?;
+ pos += 4;
+ let partition_id = if partition_flag == 1 {
+ Some(partition_raw)
+ } else {
+ None
+ };
+ Ok((
+ Self {
+ consumer,
+ stream_id,
+ topic_id,
+ partition_id,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_with_partition() {
+ let req = DeleteConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(10),
+ topic_id: WireIdentifier::numeric(20),
+ partition_id: Some(5),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_without_partition() {
+ let req = DeleteConsumerOffsetRequest {
+ consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_identifiers() {
+ let req = DeleteConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ partition_id: Some(0),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn partition_none_encodes_zero_bytes() {
+ let req = DeleteConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: None,
+ };
+ let bytes = req.to_bytes();
+ let partition_offset = req.consumer.encoded_size()
+ + req.stream_id.encoded_size()
+ + req.topic_id.encoded_size();
+ assert_eq!(bytes[partition_offset], 0);
+ assert_eq!(
+ &bytes[partition_offset + 1..partition_offset + 5],
+ &[0, 0, 0, 0]
+ );
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = DeleteConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: Some(1),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ DeleteConsumerOffsetRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/consumer_offsets/get_consumer_offset.rs b/core/binary_protocol/src/requests/consumer_offsets/get_consumer_offset.rs
new file mode 100644
index 000000000..e256916af
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_offsets/get_consumer_offset.rs
@@ -0,0 +1,175 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::consumer::WireConsumer;
+use bytes::{BufMut, BytesMut};
+
+/// `GetConsumerOffset` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetConsumerOffsetRequest {
+ pub consumer: WireConsumer,
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partition_id: Option<u32>,
+}
+
+impl WireEncode for GetConsumerOffsetRequest {
+ fn encoded_size(&self) -> usize {
+ self.consumer.encoded_size()
+ + self.stream_id.encoded_size()
+ + self.topic_id.encoded_size()
+ + 1
+ + 4
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.consumer.encode(buf);
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ if let Some(pid) = self.partition_id {
+ buf.put_u8(1);
+ buf.put_u32_le(pid);
+ } else {
+ buf.put_u8(0);
+ buf.put_u32_le(0);
+ }
+ }
+}
+
+impl WireDecode for GetConsumerOffsetRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+ pos += n;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let partition_flag = read_u8(buf, pos)?;
+ pos += 1;
+ let partition_raw = read_u32_le(buf, pos)?;
+ pos += 4;
+ let partition_id = if partition_flag == 1 {
+ Some(partition_raw)
+ } else {
+ None
+ };
+ Ok((
+ Self {
+ consumer,
+ stream_id,
+ topic_id,
+ partition_id,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_with_partition() {
+ let req = GetConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(10),
+ topic_id: WireIdentifier::numeric(20),
+ partition_id: Some(5),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_without_partition() {
+ let req = GetConsumerOffsetRequest {
+ consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_identifiers() {
+ let req = GetConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ partition_id: Some(0),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn partition_none_encodes_zero_bytes() {
+ let req = GetConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: None,
+ };
+ let bytes = req.to_bytes();
+ let partition_offset = req.consumer.encoded_size()
+ + req.stream_id.encoded_size()
+ + req.topic_id.encoded_size();
+ assert_eq!(bytes[partition_offset], 0);
+ assert_eq!(
+ &bytes[partition_offset + 1..partition_offset + 5],
+ &[0, 0, 0, 0]
+ );
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = GetConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: Some(1),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ GetConsumerOffsetRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/consumer_offsets/mod.rs
similarity index 74%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/consumer_offsets/mod.rs
index e7b687f1f..bde4d02b5 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/consumer_offsets/mod.rs
@@ -15,4 +15,10 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod delete_consumer_offset;
+pub mod get_consumer_offset;
+pub mod store_consumer_offset;
+
+pub use delete_consumer_offset::DeleteConsumerOffsetRequest;
+pub use get_consumer_offset::GetConsumerOffsetRequest;
+pub use store_consumer_offset::StoreConsumerOffsetRequest;
diff --git a/core/binary_protocol/src/requests/consumer_offsets/store_consumer_offset.rs b/core/binary_protocol/src/requests/consumer_offsets/store_consumer_offset.rs
new file mode 100644
index 000000000..10bfd94ff
--- /dev/null
+++ b/core/binary_protocol/src/requests/consumer_offsets/store_consumer_offset.rs
@@ -0,0 +1,180 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
+use crate::primitives::consumer::WireConsumer;
+use bytes::{BufMut, BytesMut};
+
+/// `StoreConsumerOffset` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE][offset:8 LE]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StoreConsumerOffsetRequest {
+ pub consumer: WireConsumer,
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partition_id: Option<u32>,
+ pub offset: u64,
+}
+
+impl WireEncode for StoreConsumerOffsetRequest {
+ fn encoded_size(&self) -> usize {
+ self.consumer.encoded_size()
+ + self.stream_id.encoded_size()
+ + self.topic_id.encoded_size()
+ + 1
+ + 4
+ + 8
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.consumer.encode(buf);
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ if let Some(pid) = self.partition_id {
+ buf.put_u8(1);
+ buf.put_u32_le(pid);
+ } else {
+ buf.put_u8(0);
+ buf.put_u32_le(0);
+ }
+ buf.put_u64_le(self.offset);
+ }
+}
+
+impl WireDecode for StoreConsumerOffsetRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+ pos += n;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let partition_flag = read_u8(buf, pos)?;
+ pos += 1;
+ let partition_raw = read_u32_le(buf, pos)?;
+ pos += 4;
+ let partition_id = if partition_flag == 1 {
+ Some(partition_raw)
+ } else {
+ None
+ };
+ let offset = read_u64_le(buf, pos)?;
+ pos += 8;
+ Ok((
+ Self {
+ consumer,
+ stream_id,
+ topic_id,
+ partition_id,
+ offset,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_with_partition() {
+ let req = StoreConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(10),
+ topic_id: WireIdentifier::numeric(20),
+ partition_id: Some(5),
+ offset: 12345,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_without_partition() {
+ let req = StoreConsumerOffsetRequest {
+ consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: None,
+ offset: u64::MAX,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_identifiers() {
+ let req = StoreConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ partition_id: Some(0),
+ offset: 0,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn offset_encoding() {
+ let req = StoreConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: Some(0),
+ offset: 0x0102_0304_0506_0708,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, _) = StoreConsumerOffsetRequest::decode(&bytes).unwrap();
+ assert_eq!(decoded.offset, 0x0102_0304_0506_0708);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = StoreConsumerOffsetRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: Some(1),
+ offset: 100,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ StoreConsumerOffsetRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/messages/flush_unsaved_buffer.rs b/core/binary_protocol/src/requests/messages/flush_unsaved_buffer.rs
new file mode 100644
index 000000000..0c276dbec
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/flush_unsaved_buffer.rs
@@ -0,0 +1,155 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `FlushUnsavedBuffer` request.
+///
+/// Wire format: `[stream_id][topic_id][partition_id:4 LE][fsync:1]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FlushUnsavedBufferRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partition_id: u32,
+ pub fsync: bool,
+}
+
+impl WireEncode for FlushUnsavedBufferRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4 + 1
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ buf.put_u32_le(self.partition_id);
+ buf.put_u8(u8::from(self.fsync));
+ }
+}
+
+impl WireDecode for FlushUnsavedBufferRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let partition_id = read_u32_le(buf, pos)?;
+ pos += 4;
+ let fsync = read_u8(buf, pos)? != 0;
+ pos += 1;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ partition_id,
+ fsync,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = FlushUnsavedBufferRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partition_id: 3,
+ fsync: true,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = FlushUnsavedBufferRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_identifiers() {
+ let req = FlushUnsavedBufferRequest {
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ partition_id: 42,
+ fsync: false,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = FlushUnsavedBufferRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn fsync_flag_encoding() {
+ let req_true = FlushUnsavedBufferRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: 0,
+ fsync: true,
+ };
+ let req_false = FlushUnsavedBufferRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: 0,
+ fsync: false,
+ };
+ let bytes_true = req_true.to_bytes();
+ let bytes_false = req_false.to_bytes();
+ assert_eq!(*bytes_true.last().unwrap(), 1);
+ assert_eq!(*bytes_false.last().unwrap(), 0);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = FlushUnsavedBufferRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partition_id: 3,
+ fsync: true,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ FlushUnsavedBufferRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn wire_compat_byte_layout() {
+ let req = FlushUnsavedBufferRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partition_id: 3,
+ fsync: true,
+ };
+ let bytes = req.to_bytes();
+ // stream_id: [1, 4, 1, 0, 0, 0] + topic_id: [1, 4, 2, 0, 0, 0]
+ // + partition_id: [3, 0, 0, 0] + fsync: [1]
+ assert_eq!(
+ &bytes[..],
+ &[1, 4, 1, 0, 0, 0, 1, 4, 2, 0, 0, 0, 3, 0, 0, 0, 1]
+ );
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/messages/mod.rs
similarity index 73%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/messages/mod.rs
index e7b687f1f..6b976e07e 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/messages/mod.rs
@@ -15,4 +15,12 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod flush_unsaved_buffer;
+pub mod poll_messages;
+pub mod send_messages;
+
+pub use flush_unsaved_buffer::FlushUnsavedBufferRequest;
+pub use poll_messages::PollMessagesRequest;
+pub use send_messages::{
+ RawMessage, SendMessagesEncoder, SendMessagesHeader, SendMessagesMetadataEncoder,
+};
diff --git a/core/binary_protocol/src/requests/messages/poll_messages.rs b/core/binary_protocol/src/requests/messages/poll_messages.rs
new file mode 100644
index 000000000..4c6413cfc
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/poll_messages.rs
@@ -0,0 +1,220 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::consumer::WireConsumer;
+use crate::primitives::polling_strategy::WirePollingStrategy;
+use bytes::{BufMut, BytesMut};
+
+/// `PollMessages` request.
+///
+/// Wire format:
+/// ```text
+/// [consumer][stream_id][topic_id][partition_flag:1][partition_id:4 LE]
+/// [strategy:9][count:4 LE][auto_commit:1]
+/// ```
+///
+/// `partition_id` encoding: a u8 flag (1=Some, 0=None) followed by 4 bytes
+/// for the u32 value (0 when None).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PollMessagesRequest {
+ pub consumer: WireConsumer,
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partition_id: Option<u32>,
+ pub strategy: WirePollingStrategy,
+ pub count: u32,
+ pub auto_commit: bool,
+}
+
+const PARTITION_FLAG_SIZE: usize = 1;
+const PARTITION_VALUE_SIZE: usize = 4;
+const STRATEGY_SIZE: usize = 9;
+const COUNT_SIZE: usize = 4;
+const AUTO_COMMIT_SIZE: usize = 1;
+
+impl WireEncode for PollMessagesRequest {
+ fn encoded_size(&self) -> usize {
+ self.consumer.encoded_size()
+ + self.stream_id.encoded_size()
+ + self.topic_id.encoded_size()
+ + PARTITION_FLAG_SIZE
+ + PARTITION_VALUE_SIZE
+ + STRATEGY_SIZE
+ + COUNT_SIZE
+ + AUTO_COMMIT_SIZE
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.consumer.encode(buf);
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ if let Some(pid) = self.partition_id {
+ buf.put_u8(1);
+ buf.put_u32_le(pid);
+ } else {
+ buf.put_u8(0);
+ buf.put_u32_le(0);
+ }
+ self.strategy.encode(buf);
+ buf.put_u32_le(self.count);
+ buf.put_u8(u8::from(self.auto_commit));
+ }
+}
+
+impl WireDecode for PollMessagesRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut pos = 0;
+ let (consumer, n) = WireConsumer::decode(&buf[pos..])?;
+ pos += n;
+ let (stream_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+ let (topic_id, n) = WireIdentifier::decode(&buf[pos..])?;
+ pos += n;
+
+ let partition_flag = read_u8(buf, pos)?;
+ pos += 1;
+ let partition_raw = read_u32_le(buf, pos)?;
+ pos += 4;
+ let partition_id = if partition_flag == 1 {
+ Some(partition_raw)
+ } else {
+ None
+ };
+
+ let (strategy, n) = WirePollingStrategy::decode(&buf[pos..])?;
+ pos += n;
+ let count = read_u32_le(buf, pos)?;
+ pos += 4;
+ let auto_commit = read_u8(buf, pos)? != 0;
+ pos += 1;
+
+ Ok((
+ Self {
+ consumer,
+ stream_id,
+ topic_id,
+ partition_id,
+ strategy,
+ count,
+ auto_commit,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_with_partition() {
+ let req = PollMessagesRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(10),
+ topic_id: WireIdentifier::numeric(20),
+ partition_id: Some(5),
+ strategy: WirePollingStrategy::offset(100),
+ count: 50,
+ auto_commit: true,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = PollMessagesRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_without_partition() {
+ let req = PollMessagesRequest {
+ consumer: WireConsumer::consumer_group(WireIdentifier::numeric(3)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: None,
+ strategy: WirePollingStrategy::first(),
+ count: 10,
+ auto_commit: false,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = PollMessagesRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_identifiers() {
+ let req = PollMessagesRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::named("my-consumer").unwrap()),
+ stream_id: WireIdentifier::named("stream-1").unwrap(),
+ topic_id: WireIdentifier::named("topic-1").unwrap(),
+ partition_id: Some(0),
+ strategy: WirePollingStrategy::offset(0),
+ count: 1,
+ auto_commit: false,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = PollMessagesRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn partition_none_encodes_zero_bytes() {
+ let req = PollMessagesRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: None,
+ strategy: WirePollingStrategy::first(),
+ count: 1,
+ auto_commit: false,
+ };
+ let bytes = req.to_bytes();
+ // After consumer(7) + stream_id(6) + topic_id(6) = offset 19
+ let partition_offset = req.consumer.encoded_size()
+ + req.stream_id.encoded_size()
+ + req.topic_id.encoded_size();
+ assert_eq!(bytes[partition_offset], 0); // flag = 0
+ assert_eq!(
+ &bytes[partition_offset + 1..partition_offset + 5],
+ &[0, 0, 0, 0]
+ );
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = PollMessagesRequest {
+ consumer: WireConsumer::consumer(WireIdentifier::numeric(1)),
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(1),
+ partition_id: Some(1),
+ strategy: WirePollingStrategy::offset(0),
+ count: 1,
+ auto_commit: false,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ PollMessagesRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/messages/send_messages.rs b/core/binary_protocol/src/requests/messages/send_messages.rs
new file mode 100644
index 000000000..559ce9e0b
--- /dev/null
+++ b/core/binary_protocol/src/requests/messages/send_messages.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.
+
+//! Zero-copy encoder for `SendMessages` wire format.
+//!
+//! Messages are written directly to the buffer without intermediate allocation.
+
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use crate::error::WireError;
+use crate::message_layout::{WIRE_MESSAGE_HEADER_SIZE, WIRE_MESSAGE_INDEX_SIZE};
+use crate::primitives::identifier::WireIdentifier;
+use crate::primitives::partitioning::WirePartitioning;
+use bytes::{BufMut, BytesMut};
+
+/// Borrowed message data for encoding. No allocation needed - the caller
+/// owns the payload and headers buffers.
+pub struct RawMessage<'a> {
+ pub id: u128,
+ pub origin_timestamp: u64,
+ pub headers: Option<&'a [u8]>,
+ pub payload: &'a [u8],
+}
+
+impl RawMessage<'_> {
+ fn wire_size(&self) -> usize {
+ WIRE_MESSAGE_HEADER_SIZE + self.payload.len() + self.headers.map_or(0, <[u8]>::len)
+ }
+
+ /// Write the 64-byte message header to `buf`. Payload and `user_headers` are NOT
+ /// written - the caller sends them separately via vectored I/O.
+ pub fn encode_header(&self, buf: &mut BytesMut) {
+ let headers_len = self.headers.map_or(0, <[u8]>::len);
+ buf.put_u64_le(0); // checksum (server-computed)
+ buf.put_u128_le(self.id);
+ buf.put_u64_le(0); // offset (server-assigned)
+ buf.put_u64_le(0); // timestamp (server-assigned)
+ buf.put_u64_le(self.origin_timestamp);
+ #[allow(clippy::cast_possible_truncation)]
+ {
+ buf.put_u32_le(headers_len as u32);
+ buf.put_u32_le(self.payload.len() as u32);
+ }
+ buf.put_u64_le(0); // reserved
+ }
+}
+
+/// Zero-copy encoder for the `SendMessages` command payload.
+///
+/// Wire layout:
+/// ```text
+/// [metadata_length:u32_le]
+/// [stream_id:variable]
+/// [topic_id:variable]
+/// [partitioning:variable]
+/// [messages_count:u32_le]
+/// [index_array: messages_count * 16 bytes]
+/// [message_data: variable]
+/// ```
+pub struct SendMessagesEncoder;
+
+impl SendMessagesEncoder {
+ #[must_use]
+ pub fn encoded_size(
+ stream_id: &WireIdentifier,
+ topic_id: &WireIdentifier,
+ partitioning: &WirePartitioning,
+ messages: &[RawMessage<'_>],
+ ) -> usize {
+ let metadata_inner =
+ stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+ let index_total = messages.len() * WIRE_MESSAGE_INDEX_SIZE;
+ let messages_total: usize = messages.iter().map(RawMessage::wire_size).sum();
+ 4 + metadata_inner + index_total + messages_total
+ }
+
+ pub fn encode(
+ buf: &mut BytesMut,
+ stream_id: &WireIdentifier,
+ topic_id: &WireIdentifier,
+ partitioning: &WirePartitioning,
+ messages: &[RawMessage<'_>],
+ ) {
+ let metadata_inner =
+ stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(metadata_inner as u32);
+
+ stream_id.encode(buf);
+ topic_id.encode(buf);
+ partitioning.encode(buf);
+
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(messages.len() as u32);
+
+ // Index array: cumulative sizes for each message
+ let mut cumulative_size: u32 = 0;
+ for msg in messages {
+ #[allow(clippy::cast_possible_truncation)]
+ {
+ cumulative_size += msg.wire_size() as u32;
+ }
+ // bytes 0-3: zero
+ buf.put_u32_le(0);
+ // bytes 4-7: cumulative size
+ buf.put_u32_le(cumulative_size);
+ // bytes 8-15: zero
+ buf.put_u64_le(0);
+ }
+
+ // Message data: header(64) + payload + optional user_headers
+ for msg in messages {
+ let headers_len = msg.headers.map_or(0, <[u8]>::len);
+
+ buf.put_u64_le(0); // checksum (server-computed)
+ buf.put_u128_le(msg.id);
+ buf.put_u64_le(0); // offset (server-assigned)
+ buf.put_u64_le(0); // timestamp (server-assigned)
+ buf.put_u64_le(msg.origin_timestamp);
+ #[allow(clippy::cast_possible_truncation)]
+ {
+ buf.put_u32_le(headers_len as u32);
+ buf.put_u32_le(msg.payload.len() as u32);
+ }
+ buf.put_u64_le(0); // reserved
+ buf.put_slice(msg.payload);
+ if let Some(headers) = msg.headers {
+ buf.put_slice(headers);
+ }
+ }
+ }
+}
+
+/// Metadata-only decoder for the `SendMessages` command.
+///
+/// Parses routing metadata (stream, topic, partitioning, message count)
+/// without touching message payloads. The server uses this to extract
+/// routing info before reading message data into pooled buffers.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SendMessagesHeader {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partitioning: WirePartitioning,
+ pub messages_count: u32,
+}
+
+impl SendMessagesHeader {
+ /// Size of the encoded metadata fields (the value written as the
+ /// `metadata_length` prefix on the wire).
+ #[must_use]
+ pub fn metadata_length(&self) -> usize {
+ self.stream_id.encoded_size()
+ + self.topic_id.encoded_size()
+ + self.partitioning.encoded_size()
+ + 4
+ }
+}
+
+impl WireEncode for SendMessagesHeader {
+ fn encoded_size(&self) -> usize {
+ self.metadata_length()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ self.partitioning.encode(buf);
+ buf.put_u32_le(self.messages_count);
+ }
+}
+
+impl WireDecode for SendMessagesHeader {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+ pos += consumed;
+ let (partitioning, consumed) = WirePartitioning::decode(&buf[pos..])?;
+ pos += consumed;
+ let messages_count = read_u32_le(buf, pos)?;
+ pos += 4;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ partitioning,
+ messages_count,
+ },
+ pos,
+ ))
+ }
+}
+
+/// Vectored I/O encoder for `SendMessages`.
+///
+/// Writes metadata + index array but stops before message frame data,
+/// enabling the caller to compose `[header_buf | msg_frames...]` via writev.
+pub struct SendMessagesMetadataEncoder;
+
+impl SendMessagesMetadataEncoder {
+ /// Exact size of metadata + indexes (everything except message frame data).
+ #[must_use]
+ pub fn header_size(
+ stream_id: &WireIdentifier,
+ topic_id: &WireIdentifier,
+ partitioning: &WirePartitioning,
+ messages_count: usize,
+ ) -> usize {
+ let metadata_inner =
+ stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+ 4 + metadata_inner + messages_count * WIRE_MESSAGE_INDEX_SIZE
+ }
+
+ /// Encode `metadata_length` + metadata fields + index array into `buf`.
+ ///
+ /// Does NOT write message frame data - the caller sends that via vectored I/O.
+ pub fn encode_header(
+ buf: &mut BytesMut,
+ stream_id: &WireIdentifier,
+ topic_id: &WireIdentifier,
+ partitioning: &WirePartitioning,
+ messages: &[RawMessage<'_>],
+ ) {
+ let metadata_inner =
+ stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(metadata_inner as u32);
+
+ stream_id.encode(buf);
+ topic_id.encode(buf);
+ partitioning.encode(buf);
+
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(messages.len() as u32);
+
+ let mut cumulative_size: u32 = 0;
+ for msg in messages {
+ #[allow(clippy::cast_possible_truncation)]
+ {
+ cumulative_size += msg.wire_size() as u32;
+ }
+ buf.put_u32_le(0);
+ buf.put_u32_le(cumulative_size);
+ buf.put_u64_le(0);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn numeric_id(id: u32) -> WireIdentifier {
+ WireIdentifier::numeric(id)
+ }
+
+ #[test]
+ fn encode_single_message_no_headers() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::Balanced;
+ let payload = b"hello";
+ let messages = [RawMessage {
+ id: 100,
+ origin_timestamp: 999,
+ headers: None,
+ payload: payload.as_slice(),
+ }];
+
+ let size =
+ SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+ let mut buf = BytesMut::with_capacity(size);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ assert_eq!(buf.len(), size);
+ }
+
+ #[test]
+ fn encode_single_message_with_headers() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::PartitionId(5);
+ let payload = b"world";
+ let headers = b"key:val";
+ let messages = [RawMessage {
+ id: 200,
+ origin_timestamp: 1000,
+ headers: Some(headers.as_slice()),
+ payload: payload.as_slice(),
+ }];
+
+ let size =
+ SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+ let mut buf = BytesMut::with_capacity(size);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ assert_eq!(buf.len(), size);
+ }
+
+ #[test]
+ fn encode_multiple_messages() {
+ let stream_id = numeric_id(10);
+ let topic_id = numeric_id(20);
+ let partitioning = WirePartitioning::MessagesKey(b"user-1".to_vec());
+ let messages = [
+ RawMessage {
+ id: 1,
+ origin_timestamp: 100,
+ headers: None,
+ payload: b"msg-1",
+ },
+ RawMessage {
+ id: 2,
+ origin_timestamp: 200,
+ headers: Some(b"h2"),
+ payload: b"msg-2",
+ },
+ RawMessage {
+ id: 3,
+ origin_timestamp: 300,
+ headers: None,
+ payload: b"msg-3",
+ },
+ ];
+
+ let size =
+ SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+ let mut buf = BytesMut::with_capacity(size);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ assert_eq!(buf.len(), size);
+ }
+
+ #[test]
+ fn verify_metadata_length_field() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [RawMessage {
+ id: 1,
+ origin_timestamp: 0,
+ headers: None,
+ payload: b"x",
+ }];
+
+ let mut buf = BytesMut::with_capacity(256);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ let metadata_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
+ let expected =
+ stream_id.encoded_size() + topic_id.encoded_size() + partitioning.encoded_size() + 4;
+ assert_eq!(metadata_len, expected);
+ }
+
+ #[test]
+ fn verify_index_entries() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [
+ RawMessage {
+ id: 1,
+ origin_timestamp: 0,
+ headers: None,
+ payload: b"aaaa", // wire_size = 64 + 4 = 68
+ },
+ RawMessage {
+ id: 2,
+ origin_timestamp: 0,
+ headers: Some(b"hh"), // wire_size = 64 + 3 + 2 = 69
+ payload: b"bbb",
+ },
+ ];
+
+ let mut buf = BytesMut::with_capacity(512);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ // Index starts after: 4 (metadata_len) + stream_id(6) + topic_id(6) + partitioning(2) + 4 (msg_count)
+ let index_start = 4 + 6 + 6 + 2 + 4;
+
+ // First index entry: cumulative = 68
+ let first_zero = u32::from_le_bytes(buf[index_start..index_start + 4].try_into().unwrap());
+ let first_cum =
+ u32::from_le_bytes(buf[index_start + 4..index_start + 8].try_into().unwrap());
+ assert_eq!(first_zero, 0);
+ assert_eq!(first_cum, 68);
+
+ // Second index entry: cumulative = 68 + 69 = 137
+ let second_start = index_start + WIRE_MESSAGE_INDEX_SIZE;
+ let second_zero =
+ u32::from_le_bytes(buf[second_start..second_start + 4].try_into().unwrap());
+ let second_cum =
+ u32::from_le_bytes(buf[second_start + 4..second_start + 8].try_into().unwrap());
+ assert_eq!(second_zero, 0);
+ assert_eq!(second_cum, 137);
+ }
+
+ #[test]
+ fn verify_message_header_layout() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [RawMessage {
+ id: 42,
+ origin_timestamp: 777,
+ headers: Some(b"hdr"),
+ payload: b"pay",
+ }];
+
+ let mut buf = BytesMut::with_capacity(256);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ // Message data starts after: 4 + 6 + 6 + 2 + 4 + 16 (one index entry)
+ let msg_start = 4 + 6 + 6 + 2 + 4 + 16;
+ let msg = &buf[msg_start..];
+
+ // Header layout matches WIRE_MESSAGE_HEADER_SIZE (64 bytes):
+ // checksum(8) + id(16) + offset(8) + timestamp(8) + origin_ts(8) + hdrs_len(4) + payload_len(4) + reserved(8)
+ let checksum = u64::from_le_bytes(msg[0..8].try_into().unwrap());
+ assert_eq!(checksum, 0);
+
+ let id = u128::from_le_bytes(msg[8..24].try_into().unwrap());
+ assert_eq!(id, 42);
+
+ let offset = u64::from_le_bytes(msg[24..32].try_into().unwrap());
+ assert_eq!(offset, 0);
+
+ let timestamp = u64::from_le_bytes(msg[32..40].try_into().unwrap());
+ assert_eq!(timestamp, 0);
+
+ let origin_ts = u64::from_le_bytes(msg[40..48].try_into().unwrap());
+ assert_eq!(origin_ts, 777);
+
+ let headers_len = u32::from_le_bytes(msg[48..52].try_into().unwrap());
+ assert_eq!(headers_len, 3);
+
+ let payload_len = u32::from_le_bytes(msg[52..56].try_into().unwrap());
+ assert_eq!(payload_len, 3);
+
+ let reserved = u64::from_le_bytes(msg[56..64].try_into().unwrap());
+ assert_eq!(reserved, 0);
+
+ // After the 64-byte header: payload then headers
+ assert_eq!(&msg[64..67], b"pay");
+ assert_eq!(&msg[67..70], b"hdr");
+ }
+
+ #[test]
+ fn empty_payload_message() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(1);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [RawMessage {
+ id: 0,
+ origin_timestamp: 0,
+ headers: None,
+ payload: b"",
+ }];
+
+ let size =
+ SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+ let mut buf = BytesMut::with_capacity(size);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+ assert_eq!(buf.len(), size);
+ }
+
+ // -- RawMessage::encode_header --
+
+ #[test]
+ fn raw_message_encode_header_is_64_bytes() {
+ let msg = RawMessage {
+ id: 42,
+ origin_timestamp: 999,
+ headers: Some(b"hdr"),
+ payload: b"pay",
+ };
+ let mut buf = BytesMut::with_capacity(WIRE_MESSAGE_HEADER_SIZE);
+ msg.encode_header(&mut buf);
+ assert_eq!(buf.len(), WIRE_MESSAGE_HEADER_SIZE);
+ }
+
+ #[test]
+ fn raw_message_encode_header_field_values() {
+ let msg = RawMessage {
+ id: 0x1234_5678_9ABC_DEF0_1234_5678_9ABC_DEF0,
+ origin_timestamp: 0xCAFE_BABE,
+ headers: Some(b"hdr"),
+ payload: b"pay",
+ };
+ let mut buf = BytesMut::with_capacity(WIRE_MESSAGE_HEADER_SIZE);
+ msg.encode_header(&mut buf);
+
+ assert_eq!(u64::from_le_bytes(buf[0..8].try_into().unwrap()), 0);
+ assert_eq!(
+ u128::from_le_bytes(buf[8..24].try_into().unwrap()),
+ 0x1234_5678_9ABC_DEF0_1234_5678_9ABC_DEF0
+ );
+ assert_eq!(u64::from_le_bytes(buf[24..32].try_into().unwrap()), 0);
+ assert_eq!(u64::from_le_bytes(buf[32..40].try_into().unwrap()), 0);
+ assert_eq!(
+ u64::from_le_bytes(buf[40..48].try_into().unwrap()),
+ 0xCAFE_BABE
+ );
+ assert_eq!(u32::from_le_bytes(buf[48..52].try_into().unwrap()), 3);
+ assert_eq!(u32::from_le_bytes(buf[52..56].try_into().unwrap()), 3);
+ assert_eq!(u64::from_le_bytes(buf[56..64].try_into().unwrap()), 0);
+ }
+
+ // -- SendMessagesHeader --
+
+ #[test]
+ fn send_messages_header_roundtrip() {
+ let header = SendMessagesHeader {
+ stream_id: numeric_id(1),
+ topic_id: numeric_id(2),
+ partitioning: WirePartitioning::Balanced,
+ messages_count: 5,
+ };
+ let bytes = header.to_bytes();
+ assert_eq!(bytes.len(), header.encoded_size());
+ let (decoded, consumed) = SendMessagesHeader::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, header);
+ }
+
+ #[test]
+ fn send_messages_header_roundtrip_with_string_ids() {
+ let header = SendMessagesHeader {
+ stream_id: WireIdentifier::named("my-stream").unwrap(),
+ topic_id: WireIdentifier::named("my-topic").unwrap(),
+ partitioning: WirePartitioning::MessagesKey(b"key-1".to_vec()),
+ messages_count: 10,
+ };
+ let bytes = header.to_bytes();
+ let (decoded, consumed) = SendMessagesHeader::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, header);
+ }
+
+ #[test]
+ fn send_messages_header_truncation() {
+ let header = SendMessagesHeader {
+ stream_id: numeric_id(1),
+ topic_id: numeric_id(2),
+ partitioning: WirePartitioning::Balanced,
+ messages_count: 1,
+ };
+ let bytes = header.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ SendMessagesHeader::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn send_messages_header_metadata_length() {
+ let header = SendMessagesHeader {
+ stream_id: numeric_id(1),
+ topic_id: numeric_id(2),
+ partitioning: WirePartitioning::Balanced,
+ messages_count: 3,
+ };
+ // numeric_id(6) + numeric_id(6) + balanced(2) + count(4) = 18
+ assert_eq!(header.metadata_length(), 18);
+ assert_eq!(header.encoded_size(), 18);
+ }
+
+ #[test]
+ fn send_messages_header_cross_validate_with_encoder() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [RawMessage {
+ id: 42,
+ origin_timestamp: 777,
+ headers: Some(b"hdr"),
+ payload: b"pay",
+ }];
+
+ let mut buf = BytesMut::with_capacity(256);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ let metadata_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
+ let (header, consumed) = SendMessagesHeader::decode(&buf[4..4 + metadata_len]).unwrap();
+ assert_eq!(consumed, metadata_len);
+ assert_eq!(header.stream_id, stream_id);
+ assert_eq!(header.topic_id, topic_id);
+ assert_eq!(header.partitioning, partitioning);
+ assert_eq!(header.messages_count, 1);
+ }
+
+ // -- SendMessagesMetadataEncoder --
+
+ #[test]
+ fn metadata_encoder_header_size_matches_encoded() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [
+ RawMessage {
+ id: 1,
+ origin_timestamp: 100,
+ headers: None,
+ payload: b"msg-1",
+ },
+ RawMessage {
+ id: 2,
+ origin_timestamp: 200,
+ headers: Some(b"h"),
+ payload: b"msg-2",
+ },
+ ];
+
+ let expected_size = SendMessagesMetadataEncoder::header_size(
+ &stream_id,
+ &topic_id,
+ &partitioning,
+ messages.len(),
+ );
+ let mut buf = BytesMut::with_capacity(expected_size);
+ SendMessagesMetadataEncoder::encode_header(
+ &mut buf,
+ &stream_id,
+ &topic_id,
+ &partitioning,
+ &messages,
+ );
+ assert_eq!(buf.len(), expected_size);
+ }
+
+ #[test]
+ fn metadata_encoder_concat_matches_full_encoder() {
+ let stream_id = numeric_id(1);
+ let topic_id = numeric_id(2);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [
+ RawMessage {
+ id: 1,
+ origin_timestamp: 100,
+ headers: None,
+ payload: b"aaa",
+ },
+ RawMessage {
+ id: 2,
+ origin_timestamp: 200,
+ headers: Some(b"hh"),
+ payload: b"bbb",
+ },
+ ];
+
+ let full_size =
+ SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+ let mut full_buf = BytesMut::with_capacity(full_size);
+ SendMessagesEncoder::encode(
+ &mut full_buf,
+ &stream_id,
+ &topic_id,
+ &partitioning,
+ &messages,
+ );
+
+ let mut vec_buf = BytesMut::with_capacity(full_size);
+ SendMessagesMetadataEncoder::encode_header(
+ &mut vec_buf,
+ &stream_id,
+ &topic_id,
+ &partitioning,
+ &messages,
+ );
+ for msg in &messages {
+ msg.encode_header(&mut vec_buf);
+ vec_buf.put_slice(msg.payload);
+ if let Some(headers) = msg.headers {
+ vec_buf.put_slice(headers);
+ }
+ }
+
+ assert_eq!(vec_buf.len(), full_buf.len());
+ assert_eq!(&vec_buf[..], &full_buf[..]);
+ }
+
+ // -- Cross-validation: encoder -> header decoder -> iterator --
+
+ #[test]
+ fn cross_validation_encoder_to_iterator() {
+ use crate::message_view::WireMessageIterator;
+
+ let stream_id = WireIdentifier::numeric(1);
+ let topic_id = WireIdentifier::numeric(2);
+ let partitioning = WirePartitioning::Balanced;
+ let messages = [
+ RawMessage {
+ id: 100,
+ origin_timestamp: 1000,
+ headers: None,
+ payload: b"first",
+ },
+ RawMessage {
+ id: 200,
+ origin_timestamp: 2000,
+ headers: Some(b"hdr"),
+ payload: b"second",
+ },
+ ];
+
+ let size =
+ SendMessagesEncoder::encoded_size(&stream_id, &topic_id, &partitioning, &messages);
+ let mut buf = BytesMut::with_capacity(size);
+ SendMessagesEncoder::encode(&mut buf, &stream_id, &topic_id, &partitioning, &messages);
+
+ let metadata_len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
+ let (header, _) = SendMessagesHeader::decode(&buf[4..4 + metadata_len]).unwrap();
+ assert_eq!(header.messages_count, 2);
+
+ let data_offset =
+ 4 + metadata_len + (header.messages_count as usize) * WIRE_MESSAGE_INDEX_SIZE;
+ let message_data = &buf[data_offset..];
+
+ let views: Vec<_> = WireMessageIterator::new(message_data, header.messages_count)
+ .collect::<Result<Vec<_>, _>>()
+ .unwrap();
+ assert_eq!(views.len(), 2);
+
+ assert_eq!(views[0].id(), 100);
+ assert_eq!(views[0].origin_timestamp(), 1000);
+ assert_eq!(views[0].payload(), b"first");
+ assert_eq!(views[0].user_headers(), b"");
+
+ assert_eq!(views[1].id(), 200);
+ assert_eq!(views[1].origin_timestamp(), 2000);
+ assert_eq!(views[1].payload(), b"second");
+ assert_eq!(views[1].user_headers(), b"hdr");
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/mod.rs
index e7b687f1f..14b72cc61 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/mod.rs
@@ -15,4 +15,13 @@
// specific language governing permissions and limitations
// under the License.
+pub mod consumer_groups;
+pub mod consumer_offsets;
+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;
diff --git a/core/binary_protocol/src/requests/partitions/create_partitions.rs b/core/binary_protocol/src/requests/partitions/create_partitions.rs
new file mode 100644
index 000000000..a3edb4f9b
--- /dev/null
+++ b/core/binary_protocol/src/requests/partitions/create_partitions.rs
@@ -0,0 +1,118 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `CreatePartitions` request.
+///
+/// Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier][partitions_count:u32_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreatePartitionsRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partitions_count: u32,
+}
+
+impl WireEncode for CreatePartitionsRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ buf.put_u32_le(self.partitions_count);
+ }
+}
+
+impl WireDecode for CreatePartitionsRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+ pos += consumed;
+ let partitions_count = read_u32_le(buf, pos)?;
+ pos += 4;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ partitions_count,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = CreatePartitionsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partitions_count: 5,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreatePartitionsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = CreatePartitionsRequest {
+ stream_id: WireIdentifier::named("stream").unwrap(),
+ topic_id: WireIdentifier::named("topic").unwrap(),
+ partitions_count: 10,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreatePartitionsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = CreatePartitionsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partitions_count: 3,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ CreatePartitionsRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = CreatePartitionsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partitions_count: 7,
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+}
diff --git a/core/binary_protocol/src/requests/partitions/delete_partitions.rs b/core/binary_protocol/src/requests/partitions/delete_partitions.rs
new file mode 100644
index 000000000..5f91b88a3
--- /dev/null
+++ b/core/binary_protocol/src/requests/partitions/delete_partitions.rs
@@ -0,0 +1,118 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `DeletePartitions` request.
+///
+/// Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier][partitions_count:u32_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeletePartitionsRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partitions_count: u32,
+}
+
+impl WireEncode for DeletePartitionsRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ buf.put_u32_le(self.partitions_count);
+ }
+}
+
+impl WireDecode for DeletePartitionsRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+ pos += consumed;
+ let partitions_count = read_u32_le(buf, pos)?;
+ pos += 4;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ partitions_count,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = DeletePartitionsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partitions_count: 3,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeletePartitionsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = DeletePartitionsRequest {
+ stream_id: WireIdentifier::named("stream").unwrap(),
+ topic_id: WireIdentifier::named("topic").unwrap(),
+ partitions_count: 2,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeletePartitionsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = DeletePartitionsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partitions_count: 3,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ DeletePartitionsRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = DeletePartitionsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partitions_count: 5,
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/partitions/mod.rs
similarity index 83%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/partitions/mod.rs
index e7b687f1f..8a5d0a1a1 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/partitions/mod.rs
@@ -15,4 +15,8 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod create_partitions;
+pub mod delete_partitions;
+
+pub use create_partitions::CreatePartitionsRequest;
+pub use delete_partitions::DeletePartitionsRequest;
diff --git a/core/binary_protocol/src/requests/personal_access_tokens/create_personal_access_token.rs b/core/binary_protocol/src/requests/personal_access_tokens/create_personal_access_token.rs
new file mode 100644
index 000000000..46dda9bc7
--- /dev/null
+++ b/core/binary_protocol/src/requests/personal_access_tokens/create_personal_access_token.rs
@@ -0,0 +1,115 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `CreatePersonalAccessToken` request.
+///
+/// Wire format: `[name_len:u8][name:N][expiry:u64_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreatePersonalAccessTokenRequest {
+ pub name: WireName,
+ pub expiry: u64,
+}
+
+impl WireEncode for CreatePersonalAccessTokenRequest {
+ fn encoded_size(&self) -> usize {
+ self.name.encoded_size() + 8
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.name.encode(buf);
+ buf.put_u64_le(self.expiry);
+ }
+}
+
+impl WireDecode for CreatePersonalAccessTokenRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (name, mut pos) = WireName::decode(buf)?;
+ let expiry = read_u64_le(buf, pos)?;
+ pos += 8;
+ Ok((Self { name, expiry }, pos))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = CreatePersonalAccessTokenRequest {
+ name: WireName::new("my-token").unwrap(),
+ expiry: 3600,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreatePersonalAccessTokenRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_zero_expiry() {
+ let req = CreatePersonalAccessTokenRequest {
+ name: WireName::new("permanent").unwrap(),
+ expiry: 0,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreatePersonalAccessTokenRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_max_expiry() {
+ let req = CreatePersonalAccessTokenRequest {
+ name: WireName::new("t").unwrap(),
+ expiry: u64::MAX,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreatePersonalAccessTokenRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = CreatePersonalAccessTokenRequest {
+ name: WireName::new("test-token").unwrap(),
+ expiry: 86400,
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = CreatePersonalAccessTokenRequest {
+ name: WireName::new("tok").unwrap(),
+ expiry: 100,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ CreatePersonalAccessTokenRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/streams/create_stream.rs b/core/binary_protocol/src/requests/personal_access_tokens/delete_personal_access_token.rs
similarity index 63%
copy from core/binary_protocol/src/requests/streams/create_stream.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/delete_personal_access_token.rs
index fb86fba94..b3a947ebc 100644
--- a/core/binary_protocol/src/requests/streams/create_stream.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/delete_personal_access_token.rs
@@ -17,16 +17,16 @@
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
use bytes::BytesMut;
-/// `CreateStream` request. Wire format: `[name_len:1][name:N]`
+/// `DeletePersonalAccessToken` request. Wire format: `[name_len:u8][name:N]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CreateStreamRequest {
+pub struct DeletePersonalAccessTokenRequest {
pub name: WireName,
}
-impl WireEncode for CreateStreamRequest {
+impl WireEncode for DeletePersonalAccessTokenRequest {
fn encoded_size(&self) -> usize {
self.name.encoded_size()
}
@@ -36,7 +36,7 @@ impl WireEncode for CreateStreamRequest {
}
}
-impl WireDecode for CreateStreamRequest {
+impl WireDecode for DeletePersonalAccessTokenRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let (name, consumed) = WireName::decode(buf)?;
Ok((Self { name }, consumed))
@@ -49,48 +49,34 @@ mod tests {
#[test]
fn roundtrip() {
- let req = CreateStreamRequest {
- name: WireName::new("test-stream").unwrap(),
+ let req = DeletePersonalAccessTokenRequest {
+ name: WireName::new("my-token").unwrap(),
};
let bytes = req.to_bytes();
- assert_eq!(bytes.len(), 1 + 11);
- let (decoded, consumed) = CreateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = DeletePersonalAccessTokenRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
- fn empty_name_rejected() {
- let buf = [0u8];
- assert!(CreateStreamRequest::decode(&buf).is_err());
+ fn encoded_size_matches_output() {
+ let req = DeletePersonalAccessTokenRequest {
+ name: WireName::new("test").unwrap(),
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
}
#[test]
fn truncated_returns_error() {
- let req = CreateStreamRequest {
- name: WireName::new("test").unwrap(),
+ let req = DeletePersonalAccessTokenRequest {
+ name: WireName::new("token").unwrap(),
};
let bytes = req.to_bytes();
for i in 0..bytes.len() {
assert!(
- CreateStreamRequest::decode(&bytes[..i]).is_err(),
+ DeletePersonalAccessTokenRequest::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
}
-
- #[test]
- fn wire_compat_byte_layout() {
- let req = CreateStreamRequest {
- name: WireName::new("test").unwrap(),
- };
- let bytes = req.to_bytes();
- assert_eq!(&bytes[..], &[4, b't', b'e', b's', b't']);
- }
-
- #[test]
- fn too_long_name_rejected_at_construction() {
- let long = "a".repeat(256);
- assert!(WireName::new(long).is_err());
- }
}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/personal_access_tokens/get_personal_access_tokens.rs
similarity index 66%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/get_personal_access_tokens.rs
index 7b062a665..7cf688b60 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/get_personal_access_tokens.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `GetPersonalAccessTokens` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetPersonalAccessTokensRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetPersonalAccessTokensRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetPersonalAccessTokensRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = GetPersonalAccessTokensRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetPersonalAccessTokensRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/personal_access_tokens/login_with_personal_access_token.rs
similarity index 58%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/login_with_personal_access_token.rs
index a8085f099..1d6982962 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/login_with_personal_access_token.rs
@@ -16,35 +16,30 @@
// under the License.
use crate::WireError;
-use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `LoginWithPersonalAccessToken` request. Wire format: `[token_len:u8][token:N]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
- pub stream_id: WireIdentifier,
- pub name: WireName,
+pub struct LoginWithPersonalAccessTokenRequest {
+ pub token: WireName,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for LoginWithPersonalAccessTokenRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.token.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
- self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.token.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for LoginWithPersonalAccessTokenRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
- pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ let (token, consumed) = WireName::decode(buf)?;
+ Ok((Self { token }, consumed))
}
}
@@ -54,26 +49,32 @@ mod tests {
#[test]
fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ let req = LoginWithPersonalAccessTokenRequest {
+ token: WireName::new("abc123def456").unwrap(),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = LoginWithPersonalAccessTokenRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = LoginWithPersonalAccessTokenRequest {
+ token: WireName::new("token-value").unwrap(),
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+
#[test]
fn truncated_returns_error() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ let req = LoginWithPersonalAccessTokenRequest {
+ token: WireName::new("tok").unwrap(),
};
let bytes = req.to_bytes();
for i in 0..bytes.len() {
assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+ LoginWithPersonalAccessTokenRequest::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/requests/personal_access_tokens/mod.rs
similarity index 64%
copy from core/binary_protocol/src/frame.rs
copy to core/binary_protocol/src/requests/personal_access_tokens/mod.rs
index b17d0d3f9..8d8a203da 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/requests/personal_access_tokens/mod.rs
@@ -15,12 +15,12 @@
// specific language governing permissions and limitations
// under the License.
-/// Request frame: `[length:4 LE][code:4 LE][payload:N]`
-/// `length` = size of code + payload = 4 + N
-pub const REQUEST_HEADER_SIZE: usize = 4;
+pub mod create_personal_access_token;
+pub mod delete_personal_access_token;
+pub mod get_personal_access_tokens;
+pub mod login_with_personal_access_token;
-/// Response frame: `[status:4 LE][length:4 LE][payload:N]`
-pub const RESPONSE_HEADER_SIZE: usize = 8;
-
-/// Status code for a successful response.
-pub const STATUS_OK: u32 = 0;
+pub use create_personal_access_token::CreatePersonalAccessTokenRequest;
+pub use delete_personal_access_token::DeletePersonalAccessTokenRequest;
+pub use get_personal_access_tokens::GetPersonalAccessTokensRequest;
+pub use login_with_personal_access_token::LoginWithPersonalAccessTokenRequest;
diff --git a/core/binary_protocol/src/requests/segments/delete_segments.rs b/core/binary_protocol/src/requests/segments/delete_segments.rs
new file mode 100644
index 000000000..daa54c963
--- /dev/null
+++ b/core/binary_protocol/src/requests/segments/delete_segments.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.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// `DeleteSegments` request.
+///
+/// Wire format:
+/// `[stream_id:WireIdentifier][topic_id:WireIdentifier][partition_id:u32_le][segments_count:u32_le]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DeleteSegmentsRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub partition_id: u32,
+ pub segments_count: u32,
+}
+
+impl WireEncode for DeleteSegmentsRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + self.topic_id.encoded_size() + 4 + 4
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ buf.put_u32_le(self.partition_id);
+ buf.put_u32_le(self.segments_count);
+ }
+}
+
+impl WireDecode for DeleteSegmentsRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+ pos += consumed;
+ let partition_id = read_u32_le(buf, pos)?;
+ pos += 4;
+ let segments_count = read_u32_le(buf, pos)?;
+ pos += 4;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ partition_id,
+ segments_count,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = DeleteSegmentsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partition_id: 3,
+ segments_count: 10,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteSegmentsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = DeleteSegmentsRequest {
+ stream_id: WireIdentifier::named("stream").unwrap(),
+ topic_id: WireIdentifier::named("topic").unwrap(),
+ partition_id: 1,
+ segments_count: 5,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteSegmentsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = DeleteSegmentsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partition_id: 3,
+ segments_count: 10,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ DeleteSegmentsRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = DeleteSegmentsRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ partition_id: 3,
+ segments_count: 10,
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/requests/segments/mod.rs
similarity index 91%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/requests/segments/mod.rs
index e7b687f1f..20960bc99 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/requests/segments/mod.rs
@@ -15,4 +15,6 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod delete_segments;
+
+pub use delete_segments::DeleteSegmentsRequest;
diff --git a/core/binary_protocol/src/requests/streams/create_stream.rs b/core/binary_protocol/src/requests/streams/create_stream.rs
index fb86fba94..93af61681 100644
--- a/core/binary_protocol/src/requests/streams/create_stream.rs
+++ b/core/binary_protocol/src/requests/streams/create_stream.rs
@@ -17,7 +17,7 @@
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
use bytes::BytesMut;
/// `CreateStream` request. Wire format: `[name_len:1][name:N]`
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/streams/update_stream.rs
index a8085f099..5fa962981 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/streams/update_stream.rs
@@ -18,7 +18,7 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
use bytes::BytesMut;
/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
diff --git a/core/binary_protocol/src/requests/streams/create_stream.rs b/core/binary_protocol/src/requests/system/get_client.rs
similarity index 55%
copy from core/binary_protocol/src/requests/streams/create_stream.rs
copy to core/binary_protocol/src/requests/system/get_client.rs
index fb86fba94..ceeb313eb 100644
--- a/core/binary_protocol/src/requests/streams/create_stream.rs
+++ b/core/binary_protocol/src/requests/system/get_client.rs
@@ -16,30 +16,29 @@
// under the License.
use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
-use bytes::BytesMut;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
-/// `CreateStream` request. Wire format: `[name_len:1][name:N]`
+/// `GetClient` request. Wire format: `[client_id:u32_le]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct CreateStreamRequest {
- pub name: WireName,
+pub struct GetClientRequest {
+ pub client_id: u32,
}
-impl WireEncode for CreateStreamRequest {
+impl WireEncode for GetClientRequest {
fn encoded_size(&self) -> usize {
- self.name.encoded_size()
+ 4
}
fn encode(&self, buf: &mut BytesMut) {
- self.name.encode(buf);
+ buf.put_u32_le(self.client_id);
}
}
-impl WireDecode for CreateStreamRequest {
+impl WireDecode for GetClientRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let (name, consumed) = WireName::decode(buf)?;
- Ok((Self { name }, consumed))
+ let client_id = read_u32_le(buf, 0)?;
+ Ok((Self { client_id }, 4))
}
}
@@ -49,48 +48,49 @@ mod tests {
#[test]
fn roundtrip() {
- let req = CreateStreamRequest {
- name: WireName::new("test-stream").unwrap(),
- };
+ let req = GetClientRequest { client_id: 42 };
let bytes = req.to_bytes();
- assert_eq!(bytes.len(), 1 + 11);
- let (decoded, consumed) = CreateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = GetClientRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
- fn empty_name_rejected() {
- let buf = [0u8];
- assert!(CreateStreamRequest::decode(&buf).is_err());
+ fn roundtrip_zero() {
+ let req = GetClientRequest { client_id: 0 };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetClientRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 4);
+ assert_eq!(decoded, req);
}
#[test]
- fn truncated_returns_error() {
- let req = CreateStreamRequest {
- name: WireName::new("test").unwrap(),
+ fn roundtrip_max() {
+ let req = GetClientRequest {
+ client_id: u32::MAX,
};
let bytes = req.to_bytes();
- for i in 0..bytes.len() {
- assert!(
- CreateStreamRequest::decode(&bytes[..i]).is_err(),
- "expected error for truncation at byte {i}"
- );
- }
+ let (decoded, consumed) = GetClientRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 4);
+ assert_eq!(decoded, req);
}
#[test]
fn wire_compat_byte_layout() {
- let req = CreateStreamRequest {
- name: WireName::new("test").unwrap(),
- };
+ let req = GetClientRequest { client_id: 1 };
let bytes = req.to_bytes();
- assert_eq!(&bytes[..], &[4, b't', b'e', b's', b't']);
+ assert_eq!(&bytes[..], &[1, 0, 0, 0]);
}
#[test]
- fn too_long_name_rejected_at_construction() {
- let long = "a".repeat(256);
- assert!(WireName::new(long).is_err());
+ fn truncated_returns_error() {
+ let req = GetClientRequest { client_id: 1 };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ GetClientRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
}
}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_clients.rs
similarity index 69%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_clients.rs
index 7b062a665..3aeed3a4d 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_clients.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `GetClients` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetClientsRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetClientsRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetClientsRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = GetClientsRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetClientsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_cluster_metadata.rs
similarity index 67%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_cluster_metadata.rs
index 7b062a665..9bd12c113 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_cluster_metadata.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `GetClusterMetadata` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetClusterMetadataRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetClusterMetadataRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetClusterMetadataRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = GetClusterMetadataRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetClusterMetadataRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_me.rs
similarity index 70%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_me.rs
index 7b062a665..7b5f458ef 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_me.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `GetMe` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetMeRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetMeRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetMeRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = GetMeRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetMeRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_snapshot.rs
similarity index 69%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_snapshot.rs
index 7b062a665..4509158ff 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_snapshot.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `GetSnapshot` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetSnapshotRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetSnapshotRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetSnapshotRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = GetSnapshotRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetSnapshotRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/get_stats.rs
similarity index 70%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/get_stats.rs
index 7b062a665..29e98ccfb 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/get_stats.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `GetStats` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetStatsRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetStatsRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetStatsRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = GetStatsRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetStatsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/requests/system/mod.rs
similarity index 66%
copy from core/binary_protocol/src/frame.rs
copy to core/binary_protocol/src/requests/system/mod.rs
index b17d0d3f9..b490d39c9 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/requests/system/mod.rs
@@ -15,12 +15,18 @@
// specific language governing permissions and limitations
// under the License.
-/// Request frame: `[length:4 LE][code:4 LE][payload:N]`
-/// `length` = size of code + payload = 4 + N
-pub const REQUEST_HEADER_SIZE: usize = 4;
+pub mod get_client;
+pub mod get_clients;
+pub mod get_cluster_metadata;
+pub mod get_me;
+pub mod get_snapshot;
+pub mod get_stats;
+pub mod ping;
-/// Response frame: `[status:4 LE][length:4 LE][payload:N]`
-pub const RESPONSE_HEADER_SIZE: usize = 8;
-
-/// Status code for a successful response.
-pub const STATUS_OK: u32 = 0;
+pub use get_client::GetClientRequest;
+pub use get_clients::GetClientsRequest;
+pub use get_cluster_metadata::GetClusterMetadataRequest;
+pub use get_me::GetMeRequest;
+pub use get_snapshot::GetSnapshotRequest;
+pub use get_stats::GetStatsRequest;
+pub use ping::PingRequest;
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/system/ping.rs
similarity index 71%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/system/ping.rs
index 7b062a665..7deb39008 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/system/ping.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `Ping` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct PingRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for PingRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for PingRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = PingRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = PingRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/requests/topics/create_topic.rs b/core/binary_protocol/src/requests/topics/create_topic.rs
new file mode 100644
index 000000000..67cc5de17
--- /dev/null
+++ b/core/binary_protocol/src/requests/topics/create_topic.rs
@@ -0,0 +1,147 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `CreateTopic` request.
+///
+/// Wire format:
+/// `[stream_id:WireIdentifier][partitions_count:u32_le][compression_algorithm:u8]
+/// [message_expiry:u64_le][max_topic_size:u64_le][replication_factor:u8][name_len:u8][name:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreateTopicRequest {
+ pub stream_id: WireIdentifier,
+ pub partitions_count: u32,
+ pub compression_algorithm: u8,
+ pub message_expiry: u64,
+ pub max_topic_size: u64,
+ pub replication_factor: u8,
+ pub name: WireName,
+}
+
+const FIXED_FIELDS_SIZE: usize = 4 + 1 + 8 + 8 + 1; // 22 bytes
+
+impl WireEncode for CreateTopicRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size() + FIXED_FIELDS_SIZE + self.name.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ buf.put_u32_le(self.partitions_count);
+ buf.put_u8(self.compression_algorithm);
+ buf.put_u64_le(self.message_expiry);
+ buf.put_u64_le(self.max_topic_size);
+ buf.put_u8(self.replication_factor);
+ self.name.encode(buf);
+ }
+}
+
+impl WireDecode for CreateTopicRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+ let partitions_count = read_u32_le(buf, pos)?;
+ pos += 4;
+ let compression_algorithm = read_u8(buf, pos)?;
+ pos += 1;
+ let message_expiry = read_u64_le(buf, pos)?;
+ pos += 8;
+ let max_topic_size = read_u64_le(buf, pos)?;
+ pos += 8;
+ let replication_factor = read_u8(buf, pos)?;
+ pos += 1;
+ let (name, consumed) = WireName::decode(&buf[pos..])?;
+ pos += consumed;
+ Ok((
+ Self {
+ stream_id,
+ partitions_count,
+ compression_algorithm,
+ message_expiry,
+ max_topic_size,
+ replication_factor,
+ name,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_request() -> CreateTopicRequest {
+ CreateTopicRequest {
+ stream_id: WireIdentifier::numeric(1),
+ partitions_count: 3,
+ compression_algorithm: 1,
+ message_expiry: 3600,
+ max_topic_size: 1_000_000,
+ replication_factor: 1,
+ name: WireName::new("orders").unwrap(),
+ }
+ }
+
+ #[test]
+ fn roundtrip() {
+ let req = sample_request();
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreateTopicRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_stream() {
+ let req = CreateTopicRequest {
+ stream_id: WireIdentifier::named("my-stream").unwrap(),
+ partitions_count: 10,
+ compression_algorithm: 2,
+ message_expiry: 0,
+ max_topic_size: u64::MAX,
+ replication_factor: 3,
+ name: WireName::new("events").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreateTopicRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = sample_request();
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ CreateTopicRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = sample_request();
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/delete_topic.rs
similarity index 57%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/delete_topic.rs
index a8085f099..d20104301 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/delete_topic.rs
@@ -18,33 +18,38 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `DeleteTopic` request. Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct DeleteTopicRequest {
pub stream_id: WireIdentifier,
- pub name: WireName,
+ pub topic_id: WireIdentifier,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for DeleteTopicRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.stream_id.encoded_size() + self.topic_id.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.topic_id.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for DeleteTopicRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ },
+ pos,
+ ))
}
}
@@ -54,26 +59,38 @@ mod tests {
#[test]
fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ let req = DeleteTopicRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(5),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = DeleteTopicRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = DeleteTopicRequest {
+ stream_id: WireIdentifier::named("stream").unwrap(),
+ topic_id: WireIdentifier::named("topic").unwrap(),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = DeleteTopicRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
fn truncated_returns_error() {
- let req = UpdateStreamRequest {
+ let req = DeleteTopicRequest {
stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ topic_id: WireIdentifier::numeric(2),
};
let bytes = req.to_bytes();
for i in 0..bytes.len() {
assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+ DeleteTopicRequest::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/get_topic.rs
similarity index 56%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/get_topic.rs
index a8085f099..35cd31ada 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/get_topic.rs
@@ -18,33 +18,38 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetTopic` request. Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct GetTopicRequest {
pub stream_id: WireIdentifier,
- pub name: WireName,
+ pub topic_id: WireIdentifier,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetTopicRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.stream_id.encoded_size() + self.topic_id.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.topic_id.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetTopicRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ },
+ pos,
+ ))
}
}
@@ -53,27 +58,39 @@ mod tests {
use super::*;
#[test]
- fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ fn roundtrip_numeric() {
+ let req = GetTopicRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = GetTopicRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named() {
+ let req = GetTopicRequest {
+ stream_id: WireIdentifier::named("my-stream").unwrap(),
+ topic_id: WireIdentifier::named("my-topic").unwrap(),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = GetTopicRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
fn truncated_returns_error() {
- let req = UpdateStreamRequest {
+ let req = GetTopicRequest {
stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ topic_id: WireIdentifier::numeric(2),
};
let bytes = req.to_bytes();
for i in 0..bytes.len() {
assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+ GetTopicRequest::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/get_topics.rs
similarity index 55%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/get_topics.rs
index a8085f099..1c52fd326 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/get_topics.rs
@@ -18,33 +18,28 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetTopics` request. Wire format: `[stream_id:WireIdentifier]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct GetTopicsRequest {
pub stream_id: WireIdentifier,
- pub name: WireName,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetTopicsRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.stream_id.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
self.stream_id.encode(buf);
- self.name.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetTopicsRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
- pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ let (stream_id, consumed) = WireIdentifier::decode(buf)?;
+ Ok((Self { stream_id }, consumed))
}
}
@@ -53,29 +48,24 @@ mod tests {
use super::*;
#[test]
- fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ fn roundtrip_numeric() {
+ let req = GetTopicsRequest {
+ stream_id: WireIdentifier::numeric(42),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = GetTopicsRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
- fn truncated_returns_error() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ fn roundtrip_named() {
+ let req = GetTopicsRequest {
+ stream_id: WireIdentifier::named("my-stream").unwrap(),
};
let bytes = req.to_bytes();
- for i in 0..bytes.len() {
- assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
- "expected error for truncation at byte {i}"
- );
- }
+ let (decoded, consumed) = GetTopicsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
}
}
diff --git a/core/binary_protocol/src/frame.rs b/core/binary_protocol/src/requests/topics/mod.rs
similarity index 68%
copy from core/binary_protocol/src/frame.rs
copy to core/binary_protocol/src/requests/topics/mod.rs
index b17d0d3f9..680105ae3 100644
--- a/core/binary_protocol/src/frame.rs
+++ b/core/binary_protocol/src/requests/topics/mod.rs
@@ -15,12 +15,16 @@
// specific language governing permissions and limitations
// under the License.
-/// Request frame: `[length:4 LE][code:4 LE][payload:N]`
-/// `length` = size of code + payload = 4 + N
-pub const REQUEST_HEADER_SIZE: usize = 4;
+pub mod create_topic;
+pub mod delete_topic;
+pub mod get_topic;
+pub mod get_topics;
+pub mod purge_topic;
+pub mod update_topic;
-/// Response frame: `[status:4 LE][length:4 LE][payload:N]`
-pub const RESPONSE_HEADER_SIZE: usize = 8;
-
-/// Status code for a successful response.
-pub const STATUS_OK: u32 = 0;
+pub use create_topic::CreateTopicRequest;
+pub use delete_topic::DeleteTopicRequest;
+pub use get_topic::GetTopicRequest;
+pub use get_topics::GetTopicsRequest;
+pub use purge_topic::PurgeTopicRequest;
+pub use update_topic::UpdateTopicRequest;
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/topics/purge_topic.rs
similarity index 66%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/topics/purge_topic.rs
index a8085f099..7a7bc4d03 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/topics/purge_topic.rs
@@ -18,33 +18,38 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `PurgeTopic` request. Wire format: `[stream_id:WireIdentifier][topic_id:WireIdentifier]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
+pub struct PurgeTopicRequest {
pub stream_id: WireIdentifier,
- pub name: WireName,
+ pub topic_id: WireIdentifier,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for PurgeTopicRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.stream_id.encoded_size() + self.topic_id.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.topic_id.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for PurgeTopicRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ },
+ pos,
+ ))
}
}
@@ -54,26 +59,26 @@ mod tests {
#[test]
fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ let req = PurgeTopicRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(3),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = PurgeTopicRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
fn truncated_returns_error() {
- let req = UpdateStreamRequest {
+ let req = PurgeTopicRequest {
stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ topic_id: WireIdentifier::numeric(2),
};
let bytes = req.to_bytes();
for i in 0..bytes.len() {
assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+ PurgeTopicRequest::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
diff --git a/core/binary_protocol/src/requests/topics/update_topic.rs b/core/binary_protocol/src/requests/topics/update_topic.rs
new file mode 100644
index 000000000..f7151242b
--- /dev/null
+++ b/core/binary_protocol/src/requests/topics/update_topic.rs
@@ -0,0 +1,150 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `UpdateTopic` request.
+///
+/// Wire format:
+/// `[stream_id:WireIdentifier][topic_id:WireIdentifier][compression_algorithm:u8]
+/// [message_expiry:u64_le][max_topic_size:u64_le][replication_factor:u8][name_len:u8][name:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateTopicRequest {
+ pub stream_id: WireIdentifier,
+ pub topic_id: WireIdentifier,
+ pub compression_algorithm: u8,
+ pub message_expiry: u64,
+ pub max_topic_size: u64,
+ pub replication_factor: u8,
+ pub name: WireName,
+}
+
+const FIXED_FIELDS_SIZE: usize = 1 + 8 + 8 + 1; // 18 bytes
+
+impl WireEncode for UpdateTopicRequest {
+ fn encoded_size(&self) -> usize {
+ self.stream_id.encoded_size()
+ + self.topic_id.encoded_size()
+ + FIXED_FIELDS_SIZE
+ + self.name.encoded_size()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.stream_id.encode(buf);
+ self.topic_id.encode(buf);
+ buf.put_u8(self.compression_algorithm);
+ buf.put_u64_le(self.message_expiry);
+ buf.put_u64_le(self.max_topic_size);
+ buf.put_u8(self.replication_factor);
+ self.name.encode(buf);
+ }
+}
+
+impl WireDecode for UpdateTopicRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
+ let (topic_id, consumed) = WireIdentifier::decode(&buf[pos..])?;
+ pos += consumed;
+ let compression_algorithm = read_u8(buf, pos)?;
+ pos += 1;
+ let message_expiry = read_u64_le(buf, pos)?;
+ pos += 8;
+ let max_topic_size = read_u64_le(buf, pos)?;
+ pos += 8;
+ let replication_factor = read_u8(buf, pos)?;
+ pos += 1;
+ let (name, name_consumed) = WireName::decode(&buf[pos..])?;
+ pos += name_consumed;
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ compression_algorithm,
+ message_expiry,
+ max_topic_size,
+ replication_factor,
+ name,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_request() -> UpdateTopicRequest {
+ UpdateTopicRequest {
+ stream_id: WireIdentifier::numeric(1),
+ topic_id: WireIdentifier::numeric(2),
+ compression_algorithm: 1,
+ message_expiry: 7200,
+ max_topic_size: 500_000,
+ replication_factor: 2,
+ name: WireName::new("updated-topic").unwrap(),
+ }
+ }
+
+ #[test]
+ fn roundtrip() {
+ let req = sample_request();
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdateTopicRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_identifiers() {
+ let req = UpdateTopicRequest {
+ stream_id: WireIdentifier::named("stream-a").unwrap(),
+ topic_id: WireIdentifier::named("topic-b").unwrap(),
+ compression_algorithm: 0,
+ message_expiry: 0,
+ max_topic_size: u64::MAX,
+ replication_factor: 1,
+ name: WireName::new("new-name").unwrap(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdateTopicRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = sample_request();
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ UpdateTopicRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = sample_request();
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+}
diff --git a/core/binary_protocol/src/requests/users/change_password.rs b/core/binary_protocol/src/requests/users/change_password.rs
new file mode 100644
index 000000000..ef0a26179
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/change_password.rs
@@ -0,0 +1,131 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8};
+use bytes::{BufMut, BytesMut};
+
+/// `ChangePassword` request.
+///
+/// Wire format:
+/// `[user_id:WireIdentifier][current_password_len:u8][current_password:N]
+/// [new_password_len:u8][new_password:N]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ChangePasswordRequest {
+ pub user_id: WireIdentifier,
+ pub current_password: String,
+ pub new_password: String,
+}
+
+impl WireEncode for ChangePasswordRequest {
+ fn encoded_size(&self) -> usize {
+ self.user_id.encoded_size() + 1 + self.current_password.len() + 1 + self.new_password.len()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.user_id.encode(buf);
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u8(self.current_password.len() as u8);
+ buf.put_slice(self.current_password.as_bytes());
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u8(self.new_password.len() as u8);
+ buf.put_slice(self.new_password.as_bytes());
+ }
+}
+
+impl WireDecode for ChangePasswordRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (user_id, mut pos) = WireIdentifier::decode(buf)?;
+
+ let current_len = read_u8(buf, pos)? as usize;
+ pos += 1;
+ let current_password = read_str(buf, pos, current_len)?;
+ pos += current_len;
+
+ let new_len = read_u8(buf, pos)? as usize;
+ pos += 1;
+ let new_password = read_str(buf, pos, new_len)?;
+ pos += new_len;
+
+ Ok((
+ Self {
+ user_id,
+ current_password,
+ new_password,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = ChangePasswordRequest {
+ user_id: WireIdentifier::numeric(1),
+ current_password: "old-pass".to_string(),
+ new_password: "new-pass-123".to_string(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = ChangePasswordRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_named_user() {
+ let req = ChangePasswordRequest {
+ user_id: WireIdentifier::named("admin").unwrap(),
+ current_password: "root".to_string(),
+ new_password: "s3cure!".to_string(),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = ChangePasswordRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = ChangePasswordRequest {
+ user_id: WireIdentifier::numeric(42),
+ current_password: "abc".to_string(),
+ new_password: "xyz123".to_string(),
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = ChangePasswordRequest {
+ user_id: WireIdentifier::numeric(1),
+ current_password: "old".to_string(),
+ new_password: "new".to_string(),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ ChangePasswordRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/users/create_user.rs b/core/binary_protocol/src/requests/users/create_user.rs
new file mode 100644
index 000000000..4348426e9
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/create_user.rs
@@ -0,0 +1,203 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8, read_u32_le};
+use crate::primitives::identifier::WireName;
+use crate::primitives::permissions::WirePermissions;
+use bytes::{BufMut, BytesMut};
+
+/// `CreateUser` request.
+///
+/// Wire format:
+/// `[username_len:u8][username:N][password_len:u8][password:N][status:u8]
+/// [has_permissions:u8][permissions_len:u32_le?][permissions:M?]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CreateUserRequest {
+ pub username: WireName,
+ pub password: String,
+ pub status: u8,
+ pub permissions: Option<WirePermissions>,
+}
+
+impl WireEncode for CreateUserRequest {
+ fn encoded_size(&self) -> usize {
+ self.username.encoded_size()
+ + 1
+ + self.password.len()
+ + 1 // status
+ + 1 // has_permissions
+ + 4 // permissions_len (always present)
+ + self.permissions.as_ref().map_or(0, WireEncode::encoded_size)
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.username.encode(buf);
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u8(self.password.len() as u8);
+ buf.put_slice(self.password.as_bytes());
+ buf.put_u8(self.status);
+ if let Some(perms) = &self.permissions {
+ buf.put_u8(1);
+ let perm_bytes = perms.to_bytes();
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(perm_bytes.len() as u32);
+ buf.put_slice(&perm_bytes);
+ } else {
+ buf.put_u8(0);
+ buf.put_u32_le(0);
+ }
+ }
+}
+
+impl WireDecode for CreateUserRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (username, mut pos) = WireName::decode(buf)?;
+
+ let password_len = read_u8(buf, pos)? as usize;
+ pos += 1;
+ let password = read_str(buf, pos, password_len)?;
+ pos += password_len;
+
+ let status = read_u8(buf, pos)?;
+ pos += 1;
+
+ let has_permissions = read_u8(buf, pos)?;
+ pos += 1;
+
+ let perm_len = read_u32_le(buf, pos)? as usize;
+ pos += 4;
+
+ let permissions = if has_permissions == 1 && perm_len > 0 {
+ let (perms, consumed) = WirePermissions::decode(&buf[pos..])?;
+ if consumed != perm_len {
+ return Err(WireError::Validation(format!(
+ "permissions length mismatch: header says {perm_len}, decoded {consumed}"
+ )));
+ }
+ pos += consumed;
+ Some(perms)
+ } else {
+ None
+ };
+
+ Ok((
+ Self {
+ username,
+ password,
+ status,
+ permissions,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::primitives::permissions::{WireGlobalPermissions, WirePermissions};
+
+ fn sample_permissions() -> WirePermissions {
+ WirePermissions {
+ global: WireGlobalPermissions {
+ manage_servers: true,
+ read_servers: true,
+ manage_users: false,
+ read_users: true,
+ manage_streams: false,
+ read_streams: true,
+ manage_topics: false,
+ read_topics: true,
+ poll_messages: true,
+ send_messages: true,
+ },
+ streams: vec![],
+ }
+ }
+
+ #[test]
+ fn roundtrip_without_permissions() {
+ let req = CreateUserRequest {
+ username: WireName::new("testuser").unwrap(),
+ password: "secret123".to_string(),
+ status: 1,
+ permissions: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreateUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_with_permissions() {
+ let req = CreateUserRequest {
+ username: WireName::new("admin").unwrap(),
+ password: "p@ssw0rd".to_string(),
+ status: 2,
+ permissions: Some(sample_permissions()),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = CreateUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = CreateUserRequest {
+ username: WireName::new("user").unwrap(),
+ password: "pw".to_string(),
+ status: 0,
+ permissions: Some(sample_permissions()),
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = CreateUserRequest {
+ username: WireName::new("user").unwrap(),
+ password: "pass".to_string(),
+ status: 1,
+ permissions: None,
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ CreateUserRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn none_permissions_wire_layout() {
+ let req = CreateUserRequest {
+ username: WireName::new("u").unwrap(),
+ password: "p".to_string(),
+ status: 0,
+ permissions: None,
+ };
+ let bytes = req.to_bytes();
+ // username: [1, b'u'] + password: [1, b'p'] + status: [0]
+ // + has_perm: [0] + perm_len: [0,0,0,0]
+ let expected: &[u8] = &[1, b'u', 1, b'p', 0, 0, 0, 0, 0, 0];
+ assert_eq!(&bytes[..], expected);
+ }
+}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/users/delete_user.rs
similarity index 52%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/users/delete_user.rs
index a8085f099..05a1474c6 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/users/delete_user.rs
@@ -18,33 +18,28 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `DeleteUser` request. Wire format: `[user_id:WireIdentifier]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
- pub stream_id: WireIdentifier,
- pub name: WireName,
+pub struct DeleteUserRequest {
+ pub user_id: WireIdentifier,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for DeleteUserRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.user_id.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
- self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.user_id.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for DeleteUserRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
- pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ let (user_id, consumed) = WireIdentifier::decode(buf)?;
+ Ok((Self { user_id }, consumed))
}
}
@@ -53,29 +48,24 @@ mod tests {
use super::*;
#[test]
- fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ fn roundtrip_numeric() {
+ let req = DeleteUserRequest {
+ user_id: WireIdentifier::numeric(5),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = DeleteUserRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
- fn truncated_returns_error() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ fn roundtrip_named() {
+ let req = DeleteUserRequest {
+ user_id: WireIdentifier::named("old-user").unwrap(),
};
let bytes = req.to_bytes();
- for i in 0..bytes.len() {
- assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
- "expected error for truncation at byte {i}"
- );
- }
+ let (decoded, consumed) = DeleteUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
}
}
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/requests/users/get_user.rs
similarity index 52%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/requests/users/get_user.rs
index a8085f099..74a4f4fd5 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/requests/users/get_user.rs
@@ -18,33 +18,28 @@
use crate::WireError;
use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// `GetUser` request. Wire format: `[user_id:WireIdentifier]`
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
- pub stream_id: WireIdentifier,
- pub name: WireName,
+pub struct GetUserRequest {
+ pub user_id: WireIdentifier,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for GetUserRequest {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.user_id.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
- self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.user_id.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for GetUserRequest {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
- pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ let (user_id, consumed) = WireIdentifier::decode(buf)?;
+ Ok((Self { user_id }, consumed))
}
}
@@ -53,29 +48,33 @@ mod tests {
use super::*;
#[test]
- fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ fn roundtrip_numeric() {
+ let req = GetUserRequest {
+ user_id: WireIdentifier::numeric(42),
};
let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let (decoded, consumed) = GetUserRequest::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, req);
}
#[test]
- fn truncated_returns_error() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ fn roundtrip_named() {
+ let req = GetUserRequest {
+ user_id: WireIdentifier::named("admin").unwrap(),
};
let bytes = req.to_bytes();
- for i in 0..bytes.len() {
- assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
- "expected error for truncation at byte {i}"
- );
- }
+ let (decoded, consumed) = GetUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn wire_compat_numeric_byte_layout() {
+ let req = GetUserRequest {
+ user_id: WireIdentifier::numeric(1),
+ };
+ let bytes = req.to_bytes();
+ assert_eq!(&bytes[..], &[1, 4, 1, 0, 0, 0]);
}
}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/users/get_users.rs
similarity index 70%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/users/get_users.rs
index 7b062a665..edc3f8672 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/users/get_users.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `GetUsers` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct GetUsersRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for GetUsersRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for GetUsersRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = GetUsersRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetUsersRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/requests/users/login_user.rs b/core/binary_protocol/src/requests/users/login_user.rs
new file mode 100644
index 000000000..ef12a9dc1
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/login_user.rs
@@ -0,0 +1,220 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8, read_u32_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `LoginUser` request.
+///
+/// Wire format:
+/// `[username_len:u8][username:N][password_len:u8][password:N]
+/// [version_len:u32_le][version:N?][context_len:u32_le][context:N?]`
+///
+/// Both `version_len` and `context_len` are always present on the wire.
+/// A length of 0 means the field is absent.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LoginUserRequest {
+ pub username: WireName,
+ pub password: String,
+ pub version: Option<String>,
+ pub context: Option<String>,
+}
+
+impl WireEncode for LoginUserRequest {
+ fn encoded_size(&self) -> usize {
+ self.username.encoded_size()
+ + 1
+ + self.password.len()
+ + 4
+ + self.version.as_ref().map_or(0, String::len)
+ + 4
+ + self.context.as_ref().map_or(0, String::len)
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.username.encode(buf);
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u8(self.password.len() as u8);
+ buf.put_slice(self.password.as_bytes());
+ match &self.version {
+ Some(v) => {
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(v.len() as u32);
+ buf.put_slice(v.as_bytes());
+ }
+ None => {
+ buf.put_u32_le(0);
+ }
+ }
+ match &self.context {
+ Some(c) => {
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(c.len() as u32);
+ buf.put_slice(c.as_bytes());
+ }
+ None => {
+ buf.put_u32_le(0);
+ }
+ }
+ }
+}
+
+impl WireDecode for LoginUserRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (username, mut pos) = WireName::decode(buf)?;
+
+ let password_len = read_u8(buf, pos)? as usize;
+ pos += 1;
+ let password = read_str(buf, pos, password_len)?;
+ pos += password_len;
+
+ let version_len = read_u32_le(buf, pos)? as usize;
+ pos += 4;
+ let version = if version_len > 0 {
+ let v = read_str(buf, pos, version_len)?;
+ pos += version_len;
+ Some(v)
+ } else {
+ None
+ };
+
+ let context_len = read_u32_le(buf, pos)? as usize;
+ pos += 4;
+ let context = if context_len > 0 {
+ let c = read_str(buf, pos, context_len)?;
+ pos += context_len;
+ Some(c)
+ } else {
+ None
+ };
+
+ Ok((
+ Self {
+ username,
+ password,
+ version,
+ context,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_full() {
+ let req = LoginUserRequest {
+ username: WireName::new("admin").unwrap(),
+ password: "secret".to_string(),
+ version: Some("1.0.0".to_string()),
+ context: Some("rust-sdk".to_string()),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_no_version_no_context() {
+ let req = LoginUserRequest {
+ username: WireName::new("user").unwrap(),
+ password: "pass".to_string(),
+ version: None,
+ context: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_version_only() {
+ let req = LoginUserRequest {
+ username: WireName::new("user").unwrap(),
+ password: "pw".to_string(),
+ version: Some("2.3.4".to_string()),
+ context: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_context_only() {
+ let req = LoginUserRequest {
+ username: WireName::new("user").unwrap(),
+ password: "pw".to_string(),
+ version: None,
+ context: Some("test-ctx".to_string()),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = LoginUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = LoginUserRequest {
+ username: WireName::new("admin").unwrap(),
+ password: "p".to_string(),
+ version: Some("v1".to_string()),
+ context: Some("ctx".to_string()),
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = LoginUserRequest {
+ username: WireName::new("u").unwrap(),
+ password: "p".to_string(),
+ version: Some("v".to_string()),
+ context: Some("c".to_string()),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ LoginUserRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn wire_layout_no_optionals() {
+ let req = LoginUserRequest {
+ username: WireName::new("u").unwrap(),
+ password: "p".to_string(),
+ version: None,
+ context: None,
+ };
+ let bytes = req.to_bytes();
+ // username: [1, b'u'] + password: [1, b'p'] + version_len: [0,0,0,0] + context_len: [0,0,0,0]
+ let expected: &[u8] = &[1, b'u', 1, b'p', 0, 0, 0, 0, 0, 0, 0, 0];
+ assert_eq!(&bytes[..], expected);
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/users/logout_user.rs
similarity index 69%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/users/logout_user.rs
index 7b062a665..54000aa65 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/users/logout_user.rs
@@ -15,17 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
-
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
use bytes::BytesMut;
-/// Marker type for commands that return an empty response payload.
+/// `LogoutUser` request. Wire format: empty.
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
+pub struct LogoutUserRequest;
-impl WireEncode for EmptyResponse {
+impl WireEncode for LogoutUserRequest {
fn encoded_size(&self) -> usize {
0
}
@@ -33,8 +31,23 @@ impl WireEncode for EmptyResponse {
fn encode(&self, _buf: &mut BytesMut) {}
}
-impl WireDecode for EmptyResponse {
+impl WireDecode for LogoutUserRequest {
fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
Ok((Self, 0))
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip() {
+ let req = LogoutUserRequest;
+ let bytes = req.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = LogoutUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, req);
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/requests/users/mod.rs
similarity index 58%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/requests/users/mod.rs
index 7b062a665..d83634415 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/requests/users/mod.rs
@@ -15,26 +15,22 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod change_password;
+pub mod create_user;
+pub mod delete_user;
+pub mod get_user;
+pub mod get_users;
+pub mod login_user;
+pub mod logout_user;
+pub mod update_permissions;
+pub mod update_user;
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
- fn encoded_size(&self) -> usize {
- 0
- }
-
- fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
- fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
- Ok((Self, 0))
- }
-}
+pub use change_password::ChangePasswordRequest;
+pub use create_user::CreateUserRequest;
+pub use delete_user::DeleteUserRequest;
+pub use get_user::GetUserRequest;
+pub use get_users::GetUsersRequest;
+pub use login_user::LoginUserRequest;
+pub use logout_user::LogoutUserRequest;
+pub use update_permissions::UpdatePermissionsRequest;
+pub use update_user::UpdateUserRequest;
diff --git a/core/binary_protocol/src/requests/users/update_permissions.rs b/core/binary_protocol/src/requests/users/update_permissions.rs
new file mode 100644
index 000000000..6b5bcbbc4
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/update_permissions.rs
@@ -0,0 +1,160 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le};
+use crate::primitives::permissions::WirePermissions;
+use bytes::{BufMut, BytesMut};
+
+/// `UpdatePermissions` request.
+///
+/// Wire format:
+/// `[user_id:WireIdentifier][has_permissions:u8][permissions_len:u32_le?][permissions:M?]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdatePermissionsRequest {
+ pub user_id: WireIdentifier,
+ pub permissions: Option<WirePermissions>,
+}
+
+impl WireEncode for UpdatePermissionsRequest {
+ fn encoded_size(&self) -> usize {
+ self.user_id.encoded_size()
+ + 1 // has_permissions
+ + 4 // permissions_len (always present)
+ + self.permissions.as_ref().map_or(0, WireEncode::encoded_size)
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.user_id.encode(buf);
+ if let Some(perms) = &self.permissions {
+ buf.put_u8(1);
+ let perm_bytes = perms.to_bytes();
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(perm_bytes.len() as u32);
+ buf.put_slice(&perm_bytes);
+ } else {
+ buf.put_u8(0);
+ buf.put_u32_le(0);
+ }
+ }
+}
+
+impl WireDecode for UpdatePermissionsRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (user_id, mut pos) = WireIdentifier::decode(buf)?;
+
+ let has_permissions = read_u8(buf, pos)?;
+ pos += 1;
+
+ let perm_len = read_u32_le(buf, pos)? as usize;
+ pos += 4;
+
+ let permissions = if has_permissions == 1 && perm_len > 0 {
+ let (perms, consumed) = WirePermissions::decode(&buf[pos..])?;
+ if consumed != perm_len {
+ return Err(WireError::Validation(format!(
+ "permissions length mismatch: header says {perm_len}, decoded {consumed}"
+ )));
+ }
+ pos += consumed;
+ Some(perms)
+ } else {
+ None
+ };
+
+ Ok((
+ Self {
+ user_id,
+ permissions,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::primitives::permissions::{WireGlobalPermissions, WirePermissions};
+
+ fn sample_permissions() -> WirePermissions {
+ WirePermissions {
+ global: WireGlobalPermissions {
+ manage_servers: true,
+ read_servers: true,
+ manage_users: true,
+ read_users: true,
+ manage_streams: false,
+ read_streams: false,
+ manage_topics: false,
+ read_topics: false,
+ poll_messages: true,
+ send_messages: true,
+ },
+ streams: vec![],
+ }
+ }
+
+ #[test]
+ fn roundtrip_with_permissions() {
+ let req = UpdatePermissionsRequest {
+ user_id: WireIdentifier::numeric(1),
+ permissions: Some(sample_permissions()),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdatePermissionsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_without_permissions() {
+ let req = UpdatePermissionsRequest {
+ user_id: WireIdentifier::named("admin").unwrap(),
+ permissions: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdatePermissionsRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = UpdatePermissionsRequest {
+ user_id: WireIdentifier::numeric(42),
+ permissions: Some(sample_permissions()),
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = UpdatePermissionsRequest {
+ user_id: WireIdentifier::numeric(1),
+ permissions: Some(sample_permissions()),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ UpdatePermissionsRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/users/update_user.rs b/core/binary_protocol/src/requests/users/update_user.rs
new file mode 100644
index 000000000..4e2d70aa1
--- /dev/null
+++ b/core/binary_protocol/src/requests/users/update_user.rs
@@ -0,0 +1,184 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::WireIdentifier;
+use crate::codec::{WireDecode, WireEncode, read_u8};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// `UpdateUser` request.
+///
+/// Wire format:
+/// `[user_id:WireIdentifier][has_username:u8][username_len:u8?][username:N?]
+/// [has_status:u8][status:u8?]`
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UpdateUserRequest {
+ pub user_id: WireIdentifier,
+ pub username: Option<WireName>,
+ pub status: Option<u8>,
+}
+
+impl WireEncode for UpdateUserRequest {
+ fn encoded_size(&self) -> usize {
+ self.user_id.encoded_size()
+ + 1 // has_username
+ + self.username.as_ref().map_or(0, WireEncode::encoded_size)
+ + 1 // has_status
+ + self.status.map_or(0, |_| 1)
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.user_id.encode(buf);
+ match &self.username {
+ Some(name) => {
+ buf.put_u8(1);
+ name.encode(buf);
+ }
+ None => {
+ buf.put_u8(0);
+ }
+ }
+ match self.status {
+ Some(s) => {
+ buf.put_u8(1);
+ buf.put_u8(s);
+ }
+ None => {
+ buf.put_u8(0);
+ }
+ }
+ }
+}
+
+impl WireDecode for UpdateUserRequest {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (user_id, mut pos) = WireIdentifier::decode(buf)?;
+
+ let has_username = read_u8(buf, pos)?;
+ pos += 1;
+ let username = if has_username == 1 {
+ let (name, consumed) = WireName::decode(&buf[pos..])?;
+ pos += consumed;
+ Some(name)
+ } else {
+ None
+ };
+
+ let has_status = read_u8(buf, pos)?;
+ pos += 1;
+ let status = if has_status == 1 {
+ let s = read_u8(buf, pos)?;
+ pos += 1;
+ Some(s)
+ } else {
+ None
+ };
+
+ Ok((
+ Self {
+ user_id,
+ username,
+ status,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_both_present() {
+ let req = UpdateUserRequest {
+ user_id: WireIdentifier::numeric(1),
+ username: Some(WireName::new("new-name").unwrap()),
+ status: Some(2),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_both_none() {
+ let req = UpdateUserRequest {
+ user_id: WireIdentifier::numeric(5),
+ username: None,
+ status: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_username_only() {
+ let req = UpdateUserRequest {
+ user_id: WireIdentifier::named("admin").unwrap(),
+ username: Some(WireName::new("super-admin").unwrap()),
+ status: None,
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn roundtrip_status_only() {
+ let req = UpdateUserRequest {
+ user_id: WireIdentifier::numeric(10),
+ username: None,
+ status: Some(0),
+ };
+ let bytes = req.to_bytes();
+ let (decoded, consumed) = UpdateUserRequest::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, req);
+ }
+
+ #[test]
+ fn encoded_size_matches_output() {
+ let req = UpdateUserRequest {
+ user_id: WireIdentifier::numeric(1),
+ username: Some(WireName::new("test").unwrap()),
+ status: Some(1),
+ };
+ assert_eq!(req.encoded_size(), req.to_bytes().len());
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let req = UpdateUserRequest {
+ user_id: WireIdentifier::numeric(1),
+ username: Some(WireName::new("name").unwrap()),
+ status: Some(1),
+ };
+ let bytes = req.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ UpdateUserRequest::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/responses/clients/client_response.rs b/core/binary_protocol/src/responses/clients/client_response.rs
new file mode 100644
index 000000000..73e871402
--- /dev/null
+++ b/core/binary_protocol/src/responses/clients/client_response.rs
@@ -0,0 +1,211 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_str, read_u8, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// Consumer group membership entry.
+///
+/// Wire format (12 bytes):
+/// ```text
+/// [stream_id:4][topic_id:4][group_id:4]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerGroupInfoResponse {
+ pub stream_id: u32,
+ pub topic_id: u32,
+ pub group_id: u32,
+}
+
+impl ConsumerGroupInfoResponse {
+ const SIZE: usize = 12;
+}
+
+impl WireEncode for ConsumerGroupInfoResponse {
+ fn encoded_size(&self) -> usize {
+ Self::SIZE
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.stream_id);
+ buf.put_u32_le(self.topic_id);
+ buf.put_u32_le(self.group_id);
+ }
+}
+
+impl WireDecode for ConsumerGroupInfoResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let stream_id = read_u32_le(buf, 0)?;
+ let topic_id = read_u32_le(buf, 4)?;
+ let group_id = read_u32_le(buf, 8)?;
+
+ Ok((
+ Self {
+ stream_id,
+ topic_id,
+ group_id,
+ },
+ Self::SIZE,
+ ))
+ }
+}
+
+/// Client header on the wire. Used in both single-client and multi-client responses.
+///
+/// Wire format:
+/// ```text
+/// [client_id:4][user_id:4][transport:1][address_len:4][address:N][consumer_groups_count:4]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ClientResponse {
+ pub client_id: u32,
+ pub user_id: u32,
+ pub transport: u8,
+ pub address: String,
+ pub consumer_groups_count: u32,
+}
+
+impl ClientResponse {
+ const FIXED_SIZE: usize = 4 + 4 + 1 + 4 + 4; // 17
+}
+
+impl WireEncode for ClientResponse {
+ fn encoded_size(&self) -> usize {
+ Self::FIXED_SIZE + self.address.len()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.client_id);
+ buf.put_u32_le(self.user_id);
+ buf.put_u8(self.transport);
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(self.address.len() as u32);
+ buf.put_slice(self.address.as_bytes());
+ buf.put_u32_le(self.consumer_groups_count);
+ }
+}
+
+impl WireDecode for ClientResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let client_id = read_u32_le(buf, 0)?;
+ let user_id = read_u32_le(buf, 4)?;
+ let transport = read_u8(buf, 8)?;
+ let address_len = read_u32_le(buf, 9)? as usize;
+ let address = read_str(buf, 13, address_len)?;
+ let consumer_groups_count = read_u32_le(buf, 13 + address_len)?;
+ let consumed = 17 + address_len;
+
+ Ok((
+ Self {
+ client_id,
+ user_id,
+ transport,
+ address,
+ consumer_groups_count,
+ },
+ consumed,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_client() -> ClientResponse {
+ ClientResponse {
+ client_id: 1,
+ user_id: 10,
+ transport: 1,
+ address: "127.0.0.1:8080".to_string(),
+ consumer_groups_count: 2,
+ }
+ }
+
+ #[test]
+ fn consumer_group_info_roundtrip() {
+ let info = ConsumerGroupInfoResponse {
+ stream_id: 1,
+ topic_id: 2,
+ group_id: 3,
+ };
+ let bytes = info.to_bytes();
+ assert_eq!(bytes.len(), 12);
+ let (decoded, consumed) = ConsumerGroupInfoResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, 12);
+ assert_eq!(decoded, info);
+ }
+
+ #[test]
+ fn client_roundtrip() {
+ let client = sample_client();
+ let bytes = client.to_bytes();
+ assert_eq!(bytes.len(), ClientResponse::FIXED_SIZE + 14);
+ let (decoded, consumed) = ClientResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, client);
+ }
+
+ #[test]
+ fn client_truncated_returns_error() {
+ let client = sample_client();
+ let bytes = client.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ ClientResponse::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn client_empty_address() {
+ let client = ClientResponse {
+ address: String::new(),
+ ..sample_client()
+ };
+ let bytes = client.to_bytes();
+ let (decoded, consumed) = ClientResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, client);
+ }
+
+ #[test]
+ fn multiple_clients_sequential() {
+ let c1 = ClientResponse {
+ client_id: 1,
+ address: "a".to_string(),
+ ..sample_client()
+ };
+ let c2 = ClientResponse {
+ client_id: 2,
+ address: "bb".to_string(),
+ ..sample_client()
+ };
+ let mut buf = BytesMut::new();
+ c1.encode(&mut buf);
+ c2.encode(&mut buf);
+ let bytes = buf.freeze();
+
+ let (d1, pos1) = ClientResponse::decode(&bytes).unwrap();
+ let (d2, pos2) = ClientResponse::decode(&bytes[pos1..]).unwrap();
+ assert_eq!(d1, c1);
+ assert_eq!(d2, c2);
+ assert_eq!(pos1 + pos2, bytes.len());
+ }
+}
diff --git a/core/binary_protocol/src/responses/clients/get_client.rs b/core/binary_protocol/src/responses/clients/get_client.rs
new file mode 100644
index 000000000..f2fe4c2f0
--- /dev/null
+++ b/core/binary_protocol/src/responses/clients/get_client.rs
@@ -0,0 +1,143 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode};
+use crate::responses::clients::client_response::{ClientResponse, ConsumerGroupInfoResponse};
+use bytes::BytesMut;
+
+/// `GetClient` response: client header followed by consumer group entries.
+///
+/// Wire format:
+/// ```text
+/// [ClientResponse][ConsumerGroupInfoResponse]*
+/// ```
+///
+/// The number of consumer group entries equals `client.consumer_groups_count`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ClientDetailsResponse {
+ pub client: ClientResponse,
+ pub consumer_groups: Vec<ConsumerGroupInfoResponse>,
+}
+
+impl WireEncode for ClientDetailsResponse {
+ fn encoded_size(&self) -> usize {
+ self.client.encoded_size()
+ + self
+ .consumer_groups
+ .iter()
+ .map(WireEncode::encoded_size)
+ .sum::<usize>()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.client.encode(buf);
+ for group in &self.consumer_groups {
+ group.encode(buf);
+ }
+ }
+}
+
+impl WireDecode for ClientDetailsResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (client, mut pos) = ClientResponse::decode(buf)?;
+ let count = client.consumer_groups_count as usize;
+ let mut consumer_groups = Vec::with_capacity(count);
+ for _ in 0..count {
+ let (group, consumed) = ConsumerGroupInfoResponse::decode(&buf[pos..])?;
+ pos += consumed;
+ consumer_groups.push(group);
+ }
+
+ Ok((
+ Self {
+ client,
+ consumer_groups,
+ },
+ pos,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_client(groups_count: u32) -> ClientResponse {
+ ClientResponse {
+ client_id: 1,
+ user_id: 10,
+ transport: 1,
+ address: "127.0.0.1:8080".to_string(),
+ consumer_groups_count: groups_count,
+ }
+ }
+
+ #[test]
+ fn roundtrip_no_groups() {
+ let resp = ClientDetailsResponse {
+ client: sample_client(0),
+ consumer_groups: vec![],
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = ClientDetailsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn roundtrip_with_groups() {
+ let resp = ClientDetailsResponse {
+ client: sample_client(2),
+ consumer_groups: vec![
+ ConsumerGroupInfoResponse {
+ stream_id: 1,
+ topic_id: 2,
+ group_id: 3,
+ },
+ ConsumerGroupInfoResponse {
+ stream_id: 4,
+ topic_id: 5,
+ group_id: 6,
+ },
+ ],
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = ClientDetailsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let resp = ClientDetailsResponse {
+ client: sample_client(1),
+ consumer_groups: vec![ConsumerGroupInfoResponse {
+ stream_id: 1,
+ topic_id: 2,
+ group_id: 3,
+ }],
+ };
+ let bytes = resp.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ ClientDetailsResponse::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/responses/clients/get_clients.rs b/core/binary_protocol/src/responses/clients/get_clients.rs
new file mode 100644
index 000000000..7b462fdb3
--- /dev/null
+++ b/core/binary_protocol/src/responses/clients/get_clients.rs
@@ -0,0 +1,98 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode};
+use crate::responses::clients::client_response::ClientResponse;
+use bytes::BytesMut;
+
+/// `GetClients` response: sequential client headers.
+///
+/// Wire format:
+/// ```text
+/// [ClientResponse]*
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetClientsResponse {
+ pub clients: Vec<ClientResponse>,
+}
+
+impl WireEncode for GetClientsResponse {
+ fn encoded_size(&self) -> usize {
+ self.clients.iter().map(WireEncode::encoded_size).sum()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ for client in &self.clients {
+ client.encode(buf);
+ }
+ }
+}
+
+impl WireDecode for GetClientsResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut clients = Vec::new();
+ let mut pos = 0;
+ while pos < buf.len() {
+ let (client, consumed) = ClientResponse::decode(&buf[pos..])?;
+ pos += consumed;
+ clients.push(client);
+ }
+ Ok((Self { clients }, pos))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn roundtrip_empty() {
+ let resp = GetClientsResponse { clients: vec![] };
+ let bytes = resp.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetClientsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn roundtrip_multiple() {
+ let resp = GetClientsResponse {
+ clients: vec![
+ ClientResponse {
+ client_id: 1,
+ user_id: 10,
+ transport: 1,
+ address: "127.0.0.1:5000".to_string(),
+ consumer_groups_count: 0,
+ },
+ ClientResponse {
+ client_id: 2,
+ user_id: 20,
+ transport: 2,
+ address: "10.0.0.1:6000".to_string(),
+ consumer_groups_count: 3,
+ },
+ ],
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = GetClientsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, resp);
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/clients/mod.rs
similarity index 78%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/clients/mod.rs
index e7b687f1f..d76aa51a5 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/clients/mod.rs
@@ -15,4 +15,10 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod client_response;
+pub mod get_client;
+pub mod get_clients;
+
+pub use client_response::{ClientResponse, ConsumerGroupInfoResponse};
+pub use get_client::ClientDetailsResponse;
+pub use get_clients::GetClientsResponse;
diff --git a/core/binary_protocol/src/responses/streams/stream_response.rs b/core/binary_protocol/src/responses/consumer_groups/consumer_group_response.rs
similarity index 51%
copy from core/binary_protocol/src/responses/streams/stream_response.rs
copy to core/binary_protocol/src/responses/consumer_groups/consumer_group_response.rs
index b6fbe02e9..46d047b95 100644
--- a/core/binary_protocol/src/responses/streams/stream_response.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/consumer_group_response.rs
@@ -16,62 +16,54 @@
// under the License.
use crate::WireError;
-use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
-use crate::identifier::WireName;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use crate::primitives::identifier::WireName;
use bytes::{BufMut, BytesMut};
-/// Stream header on the wire. Used in both single-stream and multi-stream responses.
+/// Consumer group header on the wire.
///
-/// Wire format (33 + `name_len` bytes):
+/// Wire format (12 + 1 + `name_len` bytes):
/// ```text
-/// [id:4][created_at:8][topics_count:4][size_bytes:8][messages_count:8][name_len:1][name:N]
+/// [id:4][partitions_count:4][members_count:4][name_len:1][name:N]
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct StreamResponse {
+pub struct ConsumerGroupResponse {
pub id: u32,
- pub created_at: u64,
- pub topics_count: u32,
- pub size_bytes: u64,
- pub messages_count: u64,
+ pub partitions_count: u32,
+ pub members_count: u32,
pub name: WireName,
}
-impl StreamResponse {
- const FIXED_SIZE: usize = 4 + 8 + 4 + 8 + 8 + 1; // 33
+impl ConsumerGroupResponse {
+ const FIXED_SIZE: usize = 4 + 4 + 4; // 12 (name length prefix is part of WireName)
}
-impl WireEncode for StreamResponse {
+impl WireEncode for ConsumerGroupResponse {
fn encoded_size(&self) -> usize {
- Self::FIXED_SIZE + self.name.len()
+ Self::FIXED_SIZE + self.name.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
buf.put_u32_le(self.id);
- buf.put_u64_le(self.created_at);
- buf.put_u32_le(self.topics_count);
- buf.put_u64_le(self.size_bytes);
- buf.put_u64_le(self.messages_count);
+ buf.put_u32_le(self.partitions_count);
+ buf.put_u32_le(self.members_count);
self.name.encode(buf);
}
}
-impl WireDecode for StreamResponse {
+impl WireDecode for ConsumerGroupResponse {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
let id = read_u32_le(buf, 0)?;
- let created_at = read_u64_le(buf, 4)?;
- let topics_count = read_u32_le(buf, 12)?;
- let size_bytes = read_u64_le(buf, 16)?;
- let messages_count = read_u64_le(buf, 24)?;
- let (name, name_consumed) = WireName::decode(&buf[32..])?;
- let consumed = 32 + name_consumed;
+ let partitions_count = read_u32_le(buf, 4)?;
+ let members_count = read_u32_le(buf, 8)?;
+ let (name, name_consumed) = WireName::decode(&buf[12..])?;
+ let consumed = 12 + name_consumed;
Ok((
Self {
id,
- created_at,
- topics_count,
- size_bytes,
- messages_count,
+ partitions_count,
+ members_count,
name,
},
consumed,
@@ -83,14 +75,12 @@ impl WireDecode for StreamResponse {
mod tests {
use super::*;
- fn sample() -> StreamResponse {
- StreamResponse {
+ fn sample() -> ConsumerGroupResponse {
+ ConsumerGroupResponse {
id: 1,
- created_at: 1_710_000_000_000,
- topics_count: 3,
- size_bytes: 1024,
- messages_count: 100,
- name: WireName::new("test-stream").unwrap(),
+ partitions_count: 4,
+ members_count: 2,
+ name: WireName::new("my-group").unwrap(),
}
}
@@ -98,8 +88,8 @@ mod tests {
fn roundtrip() {
let resp = sample();
let bytes = resp.to_bytes();
- assert_eq!(bytes.len(), StreamResponse::FIXED_SIZE + 11);
- let (decoded, consumed) = StreamResponse::decode(&bytes).unwrap();
+ assert_eq!(bytes.len(), 12 + 1 + 8);
+ let (decoded, consumed) = ConsumerGroupResponse::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_eq!(decoded, resp);
}
@@ -110,33 +100,33 @@ mod tests {
let bytes = resp.to_bytes();
for i in 0..bytes.len() {
assert!(
- StreamResponse::decode(&bytes[..i]).is_err(),
+ ConsumerGroupResponse::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
}
#[test]
- fn multiple_streams_sequential() {
- let s1 = StreamResponse {
+ fn multiple_sequential() {
+ let g1 = ConsumerGroupResponse {
id: 1,
name: WireName::new("a").unwrap(),
..sample()
};
- let s2 = StreamResponse {
+ let g2 = ConsumerGroupResponse {
id: 2,
name: WireName::new("bb").unwrap(),
..sample()
};
let mut buf = BytesMut::new();
- s1.encode(&mut buf);
- s2.encode(&mut buf);
+ g1.encode(&mut buf);
+ g2.encode(&mut buf);
let bytes = buf.freeze();
- let (d1, pos1) = StreamResponse::decode(&bytes).unwrap();
- let (d2, pos2) = StreamResponse::decode(&bytes[pos1..]).unwrap();
- assert_eq!(d1, s1);
- assert_eq!(d2, s2);
+ let (d1, pos1) = ConsumerGroupResponse::decode(&bytes).unwrap();
+ let (d2, pos2) = ConsumerGroupResponse::decode(&bytes[pos1..]).unwrap();
+ assert_eq!(d1, g1);
+ assert_eq!(d2, g2);
assert_eq!(pos1 + pos2, bytes.len());
}
}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/create_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/create_consumer_group.rs
index e7b687f1f..9d635e0f0 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/create_consumer_group.rs
@@ -15,4 +15,5 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+/// `CreateConsumerGroup` response is empty.
+pub type CreateConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/delete_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/delete_consumer_group.rs
index e7b687f1f..ff59f0b36 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/delete_consumer_group.rs
@@ -15,4 +15,5 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+/// `DeleteConsumerGroup` response is empty.
+pub type DeleteConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/consumer_groups/get_consumer_group.rs b/core/binary_protocol/src/responses/consumer_groups/get_consumer_group.rs
new file mode 100644
index 000000000..39ecab2d1
--- /dev/null
+++ b/core/binary_protocol/src/responses/consumer_groups/get_consumer_group.rs
@@ -0,0 +1,210 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use super::consumer_group_response::ConsumerGroupResponse;
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u32_le};
+use bytes::{BufMut, BytesMut};
+
+/// A consumer group member with its assigned partitions.
+///
+/// Wire format:
+/// ```text
+/// [id:4][partitions_count:4][partition_id:4]*
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerGroupMemberResponse {
+ pub id: u32,
+ pub partitions_count: u32,
+ pub partitions: Vec<u32>,
+}
+
+impl WireEncode for ConsumerGroupMemberResponse {
+ fn encoded_size(&self) -> usize {
+ 4 + 4 + self.partitions.len() * 4
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.id);
+ buf.put_u32_le(self.partitions_count);
+ for &partition_id in &self.partitions {
+ buf.put_u32_le(partition_id);
+ }
+ }
+}
+
+impl WireDecode for ConsumerGroupMemberResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let id = read_u32_le(buf, 0)?;
+ let partitions_count = read_u32_le(buf, 4)?;
+ let mut partitions = Vec::with_capacity(partitions_count as usize);
+ let mut offset = 8;
+ for _ in 0..partitions_count {
+ let partition_id = read_u32_le(buf, offset)?;
+ partitions.push(partition_id);
+ offset += 4;
+ }
+ Ok((
+ Self {
+ id,
+ partitions_count,
+ partitions,
+ },
+ offset,
+ ))
+ }
+}
+
+/// `GetConsumerGroup` response: group header followed by member details.
+///
+/// Wire format:
+/// ```text
+/// [ConsumerGroupResponse][ConsumerGroupMemberResponse]*
+/// ```
+///
+/// The number of members must match `group.members_count`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerGroupDetailsResponse {
+ pub group: ConsumerGroupResponse,
+ pub members: Vec<ConsumerGroupMemberResponse>,
+}
+
+impl WireEncode for ConsumerGroupDetailsResponse {
+ fn encoded_size(&self) -> usize {
+ self.group.encoded_size()
+ + self
+ .members
+ .iter()
+ .map(WireEncode::encoded_size)
+ .sum::<usize>()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.group.encode(buf);
+ for member in &self.members {
+ member.encode(buf);
+ }
+ }
+}
+
+impl WireDecode for ConsumerGroupDetailsResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (group, mut pos) = ConsumerGroupResponse::decode(buf)?;
+ let mut members = Vec::new();
+ for _ in 0..group.members_count {
+ let (member, consumed) = ConsumerGroupMemberResponse::decode(&buf[pos..])?;
+ pos += consumed;
+ members.push(member);
+ }
+ Ok((Self { group, members }, pos))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::WireName;
+
+ fn sample_group(members_count: u32) -> ConsumerGroupResponse {
+ ConsumerGroupResponse {
+ id: 1,
+ partitions_count: 6,
+ members_count,
+ name: WireName::new("my-group").unwrap(),
+ }
+ }
+
+ fn sample_member(id: u32, partitions: &[u32]) -> ConsumerGroupMemberResponse {
+ ConsumerGroupMemberResponse {
+ id,
+ #[allow(clippy::cast_possible_truncation)]
+ partitions_count: partitions.len() as u32,
+ partitions: partitions.to_vec(),
+ }
+ }
+
+ #[test]
+ fn member_roundtrip() {
+ let m = sample_member(1, &[0, 1, 2]);
+ let bytes = m.to_bytes();
+ assert_eq!(bytes.len(), 4 + 4 + 3 * 4);
+ let (decoded, consumed) = ConsumerGroupMemberResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, m);
+ }
+
+ #[test]
+ fn member_no_partitions_roundtrip() {
+ let m = sample_member(5, &[]);
+ let bytes = m.to_bytes();
+ assert_eq!(bytes.len(), 8);
+ let (decoded, consumed) = ConsumerGroupMemberResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, 8);
+ assert_eq!(decoded, m);
+ }
+
+ #[test]
+ fn member_truncated_returns_error() {
+ let m = sample_member(1, &[0, 1]);
+ let bytes = m.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ ConsumerGroupMemberResponse::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn details_roundtrip_with_members() {
+ let resp = ConsumerGroupDetailsResponse {
+ group: sample_group(2),
+ members: vec![sample_member(1, &[0, 1, 2]), sample_member(2, &[3, 4, 5])],
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = ConsumerGroupDetailsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn details_roundtrip_no_members() {
+ let resp = ConsumerGroupDetailsResponse {
+ group: sample_group(0),
+ members: vec![],
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = ConsumerGroupDetailsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn details_truncated_returns_error() {
+ let resp = ConsumerGroupDetailsResponse {
+ group: sample_group(1),
+ members: vec![sample_member(1, &[0])],
+ };
+ let bytes = resp.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ ConsumerGroupDetailsResponse::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/responses/consumer_groups/get_consumer_groups.rs b/core/binary_protocol/src/responses/consumer_groups/get_consumer_groups.rs
new file mode 100644
index 000000000..bc4324751
--- /dev/null
+++ b/core/binary_protocol/src/responses/consumer_groups/get_consumer_groups.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.
+
+use super::consumer_group_response::ConsumerGroupResponse;
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode};
+use bytes::BytesMut;
+
+/// `GetConsumerGroups` response: sequential consumer group headers.
+///
+/// Wire format:
+/// ```text
+/// [ConsumerGroupResponse]*
+/// ```
+///
+/// Empty payload means zero groups.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetConsumerGroupsResponse {
+ pub groups: Vec<ConsumerGroupResponse>,
+}
+
+impl WireEncode for GetConsumerGroupsResponse {
+ fn encoded_size(&self) -> usize {
+ self.groups.iter().map(WireEncode::encoded_size).sum()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ for group in &self.groups {
+ group.encode(buf);
+ }
+ }
+}
+
+impl WireDecode for GetConsumerGroupsResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut groups = Vec::new();
+ let mut pos = 0;
+ while pos < buf.len() {
+ let (group, consumed) = ConsumerGroupResponse::decode(&buf[pos..])?;
+ pos += consumed;
+ groups.push(group);
+ }
+ Ok((Self { groups }, pos))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::WireName;
+
+ fn sample_group(id: u32, name: &str) -> ConsumerGroupResponse {
+ ConsumerGroupResponse {
+ id,
+ partitions_count: 4,
+ members_count: 2,
+ name: WireName::new(name).unwrap(),
+ }
+ }
+
+ #[test]
+ fn roundtrip_empty() {
+ let resp = GetConsumerGroupsResponse { groups: vec![] };
+ let bytes = resp.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetConsumerGroupsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn roundtrip_multiple() {
+ let resp = GetConsumerGroupsResponse {
+ groups: vec![sample_group(1, "group-a"), sample_group(2, "group-b")],
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = GetConsumerGroupsResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let resp = GetConsumerGroupsResponse {
+ groups: vec![sample_group(1, "g")],
+ };
+ let bytes = resp.to_bytes();
+ for i in 1..bytes.len() {
+ assert!(
+ GetConsumerGroupsResponse::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/join_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/join_consumer_group.rs
index e7b687f1f..0a39201ce 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/join_consumer_group.rs
@@ -15,4 +15,5 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+/// `JoinConsumerGroup` response is empty.
+pub type JoinConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_groups/leave_consumer_group.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/leave_consumer_group.rs
index e7b687f1f..7165c4a86 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/leave_consumer_group.rs
@@ -15,4 +15,5 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+/// `LeaveConsumerGroup` response is empty.
+pub type LeaveConsumerGroupResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/responses/consumer_groups/mod.rs
similarity index 54%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/responses/consumer_groups/mod.rs
index 7b062a665..17c72a8a2 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_groups/mod.rs
@@ -15,26 +15,19 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod consumer_group_response;
+mod create_consumer_group;
+mod delete_consumer_group;
+pub mod get_consumer_group;
+pub mod get_consumer_groups;
+mod join_consumer_group;
+mod leave_consumer_group;
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
- fn encoded_size(&self) -> usize {
- 0
- }
-
- fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
- fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
- Ok((Self, 0))
- }
-}
+pub use super::EmptyResponse;
+pub use consumer_group_response::ConsumerGroupResponse;
+pub use create_consumer_group::CreateConsumerGroupResponse;
+pub use delete_consumer_group::DeleteConsumerGroupResponse;
+pub use get_consumer_group::{ConsumerGroupDetailsResponse, ConsumerGroupMemberResponse};
+pub use get_consumer_groups::GetConsumerGroupsResponse;
+pub use join_consumer_group::JoinConsumerGroupResponse;
+pub use leave_consumer_group::LeaveConsumerGroupResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_offsets/delete_consumer_offset.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_offsets/delete_consumer_offset.rs
index e7b687f1f..85921ec0a 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_offsets/delete_consumer_offset.rs
@@ -15,4 +15,5 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+/// `DeleteConsumerOffset` response is empty.
+pub type DeleteConsumerOffsetResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/consumer_offsets/get_consumer_offset.rs b/core/binary_protocol/src/responses/consumer_offsets/get_consumer_offset.rs
new file mode 100644
index 000000000..ccd592a40
--- /dev/null
+++ b/core/binary_protocol/src/responses/consumer_offsets/get_consumer_offset.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.
+
+use crate::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
+use bytes::{BufMut, BytesMut};
+
+/// `GetConsumerOffset` response.
+///
+/// Wire format (20 bytes fixed):
+/// ```text
+/// [partition_id:4][current_offset:8][stored_offset:8]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConsumerOffsetResponse {
+ pub partition_id: u32,
+ pub current_offset: u64,
+ pub stored_offset: u64,
+}
+
+impl ConsumerOffsetResponse {
+ const FIXED_SIZE: usize = 4 + 8 + 8; // 20
+}
+
+impl WireEncode for ConsumerOffsetResponse {
+ fn encoded_size(&self) -> usize {
+ Self::FIXED_SIZE
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.partition_id);
+ buf.put_u64_le(self.current_offset);
+ buf.put_u64_le(self.stored_offset);
+ }
+}
+
+impl WireDecode for ConsumerOffsetResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let partition_id = read_u32_le(buf, 0)?;
+ let current_offset = read_u64_le(buf, 4)?;
+ let stored_offset = read_u64_le(buf, 12)?;
+
+ Ok((
+ Self {
+ partition_id,
+ current_offset,
+ stored_offset,
+ },
+ Self::FIXED_SIZE,
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample() -> ConsumerOffsetResponse {
+ ConsumerOffsetResponse {
+ partition_id: 1,
+ current_offset: 1000,
+ stored_offset: 500,
+ }
+ }
+
+ #[test]
+ fn roundtrip() {
+ let resp = sample();
+ let bytes = resp.to_bytes();
+ assert_eq!(bytes.len(), ConsumerOffsetResponse::FIXED_SIZE);
+ let (decoded, consumed) = ConsumerOffsetResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, ConsumerOffsetResponse::FIXED_SIZE);
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn truncated_returns_error() {
+ let resp = sample();
+ let bytes = resp.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ ConsumerOffsetResponse::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn boundary_values() {
+ let resp = ConsumerOffsetResponse {
+ partition_id: u32::MAX,
+ current_offset: u64::MAX,
+ stored_offset: 0,
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = ConsumerOffsetResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, ConsumerOffsetResponse::FIXED_SIZE);
+ assert_eq!(decoded, resp);
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_offsets/mod.rs
similarity index 73%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_offsets/mod.rs
index e7b687f1f..915648c18 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_offsets/mod.rs
@@ -15,4 +15,11 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+mod delete_consumer_offset;
+pub mod get_consumer_offset;
+mod store_consumer_offset;
+
+pub use super::EmptyResponse;
+pub use delete_consumer_offset::DeleteConsumerOffsetResponse;
+pub use get_consumer_offset::ConsumerOffsetResponse;
+pub use store_consumer_offset::StoreConsumerOffsetResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/consumer_offsets/store_consumer_offset.rs
similarity index 88%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/consumer_offsets/store_consumer_offset.rs
index e7b687f1f..e9a41afca 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/consumer_offsets/store_consumer_offset.rs
@@ -15,4 +15,5 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+/// `StoreConsumerOffset` response is empty.
+pub type StoreConsumerOffsetResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/messages/mod.rs
similarity index 89%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/messages/mod.rs
index e7b687f1f..c272f3424 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/messages/mod.rs
@@ -15,4 +15,6 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod poll_messages;
+
+pub use poll_messages::{PollMessagesResponse, PollMessagesResponseHeader};
diff --git a/core/binary_protocol/src/responses/messages/poll_messages.rs b/core/binary_protocol/src/responses/messages/poll_messages.rs
new file mode 100644
index 000000000..cf31e4475
--- /dev/null
+++ b/core/binary_protocol/src/responses/messages/poll_messages.rs
@@ -0,0 +1,176 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
+use crate::error::WireError;
+use crate::message_view::WireMessageIterator;
+use bytes::{BufMut, BytesMut};
+
+/// Size of the `PollMessages` response header: `partition_id(4) + current_offset(8) + count(4)`.
+const POLL_RESPONSE_HEADER_SIZE: usize = 16;
+
+/// The 16-byte metadata prefix of a `PollMessages` response.
+///
+/// Layout: `partition_id(4) + current_offset(8) + messages_count(4)`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PollMessagesResponseHeader {
+ pub partition_id: u32,
+ pub current_offset: u64,
+ pub messages_count: u32,
+}
+
+impl WireEncode for PollMessagesResponseHeader {
+ fn encoded_size(&self) -> usize {
+ POLL_RESPONSE_HEADER_SIZE
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.partition_id);
+ buf.put_u64_le(self.current_offset);
+ buf.put_u32_le(self.messages_count);
+ }
+}
+
+impl WireDecode for PollMessagesResponseHeader {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let partition_id = read_u32_le(buf, 0)?;
+ let current_offset = read_u64_le(buf, 4)?;
+ let messages_count = read_u32_le(buf, 12)?;
+ Ok((
+ Self {
+ partition_id,
+ current_offset,
+ messages_count,
+ },
+ POLL_RESPONSE_HEADER_SIZE,
+ ))
+ }
+}
+
+/// Borrowed `PollMessages` response. Does not own message data.
+///
+/// Does NOT implement `WireDecode` (trait returns owned data, we borrow).
+/// Use [`PollMessagesResponse::decode`] instead.
+pub struct PollMessagesResponse<'a> {
+ pub header: PollMessagesResponseHeader,
+ pub messages: WireMessageIterator<'a>,
+}
+
+impl<'a> PollMessagesResponse<'a> {
+ /// Decode from a response payload buffer. Borrows the buffer.
+ ///
+ /// Reads the 16-byte header then creates an iterator over the remaining
+ /// message frames. Messages are validated lazily during iteration.
+ ///
+ /// # Errors
+ /// Returns `WireError` if the buffer is too short for the response header.
+ pub fn decode(buf: &'a [u8]) -> Result<Self, WireError> {
+ let (header, _) = PollMessagesResponseHeader::decode(buf)?;
+ let messages =
+ WireMessageIterator::new(&buf[POLL_RESPONSE_HEADER_SIZE..], header.messages_count);
+ Ok(Self { header, messages })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::message_layout::{
+ MSG_ID_OFFSET, MSG_ORIGIN_TIMESTAMP_OFFSET, MSG_PAYLOAD_LEN_OFFSET,
+ WIRE_MESSAGE_HEADER_SIZE,
+ };
+
+ fn make_frame(payload: &[u8], id: u128, origin_ts: u64) -> Vec<u8> {
+ let total = WIRE_MESSAGE_HEADER_SIZE + payload.len();
+ let mut frame = vec![0u8; total];
+ frame[MSG_ID_OFFSET..MSG_ID_OFFSET + 16].copy_from_slice(&id.to_le_bytes());
+ frame[MSG_ORIGIN_TIMESTAMP_OFFSET..MSG_ORIGIN_TIMESTAMP_OFFSET + 8]
+ .copy_from_slice(&origin_ts.to_le_bytes());
+ #[allow(clippy::cast_possible_truncation)]
+ frame[MSG_PAYLOAD_LEN_OFFSET..MSG_PAYLOAD_LEN_OFFSET + 4]
+ .copy_from_slice(&(payload.len() as u32).to_le_bytes());
+ frame[WIRE_MESSAGE_HEADER_SIZE..].copy_from_slice(payload);
+ frame
+ }
+
+ #[test]
+ fn response_header_roundtrip() {
+ let header = PollMessagesResponseHeader {
+ partition_id: 7,
+ current_offset: 12345,
+ messages_count: 3,
+ };
+ let bytes = header.to_bytes();
+ assert_eq!(bytes.len(), POLL_RESPONSE_HEADER_SIZE);
+ let (decoded, consumed) = PollMessagesResponseHeader::decode(&bytes).unwrap();
+ assert_eq!(consumed, POLL_RESPONSE_HEADER_SIZE);
+ assert_eq!(decoded, header);
+ }
+
+ #[test]
+ fn response_header_truncation() {
+ let header = PollMessagesResponseHeader {
+ partition_id: 1,
+ current_offset: 0,
+ messages_count: 0,
+ };
+ let bytes = header.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ PollMessagesResponseHeader::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn response_decode_with_messages() {
+ let frame1 = make_frame(b"hello", 1, 100);
+ let frame2 = make_frame(b"world", 2, 200);
+
+ let mut buf = BytesMut::new();
+ buf.put_u32_le(42);
+ buf.put_u64_le(999);
+ buf.put_u32_le(2);
+ buf.put_slice(&frame1);
+ buf.put_slice(&frame2);
+
+ let resp = PollMessagesResponse::decode(&buf).unwrap();
+ assert_eq!(resp.header.partition_id, 42);
+ assert_eq!(resp.header.current_offset, 999);
+ assert_eq!(resp.header.messages_count, 2);
+
+ let views: Vec<_> = resp.messages.collect::<Result<Vec<_>, _>>().unwrap();
+ assert_eq!(views.len(), 2);
+ assert_eq!(views[0].id(), 1);
+ assert_eq!(views[0].payload(), b"hello");
+ assert_eq!(views[1].id(), 2);
+ assert_eq!(views[1].payload(), b"world");
+ }
+
+ #[test]
+ fn response_decode_zero_messages() {
+ let mut buf = BytesMut::new();
+ buf.put_u32_le(1);
+ buf.put_u64_le(0);
+ buf.put_u32_le(0);
+
+ let resp = PollMessagesResponse::decode(&buf).unwrap();
+ assert_eq!(resp.header.messages_count, 0);
+ assert_eq!(resp.messages.count(), 0);
+ }
+}
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/responses/mod.rs
index 7b062a665..a009c15cb 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/responses/mod.rs
@@ -15,7 +15,15 @@
// specific language governing permissions and limitations
// under the License.
+pub mod clients;
+pub mod consumer_groups;
+pub mod consumer_offsets;
+pub mod messages;
+pub mod personal_access_tokens;
pub mod streams;
+pub mod system;
+pub mod topics;
+pub mod users;
use crate::WireError;
use crate::codec::{WireDecode, WireEncode};
diff --git a/core/binary_protocol/src/requests/streams/update_stream.rs b/core/binary_protocol/src/responses/personal_access_tokens/create_personal_access_token.rs
similarity index 54%
copy from core/binary_protocol/src/requests/streams/update_stream.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/create_personal_access_token.rs
index a8085f099..92ac752f9 100644
--- a/core/binary_protocol/src/requests/streams/update_stream.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/create_personal_access_token.rs
@@ -16,35 +16,35 @@
// under the License.
use crate::WireError;
-use crate::WireIdentifier;
use crate::codec::{WireDecode, WireEncode};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
use bytes::BytesMut;
-/// `UpdateStream` request. Wire format: `[identifier][name_len:1][name:N]`
+/// Response for `CreatePersonalAccessToken`: the raw token string.
+///
+/// Wire format:
+/// ```text
+/// [token_len:1][token:N]
+/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct UpdateStreamRequest {
- pub stream_id: WireIdentifier,
- pub name: WireName,
+pub struct RawPersonalAccessTokenResponse {
+ pub token: WireName,
}
-impl WireEncode for UpdateStreamRequest {
+impl WireEncode for RawPersonalAccessTokenResponse {
fn encoded_size(&self) -> usize {
- self.stream_id.encoded_size() + self.name.encoded_size()
+ self.token.encoded_size()
}
fn encode(&self, buf: &mut BytesMut) {
- self.stream_id.encode(buf);
- self.name.encode(buf);
+ self.token.encode(buf);
}
}
-impl WireDecode for UpdateStreamRequest {
+impl WireDecode for RawPersonalAccessTokenResponse {
fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
- let (stream_id, mut pos) = WireIdentifier::decode(buf)?;
- let (name, consumed) = WireName::decode(&buf[pos..])?;
- pos += consumed;
- Ok((Self { stream_id, name }, pos))
+ let (token, consumed) = WireName::decode(buf)?;
+ Ok((Self { token }, consumed))
}
}
@@ -54,26 +54,25 @@ mod tests {
#[test]
fn roundtrip() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::named("old-name").unwrap(),
- name: WireName::new("new-name").unwrap(),
+ let resp = RawPersonalAccessTokenResponse {
+ token: WireName::new("raw-secret-token-value").unwrap(),
};
- let bytes = req.to_bytes();
- let (decoded, consumed) = UpdateStreamRequest::decode(&bytes).unwrap();
+ let bytes = resp.to_bytes();
+ assert_eq!(bytes.len(), 1 + 22);
+ let (decoded, consumed) = RawPersonalAccessTokenResponse::decode(&bytes).unwrap();
assert_eq!(consumed, bytes.len());
- assert_eq!(decoded, req);
+ assert_eq!(decoded, resp);
}
#[test]
fn truncated_returns_error() {
- let req = UpdateStreamRequest {
- stream_id: WireIdentifier::numeric(1),
- name: WireName::new("test").unwrap(),
+ let resp = RawPersonalAccessTokenResponse {
+ token: WireName::new("tok").unwrap(),
};
- let bytes = req.to_bytes();
+ let bytes = resp.to_bytes();
for i in 0..bytes.len() {
assert!(
- UpdateStreamRequest::decode(&bytes[..i]).is_err(),
+ RawPersonalAccessTokenResponse::decode(&bytes[..i]).is_err(),
"expected error for truncation at byte {i}"
);
}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/personal_access_tokens/delete_personal_access_token.rs
similarity index 92%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/delete_personal_access_token.rs
index e7b687f1f..c1cc301ea 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/delete_personal_access_token.rs
@@ -15,4 +15,4 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub type DeletePersonalAccessTokenResponse = super::EmptyResponse;
diff --git a/core/binary_protocol/src/responses/personal_access_tokens/get_personal_access_tokens.rs b/core/binary_protocol/src/responses/personal_access_tokens/get_personal_access_tokens.rs
new file mode 100644
index 000000000..08ad66af6
--- /dev/null
+++ b/core/binary_protocol/src/responses/personal_access_tokens/get_personal_access_tokens.rs
@@ -0,0 +1,145 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_u64_le};
+use crate::primitives::identifier::WireName;
+use bytes::{BufMut, BytesMut};
+
+/// Single personal access token entry on the wire.
+///
+/// Wire format:
+/// ```text
+/// [name_len:1][name:N][expiry_at:8]
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PersonalAccessTokenResponse {
+ pub name: WireName,
+ pub expiry_at: u64,
+}
+
+impl WireEncode for PersonalAccessTokenResponse {
+ fn encoded_size(&self) -> usize {
+ self.name.encoded_size() + 8
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ self.name.encode(buf);
+ buf.put_u64_le(self.expiry_at);
+ }
+}
+
+impl WireDecode for PersonalAccessTokenResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let (name, name_consumed) = WireName::decode(buf)?;
+ let expiry_at = read_u64_le(buf, name_consumed)?;
+ let consumed = name_consumed + 8;
+
+ Ok((Self { name, expiry_at }, consumed))
+ }
+}
+
+/// `GetPersonalAccessTokens` response: sequential token entries.
+///
+/// Wire format:
+/// ```text
+/// [PersonalAccessTokenResponse]*
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GetPersonalAccessTokensResponse {
+ pub tokens: Vec<PersonalAccessTokenResponse>,
+}
+
+impl WireEncode for GetPersonalAccessTokensResponse {
+ fn encoded_size(&self) -> usize {
+ self.tokens.iter().map(WireEncode::encoded_size).sum()
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ for token in &self.tokens {
+ token.encode(buf);
+ }
+ }
+}
+
+impl WireDecode for GetPersonalAccessTokensResponse {
+ fn decode(buf: &[u8]) -> Result<(Self, usize), WireError> {
+ let mut tokens = Vec::new();
+ let mut pos = 0;
+ while pos < buf.len() {
+ let (token, consumed) = PersonalAccessTokenResponse::decode(&buf[pos..])?;
+ pos += consumed;
+ tokens.push(token);
+ }
+ Ok((Self { tokens }, pos))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sample_token(name: &str, expiry: u64) -> PersonalAccessTokenResponse {
+ PersonalAccessTokenResponse {
+ name: WireName::new(name).unwrap(),
+ expiry_at: expiry,
+ }
+ }
+
+ #[test]
+ fn token_roundtrip() {
+ let tok = sample_token("my-token", 1_710_000_000_000);
+ let bytes = tok.to_bytes();
+ assert_eq!(bytes.len(), 1 + 8 + 8);
+ let (decoded, consumed) = PersonalAccessTokenResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, tok);
+ }
+
+ #[test]
+ fn token_truncated_returns_error() {
+ let tok = sample_token("t", 0);
+ let bytes = tok.to_bytes();
+ for i in 0..bytes.len() {
+ assert!(
+ PersonalAccessTokenResponse::decode(&bytes[..i]).is_err(),
+ "expected error for truncation at byte {i}"
+ );
+ }
+ }
+
+ #[test]
+ fn list_roundtrip_empty() {
+ let resp = GetPersonalAccessTokensResponse { tokens: vec![] };
+ let bytes = resp.to_bytes();
+ assert!(bytes.is_empty());
+ let (decoded, consumed) = GetPersonalAccessTokensResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, 0);
+ assert_eq!(decoded, resp);
+ }
+
+ #[test]
+ fn list_roundtrip_multiple() {
+ let resp = GetPersonalAccessTokensResponse {
+ tokens: vec![sample_token("token-a", 100), sample_token("token-b", 0)],
+ };
+ let bytes = resp.to_bytes();
+ let (decoded, consumed) = GetPersonalAccessTokensResponse::decode(&bytes).unwrap();
+ assert_eq!(consumed, bytes.len());
+ assert_eq!(decoded, resp);
+ }
+}
diff --git a/core/binary_protocol/src/requests/mod.rs b/core/binary_protocol/src/responses/personal_access_tokens/login_with_personal_access_token.rs
similarity index 89%
copy from core/binary_protocol/src/requests/mod.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/login_with_personal_access_token.rs
index e7b687f1f..1d26d075a 100644
--- a/core/binary_protocol/src/requests/mod.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/login_with_personal_access_token.rs
@@ -15,4 +15,4 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub type LoginWithPersonalAccessTokenResponse = crate::responses::users::IdentityResponse;
diff --git a/core/binary_protocol/src/responses/mod.rs b/core/binary_protocol/src/responses/personal_access_tokens/mod.rs
similarity index 61%
copy from core/binary_protocol/src/responses/mod.rs
copy to core/binary_protocol/src/responses/personal_access_tokens/mod.rs
index 7b062a665..f204d2c2e 100644
--- a/core/binary_protocol/src/responses/mod.rs
+++ b/core/binary_protocol/src/responses/personal_access_tokens/mod.rs
@@ -15,26 +15,15 @@
// specific language governing permissions and limitations
// under the License.
-pub mod streams;
+pub mod create_personal_access_token;
+mod delete_personal_access_token;
+pub mod get_personal_access_tokens;
+pub mod login_with_personal_access_token;
-use crate::WireError;
-use crate::codec::{WireDecode, WireEncode};
-use bytes::BytesMut;
-
-/// Marker type for commands that return an empty response payload.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EmptyResponse;
-
-impl WireEncode for EmptyResponse {
- fn encoded_size(&self) -> usize {
- 0
- }
-
- fn encode(&self, _buf: &mut BytesMut) {}
-}
-
-impl WireDecode for EmptyResponse {
- fn decode(_buf: &[u8]) -> Result<(Self, usize), WireError> {
- Ok((Self, 0))
- }
-}
+pub use super::EmptyResponse;
+pub use create_personal_access_token::RawPersonalAccessTokenResponse;
+pub use delete_personal_access_token::DeletePersonalAccessTokenResponse;
+pub use get_personal_access_tokens::{
+ GetPersonalAccessTokensResponse, PersonalAccessTokenResponse,
+};
+pub use login_with_personal_access_token::LoginWithPersonalAccessTokenResponse;
diff --git a/core/binary_protocol/src/responses/streams/get_stream.rs b/core/binary_protocol/src/responses/streams/get_stream.rs
index 936690eaf..3470e3743 100644
--- a/core/binary_protocol/src/responses/streams/get_stream.rs
+++ b/core/binary_protocol/src/responses/streams/get_stream.rs
@@ -17,7 +17,7 @@
use crate::WireError;
use crate::codec::{WireDecode, WireEncode, read_u8, read_u32_le, read_u64_le};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
use crate::responses::streams::StreamResponse;
use bytes::{BufMut, BytesMut};
diff --git a/core/binary_protocol/src/responses/streams/stream_response.rs b/core/binary_protocol/src/responses/streams/stream_response.rs
index b6fbe02e9..aeeef4f58 100644
--- a/core/binary_protocol/src/responses/streams/stream_response.rs
+++ b/core/binary_protocol/src/responses/streams/stream_response.rs
@@ -17,7 +17,7 @@
use crate::WireError;
use crate::codec::{WireDecode, WireEncode, read_u32_le, read_u64_le};
-use crate::identifier::WireName;
+use crate::primitives::identifier::WireName;
use bytes::{BufMut, BytesMut};
/// Stream header on the wire. Used in both single-stream and multi-stream responses.
diff --git a/core/binary_protocol/src/responses/system/get_stats.rs b/core/binary_protocol/src/responses/system/get_stats.rs
new file mode 100644
index 000000000..ec9467007
--- /dev/null
+++ b/core/binary_protocol/src/responses/system/get_stats.rs
@@ -0,0 +1,427 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT 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::WireError;
+use crate::codec::{WireDecode, WireEncode, read_f32_le, read_str, read_u32_le, read_u64_le};
+use bytes::{BufMut, BytesMut};
+
+/// Per-partition cache hit/miss statistics.
+#[derive(Debug, Clone, PartialEq)]
+pub struct CacheMetricEntry {
+ pub stream_id: u32,
+ pub topic_id: u32,
+ pub partition_id: u32,
+ pub hits: u64,
+ pub misses: u64,
+ pub hit_ratio: f32,
+}
+
+impl CacheMetricEntry {
+ const SIZE: usize = 4 + 4 + 4 + 8 + 8 + 4; // 32
+}
+
+/// Server statistics response.
+///
+/// Wire format:
+/// ```text
+/// [process_id:4][cpu_usage:f32][total_cpu_usage:f32]
+/// [memory_usage:8][total_memory:8][available_memory:8]
+/// [run_time:8][start_time:8][read_bytes:8][written_bytes:8]
+/// [messages_size_bytes:8][streams_count:4][topics_count:4]
+/// [partitions_count:4][segments_count:4][messages_count:8]
+/// [clients_count:4][consumer_groups_count:4]
+/// [hostname_len:4][hostname:N]
+/// [os_name_len:4][os_name:N]
+/// [os_version_len:4][os_version:N]
+/// [kernel_version_len:4][kernel_version:N]
+/// [iggy_server_version_len:4][iggy_server_version:N]
+/// [iggy_server_semver:4]?
+/// [cache_metrics_count:4]
+/// For each: [stream_id:4][topic_id:4][partition_id:4][hits:8][misses:8][hit_ratio:f32]
+/// [threads_count:4]?
+/// [free_disk_space:8]?
+/// [total_disk_space:8]?
+/// ```
+#[derive(Debug, Clone, PartialEq)]
+pub struct StatsResponse {
+ pub process_id: u32,
+ pub cpu_usage: f32,
+ pub total_cpu_usage: f32,
+ pub memory_usage: u64,
+ pub total_memory: u64,
+ pub available_memory: u64,
+ pub run_time: u64,
+ pub start_time: u64,
+ pub read_bytes: u64,
+ pub written_bytes: u64,
+ pub messages_size_bytes: u64,
+ pub streams_count: u32,
+ pub topics_count: u32,
+ pub partitions_count: u32,
+ pub segments_count: u32,
+ pub messages_count: u64,
+ pub clients_count: u32,
+ pub consumer_groups_count: u32,
+ pub hostname: String,
+ pub os_name: String,
+ pub os_version: String,
+ pub kernel_version: String,
+ pub iggy_server_version: String,
+ pub iggy_server_semver: Option<u32>,
+ pub cache_metrics: Vec<CacheMetricEntry>,
+ pub threads_count: u32,
+ pub free_disk_space: u64,
+ pub total_disk_space: u64,
+}
+
+// Fixed-size numeric header before the variable-length string section.
+const NUMERIC_HEADER_SIZE: usize =
+ 4 + 4 + 4 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 8 + 4 + 4 + 4 + 4 + 8 + 4 + 4; // 104
+
+fn encode_len_prefixed_str(buf: &mut BytesMut, s: &str) {
+ #[allow(clippy::cast_possible_truncation)]
+ buf.put_u32_le(s.len() as u32);
+ buf.put_slice(s.as_bytes());
+}
+
+fn decode_len_prefixed_str(buf: &[u8], pos: usize) -> Result<(String, usize), WireError> {
+ let len = read_u32_le(buf, pos)? as usize;
+ let s = read_str(buf, pos + 4, len)?;
+ Ok((s, pos + 4 + len))
+}
+
+impl WireEncode for StatsResponse {
+ fn encoded_size(&self) -> usize {
+ let tail = if self.iggy_server_semver.is_some() {
+ 4 + 4 + 8 + 8 // semver + threads_count + free_disk_space + total_disk_space
+ } else {
+ 0
+ };
+ NUMERIC_HEADER_SIZE
+ + (4 + self.hostname.len())
+ + (4 + self.os_name.len())
+ + (4 + self.os_version.len())
+ + (4 + self.kernel_version.len())
+ + (4 + self.iggy_server_version.len())
+ + 4 // cache_metrics_count
+ + self.cache_metrics.len() * CacheMetricEntry::SIZE
+ + tail
+ }
+
+ fn encode(&self, buf: &mut BytesMut) {
+ buf.put_u32_le(self.process_id);
+ buf.put_f32_le(self.cpu_usage);
+ buf.put_f32_le(self.total_cpu_usage);
+ buf.put_u64_le(self.memory_usage);
+ buf.put_u64_le(self.total_memory);
+ buf.put_u64_le(self.available_memory);
+ buf.put_u64_le(self.run_time);
+ buf.put_u64_le(self.start_time);
... 1470 lines suppressed ...