From c7114b0e353a13c0ef9fecb35069c69f758e6269 Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Fri, 5 Jun 2026 15:10:23 +0200 Subject: [PATCH 1/4] feat(profile): prototype profile-based extension mapping (#94) Prototype for the extension-handling rework discussed in https://github.com/djc/instant-epp/issues/94. A `Profile` maps a (command, request-extension tuple), for a given registry, to the response type and a tuple of response-extension types, decoupling the response extension from the request extension. The existence of an impl doubles as the compile-time capability gate. This adds `RequestExts`: a trait serializing a request-extension tuple into a single `` element. Verified byte-identical to the legacy `CommandWrapper` output. `Exts`: decoded `` payload as a product of options, routing children to slots by element identity via `Deserializer::parent()`. `EppClient` gains `with_profile` and `transact_profiled`, sourcing both response types from the profile. Existing `transact` is untouched. --- src/client.rs | 147 ++++++++++++++++++++++++++- src/lib.rs | 1 + src/profile.rs | 268 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 src/profile.rs diff --git a/src/client.rs b/src/client.rs index 938450b..6e42bf6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,7 @@ +use std::marker::PhantomData; use std::time::Duration; +use instant_xml::ToXml; #[cfg(feature = "__rustls")] use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tracing::{debug, error}; @@ -9,6 +11,7 @@ pub use crate::connection::Connector; use crate::connection::EppConnection; use crate::error::Error; use crate::hello::{Greeting, Hello}; +use crate::profile::{Profile, ProfiledCommand, RequestExts}; use crate::request::{Command, CommandWrapper, Extension, Transaction}; use crate::response::{Response, ResponseStatus}; use crate::xml; @@ -64,12 +67,17 @@ use crate::xml; /// Domain: eppdev.com, Available: 1 /// Domain: eppdev.net, Available: 1 /// ``` -pub struct EppClient { +pub struct EppClient { connection: EppConnection, + profile: PhantomData P>, } +/// Marker for an [`EppClient`] that has no registry [`Profile`] attached. +#[derive(Debug)] +pub struct NoProfile; + #[cfg(feature = "__rustls")] -impl EppClient { +impl EppClient { /// Connect to the specified `addr` and `hostname` over TLS /// /// The `registry` is used as a name in internal logging; `host` provides the host name @@ -97,13 +105,33 @@ impl EppClient { } } -impl EppClient { +impl EppClient { /// Create an `EppClient` from an already established connection pub async fn new(connector: C, registry: String, timeout: Duration) -> Result { Ok(Self { connection: EppConnection::new(connector, registry, timeout).await?, + profile: PhantomData, }) } +} + +impl EppClient { + /// Reinterpret this client as targeting the registry profile `P2`. + /// + /// Prototype: a complete implementation would validate the greeting's + /// `` URIs against the profile's requirements before retyping. + /// + /// I am not entirely sure this is the best way. We should not be able to switch client profiles. + /// This should be part of the client construction and ideally we would pick advertised + /// extensions based on the profile and then send them in the login. + /// + /// This just helped me to quickly test the profile-driven transaction API. + pub fn with_profile(self) -> EppClient { + EppClient { + connection: self.connection, + profile: PhantomData, + } + } /// Executes an EPP Hello call and returns the response as a `Greeting` pub async fn hello(&mut self) -> Result { @@ -153,6 +181,97 @@ impl EppClient { Err(err) } + /// Prototype of the reworked, profile-driven transaction (see [`crate::profile`]). + /// + /// The response type and the response-extension tuple are sourced from the + /// registry [`Profile`], not from the request extension. A `(Cmd, ReqExts)` + /// combination that the profile does not declare will not compile. + /// + /// Like [`transact`](Self::transact), the request argument accepts either a + /// bare `&Cmd` (no request extension) or a `(&Cmd, ReqExts)` tuple, so the + /// empty case does not need an explicit `()`: + /// + /// ```ignore + /// client.transact_profiled(&info, id).await?; // no extension + /// client.transact_profiled((&update, (restore,)), id).await?; // with extensions + /// ``` + /// + /// Passing a request extension the client's [`Profile`] does not declare, is + /// a compile error. The `Response` type is sourced from + /// `

>` and no such `Profile` impl exists: + /// + /// ```compile_fail + /// use instant_epp::client::{Connector, EppClient}; + /// use instant_epp::domain::{DomainInfo, InfoData}; + /// use instant_epp::extensions::rgp::request::{ + /// RgpRequestInfoResponse, RgpRestoreRequest, Update, + /// }; + /// use instant_epp::profile::{Exts, Profile}; + /// + /// struct ExampleRegistry; + /// // Only the no-extension combination is declared for `DomainInfo`. + /// impl Profile, ()> for ExampleRegistry { + /// type Response = InfoData; + /// type RespExts = Exts<(Option,)>; + /// } + /// + /// async fn run(client: &mut EppClient) { + /// let restore = Update { data: RgpRestoreRequest::default() }; + /// // `(DomainInfo, (Update,))` is not declared by `ExampleRegistry`. + /// let _ = client + /// .transact_profiled((&DomainInfo::new("eppdev.com", None), (restore,)), "id") + /// .await; + /// } + /// ``` + pub async fn transact_profiled<'c, Cmd, ReqExts>( + &mut self, + request: impl Into>, + id: &str, + ) -> Result< + Response<

>::Response,

>::RespExts>, + Error, + > + where + P: Profile, + Cmd: ToXml + 'c, + ReqExts: RequestExts, + { + let request = request.into(); + let document = ProfiledCommand { + command: request.command, + exts: &request.exts, + client_tr_id: id, + }; + let xml = xml::serialize(&document)?; + + debug!("{}: request: {}", self.connection.registry, &xml); + let response = self.connection.transact(&xml)?.await?; + debug!("{}: response: {}", self.connection.registry, &response); + + let rsp = match xml::deserialize::< + Response< +

>::Response, +

>::RespExts, + >, + >(&response) + { + Ok(rsp) => rsp, + Err(e) => { + error!(%response, "failed to deserialize response for transaction: {e}"); + return Err(e); + } + }; + + if rsp.result.code.is_success() { + return Ok(rsp); + } + + Err(Error::Command(Box::new(ResponseStatus { + result: rsp.result, + tr_ids: rsp.tr_ids, + }))) + } + /// Accepts raw EPP XML and returns the raw EPP XML response to it. /// Not recommended for direct use but sometimes can be useful for debugging pub async fn transact_xml(&mut self, xml: &str) -> Result { @@ -212,6 +331,28 @@ impl Clone for RequestData<'_, '_, C, E> { // Manual impl because this does not depend on whether `C` and `E` are `Copy` impl Copy for RequestData<'_, '_, C, E> {} +/// The request argument accepted by [`EppClient::transact_profiled`]. +/// +/// Todo: should replace `RequestData` once the profile-driven API is fully implemented. +/// Todo: can we also offer this for owned Cmd? +#[derive(Debug)] +pub struct ProfiledRequest<'c, Cmd, ReqExts> { + pub(crate) command: &'c Cmd, + pub(crate) exts: ReqExts, +} + +impl<'c, Cmd> From<&'c Cmd> for ProfiledRequest<'c, Cmd, ()> { + fn from(command: &'c Cmd) -> Self { + Self { command, exts: () } + } +} + +impl<'c, Cmd, ReqExts> From<(&'c Cmd, ReqExts)> for ProfiledRequest<'c, Cmd, ReqExts> { + fn from((command, exts): (&'c Cmd, ReqExts)) -> Self { + Self { command, exts } + } +} + #[cfg(feature = "__rustls")] pub use rustls_connector::RustlsConnector; diff --git a/src/lib.rs b/src/lib.rs index a638503..504d76a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ pub mod host; pub mod login; pub mod logout; pub mod poll; +pub mod profile; pub mod request; pub mod response; pub mod xml; diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..0c2e163 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,268 @@ +use instant_xml::ser::Context; +use instant_xml::{ + Accumulate, Deserializer, Error, FromXml, FromXmlOwned, Id, Kind, Serializer, ToXml, +}; + +use crate::common::EPP_XMLNS; + +/// Maps a command and request-extension tuple, for a given registry profile, to +/// the response and the tuple of response extensions it may carry here. +pub trait Profile { + /// The `` payload (usually just `Cmd::Response`). + type Response: FromXmlOwned; + /// The decoded `` payload — typically an [`Exts`] tuple. + type RespExts: FromXmlOwned; +} + +/// A request-extension tuple that can be serialized into the `` +/// element. +/// +/// Implemented for tuples up to arity 3. A local trait is used (rather than +/// `ToXml` on bare tuples) because orphan rules forbid implementing the foreign +/// `ToXml` trait for foreign tuple types. +pub trait RequestExts { + /// Whether the set is empty; when `true` the `` element is omitted. + const IS_EMPTY: bool; + + /// Serialize each extension as a child of the already-open ``. + fn serialize_into( + &self, + serializer: &mut Serializer, + ) -> Result<(), Error>; +} + +impl RequestExts for () { + const IS_EMPTY: bool = true; + + fn serialize_into( + &self, + _: &mut Serializer, + ) -> Result<(), Error> { + Ok(()) + } +} + +macro_rules! impl_request_exts { + ($($T:ident . $idx:tt),+) => { + impl<$($T: ToXml),+> RequestExts for ($($T,)+) { + const IS_EMPTY: bool = false; + + fn serialize_into( + &self, + serializer: &mut Serializer, + ) -> Result<(), Error> { + $( self.$idx.serialize(None, serializer)?; )+ + Ok(()) + } + } + }; +} + +impl_request_exts!(A.0); +impl_request_exts!(A.0, B.1); +impl_request_exts!(A.0, B.1, C.2); +// Todo: do we need more? + +/// The `` document for the profiled path. +/// +/// Mirrors [`CommandWrapper`](crate::request::CommandWrapper), but serializes a +/// [`RequestExts`] tuple into a single `` element instead of one +/// optional extension. +/// TODO: replace CommandWrapper with this. +pub(crate) struct ProfiledCommand<'a, Cmd, ReqExts> { + pub(crate) command: &'a Cmd, + pub(crate) exts: &'a ReqExts, + pub(crate) client_tr_id: &'a str, +} + +impl ToXml for ProfiledCommand<'_, Cmd, ReqExts> { + fn serialize( + &self, + _: Option>, + serializer: &mut Serializer, + ) -> Result<(), Error> { + let command = serializer.write_start("command", EPP_XMLNS, None::>)?; + serializer.end_start()?; + self.command.serialize(None, serializer)?; + + // This bit is important for strict EPP servers. + if !ReqExts::IS_EMPTY { + let extension = serializer.write_start("extension", EPP_XMLNS, None::>)?; + serializer.end_start()?; + self.exts.serialize_into(serializer)?; + serializer.write_close(extension)?; + } + + let cl_tr_id = serializer.write_start("clTRID", EPP_XMLNS, None::>)?; + serializer.end_start()?; + serializer.write_str(self.client_tr_id)?; + serializer.write_close(cl_tr_id)?; + + serializer.write_close(command)?; + Ok(()) + } +} + +/// Decoded `` payload +/// +/// Should store a tuple of optional response extensions. +#[derive(Debug, PartialEq)] +pub struct Exts(pub T); + +/// Accumulator for [`Exts`]. A dedicated local type is required because orphan +/// rules forbid implementing the foreign `Accumulate` trait for a bare tuple. +pub struct ExtsAcc(T); + +impl Default for ExtsAcc { + fn default() -> Self { + Self(T::default()) + } +} + +impl<'xml, A: FromXml<'xml>> FromXml<'xml> for Exts<(Option,)> { + fn matches(id: Id<'_>, field: Option>) -> bool { + A::matches(id, field) + } + + fn deserialize<'cx>( + into: &mut Self::Accumulator, + field: &'static str, + deserializer: &mut Deserializer<'cx, 'xml>, + ) -> Result<(), Error> { + let id = deserializer.parent(); + if A::matches(id, None) { + A::deserialize(&mut into.0 .0, field, deserializer)?; + } else { + deserializer.ignore()?; + } + Ok(()) + } + + type Accumulator = ExtsAcc<(A::Accumulator,)>; + const KIND: Kind = Kind::Element; +} + +impl<'xml, A: FromXml<'xml>> Accumulate,)>> for ExtsAcc<(A::Accumulator,)> { + fn try_done(self, field: &'static str) -> Result,)>, Error> { + Ok(Exts((self.0 .0.try_done(field).ok(),))) + } +} + +// Todo: we might want to also implement this for higher arity. maybe add a impl_request_exts-style macro for it. +impl<'xml, A: FromXml<'xml>, B: FromXml<'xml>> FromXml<'xml> for Exts<(Option, Option)> { + fn matches(id: Id<'_>, field: Option>) -> bool { + A::matches(id, field) || B::matches(id, field) + } + + fn deserialize<'cx>( + into: &mut Self::Accumulator, + field: &'static str, + deserializer: &mut Deserializer<'cx, 'xml>, + ) -> Result<(), Error> { + let id = deserializer.parent(); + if A::matches(id, None) { + A::deserialize(&mut into.0 .0, field, deserializer)?; + } else if B::matches(id, None) { + B::deserialize(&mut into.0 .1, field, deserializer)?; + } else { + deserializer.ignore()?; + } + Ok(()) + } + + type Accumulator = ExtsAcc<(A::Accumulator, B::Accumulator)>; + const KIND: Kind = Kind::Element; +} + +impl<'xml, A: FromXml<'xml>, B: FromXml<'xml>> Accumulate, Option)>> + for ExtsAcc<(A::Accumulator, B::Accumulator)> +{ + fn try_done(self, field: &'static str) -> Result, Option)>, Error> { + let (a, b) = self.0; + Ok(Exts((a.try_done(field).ok(), b.try_done(field).ok()))) + } +} + +#[cfg(test)] +mod tests { + use super::{Exts, Profile, ProfiledCommand}; + use crate::domain::info::{DomainInfo, InfoData}; + use crate::domain::update::{DomainChangeInfo, DomainUpdate}; + use crate::extensions::rgp::request::{ + RgpRequestInfoResponse, RgpRequestUpdateResponse, RgpRestoreRequest, Update, + }; + use crate::extensions::rgp::RgpStatus; + use crate::response::Response; + use crate::tests::{get_xml, CLTRID}; + use crate::xml; + + /// Example registry profile. + struct Verisign; + + // (domain, info) with no request extension still yields the RGP `infData` + // that the server volunteers + impl Profile, ()> for Verisign { + type Response = InfoData; + type RespExts = Exts<(Option,)>; + } + + #[test] + fn request_tuple_matches_legacy_serialization() { + // Build the same RGP restore request as the legacy test, but through the + // `RequestExts` tuple path, and assert byte-identical output. + let mut command = DomainUpdate::new("eppdev.com"); + command.info(DomainChangeInfo { + registrant: None, + auth_info: None, + }); + let exts = (Update { + data: RgpRestoreRequest::default(), + },); + + let document = ProfiledCommand { + command: &command, + exts: &exts, + client_tr_id: CLTRID, + }; + + let expected = get_xml("request/extensions/rgp_restore_request.xml").unwrap(); + similar_asserts::assert_eq!(expected, xml::serialize(&document).unwrap()); + } + + #[test] + fn profile_response_only_rgp() { + // Deserialize through the profile-determined types: resData -> InfoData, + // extension -> Exts<(Option,)>. + type Resp = Response< + , ()>>::Response, + , ()>>::RespExts, + >; + + let xml = get_xml("response/extensions/domain_info_rgp.xml").unwrap(); + let rsp = xml::deserialize::(&xml).unwrap(); + + assert_eq!(rsp.res_data().unwrap().name, "eppdev-1.com"); + let exts = rsp.extension().unwrap(); + let rgp = exts.0 .0.as_ref().expect("rgp infData present"); + assert_eq!(rgp.rgp_status.len(), 2); + assert_eq!(rgp.rgp_status[0], RgpStatus::AddPeriod); + assert_eq!(rgp.rgp_status[1], RgpStatus::RenewPeriod); + } + + #[test] + fn exts_product_of_options_routes_by_element() { + // Two declared response extensions; only `rgp:infData` is in the fixture, + // so the `rgp:upData` slot must come back `None`. + type E = Exts<( + Option, + Option, + )>; + + let xml = get_xml("response/extensions/domain_info_rgp.xml").unwrap(); + let rsp = xml::deserialize::>(&xml).unwrap(); + + let exts = rsp.extension().unwrap(); + assert!(exts.0 .0.is_some(), "infData slot populated"); + assert!(exts.0 .1.is_none(), "upData slot absent"); + } +} From 44ec81e220f0f7e20c37721f626385269f8d72b1 Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Fri, 5 Jun 2026 15:36:11 +0200 Subject: [PATCH 2/4] test(profile): end-to-end roundtrip for transact_profiled (#94) Drive the profile-driven path through the existing mock-TCP harness. --- tests/basic.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/tests/basic.rs b/tests/basic.rs index e29b4cc..1302604 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -9,13 +9,27 @@ use tokio::time::timeout; use tokio_test::io::Builder; use instant_epp::client::{Connector, EppClient}; -use instant_epp::domain::{DomainCheck, DomainContact, DomainCreate, Period, PeriodLength}; +use instant_epp::domain::{ + DomainCheck, DomainContact, DomainCreate, DomainInfo, InfoData, Period, PeriodLength, +}; +use instant_epp::extensions::rgp::request::RgpRequestInfoResponse; +use instant_epp::extensions::rgp::RgpStatus; use instant_epp::login::Login; +use instant_epp::profile::{Exts, Profile}; use instant_epp::response::ResultCode; use instant_epp::Error; const CLTRID: &str = "cltrid:1626454866"; +/// Example registry profile: a domain info command (carrying no request +/// extension) may still return RGP `infData` that the server volunteers. +struct ExampleRegistry; + +impl Profile, ()> for ExampleRegistry { + type Response = InfoData; + type RespExts = Exts<(Option,)>; +} + struct TestWriter; impl Write for TestWriter { @@ -242,3 +256,57 @@ async fn dropped() { let rsp = client.transact(&create, CLTRID).await.unwrap(); assert_eq!(rsp.result.code, ResultCode::CommandCompletedSuccessfully); } + +/// End-to-end roundtrip for the profile-driven path: a domain info request that +/// sends no request extension must still surface the response-only RGP `infData` +/// the server volunteers, typed via the registry [`Profile`]. +#[tokio::test] +async fn transact_profiled_info_response_only_extension() { + let _guard = log_to_stdout(); + + struct FakeConnector; + + #[async_trait] + impl Connector for FakeConnector { + type Connection = tokio_test::io::Mock; + + async fn connect(&self, _: Duration) -> Result { + Ok(build_stream(&[ + "response/greeting.xml", + // The client sends a plain `` command with no ``. + "request/domain/info.xml", + // The server volunteers an RGP `infData` extension in the response. + "response/extensions/domain_info_rgp.xml", + ]) + .build()) + } + } + + let mut client = EppClient::new(FakeConnector, "test".into(), Duration::from_secs(5)) + .await + .unwrap() + .with_profile::(); + + let rsp = client + .transact_profiled(&DomainInfo::new("eppdev.com", Some("2fooBAR")), CLTRID) + .await + .unwrap(); + + assert_eq!(rsp.result.code, ResultCode::CommandCompletedSuccessfully); + + // The command `resData` deserializes as usual. + assert_eq!(rsp.res_data().unwrap().name, "eppdev-1.com"); + + // The response-only RGP extension is present, even though the request + // carried no extension at all. + let exts = rsp.extension().expect("extension present"); + let rgp = exts + .0 + .0 + .as_ref() + .expect("rgp infData populated in the response-extension tuple"); + assert_eq!( + rgp.rgp_status, + vec![RgpStatus::AddPeriod, RgpStatus::RenewPeriod] + ); +} From eec30848c72356020cc52422543f464afa3fe91a Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Fri, 5 Jun 2026 16:29:25 +0200 Subject: [PATCH 3/4] feat(profile): typed, flattened response-extension accessors (#94) Replace the positional `exts.0 .0` tuple indexing with typed lookup (inspired by hyper). --- src/profile.rs | 71 ++++++++++++++++++++++++++++++++++++++++++++++---- tests/basic.rs | 12 ++++----- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/src/profile.rs b/src/profile.rs index 0c2e163..9d9d350 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -1,9 +1,12 @@ +use std::any::Any; + use instant_xml::ser::Context; use instant_xml::{ Accumulate, Deserializer, Error, FromXml, FromXmlOwned, Id, Kind, Serializer, ToXml, }; use crate::common::EPP_XMLNS; +use crate::response::Response; /// Maps a command and request-extension tuple, for a given registry profile, to /// the response and the tuple of response extensions it may carry here. @@ -183,6 +186,58 @@ impl<'xml, A: FromXml<'xml>, B: FromXml<'xml>> Accumulate, Optio } } +impl Exts { + /// Borrow the response extension of type `X`, if the server returned it. + /// + /// ```ignore + /// let rgp = rsp.extension().and_then(Exts::get::); + /// ``` + pub fn get(&self) -> Option<&X> { + self.0.find::() + } +} + +/// A tuple of optional response extensions supporting type-directed lookup. +/// +/// Implemented for tuples up to arity 3. Used by [`Exts::get`]; callers do not +/// interact with it directly. +pub trait ExtTuple { + /// Borrow the contained extension of type `X`, if present. + fn find(&self) -> Option<&X>; +} + +impl ExtTuple for () { + fn find(&self) -> Option<&X> { + None + } +} + +macro_rules! impl_ext_tuple { + ($($T:ident . $idx:tt),+) => { + impl<$($T: 'static),+> ExtTuple for ($(Option<$T>,)+) { + fn find(&self) -> Option<&X> { + $( + if let Some(found) = self.$idx.as_ref().and_then(|v| (v as &dyn Any).downcast_ref::()) { + return Some(found); + } + )+ + None + } + } + }; +} + +impl_ext_tuple!(A.0); +impl_ext_tuple!(A.0, B.1); +impl_ext_tuple!(A.0, B.1, C.2); + +impl Response> { + /// Borrow the response extension of type `X`, if the server returned it. + pub fn ext(&self) -> Option<&X> { + self.extension().and_then(|exts| exts.get::()) + } +} + #[cfg(test)] mod tests { use super::{Exts, Profile, ProfiledCommand}; @@ -242,8 +297,9 @@ mod tests { let rsp = xml::deserialize::(&xml).unwrap(); assert_eq!(rsp.res_data().unwrap().name, "eppdev-1.com"); - let exts = rsp.extension().unwrap(); - let rgp = exts.0 .0.as_ref().expect("rgp infData present"); + let rgp = rsp + .ext::() + .expect("rgp infData present"); assert_eq!(rgp.rgp_status.len(), 2); assert_eq!(rgp.rgp_status[0], RgpStatus::AddPeriod); assert_eq!(rgp.rgp_status[1], RgpStatus::RenewPeriod); @@ -261,8 +317,13 @@ mod tests { let xml = get_xml("response/extensions/domain_info_rgp.xml").unwrap(); let rsp = xml::deserialize::>(&xml).unwrap(); - let exts = rsp.extension().unwrap(); - assert!(exts.0 .0.is_some(), "infData slot populated"); - assert!(exts.0 .1.is_none(), "upData slot absent"); + assert!( + rsp.ext::().is_some(), + "infData present" + ); + assert!( + rsp.ext::().is_none(), + "upData absent" + ); } } diff --git a/tests/basic.rs b/tests/basic.rs index 1302604..3e2fd1f 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -298,13 +298,11 @@ async fn transact_profiled_info_response_only_extension() { assert_eq!(rsp.res_data().unwrap().name, "eppdev-1.com"); // The response-only RGP extension is present, even though the request - // carried no extension at all. - let exts = rsp.extension().expect("extension present"); - let rgp = exts - .0 - .0 - .as_ref() - .expect("rgp infData populated in the response-extension tuple"); + // carried no extension at all. `ext::()` flattens the `` + // presence check — no `unwrap` needed. + let rgp = rsp + .ext::() + .expect("rgp infData populated in the response"); assert_eq!( rgp.rgp_status, vec![RgpStatus::AddPeriod, RgpStatus::RenewPeriod] From a1e886f064c59b2b9389d29d57e89279c3ae11d9 Mon Sep 17 00:00:00 2001 From: Rudi Floren Date: Fri, 5 Jun 2026 16:47:31 +0200 Subject: [PATCH 4/4] feat(profile): axum-style extractor for response extensions (#94) Add `Response::extract()` with a `FromExts` trait so several extensions can be pulled in one destructure, with required-vs-optional encoded by type: This grealy improves readability at callsites. --- src/profile.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/profile.rs b/src/profile.rs index 9d9d350..d5ca9ab 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -236,8 +236,59 @@ impl Response> { pub fn ext(&self) -> Option<&X> { self.extension().and_then(|exts| exts.get::()) } + + /// Extract several response extensions in one destructure, axum-extractor style. + /// + /// ```ignore + /// let (rgp, fee): (&RgpInfData, Option<&FeeData>) = rsp.extract()?; + /// ``` + pub fn extract<'a, E: FromExts<'a>>(&'a self) -> Result { + E::from_exts(self.extension()) + } +} + +/// Error returned by [`Response::extract`] when a *required* extension (a bare +/// `&X` in the extracted tuple) is not present in the response. +#[derive(Debug)] +pub struct MissingExtension(pub &'static str); + +/// Extract typed values from a response's `` set (axum-extractor style). +/// +/// Implemented for `&X` (required), `Option<&X>` (optional), and tuples of those +/// so several extensions can be pulled in a single [`Response::extract`]. +pub trait FromExts<'a>: Sized { + /// Pull `Self` out of the (possibly absent) extension set. + fn from_exts(exts: Option<&'a Exts>) -> Result; +} + +impl<'a, X: 'static> FromExts<'a> for &'a X { + fn from_exts(exts: Option<&'a Exts>) -> Result { + exts.and_then(|e| e.get::()) + // Todo should we instead use the XML namespace here? + .ok_or(MissingExtension(std::any::type_name::())) + } +} + +impl<'a, X: 'static> FromExts<'a> for Option<&'a X> { + fn from_exts(exts: Option<&'a Exts>) -> Result { + Ok(exts.and_then(|e| e.get::())) + } +} + +macro_rules! impl_from_exts_tuple { + ($($G:ident),+) => { + impl<'a, $($G: FromExts<'a>),+> FromExts<'a> for ($($G,)+) { + fn from_exts(exts: Option<&'a Exts>) -> Result { + Ok(($($G::from_exts(exts)?,)+)) + } + } + }; } +impl_from_exts_tuple!(A); +impl_from_exts_tuple!(A, B); +impl_from_exts_tuple!(A, B, C); + #[cfg(test)] mod tests { use super::{Exts, Profile, ProfiledCommand}; @@ -326,4 +377,26 @@ mod tests { "upData absent" ); } + + #[test] + fn extract_required_and_optional() { + // axum-style: `&X` is required, `Option<&X>` is optional, pulled in one + // destructure with the types annotated at the call site. + type E = Exts<( + Option, + Option, + )>; + + let xml = get_xml("response/extensions/domain_info_rgp.xml").unwrap(); + let rsp = xml::deserialize::>(&xml).unwrap(); + + let (rgp, upd): (&RgpRequestInfoResponse, Option<&RgpRequestUpdateResponse>) = + rsp.extract().unwrap(); + assert_eq!(rgp.rgp_status.len(), 2); + assert!(upd.is_none()); + + // A required extension the server did not return is an error, not a panic. + let err = rsp.extract::<&RgpRequestUpdateResponse>().unwrap_err(); + assert!(err.0.contains("RgpRequestUpdateResponse")); + } }