Skip to main content

spire_core/request/
meta.rs

1//! Crawl metadata attached to requests and responses.
2
3use std::borrow::Cow;
4use std::fmt;
5
6use http::Uri;
7
8use crate::request::{Request, Response};
9
10/// A label that routes a request to its handler.
11///
12/// Requests without a tag go to the fallback handler.
13#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct Tag(Cow<'static, str>);
15
16impl Tag {
17    /// Creates a tag from a static string without allocating.
18    #[must_use]
19    pub const fn from_static(value: &'static str) -> Self {
20        Self(Cow::Borrowed(value))
21    }
22
23    /// Returns the tag as a string slice.
24    #[must_use]
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28}
29
30impl From<&str> for Tag {
31    fn from(value: &str) -> Self {
32        Self(Cow::Owned(value.to_owned()))
33    }
34}
35
36impl From<String> for Tag {
37    fn from(value: String) -> Self {
38        Self(Cow::Owned(value))
39    }
40}
41
42impl AsRef<str> for Tag {
43    fn as_ref(&self) -> &str {
44        self.as_str()
45    }
46}
47
48impl fmt::Display for Tag {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54/// A closed set of [`Tag`]s, typically an enum of the kinds of page a crawl
55/// visits.
56///
57/// Deriving it with `#[derive(Tags)]` (the `macros` feature of `spire`)
58/// names each unit variant in `snake_case`, so tags are checked by the
59/// compiler while plain string tags keep working:
60///
61/// - `#[tag(rename_all = "...")]` on the enum picks another case, with
62///   serde's names: `lowercase`, `UPPERCASE`, `PascalCase`, `camelCase`,
63///   `snake_case`, `SCREAMING_SNAKE_CASE`, `kebab-case` or
64///   `SCREAMING-KEBAB-CASE`.
65/// - `#[tag(rename = "...")]` on a variant names it outright.
66///
67/// Persistent frontiers store tags as strings: renaming a tag strands the
68/// seeds stored under its old name, unless `rename` keeps the old one.
69///
70/// Implemented by hand, it looks like this:
71///
72/// ```
73/// use spire_core::request::{Tag, Tags};
74///
75/// #[derive(Debug, Clone, Copy, PartialEq)]
76/// enum Page {
77///     List,
78///     Item,
79/// }
80///
81/// impl Page {
82///     const fn as_str(self) -> &'static str {
83///         match self {
84///             Self::List => "list",
85///             Self::Item => "item",
86///         }
87///     }
88/// }
89///
90/// impl From<Page> for Tag {
91///     fn from(page: Page) -> Self {
92///         Tag::from_static(page.as_str())
93///     }
94/// }
95///
96/// impl Tags for Page {
97///     const ALL: &'static [Self] = &[Self::List, Self::Item];
98/// }
99///
100/// assert_eq!(Page::from_tag(&Tag::from("item")), Some(Page::Item));
101/// assert_eq!(Page::from_tag(&Tag::from("other")), None);
102/// ```
103pub trait Tags: Copy + Into<Tag> + 'static {
104    /// Every tag in the set.
105    const ALL: &'static [Self];
106
107    /// Returns the member named `tag`, if any.
108    #[must_use]
109    fn from_tag(tag: &Tag) -> Option<Self> {
110        Self::ALL
111            .iter()
112            .copied()
113            .find(|member| (*member).into() == *tag)
114    }
115}
116
117/// How many links away from a seed a request is.
118///
119/// Seeds have depth 0; every followed link is one deeper than its parent.
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
121pub struct Depth(u32);
122
123impl Depth {
124    /// The depth of a seed.
125    pub const ROOT: Self = Self(0);
126
127    /// Creates a depth.
128    #[must_use]
129    pub const fn new(depth: u32) -> Self {
130        Self(depth)
131    }
132
133    /// Returns the depth as a number.
134    #[must_use]
135    pub const fn get(self) -> u32 {
136        self.0
137    }
138
139    /// Returns the depth of a link followed from this one.
140    #[must_use]
141    pub const fn child(self) -> Self {
142        Self(self.0.saturating_add(1))
143    }
144}
145
146impl fmt::Display for Depth {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        self.0.fmt(f)
149    }
150}
151
152/// How many times a request was tried before.
153///
154/// The first try is attempt 0.
155#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
156pub struct Attempt(u32);
157
158impl Attempt {
159    /// The first try.
160    pub const FIRST: Self = Self(0);
161
162    /// Creates an attempt.
163    #[must_use]
164    pub const fn new(attempt: u32) -> Self {
165        Self(attempt)
166    }
167
168    /// Returns the attempt as a number.
169    #[must_use]
170    pub const fn get(self) -> u32 {
171        self.0
172    }
173
174    /// Returns the attempt after this one.
175    #[must_use]
176    pub const fn next(self) -> Self {
177        Self(self.0.saturating_add(1))
178    }
179}
180
181impl fmt::Display for Attempt {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        self.0.fmt(f)
184    }
185}
186
187/// Reads crawl metadata from a [`Request`].
188///
189/// The crawler stores the [`Tag`], [`Depth`] and [`Attempt`] of every request
190/// in its extensions.
191pub trait RequestExt {
192    /// Returns the tag, if the request has one.
193    fn tag(&self) -> Option<&Tag>;
194
195    /// Returns the depth, defaulting to [`Depth::ROOT`].
196    fn depth(&self) -> Depth;
197
198    /// Returns the attempt, defaulting to [`Attempt::FIRST`].
199    fn attempt(&self) -> Attempt;
200}
201
202impl<B> RequestExt for Request<B> {
203    fn tag(&self) -> Option<&Tag> {
204        self.extensions().get()
205    }
206
207    fn depth(&self) -> Depth {
208        self.extensions().get().copied().unwrap_or_default()
209    }
210
211    fn attempt(&self) -> Attempt {
212        self.extensions().get().copied().unwrap_or_default()
213    }
214}
215
216/// The URI a response was finally served from, after redirects.
217#[derive(Debug, Clone)]
218struct FinalUri(Uri);
219
220/// Reads and writes crawl metadata on a [`Response`].
221pub trait ResponseExt {
222    /// Returns the URI the response was served from after redirects, if the
223    /// backend recorded it.
224    ///
225    /// Resolve relative links against this rather than the request URI.
226    fn final_uri(&self) -> Option<&Uri>;
227
228    /// Records the URI the response was served from; called by backends.
229    fn set_final_uri(&mut self, uri: Uri);
230}
231
232impl<B> ResponseExt for Response<B> {
233    fn final_uri(&self) -> Option<&Uri> {
234        self.extensions().get::<FinalUri>().map(|FinalUri(uri)| uri)
235    }
236
237    fn set_final_uri(&mut self, uri: Uri) {
238        self.extensions_mut().insert(FinalUri(uri));
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn request_metadata_defaults() {
248        let request = Request::new(());
249        assert_eq!(request.tag(), None);
250        assert_eq!(request.depth(), Depth::ROOT);
251        assert_eq!(request.attempt(), Attempt::FIRST);
252    }
253
254    #[test]
255    fn response_final_uri() {
256        let mut response = Response::new(());
257        assert_eq!(response.final_uri(), None);
258        response.set_final_uri(Uri::from_static("https://example.com/moved"));
259        assert_eq!(response.final_uri().unwrap(), "https://example.com/moved");
260    }
261
262    #[test]
263    fn request_metadata_from_extensions() {
264        let mut request = Request::new(());
265        request.extensions_mut().insert(Tag::from_static("page"));
266        request.extensions_mut().insert(Depth::new(2));
267        assert_eq!(request.tag(), Some(&Tag::from("page")));
268        assert_eq!(request.depth().child(), Depth::new(3));
269    }
270}