From e1b6bac8ed27fdfc0cfce88c5ec62be1bcc335ca Mon Sep 17 00:00:00 2001 From: Leynos Date: Thu, 19 Jun 2025 14:14:16 +0100 Subject: [PATCH 1/5] Update limitation note --- README.md | 58 ++++++++++++++++++++++++++++------ src/extractor.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++- tests/extractor.rs | 35 +++++++++++++++++++++ 3 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 tests/extractor.rs diff --git a/README.md b/README.md index d8bc453b..9d22cef1 100644 --- a/README.md +++ b/README.md @@ -99,19 +99,59 @@ produced by `on_connection_setup` is passed to `on_connection_teardown` when the connection ends. ```rust -let app = WireframeApp::new() - .on_connection_setup(|| async { 42u32 }) - .on_connection_teardown(|state| async move { - println!("closing with {state}"); - }); + let app = WireframeApp::new() + .on_connection_setup(|| async { 42u32 }) + .on_connection_teardown(|state| async move { + println!("closing with {state}"); + }); +``` + +## Custom Extractors + +Extractors are types that implement `FromMessageRequest`. When a handler lists +an extractor as a parameter, `wireframe` automatically constructs it using the +incoming \[`MessageRequest`\] and remaining \[`Payload`\]. Built‑in extractors like +`Message`, `SharedState` and `ConnectionInfo` decode the payload, access +app state or expose peer information. + +Custom extractors let you centralize parsing and validation logic that would +otherwise be duplicated across handlers. A session token parser, for example, +can verify the token before any route-specific code executes +【F:docs/rust-binary-router-library-design.md†L842-L858】. + +```rust +use wireframe::extractor::{ConnectionInfo, FromMessageRequest, MessageRequest, Payload}; + +pub struct SessionToken(String); + +impl FromMessageRequest for SessionToken { + type Error = std::convert::Infallible; + + fn from_message_request( + _req: &MessageRequest, + payload: &mut Payload<'_>, + ) -> Result { + let len = payload.data[0] as usize; + let token = std::str::from_utf8(&payload.data[1..=len]).unwrap().to_string(); + payload.advance(1 + len); + Ok(Self(token)) + } +} +``` + +Custom extractors integrate seamlessly with other parameters: + +```rust +async fn handle_ping(token: SessionToken, info: ConnectionInfo) { + println!("{} from {:?}", token.0, info.peer_addr()); +} ``` ## Current Limitations -Connection processing is not implemented yet. After the optional -preamble is read, the server logs a warning and immediately closes the -stream. Release builds fail to compile to prevent accidental production -use. +Connection handling now processes frames and routes messages, but the +server is still experimental. Release builds fail to compile so the +library cannot be used accidentally in production. ## Roadmap diff --git a/src/extractor.rs b/src/extractor.rs index 0351fd1c..826c95c7 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -11,6 +11,10 @@ use std::{ sync::Arc, }; +use bincode::error::DecodeError; + +use crate::message::Message as WireMessage; + /// Request context passed to extractors. /// /// This type contains metadata about the current connection and provides @@ -65,6 +69,12 @@ impl Payload<'_> { } /// Trait for extracting data from a [`MessageRequest`]. +/// +/// Types implementing this trait can be used as parameters to handler +/// functions. When invoked, `wireframe` passes the current request metadata and +/// message payload, allowing the extractor to parse bytes or inspect +/// connection information. This makes it easy to share common parsing and +/// validation logic across handlers. pub trait FromMessageRequest: Sized { /// Error type returned when extraction fails. type Error: std::error::Error + Send + Sync + 'static; @@ -133,17 +143,27 @@ impl From for SharedState { pub enum ExtractError { /// No shared state of the requested type was found. MissingState(&'static str), + /// Failed to deserialize the message payload. + Deserialize(bincode::error::DecodeError), } impl std::fmt::Display for ExtractError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingState(ty) => write!(f, "no shared state registered for {ty}"), + Self::Deserialize(e) => write!(f, "failed to decode payload: {e}"), } } } -impl std::error::Error for ExtractError {} +impl std::error::Error for ExtractError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Deserialize(e) => Some(e), + _ => None, + } + } +} impl FromMessageRequest for SharedState where @@ -180,3 +200,59 @@ impl std::ops::Deref for SharedState { /// ``` fn deref(&self) -> &Self::Target { &self.0 } } + +/// Extractor that deserializes the message payload into `T`. +pub struct Message(T); + +impl Message { + /// Consume the extractor, returning the inner message. + #[must_use] + pub fn into_inner(self) -> T { self.0 } +} + +impl std::ops::Deref for Message { + type Target = T; + + fn deref(&self) -> &Self::Target { &self.0 } +} + +impl FromMessageRequest for Message +where + T: WireMessage, +{ + type Error = DecodeError; + + fn from_message_request( + _req: &MessageRequest, + payload: &mut Payload<'_>, + ) -> Result { + let (msg, consumed) = T::from_bytes(payload.data)?; + payload.advance(consumed); + Ok(Self(msg)) + } +} + +/// Extractor providing peer connection metadata. +#[derive(Clone, Copy)] +pub struct ConnectionInfo { + peer_addr: Option, +} + +impl ConnectionInfo { + /// Returns the peer's socket address, if known. + #[must_use] + pub fn peer_addr(&self) -> Option { self.peer_addr } +} + +impl FromMessageRequest for ConnectionInfo { + type Error = std::convert::Infallible; + + fn from_message_request( + req: &MessageRequest, + _payload: &mut Payload<'_>, + ) -> Result { + Ok(Self { + peer_addr: req.peer_addr, + }) + } +} diff --git a/tests/extractor.rs b/tests/extractor.rs new file mode 100644 index 00000000..739b9a1d --- /dev/null +++ b/tests/extractor.rs @@ -0,0 +1,35 @@ +use std::{collections::HashMap, net::SocketAddr}; + +use wireframe::{ + extractor::{ConnectionInfo, FromMessageRequest, Message, MessageRequest, Payload}, + message::Message as MessageTrait, +}; + +#[derive(bincode::Encode, bincode::BorrowDecode, PartialEq, Debug)] +struct TestMsg(u8); + +#[test] +fn message_extractor_parses_and_advances() { + let msg = TestMsg(42); + let bytes = msg.to_bytes().unwrap(); + let mut payload = Payload { + data: bytes.as_slice(), + }; + let req = MessageRequest::default(); + + let extracted = Message::::from_message_request(&req, &mut payload).unwrap(); + assert_eq!(*extracted, msg); + assert_eq!(payload.remaining(), 0); +} + +#[test] +fn connection_info_reports_peer() { + let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap(); + let req = MessageRequest { + peer_addr: Some(addr), + app_data: HashMap::default(), + }; + let mut payload = Payload::default(); + let info = ConnectionInfo::from_message_request(&req, &mut payload).unwrap(); + assert_eq!(info.peer_addr(), Some(addr)); +} From ef466a35b75d72db0965b7b5513c75138e49098b Mon Sep 17 00:00:00 2001 From: Leynos Date: Thu, 19 Jun 2025 15:54:12 +0100 Subject: [PATCH 2/5] Add extractor class diagram --- docs/rust-binary-router-library-design.md | 40 +++++++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/rust-binary-router-library-design.md b/docs/rust-binary-router-library-design.md index da3e840b..708a5263 100644 --- a/docs/rust-binary-router-library-design.md +++ b/docs/rust-binary-router-library-design.md @@ -855,17 +855,51 @@ its context. For example, a custom extractor could parse a session token from a specific field in all messages, validate it, and provide a `UserSession` object to the handler. + This extractor system, backed by Rust's strong type system, ensures that handlers receive correctly typed and validated data, significantly reducing the likelihood of runtime errors and boilerplate parsing code within the handler logic itself. Custom extractors are particularly valuable as they allow common, -protocol-specific data extraction and validation logic (e.g., extracting and -verifying a session token from a custom frame header) to be encapsulated into -reusable components. This further reduces code duplication across multiple +protocol-specific data extraction and validation logic (for example extracting +and verifying a session token from a custom frame header) to be encapsulated +into reusable components. This further reduces code duplication across multiple handlers and keeps the handler functions lean and focused on their specific business tasks, mirroring the benefits seen with Actix Web's `FromRequest` trait.24 +```mermaid +classDiagram + class FromMessageRequest { + <> + +from_message_request(req: &MessageRequest, payload: &mut Payload) Result + +Error + } + class Message~T~ { + +Message(T) + +into_inner() T + +deref() &T + } + class ConnectionInfo { + +peer_addr: Option + +peer_addr() Option + } + class SharedState~T~ { + +deref() &T + } + class ExtractError { + +MissingState(&'static str) + +Deserialize(DecodeError) + } + FromMessageRequest <|.. Message + FromMessageRequest <|.. ConnectionInfo + FromMessageRequest <|.. SharedState + SharedState --> ExtractError + ExtractError o-- DecodeError + Message o-- T + SharedState o-- T + ConnectionInfo o-- SocketAddr +``` + ### 5.4. Middleware and Extensibility "wireframe" will incorporate a middleware system conceptually similar to Actix From fee340bc79680e607d7f474d6ce5532a8b40bc9a Mon Sep 17 00:00:00 2001 From: Leynos Date: Thu, 19 Jun 2025 15:54:18 +0100 Subject: [PATCH 3/5] Refine extractor docs and tests --- README.md | 4 ++-- src/extractor.rs | 11 ++++------- tests/extractor.rs | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9d22cef1..0d8a0440 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ app state or expose peer information. Custom extractors let you centralize parsing and validation logic that would otherwise be duplicated across handlers. A session token parser, for example, can verify the token before any route-specific code executes -【F:docs/rust-binary-router-library-design.md†L842-L858】. +[Design Guide: Data Extraction and Type Safety](docs/rust-binary-router-library-design.md#53-data-extraction-and-type-safety). ```rust use wireframe::extractor::{ConnectionInfo, FromMessageRequest, MessageRequest, Payload}; @@ -150,7 +150,7 @@ async fn handle_ping(token: SessionToken, info: ConnectionInfo) { ## Current Limitations Connection handling now processes frames and routes messages, but the -server is still experimental. Release builds fail to compile so the +server is still experimental. Release builds fail to compile, so the library cannot be used accidentally in production. ## Roadmap diff --git a/src/extractor.rs b/src/extractor.rs index 826c95c7..8f3f23f5 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,8 +1,7 @@ //! Request context types and extractor traits. -//! //! The `MessageRequest` struct carries connection metadata and shared -//! application state. Implement [`FromMessageRequest`] to extract data -//! for handlers. +//! application state. Implement [`FromMessageRequest`] for custom +//! extraction logic consumed by handlers. use std::{ any::{Any, TypeId}, @@ -11,8 +10,6 @@ use std::{ sync::Arc, }; -use bincode::error::DecodeError; - use crate::message::Message as WireMessage; /// Request context passed to extractors. @@ -220,13 +217,13 @@ impl FromMessageRequest for Message where T: WireMessage, { - type Error = DecodeError; + type Error = ExtractError; fn from_message_request( _req: &MessageRequest, payload: &mut Payload<'_>, ) -> Result { - let (msg, consumed) = T::from_bytes(payload.data)?; + let (msg, consumed) = T::from_bytes(payload.data).map_err(ExtractError::Deserialize)?; payload.advance(consumed); Ok(Self(msg)) } diff --git a/tests/extractor.rs b/tests/extractor.rs index 739b9a1d..c2994168 100644 --- a/tests/extractor.rs +++ b/tests/extractor.rs @@ -33,3 +33,38 @@ fn connection_info_reports_peer() { let info = ConnectionInfo::from_message_request(&req, &mut payload).unwrap(); assert_eq!(info.peer_addr(), Some(addr)); } + +#[test] +fn shared_state_extractor() { + let mut data = HashMap::default(); + data.insert( + std::any::TypeId::of::(), + std::sync::Arc::new(42u8) as std::sync::Arc, + ); + let req = MessageRequest { + peer_addr: None, + app_data: data, + }; + let mut payload = Payload::default(); + + let state = + wireframe::extractor::SharedState::::from_message_request(&req, &mut payload).unwrap(); + assert_eq!(*state, 42); +} + +#[test] +fn shared_state_missing_error() { + let req = MessageRequest::default(); + let mut payload = Payload::default(); + let Err(err) = + wireframe::extractor::SharedState::::from_message_request(&req, &mut payload) + else { + panic!("expected error"); + }; + match err { + wireframe::extractor::ExtractError::MissingState(name) => { + assert!(name.contains("u8")); + } + _ => panic!("unexpected error"), + } +} From a0d2ba6372d846b93f49a893afe7b3be12be2ed0 Mon Sep 17 00:00:00 2001 From: Leynos Date: Thu, 19 Jun 2025 23:29:37 +0100 Subject: [PATCH 4/5] Refine extractor errors and add debug derives --- docs/rust-binary-router-library-design.md | 2 +- src/extractor.rs | 24 +++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/rust-binary-router-library-design.md b/docs/rust-binary-router-library-design.md index 708a5263..d3a27ba9 100644 --- a/docs/rust-binary-router-library-design.md +++ b/docs/rust-binary-router-library-design.md @@ -888,7 +888,7 @@ classDiagram } class ExtractError { +MissingState(&'static str) - +Deserialize(DecodeError) + +InvalidPayload(DecodeError) } FromMessageRequest <|.. Message FromMessageRequest <|.. ConnectionInfo diff --git a/src/extractor.rs b/src/extractor.rs index 8f3f23f5..e4781ab3 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,7 +1,10 @@ -//! Request context types and extractor traits. -//! The `MessageRequest` struct carries connection metadata and shared -//! application state. Implement [`FromMessageRequest`] for custom -//! extraction logic consumed by handlers. +//! Extractor and request context definitions. +//! +//! This module provides [`MessageRequest`], which carries connection +//! metadata and shared application state, along with a set of extractor +//! types. Implement [`FromMessageRequest`] for custom extractors to +//! parse payload bytes or inspect connection info before your handler +//! runs. use std::{ any::{Any, TypeId}, @@ -140,15 +143,15 @@ impl From for SharedState { pub enum ExtractError { /// No shared state of the requested type was found. MissingState(&'static str), - /// Failed to deserialize the message payload. - Deserialize(bincode::error::DecodeError), + /// Failed to decode the message payload. + InvalidPayload(bincode::error::DecodeError), } impl std::fmt::Display for ExtractError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingState(ty) => write!(f, "no shared state registered for {ty}"), - Self::Deserialize(e) => write!(f, "failed to decode payload: {e}"), + Self::InvalidPayload(e) => write!(f, "failed to decode payload: {e}"), } } } @@ -156,7 +159,7 @@ impl std::fmt::Display for ExtractError { impl std::error::Error for ExtractError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::Deserialize(e) => Some(e), + Self::InvalidPayload(e) => Some(e), _ => None, } } @@ -199,6 +202,7 @@ impl std::ops::Deref for SharedState { } /// Extractor that deserializes the message payload into `T`. +#[derive(Debug, Clone)] pub struct Message(T); impl Message { @@ -223,14 +227,14 @@ where _req: &MessageRequest, payload: &mut Payload<'_>, ) -> Result { - let (msg, consumed) = T::from_bytes(payload.data).map_err(ExtractError::Deserialize)?; + let (msg, consumed) = T::from_bytes(payload.data).map_err(ExtractError::InvalidPayload)?; payload.advance(consumed); Ok(Self(msg)) } } /// Extractor providing peer connection metadata. -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy)] pub struct ConnectionInfo { peer_addr: Option, } From e08b91490890bef792fb6bf0b386e34f62abf3bb Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 20 Jun 2025 01:06:50 +0100 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`cod?= =?UTF-8?q?ex/define-extractors-and-implement-frommessagerequest`=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📝 Add docstrings to `codex/define-extractors-and-implement-frommessagerequest` Docstrings generation was requested by @leynos. * https://github.com/leynos/wireframe/pull/79#issuecomment-2988066991 The following files were modified: * `src/extractor.rs` * `tests/extractor.rs` * Reorder attributes after doc comments (#84) --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Leynos --- src/extractor.rs | 41 +++++++++++++++++++++++++---------------- src/middleware.rs | 7 +++---- src/server.rs | 5 ++--- tests/extractor.rs | 17 +++++++++++++++++ 4 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/extractor.rs b/src/extractor.rs index e4781ab3..f315c46c 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -95,7 +95,7 @@ pub trait FromMessageRequest: Sized { pub struct SharedState(Arc); impl SharedState { - /// Construct a new [`SharedState`]. + /// Creates a new [`SharedState`] instance wrapping the provided `Arc`. /// /// # Examples /// @@ -109,19 +109,6 @@ impl SharedState { /// assert_eq!(*state, 5); /// ``` #[must_use] - /// Creates a new `SharedState` instance wrapping the provided `Arc`. - /// - /// # Examples - /// - /// ```no_run - /// use std::sync::Arc; - /// - /// use wireframe::extractor::SharedState; - /// - /// let state = Arc::new(42); - /// let shared: SharedState = state.clone().into(); - /// assert_eq!(*shared, 42); - /// ``` #[deprecated(since = "0.2.0", note = "construct via `inner.into()` instead")] pub fn new(inner: Arc) -> Self { Self(inner) } } @@ -148,6 +135,9 @@ pub enum ExtractError { } impl std::fmt::Display for ExtractError { + /// Formats the `ExtractError` for display purposes. + /// + /// Displays a descriptive message for missing shared state or payload decoding errors. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingState(ty) => write!(f, "no shared state registered for {ty}"), @@ -157,6 +147,10 @@ impl std::fmt::Display for ExtractError { } impl std::error::Error for ExtractError { + /// Returns the underlying error if this is an `InvalidPayload` variant. + /// + /// # Returns + /// An optional reference to the underlying decode error, or `None` if not applicable. fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::InvalidPayload(e) => Some(e), @@ -206,7 +200,7 @@ impl std::ops::Deref for SharedState { pub struct Message(T); impl Message { - /// Consume the extractor, returning the inner message. + /// Consumes the extractor and returns the inner deserialised message value. #[must_use] pub fn into_inner(self) -> T { self.0 } } @@ -214,6 +208,9 @@ impl Message { impl std::ops::Deref for Message { type Target = T; + /// Returns a reference to the inner value. + /// + /// This enables transparent access to the wrapped type via dereferencing. fn deref(&self) -> &Self::Target { &self.0 } } @@ -223,6 +220,14 @@ where { type Error = ExtractError; + /// Attempts to extract and deserialize a message of type `T` from the payload. + /// + /// Advances the payload by the number of bytes consumed during deserialization. + /// Returns an error if the payload cannot be decoded into the target type. + /// + /// # Returns + /// - `Ok(Self)`: The successfully extracted and deserialized message. + /// - `Err(ExtractError::InvalidPayload)`: If deserialization fails. fn from_message_request( _req: &MessageRequest, payload: &mut Payload<'_>, @@ -240,7 +245,7 @@ pub struct ConnectionInfo { } impl ConnectionInfo { - /// Returns the peer's socket address, if known. + /// Returns the peer's socket address for the current connection, if available. #[must_use] pub fn peer_addr(&self) -> Option { self.peer_addr } } @@ -248,6 +253,10 @@ impl ConnectionInfo { impl FromMessageRequest for ConnectionInfo { type Error = std::convert::Infallible; + /// Extracts connection metadata from the message request. + /// + /// Returns a `ConnectionInfo` containing the peer's socket address, if available. This + /// extraction is infallible. fn from_message_request( req: &MessageRequest, _payload: &mut Payload<'_>, diff --git a/src/middleware.rs b/src/middleware.rs index 927648c0..10e10991 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -25,10 +25,7 @@ impl<'a, S> Next<'a, S> where S: Service + ?Sized, { - /// Create a new [`Next`] wrapping the given service. - #[inline] - #[must_use] - /// Creates a new `Next` instance wrapping a reference to the given service. + /// Creates a new [`Next`] instance wrapping a reference to the given service. /// /// # Examples /// @@ -46,6 +43,8 @@ where /// let service = MyService; /// let next = Next::new(&service); /// ``` + #[inline] + #[must_use] pub fn new(service: &'a S) -> Self { Self { service } } /// Call the next service with the provided request. diff --git a/src/server.rs b/src/server.rs index c0e0bb7b..cdf8e76c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -183,9 +183,6 @@ where self } - /// Get the configured worker count. - #[inline] - #[must_use] /// Returns the configured number of worker tasks for the server. /// /// # Examples @@ -197,6 +194,8 @@ where /// let server = WireframeServer::new(factory); /// assert!(server.worker_count() >= 1); /// ``` + #[inline] + #[must_use] pub const fn worker_count(&self) -> usize { self.workers } /// Get the socket address the server is bound to, if available. diff --git a/tests/extractor.rs b/tests/extractor.rs index c2994168..488f2a68 100644 --- a/tests/extractor.rs +++ b/tests/extractor.rs @@ -9,6 +9,11 @@ use wireframe::{ struct TestMsg(u8); #[test] +/// Tests that a message can be extracted from a payload and that the payload cursor advances fully. +/// +/// Verifies that a `TestMsg` instance serialised into bytes can be correctly extracted from a +/// `Payload` using `Message::::from_message_request`, and asserts that the payload has no +/// remaining unread data after extraction. fn message_extractor_parses_and_advances() { let msg = TestMsg(42); let bytes = msg.to_bytes().unwrap(); @@ -23,6 +28,8 @@ fn message_extractor_parses_and_advances() { } #[test] +/// Tests that `ConnectionInfo` correctly reports the peer socket address extracted from a +/// `MessageRequest`. fn connection_info_reports_peer() { let addr: SocketAddr = "127.0.0.1:12345".parse().unwrap(); let req = MessageRequest { @@ -35,6 +42,11 @@ fn connection_info_reports_peer() { } #[test] +/// Tests that shared state of type `u8` can be successfully extracted from a `MessageRequest`'s +/// `app_data`. +/// +/// Inserts an `Arc` into the request's shared state, extracts it using the `SharedState` +/// extractor, and asserts that the extracted value matches the original. fn shared_state_extractor() { let mut data = HashMap::default(); data.insert( @@ -53,6 +65,11 @@ fn shared_state_extractor() { } #[test] +/// Tests that extracting a missing shared state from a `MessageRequest` +/// returns an `ExtractError::MissingState` containing the type name. +/// +/// Ensures that when no shared state of the requested type is present, +/// the correct error is produced and includes the expected type information. fn shared_state_missing_error() { let req = MessageRequest::default(); let mut payload = Payload::default();