Skip to main content

use_api_route/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned when API primitive text or labels are invalid.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum ApiPrimitiveError {
10    /// The supplied value was empty after trimming.
11    Empty,
12    /// The supplied value used syntax this crate rejects.
13    Invalid,
14    /// The supplied label was not recognized.
15    Unknown,
16}
17
18impl fmt::Display for ApiPrimitiveError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::Empty => formatter.write_str("API primitive value cannot be empty"),
22            Self::Invalid => formatter.write_str("invalid API primitive value"),
23            Self::Unknown => formatter.write_str("unknown API primitive label"),
24        }
25    }
26}
27
28impl Error for ApiPrimitiveError {}
29
30fn validate_api_text(value: &str) -> Result<&str, ApiPrimitiveError> {
31    let trimmed = value.trim();
32    if trimmed.is_empty() {
33        return Err(ApiPrimitiveError::Empty);
34    }
35    if trimmed.chars().any(char::is_control) {
36        return Err(ApiPrimitiveError::Invalid);
37    }
38    Ok(trimmed)
39}
40
41macro_rules! text_newtype {
42    ($name:ident) => {
43        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44        pub struct $name(String);
45
46        impl $name {
47            /// Creates validated text metadata.
48            ///
49            /// # Errors
50            ///
51            /// Returns [ApiPrimitiveError] when the value is empty or contains control characters.
52            pub fn new(value: impl AsRef<str>) -> Result<Self, ApiPrimitiveError> {
53                validate_api_text(value.as_ref()).map(|value| Self(value.to_owned()))
54            }
55
56            /// Parses validated text metadata.
57            ///
58            /// # Errors
59            ///
60            /// Returns [ApiPrimitiveError] when validation fails.
61            pub fn parse(value: impl AsRef<str>) -> Result<Self, ApiPrimitiveError> {
62                Self::new(value)
63            }
64
65            /// Returns the stored text.
66            #[must_use]
67            pub fn as_str(&self) -> &str {
68                &self.0
69            }
70
71            /// Consumes the value and returns the stored text.
72            #[must_use]
73            pub fn into_string(self) -> String {
74                self.0
75            }
76        }
77
78        impl AsRef<str> for $name {
79            fn as_ref(&self) -> &str {
80                self.as_str()
81            }
82        }
83
84        impl fmt::Display for $name {
85            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86                formatter.write_str(self.as_str())
87            }
88        }
89
90        impl FromStr for $name {
91            type Err = ApiPrimitiveError;
92
93            fn from_str(value: &str) -> Result<Self, Self::Err> {
94                Self::new(value)
95            }
96        }
97
98        impl TryFrom<&str> for $name {
99            type Error = ApiPrimitiveError;
100
101            fn try_from(value: &str) -> Result<Self, Self::Error> {
102                Self::new(value)
103            }
104        }
105    };
106}
107
108text_newtype!(RouteTemplate);
109text_newtype!(StaticSegment);
110text_newtype!(DynamicParam);
111text_newtype!(WildcardParam);
112text_newtype!(OptionalSegment);
113
114/// Route segment kind labels.
115#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
116pub enum RouteSegmentKind {
117    /// A stable label variant.
118    Static,
119    /// A stable label variant.
120    Dynamic,
121    /// A stable label variant.
122    Wildcard,
123    /// A stable label variant.
124    Optional,
125}
126
127impl RouteSegmentKind {
128    /// Returns the stable label.
129    #[must_use]
130    pub const fn as_str(self) -> &'static str {
131        match self {
132            Self::Static => "static",
133            Self::Dynamic => "dynamic",
134            Self::Wildcard => "wildcard",
135            Self::Optional => "optional",
136        }
137    }
138}
139
140impl Default for RouteSegmentKind {
141    fn default() -> Self {
142        Self::Static
143    }
144}
145
146impl fmt::Display for RouteSegmentKind {
147    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148        formatter.write_str(self.as_str())
149    }
150}
151
152impl FromStr for RouteSegmentKind {
153    type Err = ApiPrimitiveError;
154
155    fn from_str(value: &str) -> Result<Self, Self::Err> {
156        let trimmed = value.trim();
157        if trimmed.is_empty() {
158            return Err(ApiPrimitiveError::Empty);
159        }
160        let normalized = trimmed.to_ascii_lowercase().replace('_', "-");
161        match normalized.as_str() {
162            "static" => Ok(Self::Static),
163            "dynamic" => Ok(Self::Dynamic),
164            "wildcard" => Ok(Self::Wildcard),
165            "optional" => Ok(Self::Optional),
166            _ => Err(ApiPrimitiveError::Unknown),
167        }
168    }
169}
170/// Route matching metadata labels.
171#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
172pub enum RouteMatchKind {
173    /// A stable label variant.
174    Exact,
175    /// A stable label variant.
176    Pattern,
177    /// A stable label variant.
178    Prefix,
179}
180
181impl RouteMatchKind {
182    /// Returns the stable label.
183    #[must_use]
184    pub const fn as_str(self) -> &'static str {
185        match self {
186            Self::Exact => "exact",
187            Self::Pattern => "pattern",
188            Self::Prefix => "prefix",
189        }
190    }
191}
192
193impl Default for RouteMatchKind {
194    fn default() -> Self {
195        Self::Exact
196    }
197}
198
199impl fmt::Display for RouteMatchKind {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter.write_str(self.as_str())
202    }
203}
204
205impl FromStr for RouteMatchKind {
206    type Err = ApiPrimitiveError;
207
208    fn from_str(value: &str) -> Result<Self, Self::Err> {
209        let trimmed = value.trim();
210        if trimmed.is_empty() {
211            return Err(ApiPrimitiveError::Empty);
212        }
213        let normalized = trimmed.to_ascii_lowercase().replace('_', "-");
214        match normalized.as_str() {
215            "exact" => Ok(Self::Exact),
216            "pattern" => Ok(Self::Pattern),
217            "prefix" => Ok(Self::Prefix),
218            _ => Err(ApiPrimitiveError::Unknown),
219        }
220    }
221}
222
223/// Lightweight metadata tying this crate's primary text and label together.
224#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct PrimitiveMetadata {
226    name: RouteTemplate,
227    kind: RouteSegmentKind,
228}
229
230impl PrimitiveMetadata {
231    /// Creates primitive metadata.
232    #[must_use]
233    pub const fn new(name: RouteTemplate, kind: RouteSegmentKind) -> Self {
234        Self { name, kind }
235    }
236
237    /// Returns the primary text value.
238    #[must_use]
239    pub const fn name(&self) -> &RouteTemplate {
240        &self.name
241    }
242
243    /// Returns the primary label.
244    #[must_use]
245    pub const fn kind(&self) -> RouteSegmentKind {
246        self.kind
247    }
248}
249
250/// Lightweight route segment metadata.
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct RouteSegment {
253    value: String,
254    kind: RouteSegmentKind,
255}
256
257impl RouteSegment {
258    /// Classifies a route segment.
259    #[must_use]
260    pub fn classify(value: impl AsRef<str>) -> Self {
261        let value = value.as_ref().trim().to_owned();
262        let kind = if value.starts_with(':') {
263            RouteSegmentKind::Dynamic
264        } else if value.starts_with('*') {
265            RouteSegmentKind::Wildcard
266        } else if value.starts_with('[') && value.ends_with(']') {
267            RouteSegmentKind::Optional
268        } else {
269            RouteSegmentKind::Static
270        };
271        Self { value, kind }
272    }
273
274    /// Returns the segment text.
275    #[must_use]
276    pub fn as_str(&self) -> &str {
277        &self.value
278    }
279
280    /// Returns the segment kind.
281    #[must_use]
282    pub const fn kind(&self) -> RouteSegmentKind {
283        self.kind
284    }
285}
286
287impl RouteTemplate {
288    /// Returns non-empty classified route segments.
289    #[must_use]
290    pub fn segments(&self) -> Vec<RouteSegment> {
291        self.as_str()
292            .split('/')
293            .filter(|segment| !segment.is_empty())
294            .map(RouteSegment::classify)
295            .collect()
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn parses_and_displays_text() -> Result<(), ApiPrimitiveError> {
305        let value = RouteTemplate::new("/users/:id")?;
306
307        assert_eq!(value.as_str(), "/users/:id");
308        assert_eq!(value.to_string(), "/users/:id");
309        assert_eq!("/users/:id".parse::<RouteTemplate>()?, value);
310        Ok(())
311    }
312
313    #[test]
314    fn rejects_empty_text() {
315        assert_eq!(RouteTemplate::new(""), Err(ApiPrimitiveError::Empty));
316    }
317
318    #[test]
319    fn parses_and_displays_labels() -> Result<(), ApiPrimitiveError> {
320        let kind = "static".parse::<RouteSegmentKind>()?;
321
322        assert_eq!(kind, RouteSegmentKind::Static);
323        assert_eq!(kind.to_string(), "static");
324        Ok(())
325    }
326
327    #[test]
328    fn creates_metadata() -> Result<(), ApiPrimitiveError> {
329        let metadata = PrimitiveMetadata::new(
330            RouteTemplate::new("/users/:id")?,
331            RouteSegmentKind::default(),
332        );
333
334        assert_eq!(metadata.name().as_str(), "/users/:id");
335        assert_eq!(metadata.kind(), RouteSegmentKind::default());
336        Ok(())
337    }
338}