Skip to main content

nautilus_network/http/
types.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! HTTP types including status codes, methods, and responses.
17
18use std::{collections::HashMap, hash::Hash, time::Duration};
19
20use bytes::Bytes;
21use http::{Method, StatusCode, status::InvalidStatusCode};
22use jiff::{Timestamp, fmt::rfc2822::DateTimeParser};
23
24/// An HTTP status code.
25///
26/// Wraps [`http::StatusCode`] to reuse its validation and convenience methods.
27#[derive(Clone, Debug)]
28pub struct HttpStatus {
29    inner: StatusCode,
30}
31
32impl HttpStatus {
33    /// Creates an [`HttpStatus`] from a [`StatusCode`].
34    #[must_use]
35    pub const fn new(code: StatusCode) -> Self {
36        Self { inner: code }
37    }
38
39    /// Returns the status code as a `u16` (e.g., `200` for OK).
40    #[inline]
41    #[must_use]
42    pub const fn as_u16(&self) -> u16 {
43        self.inner.as_u16()
44    }
45
46    /// Returns the three-digit ASCII representation of this status (e.g., `"200"`).
47    #[inline]
48    #[must_use]
49    pub fn as_str(&self) -> &str {
50        self.inner.as_str()
51    }
52
53    /// Checks if this status is in the 1xx (informational) range.
54    #[inline]
55    #[must_use]
56    pub fn is_informational(&self) -> bool {
57        self.inner.is_informational()
58    }
59
60    /// Checks if this status is in the 2xx (success) range.
61    #[inline]
62    #[must_use]
63    pub fn is_success(&self) -> bool {
64        self.inner.is_success()
65    }
66
67    /// Checks if this status is in the 3xx (redirection) range.
68    #[inline]
69    #[must_use]
70    pub fn is_redirection(&self) -> bool {
71        self.inner.is_redirection()
72    }
73
74    /// Checks if this status is in the 4xx (client error) range.
75    #[inline]
76    #[must_use]
77    pub fn is_client_error(&self) -> bool {
78        self.inner.is_client_error()
79    }
80
81    /// Checks if this status is in the 5xx (server error) range.
82    #[inline]
83    #[must_use]
84    pub fn is_server_error(&self) -> bool {
85        self.inner.is_server_error()
86    }
87}
88
89impl TryFrom<u16> for HttpStatus {
90    type Error = InvalidStatusCode;
91
92    /// Attempts to construct a [`HttpStatus`] from a `u16`.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the code is not in the valid `100..999` range.
97    fn try_from(code: u16) -> Result<Self, Self::Error> {
98        Ok(Self {
99            inner: StatusCode::from_u16(code)?,
100        })
101    }
102}
103
104/// An HTTP method supported by [`super::HttpClient`].
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106pub enum HttpMethod {
107    GET,
108    POST,
109    PUT,
110    DELETE,
111    PATCH,
112}
113
114impl From<HttpMethod> for Method {
115    fn from(value: HttpMethod) -> Self {
116        match value {
117            HttpMethod::GET => Self::GET,
118            HttpMethod::POST => Self::POST,
119            HttpMethod::PUT => Self::PUT,
120            HttpMethod::DELETE => Self::DELETE,
121            HttpMethod::PATCH => Self::PATCH,
122        }
123    }
124}
125
126/// The status, selected headers, and decoded body returned by a buffered HTTP request.
127#[derive(Clone, Debug)]
128pub struct HttpResponse {
129    /// The HTTP status code.
130    pub status: HttpStatus,
131    /// The response headers as a map of key-value pairs.
132    pub headers: HashMap<String, String>,
133    /// The response body after HTTP content decoding.
134    pub body: Bytes,
135}
136
137/// Parses a `Retry-After` delay in seconds or an HTTP date relative to `now`.
138///
139/// Returns zero for a past date and `None` for an invalid value.
140#[must_use]
141pub fn parse_retry_after(value: &str, now: Timestamp) -> Option<Duration> {
142    if let Ok(seconds) = value.parse::<u64>() {
143        return Some(Duration::from_secs(seconds));
144    }
145
146    let retry_at = DateTimeParser::new().parse_timestamp(value).ok()?;
147    let delay = retry_at.duration_since(now);
148    if delay.is_negative() {
149        Some(Duration::ZERO)
150    } else {
151        Some(delay.unsigned_abs())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use rstest::rstest;
158
159    use super::*;
160
161    #[rstest]
162    #[case(100, [true, false, false, false, false])]
163    #[case(199, [true, false, false, false, false])]
164    #[case(200, [false, true, false, false, false])]
165    #[case(299, [false, true, false, false, false])]
166    #[case(300, [false, false, true, false, false])]
167    #[case(399, [false, false, true, false, false])]
168    #[case(400, [false, false, false, true, false])]
169    #[case(499, [false, false, false, true, false])]
170    #[case(500, [false, false, false, false, true])]
171    #[case(599, [false, false, false, false, true])]
172    #[case(600, [false; 5])]
173    #[case(999, [false; 5])]
174    fn status_classifies_boundaries(#[case] code: u16, #[case] expected: [bool; 5]) {
175        let status = HttpStatus::try_from(code).unwrap();
176
177        assert_eq!(status.as_u16(), code);
178        assert_eq!(status.as_str(), code.to_string());
179        assert_eq!(
180            [
181                status.is_informational(),
182                status.is_success(),
183                status.is_redirection(),
184                status.is_client_error(),
185                status.is_server_error()
186            ],
187            expected,
188        );
189    }
190
191    #[rstest]
192    #[case(0)]
193    #[case(99)]
194    #[case(1000)]
195    #[case(u16::MAX)]
196    fn status_rejects_invalid_code(#[case] code: u16) {
197        let error = HttpStatus::try_from(code).unwrap_err();
198
199        assert_eq!(error.to_string(), "invalid status code");
200    }
201
202    #[rstest]
203    #[case(HttpMethod::GET, Method::GET)]
204    #[case(HttpMethod::POST, Method::POST)]
205    #[case(HttpMethod::PUT, Method::PUT)]
206    #[case(HttpMethod::DELETE, Method::DELETE)]
207    #[case(HttpMethod::PATCH, Method::PATCH)]
208    fn method_conversion_preserves_verb(#[case] input: HttpMethod, #[case] expected: Method) {
209        assert_eq!(Method::from(input), expected);
210    }
211}