scraper_trail/
exchange.rs

1use crate::{multi_value::MultiValue, request::Request};
2use bounded_static::{IntoBoundedStatic, ToBoundedStatic};
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, thiserror::Error)]
8pub enum Error {
9    #[error("URL parse error")]
10    UrlParse(#[from] url::ParseError),
11    #[error("Header value error")]
12    RequestHeaderValue(#[from] http::header::InvalidHeaderValue),
13    #[error("Header value error")]
14    ResponseHeaderValue(#[from] http::header::ToStrError),
15}
16
17#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
18pub struct Exchange<'a, T> {
19    #[serde(borrow)]
20    pub request: Request<'a>,
21    pub response: Response<'a, T>,
22}
23
24impl<'a, T> Exchange<'a, T> {
25    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Exchange<'a, U> {
26        Exchange {
27            request: self.request,
28            response: self.response.map(f),
29        }
30    }
31}
32
33impl<'a, T: IntoBoundedStatic + 'a> IntoBoundedStatic for Exchange<'a, T> {
34    type Static = Exchange<'static, T::Static>;
35
36    fn into_static(self) -> Self::Static {
37        Self::Static {
38            request: self.request.into_static(),
39            response: self.response.into_static(),
40        }
41    }
42}
43
44impl<T: ToBoundedStatic> ToBoundedStatic for Exchange<'_, T> {
45    type Static = Exchange<'static, T::Static>;
46
47    fn to_static(&self) -> Self::Static {
48        Self::Static {
49            request: self.request.to_static(),
50            response: self.response.to_static(),
51        }
52    }
53}
54
55impl<T: serde::ser::Serialize> Exchange<'_, T> {
56    pub fn save_file<P: AsRef<Path>>(&self, base: P) -> Result<PathBuf, std::io::Error> {
57        std::fs::create_dir_all(&base)?;
58
59        let output_path = base.as_ref().join(format!(
60            "{}.json",
61            self.request.timestamp.timestamp_millis()
62        ));
63
64        std::fs::write(&output_path, serde_json::json!(self).to_string())?;
65
66        Ok(output_path)
67    }
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
71pub struct Response<'a, T> {
72    #[serde(borrow)]
73    pub headers: HashMap<Cow<'a, str>, MultiValue<'a>>,
74    pub data: T,
75}
76
77impl<'a, T> Response<'a, T> {
78    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Response<'a, U> {
79        Response {
80            headers: self.headers,
81            data: f(self.data),
82        }
83    }
84
85    pub fn and_then<U, E, F: FnOnce(T) -> Result<U, E>>(self, f: F) -> Result<Response<'a, U>, E> {
86        f(self.data).map(|new_data| Response {
87            headers: self.headers,
88            data: new_data,
89        })
90    }
91}
92
93impl<'a, T: IntoBoundedStatic + 'a> IntoBoundedStatic for Response<'a, T> {
94    type Static = Response<'static, T::Static>;
95
96    fn into_static(self) -> Self::Static {
97        Self::Static {
98            headers: self
99                .headers
100                .into_iter()
101                .map(|(key, values)| (key.into_static(), values.into_static()))
102                .collect(),
103            data: self.data.into_static(),
104        }
105    }
106}
107
108impl<T: ToBoundedStatic> ToBoundedStatic for Response<'_, T> {
109    type Static = Response<'static, T::Static>;
110
111    fn to_static(&self) -> Self::Static {
112        Self::Static {
113            headers: self
114                .headers
115                .iter()
116                .map(|(key, values)| (key.to_static(), values.to_static()))
117                .collect(),
118            data: self.data.to_static(),
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::Exchange;
126
127    const APPLE_ITUNES_01_EXAMPLE: &str = include_str!("../../examples/apple-itunes-01.json");
128    const GOOGLE_PLAY_01_EXAMPLE: &str = include_str!("../../examples/google-play-01.json");
129
130    #[test]
131    fn deserialize_example_apple_itunes_01() -> Result<(), Box<dyn std::error::Error>> {
132        let example: Exchange<'_, serde_json::Value> =
133            serde_json::from_str(APPLE_ITUNES_01_EXAMPLE)?;
134
135        assert!(
136            example
137                .request
138                .url
139                .as_str()
140                .starts_with("https://itunes.apple.com/lookup")
141        );
142
143        assert_eq!(
144            example.request.timestamp,
145            chrono::DateTime::from_timestamp_millis(1760252742866).unwrap()
146        );
147
148        Ok(())
149    }
150
151    #[test]
152    fn deserialize_example_google_play_01() -> Result<(), Box<dyn std::error::Error>> {
153        let example: Exchange<'_, serde_json::Value> =
154            serde_json::from_str(GOOGLE_PLAY_01_EXAMPLE)?;
155
156        assert!(
157            example
158                .request
159                .url
160                .as_str()
161                .starts_with("https://play.google.com/_/PlayStoreUi/data/")
162        );
163
164        assert_eq!(
165            example.request.timestamp,
166            chrono::DateTime::from_timestamp_millis(1759391955666).unwrap()
167        );
168
169        Ok(())
170    }
171}