Skip to main content

kellnr_common/
name_or_description.rs

1use std::ops::Deref;
2
3use thiserror::Error;
4use utoipa::ToSchema;
5
6/// A free-form crate search query, matched against crate names and descriptions.
7///
8/// Unlike [`crate::original_name::OriginalName`] the content is not restricted
9/// to crate-name characters, because a description search takes arbitrary text.
10/// Only the length is bounded, so an oversized query cannot turn into an
11/// oversized `LIKE` pattern scanned across every row.
12#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq, Clone, Hash, ToSchema)]
13#[schema(value_type = String)]
14// Validate on every deserialization (query strings, JSON bodies) instead of only
15// in the explicit `TryFrom` constructor. Otherwise serde-based extractors such as
16// the web UI's `Query<SearchParams>` bypass the length bound entirely. Mirrors
17// the custom `Deserialize` on `OriginalName`.
18#[serde(try_from = "String")]
19pub struct NameOrDescription(String);
20
21/// Upper bound on a search query, in characters.
22const MAX_LEN: usize = 64;
23
24#[derive(Debug, PartialEq, Eq, Error)]
25pub enum NameOrDescriptionError {
26    #[error("Search query must not be longer than {MAX_LEN} characters")]
27    InvalidLength,
28}
29
30impl NameOrDescription {
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34
35    pub fn into_inner(self) -> String {
36        self.0
37    }
38}
39
40impl TryFrom<String> for NameOrDescription {
41    type Error = NameOrDescriptionError;
42
43    fn try_from(value: String) -> Result<Self, Self::Error> {
44        if value.chars().count() > MAX_LEN {
45            Err(NameOrDescriptionError::InvalidLength)
46        } else {
47            Ok(Self(value))
48        }
49    }
50}
51
52impl TryFrom<&str> for NameOrDescription {
53    type Error = NameOrDescriptionError;
54
55    fn try_from(value: &str) -> Result<Self, Self::Error> {
56        Self::try_from(value.to_string())
57    }
58}
59
60impl From<NameOrDescription> for String {
61    fn from(value: NameOrDescription) -> Self {
62        value.0
63    }
64}
65
66impl Deref for NameOrDescription {
67    type Target = String;
68
69    fn deref(&self) -> &Self::Target {
70        &self.0
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn accepts_arbitrary_text() {
80        assert_eq!(
81            NameOrDescription::try_from("vcard parser")
82                .unwrap()
83                .as_str(),
84            "vcard parser"
85        );
86    }
87
88    #[test]
89    fn accepts_query_at_the_limit() {
90        let query = "a".repeat(MAX_LEN);
91        assert_eq!(
92            NameOrDescription::try_from(query.clone()).unwrap().as_str(),
93            query
94        );
95    }
96
97    #[test]
98    fn rejects_query_over_the_limit() {
99        let query = "a".repeat(MAX_LEN + 1);
100        assert_eq!(
101            NameOrDescription::try_from(query),
102            Err(NameOrDescriptionError::InvalidLength)
103        );
104    }
105
106    // The bound counts characters, not bytes, so a query of multi-byte
107    // characters is not rejected earlier than an ASCII one of the same length.
108    #[test]
109    fn counts_characters_not_bytes() {
110        let query = "รค".repeat(MAX_LEN);
111        assert!(query.len() > MAX_LEN);
112        assert!(NameOrDescription::try_from(query).is_ok());
113    }
114
115    // The length bound must hold for serde-based extractors too, not only for
116    // the explicit `TryFrom` constructor.
117    #[test]
118    fn deserialization_enforces_the_limit() {
119        let ok =
120            serde_json::from_value::<NameOrDescription>(serde_json::json!("a".repeat(MAX_LEN)));
121        assert!(ok.is_ok());
122
123        let too_long =
124            serde_json::from_value::<NameOrDescription>(serde_json::json!("a".repeat(MAX_LEN + 1)));
125        assert!(too_long.is_err());
126    }
127}