scraper_trail/
exchange.rs1use 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}