Skip to main content

modo/ip/
client_info.rs

1use axum::extract::FromRequestParts;
2use http::request::Parts;
3
4use crate::error::Error;
5use crate::ip::ClientIp;
6
7/// Client request context: IP address, user-agent, and fingerprint.
8///
9/// Implements [`FromRequestParts`] for automatic extraction in handlers.
10/// Requires [`ClientIpLayer`](crate::ip::ClientIpLayer) for the `ip` field;
11/// if the layer is absent, `ip` will be `None`.
12///
13/// For non-HTTP contexts (background jobs, CLI tools), use the builder:
14///
15/// ```
16/// use modo::ip::ClientInfo;
17///
18/// let info = ClientInfo::new()
19///     .ip("1.2.3.4")
20///     .user_agent("my-script/1.0");
21/// ```
22#[derive(Debug, Clone, Default)]
23pub struct ClientInfo {
24    ip: Option<String>,
25    user_agent: Option<String>,
26    fingerprint: Option<String>,
27}
28
29impl ClientInfo {
30    /// Create an empty `ClientInfo` with all fields set to `None`.
31    pub fn new() -> Self {
32        Self::default()
33    }
34
35    /// Set the client IP address.
36    pub fn ip(mut self, ip: impl Into<String>) -> Self {
37        self.ip = Some(ip.into());
38        self
39    }
40
41    /// Set the user-agent string.
42    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
43        self.user_agent = Some(ua.into());
44        self
45    }
46
47    /// Set the client fingerprint (from the `x-fingerprint` header).
48    pub fn fingerprint(mut self, fp: impl Into<String>) -> Self {
49        self.fingerprint = Some(fp.into());
50        self
51    }
52
53    /// The client IP address, if available.
54    pub fn ip_value(&self) -> Option<&str> {
55        self.ip.as_deref()
56    }
57
58    /// The client user-agent string, if available.
59    pub fn user_agent_value(&self) -> Option<&str> {
60        self.user_agent.as_deref()
61    }
62
63    /// The client fingerprint, if available.
64    pub fn fingerprint_value(&self) -> Option<&str> {
65        self.fingerprint.as_deref()
66    }
67}
68
69impl<S: Send + Sync> FromRequestParts<S> for ClientInfo {
70    type Rejection = Error;
71
72    /// Builds [`ClientInfo`] from request extensions and headers.
73    ///
74    /// Reads the IP from the [`ClientIp`] extension (inserted by
75    /// [`ClientIpLayer`](crate::ip::ClientIpLayer)), the `User-Agent` header,
76    /// and the `X-Fingerprint` header. Any field that cannot be read is `None`.
77    ///
78    /// # Errors
79    ///
80    /// This extractor never fails — the `Result` type is required by
81    /// [`FromRequestParts`] but the implementation always returns `Ok`.
82    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
83        let ip = parts.extensions.get::<ClientIp>().map(|c| c.0.to_string());
84
85        let user_agent = parts
86            .headers
87            .get(http::header::USER_AGENT)
88            .and_then(|v| v.to_str().ok())
89            .map(|s| s.to_string());
90
91        let fingerprint = parts
92            .headers
93            .get("x-fingerprint")
94            .and_then(|v| v.to_str().ok())
95            .map(|s| s.to_string());
96
97        Ok(Self {
98            ip,
99            user_agent,
100            fingerprint,
101        })
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn default_has_all_none() {
111        let info = ClientInfo::new();
112        assert!(info.ip_value().is_none());
113        assert!(info.user_agent_value().is_none());
114        assert!(info.fingerprint_value().is_none());
115    }
116
117    #[test]
118    fn builder_sets_fields() {
119        let info = ClientInfo::new()
120            .ip("1.2.3.4")
121            .user_agent("Mozilla/5.0")
122            .fingerprint("abc123");
123        assert_eq!(info.ip_value(), Some("1.2.3.4"));
124        assert_eq!(info.user_agent_value(), Some("Mozilla/5.0"));
125        assert_eq!(info.fingerprint_value(), Some("abc123"));
126    }
127
128    #[tokio::test]
129    async fn extracts_from_request_parts() {
130        use crate::ip::ClientIp;
131        use std::net::IpAddr;
132
133        let mut req = http::Request::builder()
134            .header("user-agent", "TestAgent/1.0")
135            .header("x-fingerprint", "fp_abc")
136            .body(())
137            .unwrap();
138        let ip: IpAddr = "10.0.0.1".parse().unwrap();
139        req.extensions_mut().insert(ClientIp(ip));
140
141        let (mut parts, _) = req.into_parts();
142        let info = ClientInfo::from_request_parts(&mut parts, &())
143            .await
144            .unwrap();
145
146        assert_eq!(info.ip_value(), Some("10.0.0.1"));
147        assert_eq!(info.user_agent_value(), Some("TestAgent/1.0"));
148        assert_eq!(info.fingerprint_value(), Some("fp_abc"));
149    }
150
151    #[tokio::test]
152    async fn extracts_with_missing_fields() {
153        let req = http::Request::builder().body(()).unwrap();
154        let (mut parts, _) = req.into_parts();
155        let info = ClientInfo::from_request_parts(&mut parts, &())
156            .await
157            .unwrap();
158
159        assert!(info.ip_value().is_none());
160        assert!(info.user_agent_value().is_none());
161        assert!(info.fingerprint_value().is_none());
162    }
163}