Skip to main content

use_api_header/
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!(ApiHeaderName);
109text_newtype!(CustomHeaderName);
110text_newtype!(CorrelationHeaderName);
111text_newtype!(AuthHeaderName);
112text_newtype!(IdempotencyHeaderName);
113text_newtype!(RateLimitHeaderName);
114
115/// Common API header labels.
116#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117pub enum CommonApiHeader {
118    /// A stable label variant.
119    Authorization,
120    /// A stable label variant.
121    ContentType,
122    /// A stable label variant.
123    Accept,
124    /// A stable label variant.
125    CorrelationId,
126    /// A stable label variant.
127    IdempotencyKey,
128    /// A stable label variant.
129    RateLimitLimit,
130    /// A stable label variant.
131    RateLimitRemaining,
132    /// A stable label variant.
133    RateLimitReset,
134}
135
136impl CommonApiHeader {
137    /// Returns the stable label.
138    #[must_use]
139    pub const fn as_str(self) -> &'static str {
140        match self {
141            Self::Authorization => "authorization",
142            Self::ContentType => "content-type",
143            Self::Accept => "accept",
144            Self::CorrelationId => "correlation-id",
145            Self::IdempotencyKey => "idempotency-key",
146            Self::RateLimitLimit => "rate-limit-limit",
147            Self::RateLimitRemaining => "rate-limit-remaining",
148            Self::RateLimitReset => "rate-limit-reset",
149        }
150    }
151}
152
153impl Default for CommonApiHeader {
154    fn default() -> Self {
155        Self::Authorization
156    }
157}
158
159impl fmt::Display for CommonApiHeader {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        formatter.write_str(self.as_str())
162    }
163}
164
165impl FromStr for CommonApiHeader {
166    type Err = ApiPrimitiveError;
167
168    fn from_str(value: &str) -> Result<Self, Self::Err> {
169        let trimmed = value.trim();
170        if trimmed.is_empty() {
171            return Err(ApiPrimitiveError::Empty);
172        }
173        let normalized = trimmed.to_ascii_lowercase().replace('_', "-");
174        match normalized.as_str() {
175            "authorization" => Ok(Self::Authorization),
176            "content-type" => Ok(Self::ContentType),
177            "accept" => Ok(Self::Accept),
178            "correlation-id" => Ok(Self::CorrelationId),
179            "idempotency-key" => Ok(Self::IdempotencyKey),
180            "rate-limit-limit" => Ok(Self::RateLimitLimit),
181            "rate-limit-remaining" => Ok(Self::RateLimitRemaining),
182            "rate-limit-reset" => Ok(Self::RateLimitReset),
183            _ => Err(ApiPrimitiveError::Unknown),
184        }
185    }
186}
187
188/// Lightweight metadata tying this crate's primary text and label together.
189#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct PrimitiveMetadata {
191    name: ApiHeaderName,
192    kind: CommonApiHeader,
193}
194
195impl PrimitiveMetadata {
196    /// Creates primitive metadata.
197    #[must_use]
198    pub const fn new(name: ApiHeaderName, kind: CommonApiHeader) -> Self {
199        Self { name, kind }
200    }
201
202    /// Returns the primary text value.
203    #[must_use]
204    pub const fn name(&self) -> &ApiHeaderName {
205        &self.name
206    }
207
208    /// Returns the primary label.
209    #[must_use]
210    pub const fn kind(&self) -> CommonApiHeader {
211        self.kind
212    }
213}
214
215impl CommonApiHeader {
216    /// Returns the conventional HTTP header spelling.
217    #[must_use]
218    pub const fn header_name(self) -> &'static str {
219        match self {
220            Self::Authorization => "Authorization",
221            Self::ContentType => "Content-Type",
222            Self::Accept => "Accept",
223            Self::CorrelationId => "X-Correlation-Id",
224            Self::IdempotencyKey => "Idempotency-Key",
225            Self::RateLimitLimit => "RateLimit-Limit",
226            Self::RateLimitRemaining => "RateLimit-Remaining",
227            Self::RateLimitReset => "RateLimit-Reset",
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn parses_and_displays_text() -> Result<(), ApiPrimitiveError> {
238        let value = ApiHeaderName::new("X-Request-Id")?;
239
240        assert_eq!(value.as_str(), "X-Request-Id");
241        assert_eq!(value.to_string(), "X-Request-Id");
242        assert_eq!("X-Request-Id".parse::<ApiHeaderName>()?, value);
243        Ok(())
244    }
245
246    #[test]
247    fn rejects_empty_text() {
248        assert_eq!(ApiHeaderName::new(""), Err(ApiPrimitiveError::Empty));
249    }
250
251    #[test]
252    fn parses_and_displays_labels() -> Result<(), ApiPrimitiveError> {
253        let kind = "authorization".parse::<CommonApiHeader>()?;
254
255        assert_eq!(kind, CommonApiHeader::Authorization);
256        assert_eq!(kind.to_string(), "authorization");
257        Ok(())
258    }
259
260    #[test]
261    fn creates_metadata() -> Result<(), ApiPrimitiveError> {
262        let metadata = PrimitiveMetadata::new(
263            ApiHeaderName::new("X-Request-Id")?,
264            CommonApiHeader::default(),
265        );
266
267        assert_eq!(metadata.name().as_str(), "X-Request-Id");
268        assert_eq!(metadata.kind(), CommonApiHeader::default());
269        Ok(())
270    }
271}