Skip to main content

modo/extractor/
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::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::extractor::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    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
73        let ip = parts.extensions.get::<ClientIp>().map(|c| c.0.to_string());
74
75        let user_agent = parts
76            .headers
77            .get(http::header::USER_AGENT)
78            .and_then(|v| v.to_str().ok())
79            .map(|s| s.to_string());
80
81        let fingerprint = parts
82            .headers
83            .get("x-fingerprint")
84            .and_then(|v| v.to_str().ok())
85            .map(|s| s.to_string());
86
87        Ok(Self {
88            ip,
89            user_agent,
90            fingerprint,
91        })
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn default_has_all_none() {
101        let info = ClientInfo::new();
102        assert!(info.ip_value().is_none());
103        assert!(info.user_agent_value().is_none());
104        assert!(info.fingerprint_value().is_none());
105    }
106
107    #[test]
108    fn builder_sets_fields() {
109        let info = ClientInfo::new()
110            .ip("1.2.3.4")
111            .user_agent("Mozilla/5.0")
112            .fingerprint("abc123");
113        assert_eq!(info.ip_value(), Some("1.2.3.4"));
114        assert_eq!(info.user_agent_value(), Some("Mozilla/5.0"));
115        assert_eq!(info.fingerprint_value(), Some("abc123"));
116    }
117
118    #[tokio::test]
119    async fn extracts_from_request_parts() {
120        use crate::ip::ClientIp;
121        use std::net::IpAddr;
122
123        let mut req = http::Request::builder()
124            .header("user-agent", "TestAgent/1.0")
125            .header("x-fingerprint", "fp_abc")
126            .body(())
127            .unwrap();
128        let ip: IpAddr = "10.0.0.1".parse().unwrap();
129        req.extensions_mut().insert(ClientIp(ip));
130
131        let (mut parts, _) = req.into_parts();
132        let info = ClientInfo::from_request_parts(&mut parts, &())
133            .await
134            .unwrap();
135
136        assert_eq!(info.ip_value(), Some("10.0.0.1"));
137        assert_eq!(info.user_agent_value(), Some("TestAgent/1.0"));
138        assert_eq!(info.fingerprint_value(), Some("fp_abc"));
139    }
140
141    #[tokio::test]
142    async fn extracts_with_missing_fields() {
143        let req = http::Request::builder().body(()).unwrap();
144        let (mut parts, _) = req.into_parts();
145        let info = ClientInfo::from_request_parts(&mut parts, &())
146            .await
147            .unwrap();
148
149        assert!(info.ip_value().is_none());
150        assert!(info.user_agent_value().is_none());
151        assert!(info.fingerprint_value().is_none());
152    }
153}