This is an automated email from the ASF dual-hosted git repository.

numinnex pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new ec6b651b2 fix(common): reject malformed user headers during 
deserialization (#4670)
ec6b651b2 is described below

commit ec6b651b2679f37c58ad0d5c03b3d9ec23fbe4fc
Author: Prabhath <[email protected]>
AuthorDate: Wed Nov 4 02:13:40 2026 +0530

    fix(common): reject malformed user headers during deserialization (#4670)
    
    ## Which issue does this PR address?
    
    Closes #4604
    
    ## Rationale
    
    The HTTP produce endpoint accepted typed user headers that violate the
    wire protocol's TLV bounds (0-byte values, widths mismatched with
    fixed-size types like `int32`, or values > 255 bytes). When encoded by
    `user_headers_to_wire`, these headers corrupted the message TLV stream,
    causing consumers polling the message to fail header decoding
    (`Err(InvalidHeaderValue)`) or silently drop all headers on the message
    (`Ok(None)`).
    
    
    ## What changed?
    
    - **Marker-specific error mapping:** Added a sealed `HeaderFieldMarker`
    trait on `KeyMarker` and `ValueMarker` returning
    `IggyError::InvalidHeaderKey` and `IggyError::InvalidHeaderValue`
    respectively.
    - **Constructor validation:** Parameterized `HeaderField<T>::from_raw`
    by `T: HeaderFieldMarker`, returning the marker-specific error on empty
    fields, lengths > 255 bytes, or fixed-kind width mismatches.
    - **Serde deserialization validation:** Implemented custom `Deserialize`
    for `HeaderField<T>` that deserializes raw base64 data and routes it
    through `Self::from_raw()`, rejecting malformed keys/values at the
    HTTP/JSON boundary (HTTP 400).
    - **Defense-in-depth:** Added a `debug_assert!` in
    `user_headers_to_wire` verifying that the encoded buffer is structurally
    valid TLV.
    - **Tests:** Enabled and passed the boundary rejection tests in
    `send_messages.rs`, and added unit tests for `HeaderField`,
    `HeaderEntry`, `HeaderKey`, `HeaderValue`, and `IggyMessage`
    deserialization.
---
 core/common/src/http/messages/send_messages.rs  | 202 ++++++++++--
 core/common/src/types/message/iggy_message.rs   |  53 ++++
 core/common/src/types/message/mod.rs            |   5 +-
 core/common/src/types/message/user_headers.rs   | 391 ++++++++++++++++++++++--
 core/common/src/wire_conversions.rs             |  18 +-
 core/integration/tests/server/http_vsr.rs       |  83 +++++
 core/sdk/src/prelude.rs                         |  34 +--
 core/server/src/http/extractor.rs               |  40 ++-
 core/server/src/http/handlers.rs                |  12 +-
 foreign/cpp/src/type_conversion.rs              |  18 +-
 foreign/python/src/user_headers.rs              |   7 +-
 foreign/python/tests/test_message_operations.py |   4 +-
 12 files changed, 784 insertions(+), 83 deletions(-)

diff --git a/core/common/src/http/messages/send_messages.rs 
b/core/common/src/http/messages/send_messages.rs
index 5851f3de6..7b1856c35 100644
--- a/core/common/src/http/messages/send_messages.rs
+++ b/core/common/src/http/messages/send_messages.rs
@@ -21,8 +21,8 @@ use crate::PartitionContext;
 use crate::PartitioningKind;
 use crate::Validatable;
 use crate::error::IggyError;
-use crate::types::message::HeaderEntry;
 use crate::types::message::partitioning::Partitioning;
+use crate::types::message::{HeaderEntry, RawHeaderEntry, UserHeaders};
 use crate::{
     IggyMessage, IggyMessagesBatch, SendMessagesConfirmationResponse, 
SendMessagesResponse,
 };
@@ -31,7 +31,6 @@ use bytes::Bytes;
 use serde::de::{self, MapAccess, Visitor};
 use serde::ser::SerializeStruct;
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
-use std::collections::BTreeMap;
 use std::fmt::Formatter;
 
 /// `SendMessages` command is used to send messages to a topic in a stream.
@@ -139,6 +138,33 @@ impl Serialize for SendMessages {
 
 impl<'de> Deserialize<'de> for SendMessages {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        Self::deserialize_recording(deserializer, &mut None)
+    }
+}
+
+impl SendMessages {
+    /// The error of the typed user header that refused the JSON `body`, `None`
+    /// when the body parses or failed on something else. A refused header is
+    /// otherwise only a serde error, so the server reads this to answer the
+    /// header's own `InvalidHeaderKey` or `InvalidHeaderValue`.
+    pub fn refused_user_header(body: &[u8]) -> Option<IggyError> {
+        let mut refused = None;
+        let parsed = Self::deserialize_recording(
+            &mut serde_json::Deserializer::from_slice(body),
+            &mut refused,
+        );
+        parsed.err().and(refused)
+    }
+
+    /// Deserializes the HTTP body, keeping in `refused` the error of a typed
+    /// user header that the header codec refuses.
+    fn deserialize_recording<'de, D>(
+        deserializer: D,
+        refused: &mut Option<IggyError>,
+    ) -> Result<Self, D::Error>
     where
         D: Deserializer<'de>,
     {
@@ -181,9 +207,11 @@ impl<'de> Deserialize<'de> for SendMessages {
             }
         }
 
-        struct SendMessagesVisitor;
+        struct SendMessagesVisitor<'a> {
+            refused: &'a mut Option<IggyError>,
+        }
 
-        impl<'de> Visitor<'de> for SendMessagesVisitor {
+        impl<'de> Visitor<'de> for SendMessagesVisitor<'_> {
             type Value = SendMessages;
 
             fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result 
{
@@ -246,18 +274,7 @@ impl<'de> Deserialize<'de> for SendMessages {
                                             })?;
                                             (None, Some(Bytes::from(raw)))
                                         } else {
-                                            let entries: Vec<HeaderEntry> = 
serde_json::from_value(
-                                                headers.clone(),
-                                            )
-                                            .map_err(|e| {
-                                                de::Error::custom(format!(
-                                                    "Invalid headers format: 
{e}"
-                                                ))
-                                            })?;
-                                            let mut map = BTreeMap::new();
-                                            for entry in entries {
-                                                map.insert(entry.key, 
entry.value);
-                                            }
+                                            let map = 
typed_user_headers(headers, self.refused)?;
                                             (Some(map), None)
                                         }
                                     } else {
@@ -317,7 +334,7 @@ impl<'de> Deserialize<'de> for SendMessages {
             }
         }
 
-        deserializer.deserialize_struct("SendMessages", FIELDS, 
SendMessagesVisitor)
+        deserializer.deserialize_struct("SendMessages", FIELDS, 
SendMessagesVisitor { refused })
     }
 }
 
@@ -375,6 +392,27 @@ impl From<SendMessagesConfirmations> for 
SendMessagesResponse {
     }
 }
 
+/// The typed `user_headers` of one HTTP message, keeping in `refused` the
+/// error of a field the header codec refuses.
+fn typed_user_headers<E: de::Error>(
+    headers: &serde_json::Value,
+    refused: &mut Option<IggyError>,
+) -> Result<UserHeaders, E> {
+    let entries: Vec<RawHeaderEntry> = serde_json::from_value(headers.clone())
+        .map_err(|e| E::custom(format!("Invalid headers format: {e}")))?;
+    entries
+        .into_iter()
+        .map(|entry| {
+            let entry = HeaderEntry::try_from(entry).map_err(|error| {
+                let message = format!("Invalid headers format: {error}");
+                *refused = Some(error);
+                E::custom(message)
+            })?;
+            Ok((entry.key, entry.value))
+        })
+        .collect()
+}
+
 fn parse_message_id(value: Option<&serde_json::Value>) -> Result<u128, String> 
{
     let value = match value {
         Some(v) => v,
@@ -577,4 +615,134 @@ mod tests {
         let result = parse_message_id(Some(&value));
         assert!(result.is_err());
     }
+
+    fn send(headers: serde_json::Value) -> Result<SendMessages, 
serde_json::Error> {
+        serde_json::from_value(serde_json::json!({
+            "partitioning": { "kind": "balanced", "value": "" },
+            "messages": [{ "id": 1, "payload": "aGVsbG8=", "user_headers": 
headers }]
+        }))
+    }
+
+    #[test]
+    fn typed_header_with_valid_headers_is_accepted_at_the_http_boundary() {
+        let parsed = send(serde_json::json!([{
+            "key":   { "kind": "string", "value": "a2V5" },
+            "value": { "kind": "string", "value": "dmFs" }
+        }]));
+        assert!(parsed.is_ok());
+    }
+
+    #[test]
+    fn typed_header_with_wrong_size_value_is_rejected_at_the_http_boundary() {
+        let parsed = send(serde_json::json!([{
+            "key":   { "kind": "string", "value": "a2V5" },
+            "value": { "kind": "int32",  "value": "AQ==" }
+        }]));
+        assert!(parsed.is_err());
+    }
+
+    #[test]
+    fn typed_header_with_empty_value_is_rejected_at_the_http_boundary() {
+        let parsed = send(serde_json::json!([
+            { "key": { "kind": "string", "value": "b2s=" }, "value": { "kind": 
"string", "value": "dmFs" } },
+            { "key": { "kind": "string", "value": "a2V5" }, "value": { "kind": 
"raw", "value": "" } }
+        ]));
+        assert!(parsed.is_err());
+    }
+
+    #[test]
+    fn typed_header_with_wrong_size_key_is_rejected_at_the_http_boundary() {
+        let parsed = send(serde_json::json!([{
+            "key":   { "kind": "int32", "value": "AQ==" },
+            "value": { "kind": "string", "value": "dmFs" }
+        }]));
+        assert!(parsed.is_err());
+    }
+
+    #[test]
+    fn typed_header_with_empty_key_is_rejected_at_the_http_boundary() {
+        let parsed = send(serde_json::json!([{
+            "key":   { "kind": "string", "value": "" },
+            "value": { "kind": "string", "value": "dmFs" }
+        }]));
+        assert!(parsed.is_err());
+    }
+
+    #[test]
+    fn typed_header_with_oversized_fields_is_rejected_at_the_http_boundary() {
+        let oversized = BASE64.encode([0u8; 256]);
+        let parsed_key = send(serde_json::json!([{
+            "key":   { "kind": "string", "value": oversized },
+            "value": { "kind": "string", "value": "dmFs" }
+        }]));
+        assert!(parsed_key.is_err());
+
+        let parsed_val = send(serde_json::json!([{
+            "key":   { "kind": "string", "value": "a2V5" },
+            "value": { "kind": "string", "value": oversized }
+        }]));
+        assert!(parsed_val.is_err());
+    }
+
+    fn body(payload: &str, headers: serde_json::Value) -> Vec<u8> {
+        serde_json::to_vec(&serde_json::json!({
+            "partitioning": { "kind": "balanced", "value": "" },
+            "messages": [
+                { "id": 1, "payload": "aGVsbG8=" },
+                { "id": 2, "payload": payload, "user_headers": headers }
+            ]
+        }))
+        .unwrap()
+    }
+
+    #[test]
+    fn refused_user_header_names_the_error_of_the_refused_field() {
+        let wrong_size_key = body(
+            "aGVsbG8=",
+            serde_json::json!([{
+                "key":   { "kind": "int32",  "value": "AQ==" },
+                "value": { "kind": "string", "value": "dmFs" }
+            }]),
+        );
+        assert_eq!(
+            SendMessages::refused_user_header(&wrong_size_key),
+            Some(IggyError::InvalidHeaderKey)
+        );
+
+        let empty_value = body(
+            "aGVsbG8=",
+            serde_json::json!([
+                { "key": { "kind": "string", "value": "b2s=" }, "value": { 
"kind": "string", "value": "dmFs" } },
+                { "key": { "kind": "string", "value": "a2V5" }, "value": { 
"kind": "raw", "value": "" } }
+            ]),
+        );
+        assert_eq!(
+            SendMessages::refused_user_header(&empty_value),
+            Some(IggyError::InvalidHeaderValue)
+        );
+    }
+
+    #[test]
+    fn refused_user_header_is_none_when_no_header_refused_the_body() {
+        let valid_header = serde_json::json!([{
+            "key":   { "kind": "string", "value": "a2V5" },
+            "value": { "kind": "int32",  "value": "AQAAAA==" }
+        }]);
+        assert_eq!(
+            SendMessages::refused_user_header(&body("aGVsbG8=", 
valid_header.clone())),
+            None
+        );
+        assert_eq!(
+            SendMessages::refused_user_header(&body("not base64!", 
valid_header)),
+            None
+        );
+        let missing_kind = serde_json::json!([{
+            "key":   { "value": "a2V5" },
+            "value": { "kind": "string", "value": "dmFs" }
+        }]);
+        assert_eq!(
+            SendMessages::refused_user_header(&body("aGVsbG8=", missing_kind)),
+            None
+        );
+    }
 }
diff --git a/core/common/src/types/message/iggy_message.rs 
b/core/common/src/types/message/iggy_message.rs
index 7fe3a3a7c..6b3da9b50 100644
--- a/core/common/src/types/message/iggy_message.rs
+++ b/core/common/src/types/message/iggy_message.rs
@@ -884,4 +884,57 @@ mod tests {
         assert_eq!(&buf[..8], b"existing");
         assert_eq!(&buf[8..], &message.to_bytes()[..]);
     }
+
+    #[test]
+    fn deserializing_message_with_malformed_typed_headers_fails() {
+        let header = 
serde_json::to_value(IggyMessageHeader::default()).unwrap();
+
+        // Valid headers deserialize successfully
+        let json_valid = serde_json::json!({
+            "header": header,
+            "payload": "aGVsbG8=",
+            "user_headers": [{
+                "key": { "kind": "string", "value": "a2V5" },
+                "value": { "kind": "string", "value": "dmFs" }
+            }]
+        });
+        let result: Result<IggyMessage, _> = 
serde_json::from_value(json_valid);
+        assert!(result.is_ok());
+
+        // Wrong size value (int32 with 1 byte)
+        let json_wrong_val = serde_json::json!({
+            "header": header,
+            "payload": "aGVsbG8=",
+            "user_headers": [{
+                "key": { "kind": "string", "value": "a2V5" },
+                "value": { "kind": "int32", "value": "AQ==" }
+            }]
+        });
+        let result: Result<IggyMessage, _> = 
serde_json::from_value(json_wrong_val);
+        assert!(result.is_err());
+
+        // Empty value
+        let json_empty_val = serde_json::json!({
+            "header": header,
+            "payload": "aGVsbG8=",
+            "user_headers": [{
+                "key": { "kind": "string", "value": "a2V5" },
+                "value": { "kind": "raw", "value": "" }
+            }]
+        });
+        let result: Result<IggyMessage, _> = 
serde_json::from_value(json_empty_val);
+        assert!(result.is_err());
+
+        // Wrong size key
+        let json_wrong_key = serde_json::json!({
+            "header": header,
+            "payload": "aGVsbG8=",
+            "user_headers": [{
+                "key": { "kind": "int32", "value": "AQ==" },
+                "value": { "kind": "string", "value": "dmFs" }
+            }]
+        });
+        let result: Result<IggyMessage, _> = 
serde_json::from_value(json_wrong_key);
+        assert!(result.is_err());
+    }
 }
diff --git a/core/common/src/types/message/mod.rs 
b/core/common/src/types/message/mod.rs
index 5a4de21ab..ea1f90b34 100644
--- a/core/common/src/types/message/mod.rs
+++ b/core/common/src/types/message/mod.rs
@@ -70,7 +70,8 @@ pub use poll_options::{AutoCommit, CommitAfter, CommitEvery, 
CommitPolicy, PollO
 pub use polled_messages::{DecodeError, PolledMessages, UndecodedRecord, 
record_at};
 pub use polling_kind::PollingKind;
 pub use polling_strategy::PollingStrategy;
+pub(crate) use user_headers::RawHeaderEntry;
 pub use user_headers::{
-    HeaderEntry, HeaderField, HeaderKey, HeaderKind, HeaderValue, KeyMarker, 
UserHeaders,
-    ValueMarker, deserialize_headers, serialize_headers,
+    HeaderEntry, HeaderField, HeaderFieldMarker, HeaderKey, HeaderKind, 
HeaderValue, KeyMarker,
+    UserHeaders, ValueMarker, deserialize_headers, serialize_headers,
 };
diff --git a/core/common/src/types/message/user_headers.rs 
b/core/common/src/types/message/user_headers.rs
index 07223ce80..a4ce80fb7 100644
--- a/core/common/src/types/message/user_headers.rs
+++ b/core/common/src/types/message/user_headers.rs
@@ -91,6 +91,31 @@ pub struct KeyMarker;
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub struct ValueMarker;
 
+mod private {
+    pub trait Sealed {}
+    impl Sealed for super::KeyMarker {}
+    impl Sealed for super::ValueMarker {}
+}
+
+/// Sealed trait marking valid header field markers ([`KeyMarker`] and 
[`ValueMarker`]).
+pub trait HeaderFieldMarker:
+    std::fmt::Debug + Clone + Copy + PartialEq + Eq + Hash + private::Sealed
+{
+    fn invalid_error() -> IggyError;
+}
+
+impl HeaderFieldMarker for KeyMarker {
+    fn invalid_error() -> IggyError {
+        IggyError::InvalidHeaderKey
+    }
+}
+
+impl HeaderFieldMarker for ValueMarker {
+    fn invalid_error() -> IggyError {
+        IggyError::InvalidHeaderValue
+    }
+}
+
 /// A typed header field that can be used as either a key or value in message 
headers.
 ///
 /// `HeaderField` is a generic struct parameterized by a marker type 
(`KeyMarker` or `ValueMarker`)
@@ -142,7 +167,7 @@ pub struct ValueMarker;
 /// # Ok::<(), IggyError>(())
 /// ```
 #[serde_as]
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
+#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
 pub struct HeaderField<T> {
     kind: HeaderKind,
     #[serde_as(as = "Base64")]
@@ -151,6 +176,24 @@ pub struct HeaderField<T> {
     _marker: PhantomData<T>,
 }
 
+#[serde_as]
+#[derive(Deserialize)]
+struct RawHeaderField {
+    kind: HeaderKind,
+    #[serde_as(as = "Base64")]
+    value: Bytes,
+}
+
+impl<'de, T: HeaderFieldMarker> Deserialize<'de> for HeaderField<T> {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let raw = RawHeaderField::deserialize(deserializer)?;
+        Self::from_raw_bytes(raw.kind, 
raw.value).map_err(serde::de::Error::custom)
+    }
+}
+
 impl<T> HeaderField<T> {
     /// Returns the kind of this header field.
     pub fn kind(&self) -> HeaderKind {
@@ -353,7 +396,7 @@ impl<T> Hash for HeaderField<T> {
     }
 }
 
-impl<T> FromStr for HeaderField<T> {
+impl<T: HeaderFieldMarker> FromStr for HeaderField<T> {
     type Err = IggyError;
     fn from_str(s: &str) -> Result<Self, Self::Err> {
         Self::try_from(s)
@@ -616,7 +659,9 @@ impl<T> HeaderField<T> {
             _marker: PhantomData,
         }
     }
+}
 
+impl<T: HeaderFieldMarker> HeaderField<T> {
     /// Wrap raw wire bytes as a field of `kind`.
     ///
     /// The option catalog hands out each key's default as a kind plus bytes, 
so
@@ -624,19 +669,43 @@ impl<T> HeaderField<T> {
     ///
     /// # Errors
     ///
-    /// `IggyError::InvalidHeaderValue` when a fixed-size kind's payload is the
-    /// wrong length, or the payload exceeds the field length limit.
+    /// `IggyError::InvalidHeaderKey` or `IggyError::InvalidHeaderValue` when a
+    /// fixed-size kind's payload is the wrong length, or the payload exceeds
+    /// the field length limit.
     pub fn from_raw(kind: HeaderKind, value: &[u8]) -> Result<Self, IggyError> 
{
+        Self::validate_raw(kind, value.len())?;
+        Ok(Self::new_unchecked(kind, value))
+    }
+
+    /// Wrap owned wire `Bytes` as a field of `kind`.
+    ///
+    /// Validates length constraints and moves `value` without copying.
+    ///
+    /// # Errors
+    ///
+    /// `IggyError::InvalidHeaderKey` or `IggyError::InvalidHeaderValue` when a
+    /// fixed-size kind's payload is the wrong length, or the payload exceeds
+    /// the field length limit.
+    pub fn from_raw_bytes(kind: HeaderKind, value: Bytes) -> Result<Self, 
IggyError> {
+        Self::validate_raw(kind, value.len())?;
+        Ok(Self {
+            kind,
+            value,
+            _marker: PhantomData,
+        })
+    }
+
+    fn validate_raw(kind: HeaderKind, len: usize) -> Result<(), IggyError> {
         // 255 is the field length the TLV codec's own `TryFrom` impls enforce.
-        if value.is_empty() || value.len() > 255 {
-            return Err(IggyError::InvalidHeaderValue);
+        if len == 0 || len > 255 {
+            return Err(T::invalid_error());
         }
         if let Some(expected) = kind.expected_size()
-            && value.len() != expected
+            && len != expected
         {
-            return Err(IggyError::InvalidHeaderValue);
+            return Err(T::invalid_error());
         }
-        Ok(Self::new_unchecked(kind, value))
+        Ok(())
     }
 }
 
@@ -652,34 +721,34 @@ impl<T: Eq> PartialOrd for HeaderField<T> {
     }
 }
 
-impl<T> TryFrom<&str> for HeaderField<T> {
+impl<T: HeaderFieldMarker> TryFrom<&str> for HeaderField<T> {
     type Error = IggyError;
     fn try_from(value: &str) -> Result<Self, Self::Error> {
         if value.is_empty() || value.len() > 255 {
-            return Err(IggyError::InvalidHeaderValue);
+            return Err(T::invalid_error());
         }
         Ok(Self::new_unchecked(HeaderKind::String, value.as_bytes()))
     }
 }
 
-impl<T> TryFrom<String> for HeaderField<T> {
+impl<T: HeaderFieldMarker> TryFrom<String> for HeaderField<T> {
     type Error = IggyError;
     fn try_from(value: String) -> Result<Self, Self::Error> {
         Self::try_from(value.as_str())
     }
 }
 
-impl<T> TryFrom<&[u8]> for HeaderField<T> {
+impl<T: HeaderFieldMarker> TryFrom<&[u8]> for HeaderField<T> {
     type Error = IggyError;
     fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
         if value.is_empty() || value.len() > 255 {
-            return Err(IggyError::InvalidHeaderValue);
+            return Err(T::invalid_error());
         }
         Ok(Self::new_unchecked(HeaderKind::Raw, value))
     }
 }
 
-impl<T> TryFrom<Vec<u8>> for HeaderField<T> {
+impl<T: HeaderFieldMarker> TryFrom<Vec<u8>> for HeaderField<T> {
     type Error = IggyError;
     fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {
         Self::try_from(value.as_slice())
@@ -980,6 +1049,26 @@ pub struct HeaderEntry {
     pub value: HeaderValue,
 }
 
+/// A [`HeaderEntry`] as JSON carries it, before either field is checked, so a
+/// caller that needs the refusal as an [`IggyError`] rather than a serde error
+/// can convert it with `HeaderEntry::try_from`.
+#[derive(Deserialize)]
+pub(crate) struct RawHeaderEntry {
+    key: RawHeaderField,
+    value: RawHeaderField,
+}
+
+impl TryFrom<RawHeaderEntry> for HeaderEntry {
+    type Error = IggyError;
+
+    fn try_from(entry: RawHeaderEntry) -> Result<Self, Self::Error> {
+        Ok(Self {
+            key: HeaderKey::from_raw_bytes(entry.key.kind, entry.key.value)?,
+            value: HeaderValue::from_raw_bytes(entry.value.kind, 
entry.value.value)?,
+        })
+    }
+}
+
 pub fn serialize_headers<S>(headers: &Option<UserHeaders>, serializer: S) -> 
Result<S::Ok, S::Error>
 where
     S: serde::Serializer,
@@ -1022,6 +1111,7 @@ where
 mod tests {
     use super::*;
     use crate::wire_conversions::{user_headers_from_wire, 
user_headers_to_wire};
+    use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
     use bytes::{BufMut, BytesMut};
     use iggy_binary_protocol::WireUserHeaders;
 
@@ -1041,7 +1131,35 @@ mod tests {
         let header_key = HeaderKey::try_from(value);
         assert!(header_key.is_err());
         let error = header_key.unwrap_err();
-        assert_eq!(error.as_code(), IggyError::InvalidHeaderValue.as_code());
+        assert_eq!(error.as_code(), IggyError::InvalidHeaderKey.as_code());
+
+        let from_str_res = HeaderKey::from_str(value);
+        assert!(from_str_res.is_err());
+        assert_eq!(
+            from_str_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+
+        let from_string_res = HeaderKey::try_from(value.to_string());
+        assert!(from_string_res.is_err());
+        assert_eq!(
+            from_string_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+
+        let from_slice_res = HeaderKey::try_from([].as_slice());
+        assert!(from_slice_res.is_err());
+        assert_eq!(
+            from_slice_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+
+        let from_vec_res = HeaderKey::try_from(Vec::<u8>::new());
+        assert!(from_vec_res.is_err());
+        assert_eq!(
+            from_vec_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
     }
 
     #[test]
@@ -1050,7 +1168,35 @@ mod tests {
         let header_key = HeaderKey::try_from(value.as_str());
         assert!(header_key.is_err());
         let error = header_key.unwrap_err();
-        assert_eq!(error.as_code(), IggyError::InvalidHeaderValue.as_code());
+        assert_eq!(error.as_code(), IggyError::InvalidHeaderKey.as_code());
+
+        let from_str_res = HeaderKey::from_str(value.as_str());
+        assert!(from_str_res.is_err());
+        assert_eq!(
+            from_str_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+
+        let from_string_res = HeaderKey::try_from(value.clone());
+        assert!(from_string_res.is_err());
+        assert_eq!(
+            from_string_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+
+        let from_slice_res = HeaderKey::try_from(vec![0u8; 256].as_slice());
+        assert!(from_slice_res.is_err());
+        assert_eq!(
+            from_slice_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+
+        let from_vec_res = HeaderKey::try_from(vec![0u8; 256]);
+        assert!(from_vec_res.is_err());
+        assert_eq!(
+            from_vec_res.unwrap_err().as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
     }
 
     #[test]
@@ -1659,4 +1805,215 @@ mod tests {
         };
         assert_eq!(field.to_string_value(), "<malformed bool>");
     }
+
+    #[test]
+    fn header_key_from_raw_rejects_empty_and_oversized_and_wrong_size() {
+        assert_eq!(
+            HeaderKey::from_raw(HeaderKind::String, b"")
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+        assert_eq!(
+            HeaderKey::from_raw(HeaderKind::String, &[0u8; 256])
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+        assert_eq!(
+            HeaderKey::from_raw(HeaderKind::Int32, &[1, 2])
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+    }
+
+    #[test]
+    fn header_value_from_raw_rejects_empty_and_oversized_and_wrong_size() {
+        assert_eq!(
+            HeaderValue::from_raw(HeaderKind::Raw, b"")
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderValue.as_code()
+        );
+        assert_eq!(
+            HeaderValue::from_raw(HeaderKind::Raw, &[0u8; 256])
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderValue.as_code()
+        );
+        assert_eq!(
+            HeaderValue::from_raw(HeaderKind::Int32, &[1, 2])
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderValue.as_code()
+        );
+    }
+
+    #[test]
+    fn header_key_from_raw_bytes_accepts_valid_and_rejects_invalid() {
+        let valid = HeaderKey::from_raw_bytes(HeaderKind::String, 
Bytes::from_static(b"my-key"));
+        assert!(valid.is_ok());
+        let key = valid.unwrap();
+        assert_eq!(key.as_str().unwrap(), "my-key");
+        assert_eq!(key.value(), Bytes::from_static(b"my-key"));
+
+        assert_eq!(
+            HeaderKey::from_raw_bytes(HeaderKind::String, Bytes::new())
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+        assert_eq!(
+            HeaderKey::from_raw_bytes(HeaderKind::String, 
Bytes::copy_from_slice(&[0u8; 256]))
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+        assert_eq!(
+            HeaderKey::from_raw_bytes(HeaderKind::Int32, 
Bytes::from_static(&[1, 2]))
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderKey.as_code()
+        );
+    }
+
+    #[test]
+    fn header_value_from_raw_bytes_accepts_valid_and_rejects_invalid() {
+        let valid = HeaderValue::from_raw_bytes(
+            HeaderKind::Int32,
+            Bytes::copy_from_slice(&42i32.to_le_bytes()),
+        );
+        assert!(valid.is_ok());
+        let value = valid.unwrap();
+        assert_eq!(value.as_int32().unwrap(), 42);
+
+        assert_eq!(
+            HeaderValue::from_raw_bytes(HeaderKind::Raw, Bytes::new())
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderValue.as_code()
+        );
+        assert_eq!(
+            HeaderValue::from_raw_bytes(HeaderKind::Raw, 
Bytes::copy_from_slice(&[0u8; 256]))
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderValue.as_code()
+        );
+        assert_eq!(
+            HeaderValue::from_raw_bytes(HeaderKind::Int32, 
Bytes::from_static(&[1, 2]))
+                .unwrap_err()
+                .as_code(),
+            IggyError::InvalidHeaderValue.as_code()
+        );
+    }
+
+    #[test]
+    fn deserialize_header_field_validates_value() {
+        // Valid string header
+        let valid_json = serde_json::json!({
+            "kind": "string",
+            "value": "aGVsbG8=" // "hello"
+        });
+        let key: Result<HeaderKey, _> = 
serde_json::from_value(valid_json.clone());
+        assert!(key.is_ok());
+        let val: Result<HeaderValue, _> = serde_json::from_value(valid_json);
+        assert!(val.is_ok());
+
+        // Empty value rejected
+        let empty_json = serde_json::json!({
+            "kind": "raw",
+            "value": ""
+        });
+        let key_err: Result<HeaderKey, _> = 
serde_json::from_value(empty_json.clone());
+        assert!(key_err.is_err());
+        assert!(
+            key_err
+                .unwrap_err()
+                .to_string()
+                .contains("Invalid header key")
+        );
+
+        let val_err: Result<HeaderValue, _> = 
serde_json::from_value(empty_json);
+        assert!(val_err.is_err());
+        assert!(
+            val_err
+                .unwrap_err()
+                .to_string()
+                .contains("Invalid header value")
+        );
+
+        // Wrong size for fixed-size kind rejected (int32 needs 4 bytes, got 1)
+        let wrong_size_json = serde_json::json!({
+            "kind": "int32",
+            "value": "AQ==" // 1 byte [1]
+        });
+        let key_err: Result<HeaderKey, _> = 
serde_json::from_value(wrong_size_json.clone());
+        assert!(key_err.is_err());
+        assert!(
+            key_err
+                .unwrap_err()
+                .to_string()
+                .contains("Invalid header key")
+        );
+
+        let val_err: Result<HeaderValue, _> = 
serde_json::from_value(wrong_size_json);
+        assert!(val_err.is_err());
+        assert!(
+            val_err
+                .unwrap_err()
+                .to_string()
+                .contains("Invalid header value")
+        );
+
+        // Oversized (> 255 bytes) rejected
+        let oversized_b64 = BASE64.encode([0u8; 256]);
+        let oversized_json = serde_json::json!({
+            "kind": "string",
+            "value": oversized_b64
+        });
+        let key_err: Result<HeaderKey, _> = 
serde_json::from_value(oversized_json.clone());
+        assert!(key_err.is_err());
+        let val_err: Result<HeaderValue, _> = 
serde_json::from_value(oversized_json);
+        assert!(val_err.is_err());
+    }
+
+    #[test]
+    fn deserialize_header_entry_validates_key_and_value() {
+        // Valid entry
+        let valid_entry = serde_json::json!({
+            "key": { "kind": "string", "value": "a2V5" },
+            "value": { "kind": "int32", "value": "AQAAAA==" }
+        });
+        let entry: Result<HeaderEntry, _> = 
serde_json::from_value(valid_entry);
+        assert!(entry.is_ok());
+
+        // Malformed key
+        let malformed_key = serde_json::json!({
+            "key": { "kind": "int32", "value": "AQ==" },
+            "value": { "kind": "string", "value": "dmFs" }
+        });
+        let entry: Result<HeaderEntry, _> = 
serde_json::from_value(malformed_key);
+        assert!(entry.is_err());
+        assert!(
+            entry
+                .unwrap_err()
+                .to_string()
+                .contains("Invalid header key")
+        );
+
+        // Malformed value
+        let malformed_value = serde_json::json!({
+            "key": { "kind": "string", "value": "a2V5" },
+            "value": { "kind": "int32", "value": "AQ==" }
+        });
+        let entry: Result<HeaderEntry, _> = 
serde_json::from_value(malformed_value);
+        assert!(entry.is_err());
+        assert!(
+            entry
+                .unwrap_err()
+                .to_string()
+                .contains("Invalid header value")
+        );
+    }
 }
diff --git a/core/common/src/wire_conversions.rs 
b/core/common/src/wire_conversions.rs
index 965fc4713..e87723ba7 100644
--- a/core/common/src/wire_conversions.rs
+++ b/core/common/src/wire_conversions.rs
@@ -789,6 +789,10 @@ pub fn user_headers_to_wire(
     }
     // Buffer was just encoded from valid HeaderKey/HeaderValue entries,
     // so structural TLV validity is guaranteed by construction.
+    debug_assert!(
+        iggy_binary_protocol::validate_user_headers(&buf).is_ok(),
+        "encoded user headers must be structurally valid TLV"
+    );
     WireUserHeaders::from_validated(buf.freeze())
 }
 
@@ -850,20 +854,10 @@ fn headers_from_validated_slice(
                 return Err(error);
             }
         };
-        if let Some(expected) = key_kind.expected_size()
-            && entry.key.len() != expected
-        {
-            return Err(IggyError::InvalidHeaderKey);
-        }
-        if let Some(expected) = value_kind.expected_size()
-            && entry.value.len() != expected
-        {
-            return Err(IggyError::InvalidHeaderValue);
-        }
 
         headers.insert(
-            HeaderKey::new_unchecked(key_kind, entry.key),
-            HeaderValue::new_unchecked(value_kind, entry.value),
+            HeaderKey::from_raw(key_kind, entry.key)?,
+            HeaderValue::from_raw(value_kind, entry.value)?,
         );
     }
     Ok(headers)
diff --git a/core/integration/tests/server/http_vsr.rs 
b/core/integration/tests/server/http_vsr.rs
index 27f30cd48..297ddaaa1 100644
--- a/core/integration/tests/server/http_vsr.rs
+++ b/core/integration/tests/server/http_vsr.rs
@@ -854,6 +854,89 @@ async fn 
given_oversized_body_when_producing_should_reject_413(harness: &TestHar
     );
 }
 
+#[iggy_harness]
+async fn 
given_refused_typed_header_when_producing_should_reject_400_with_header_error(
+    harness: &TestHarness,
+) {
+    let http = HttpClient::login_root(harness).await;
+    http.create_stream_and_topic("http-headers", "typed", 1)
+        .await;
+    let path = "/streams/http-headers/topics/typed/messages";
+    let produce = async |key: serde_json::Value, value: serde_json::Value| {
+        let body = json!({
+            "partitioning": { "kind": "balanced", "value": "" },
+            "messages": [{
+                "id": 1,
+                "payload": "aGVsbG8=",
+                "user_headers": [{ "key": key, "value": value }]
+            }]
+        });
+        http.client
+            .post(http.url(path))
+            .bearer_auth(&http.token)
+            .json(&body)
+            .send()
+            .await
+            .expect("produce request")
+    };
+    let string = |value: &str| json!({ "kind": "string", "value": value });
+    let one_byte_int32 = json!({ "kind": "int32", "value": "AQ==" });
+
+    let cases = [
+        (
+            one_byte_int32.clone(),
+            string("dmFs"),
+            IggyError::InvalidHeaderKey,
+        ),
+        (string(""), string("dmFs"), IggyError::InvalidHeaderKey),
+        (
+            string("a2V5"),
+            one_byte_int32,
+            IggyError::InvalidHeaderValue,
+        ),
+        (
+            string("a2V5"),
+            json!({ "kind": "raw", "value": "" }),
+            IggyError::InvalidHeaderValue,
+        ),
+    ];
+    for (key, value, refusal) in cases {
+        let case = format!("key={key} value={value}");
+        let response = produce(key, value).await;
+        assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case}");
+        let error: serde_json::Value = response.json().await.expect("decode 
the error body");
+        assert_eq!(error["id"], refusal.as_code(), "{case}");
+        assert_eq!(error["code"], refusal.as_string(), "{case}");
+    }
+
+    let response = produce(
+        string("a2V5"),
+        json!({ "kind": "int32", "value": "AQAAAA==" }),
+    )
+    .await;
+    assert_eq!(
+        response.status(),
+        StatusCode::CREATED,
+        "a well-formed typed header must still commit"
+    );
+    let polled = http
+        .poll("http-headers", "typed", PARTITION_ID, 0, 10)
+        .await;
+    assert_eq!(
+        polled.messages.len(),
+        1,
+        "only the well-formed produce must land"
+    );
+    let user_headers = polled.messages[0]
+        .user_headers_map()
+        .expect("headers decode")
+        .expect("the message carries user headers");
+    assert_eq!(
+        user_headers.get(&HeaderKey::try_from("key").expect("header key")),
+        Some(&HeaderValue::from(1i32)),
+    );
+}
+
 #[iggy_harness]
 async fn given_ack_none_when_producing_should_return_202_and_commit(harness: 
&TestHarness) {
     const BURST_SIZE: u32 = 8;
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index e252507ff..345322b9f 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -55,23 +55,23 @@ pub use iggy_common::{
     ClusterNodeRole, ClusterNodeStatus, CommitAfter, CommitEvery, CommitPolicy,
     CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupAssignment, 
ConsumerGroupDetails,
     ConsumerGroupMember, ConsumerKind, Credentials, Durability, EncryptorKind, 
GlobalPermissions,
-    HeaderField, HeaderKey, HeaderKind, HeaderValue, HttpClientConfig, 
HttpClientConfigBuilder,
-    HttpMethod, IdKind, Identifier, IdentityInfo, IggyByteSize, IggyDuration, 
IggyError,
-    IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader, 
IggyMessageHeaderView,
-    IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize, 
NonZeroDurationError,
-    NonZeroIggyDuration, OptionKey, OptionSpec, OptionValue, OptionsScope, 
Partition,
-    PartitionContext, PartitionResizePolicy, Partitioner, Partitioning, 
PendingOperationsConfig,
-    Permissions, PersonalAccessTokenExpiry, PollMessages, PollOptions, 
PolledMessages, PollingKind,
-    PollingStrategy, QuicClientConfig, QuicClientConfigBuilder, 
QuicClientReconnectionConfig,
-    ResourceOptions, SendMessages, SendMessagesConfirmationResponse, 
SendMessagesResponse,
-    Sizeable, SnapshotCompression, Stats, Stream, StreamCreateOptions, 
StreamDetails,
-    StreamEncryptionKey, StreamEncryptionSource, StreamPermissions, 
StreamUpdateOptions,
-    SystemSnapshotType, TcpClientConfig, TcpClientConfigBuilder, 
TcpClientReconnectionConfig,
-    Topic, TopicCreateOptions, TopicDetails, TopicPermissions, 
TopicResolvedOptions,
-    TopicUpdateOptions, TransportEndpoints, TransportProtocol, 
UndecodedRecord, UserCreateOptions,
-    UserId, UserInfo, UserInfoDetails, UserStatus, UserUpdateOptions, 
Validatable,
-    WebSocketClientConfig, WebSocketClientConfigBuilder, 
WebSocketClientReconnectionConfig,
-    WebSocketConfig, defaults, locking, topic_option_keys,
+    HeaderField, HeaderFieldMarker, HeaderKey, HeaderKind, HeaderValue, 
HttpClientConfig,
+    HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, IdentityInfo, 
IggyByteSize,
+    IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, 
IggyMessageHeader,
+    IggyMessageHeaderView, IggyMessageView, IggyMessageViewIterator, 
IggyTimestamp, MaxTopicSize,
+    NonZeroDurationError, NonZeroIggyDuration, OptionKey, OptionSpec, 
OptionValue, OptionsScope,
+    Partition, PartitionContext, PartitionResizePolicy, Partitioner, 
Partitioning,
+    PendingOperationsConfig, Permissions, PersonalAccessTokenExpiry, 
PollMessages, PollOptions,
+    PolledMessages, PollingKind, PollingStrategy, QuicClientConfig, 
QuicClientConfigBuilder,
+    QuicClientReconnectionConfig, ResourceOptions, SendMessages, 
SendMessagesConfirmationResponse,
+    SendMessagesResponse, Sizeable, SnapshotCompression, Stats, Stream, 
StreamCreateOptions,
+    StreamDetails, StreamEncryptionKey, StreamEncryptionSource, 
StreamPermissions,
+    StreamUpdateOptions, SystemSnapshotType, TcpClientConfig, 
TcpClientConfigBuilder,
+    TcpClientReconnectionConfig, Topic, TopicCreateOptions, TopicDetails, 
TopicPermissions,
+    TopicResolvedOptions, TopicUpdateOptions, TransportEndpoints, 
TransportProtocol,
+    UndecodedRecord, UserCreateOptions, UserId, UserInfo, UserInfoDetails, 
UserStatus,
+    UserUpdateOptions, Validatable, WebSocketClientConfig, 
WebSocketClientConfigBuilder,
+    WebSocketClientReconnectionConfig, WebSocketConfig, defaults, locking, 
topic_option_keys,
 };
 pub use iggy_common::{
     Client, ClusterClient, ConsumerGroupClient, ConsumerOffsetClient, 
MessageClient,
diff --git a/core/server/src/http/extractor.rs 
b/core/server/src/http/extractor.rs
index 130a63b15..78376d3a2 100644
--- a/core/server/src/http/extractor.rs
+++ b/core/server/src/http/extractor.rs
@@ -25,6 +25,7 @@ use std::rc::Rc;
 
 use axum::Json;
 use axum::body::{Body, Bytes};
+use axum::extract::rejection::JsonRejection;
 use axum::extract::{ConnectInfo, FromRequest, FromRequestParts, Request};
 use axum::http::HeaderMap;
 use axum::http::header::AUTHORIZATION;
@@ -32,7 +33,7 @@ use axum::http::request::Parts;
 use axum::response::{IntoResponse, Response};
 use iggy_common::{
     IggyError, IggyTimestamp, OptionInputValue, OptionsUpdate, 
PersonalAccessToken, RESET_FIELD,
-    erase_buffer,
+    SendMessages, erase_buffer,
 };
 use send_wrapper::SendWrapper;
 use serde::Deserializer as _;
@@ -40,7 +41,7 @@ use serde::de::{DeserializeOwned, Error as _, IgnoredAny, 
MapAccess, Visitor};
 use tracing::debug;
 
 use super::HttpState;
-use super::error::{AuthError, WriteError};
+use super::error::{AuthError, PartitionWriteError, WriteError};
 use super::jwt::TokenSubject;
 use super::session::HttpSession;
 use super::state::HttpInner;
@@ -92,6 +93,12 @@ pub struct JsonMembers<T> {
 /// at fault where one is.
 pub(in crate::http) struct JsonUpdate(pub OptionsUpdate);
 
+/// A produce body. A typed user header the header codec refuses answers 400
+/// with that header's `InvalidHeaderKey` or `InvalidHeaderValue`, the body
+/// `SendMessages::validate` gives on the same route. Every other rejection
+/// stays axum's own.
+pub(in crate::http) struct JsonSendMessages(pub SendMessages);
+
 /// Walks an update body with the member types [`OptionsUpdate`] parses and
 /// records the first member that does not parse: a repeated name, a `reset`
 /// that is not a list of distinct names, or another member that is not a
@@ -178,6 +185,35 @@ where
     }
 }
 
+impl<S> FromRequest<S> for JsonSendMessages
+where
+    S: Send + Sync,
+{
+    type Rejection = Response;
+
+    async fn from_request(request: Request, state: &S) -> Result<Self, 
Self::Rejection> {
+        let (parts, body) = request.into_parts();
+        let bytes = Bytes::from_request(Request::from_parts(parts.clone(), 
body), state)
+            .await
+            .map_err(IntoResponse::into_response)?;
+        match Json::<SendMessages>::from_request(
+            Request::from_parts(parts, Body::from(bytes.clone())),
+            state,
+        )
+        .await
+        {
+            Ok(Json(command)) => Ok(Self(command)),
+            Err(JsonRejection::JsonDataError(rejection)) => {
+                Err(SendMessages::refused_user_header(&bytes).map_or_else(
+                    || rejection.into_response(),
+                    |error| 
PartitionWriteError::Rejected(error).into_response(),
+                ))
+            }
+            Err(rejection) => Err(rejection.into_response()),
+        }
+    }
+}
+
 impl<'de> Visitor<'de> for RefusedMemberVisitor<'_> {
     type Value = ();
 
diff --git a/core/server/src/http/handlers.rs b/core/server/src/http/handlers.rs
index 1e379d0dc..510aab447 100644
--- a/core/server/src/http/handlers.rs
+++ b/core/server/src/http/handlers.rs
@@ -105,8 +105,8 @@ use iggy_common::{
     ConsumerOffsetInfo, DEFAULT_MAX_TOPIC_SIZE, Identifier, IdentityInfo, 
IggyError, IggyTimestamp,
     MetadataChanges, MetadataChangesQuery, OptionSpec, OptionStorage, 
OptionsScope, Partitioning,
     PersonalAccessTokenInfo, PollMessages, PolledMessages, PollingStrategy, 
RawPersonalAccessToken,
-    SendMessages, SendMessagesConfirmations, Stats, Stream, StreamDetails, 
TokenInfo, Topic,
-    TopicDetails, UserInfo, UserInfoDetails, Validatable, descriptors_for, 
erase_buffer,
+    SendMessagesConfirmations, Stats, Stream, StreamDetails, TokenInfo, Topic, 
TopicDetails,
+    UserInfo, UserInfoDetails, Validatable, descriptors_for, erase_buffer,
 };
 use metadata::impls::metadata::StreamsFrontend;
 use metadata::permissioner::Permissioner;
@@ -129,7 +129,9 @@ use crate::http::error::{
     Consistency, ConsistencyQuery, CustomError, PartitionWriteError, 
ProduceAck, ProduceQuery,
     ReadError, WriteError,
 };
-use crate::http::extractor::{Authenticated, Credential, Identity, JsonMembers, 
JsonUpdate};
+use crate::http::extractor::{
+    Authenticated, Credential, Identity, JsonMembers, JsonSendMessages, 
JsonUpdate,
+};
 use crate::http::forward::{
     FirstAttempt, PartitionEffect, PartitionHop, PartitionRequest, 
require_local_primary,
     serve_partition,
@@ -1677,7 +1679,7 @@ pub(in crate::http) async fn send_messages(
     Path((stream_id, topic_id)): Path<(String, String)>,
     Query(query): Query<ProduceQuery>,
     Extension(request): Extension<Arc<PartitionRequest>>,
-    Json(mut command): Json<SendMessages>,
+    JsonSendMessages(mut command): JsonSendMessages,
 ) -> Result<Response, PartitionWriteError> {
     let stream_id =
         
Identifier::from_str_value(&stream_id).map_err(PartitionWriteError::Rejected)?;
@@ -2361,7 +2363,7 @@ mod tests {
     use iggy_binary_protocol::responses::messages::{
         SendMessagesConfirmationResponse, SendMessagesResponse,
     };
-    use iggy_common::{OptionInputMap, OptionInputValue, OptionKey};
+    use iggy_common::{OptionInputMap, OptionInputValue, OptionKey, 
SendMessages};
     use serde_json::Value;
 
     use crate::http::reply::classify_partition_reply;
diff --git a/foreign/cpp/src/type_conversion.rs 
b/foreign/cpp/src/type_conversion.rs
index 6ac247690..0ad50a458 100644
--- a/foreign/cpp/src/type_conversion.rs
+++ b/foreign/cpp/src/type_conversion.rs
@@ -56,12 +56,13 @@ use iggy_common::{
     ConsumerGroup as RustConsumerGroup, ConsumerGroupInfo as 
RustConsumerGroupInfo,
     ConsumerGroupMember as RustConsumerGroupMember, ConsumerOffsetInfo as 
RustConsumerOffsetInfo,
     GlobalPermissions as RustGlobalPermissions, HeaderEntry as RustHeaderEntry,
-    HeaderField as RustHeaderField, HeaderKind as RustHeaderKind, IdentityInfo 
as RustIdentityInfo,
-    IggyError, IggyTimestamp, MetadataChange, MetadataChangeCursor, 
MetadataChangeGroup,
-    MetadataChangeRecord, MetadataChangeStart, MetadataChanges, 
MetadataChangesQuery,
-    MetadataConsumerGroupCreated, MetadataConsumerGroupDeleted, 
MetadataConsumerGroupItem,
-    MetadataCurrentItem, MetadataCurrentPageToken, MetadataMemberItem, 
MetadataMemberJoined,
-    MetadataMemberLeft, MetadataOptions, MetadataPartitionHandedOver, 
MetadataPartitionsChanged,
+    HeaderField as RustHeaderField, HeaderFieldMarker, HeaderKind as 
RustHeaderKind,
+    IdentityInfo as RustIdentityInfo, IggyError, IggyTimestamp, MetadataChange,
+    MetadataChangeCursor, MetadataChangeGroup, MetadataChangeRecord, 
MetadataChangeStart,
+    MetadataChanges, MetadataChangesQuery, MetadataConsumerGroupCreated,
+    MetadataConsumerGroupDeleted, MetadataConsumerGroupItem, 
MetadataCurrentItem,
+    MetadataCurrentPageToken, MetadataMemberItem, MetadataMemberJoined, 
MetadataMemberLeft,
+    MetadataOptions, MetadataPartitionHandedOver, MetadataPartitionsChanged,
     MetadataPartitionsItem, MetadataSession, MetadataSessionClosed, 
MetadataStreamChange,
     MetadataStreamDeleted, MetadataTokenChange, MetadataTokenDeleted, 
MetadataTopicChange,
     MetadataTopicDeleted, MetadataTopicItem, MetadataUserChange, 
MetadataUserIdentity,
@@ -1025,7 +1026,10 @@ impl TryFrom<ffi::HeaderEntry> for RustHeaderEntry {
     }
 }
 
-fn decode_field<T>(kind: u8, value: Vec<u8>) -> Result<RustHeaderField<T>, 
String> {
+fn decode_field<T: HeaderFieldMarker>(
+    kind: u8,
+    value: Vec<u8>,
+) -> Result<RustHeaderField<T>, String> {
     let kind = RustHeaderKind::from_code(kind)
         .map_err(|error| format!("Could not convert header field: {error}"))?;
 
diff --git a/foreign/python/src/user_headers.rs 
b/foreign/python/src/user_headers.rs
index e3fc21082..bb2d9d20b 100644
--- a/foreign/python/src/user_headers.rs
+++ b/foreign/python/src/user_headers.rs
@@ -20,7 +20,8 @@ use std::collections::hash_map::DefaultHasher;
 use std::hash::{Hash, Hasher};
 
 use iggy::prelude::{
-    HeaderField, HeaderKey as RustHeaderKey, HeaderKind, HeaderValue as 
RustHeaderValue,
+    HeaderField, HeaderFieldMarker, HeaderKey as RustHeaderKey, HeaderKind,
+    HeaderValue as RustHeaderValue,
 };
 use pyo3::PyClass;
 use pyo3::exceptions::PyValueError;
@@ -698,7 +699,9 @@ fn py_header_to_plain<'py, T: ToPlain + PyClass>(
 /// values). Returns `Ok(None)` when the object is not a supported plain type 
so
 /// the caller can raise a message naming keys vs values; genuine range/length
 /// errors are returned as `Err`.
-fn plain_to_rust_header_field<T>(obj: &Bound<'_, PyAny>) -> 
PyResult<Option<HeaderField<T>>> {
+fn plain_to_rust_header_field<T: HeaderFieldMarker>(
+    obj: &Bound<'_, PyAny>,
+) -> PyResult<Option<HeaderField<T>>> {
     if obj.is_instance_of::<PyBool>() {
         return Ok(Some(obj.extract::<bool>()?.into()));
     }
diff --git a/foreign/python/tests/test_message_operations.py 
b/foreign/python/tests/test_message_operations.py
index c15c34968..e24207491 100644
--- a/foreign/python/tests/test_message_operations.py
+++ b/foreign/python/tests/test_message_operations.py
@@ -873,8 +873,8 @@ class TestMessageOperations:
     @pytest.mark.parametrize(
         ("headers", "error"),
         [
-            ({"": "value"}, "Invalid header value"),
-            ({"x" * 256: "value"}, "Invalid header value"),
+            ({"": "value"}, "Invalid header key"),
+            ({"x" * 256: "value"}, "Invalid header key"),
             ({"key": ""}, "Invalid header value"),
             ({"key": b""}, "Invalid header value"),
             ({"key": "x" * 256}, "Invalid header value"),

Reply via email to