Skip to main content

use_http/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4/// A small set of HTTP versions commonly encountered in textual protocol surfaces.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum HttpVersion {
7    Http10,
8    Http11,
9    Http2,
10    Http3,
11    Unknown,
12}
13
14/// A parsed HTTP request line.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct HttpRequestLine {
17    pub method: String,
18    pub target: String,
19    pub version: HttpVersion,
20}
21
22/// A parsed HTTP status line.
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct HttpStatusLine {
25    pub version: HttpVersion,
26    pub code: u16,
27    pub reason: Option<String>,
28}
29
30/// Returns `true` when the input looks like a valid HTTP request line.
31#[must_use]
32pub fn looks_like_http_request_line(input: &str) -> bool {
33    parse_request_line(input).is_some()
34}
35
36/// Returns `true` when the input looks like a valid HTTP status line.
37#[must_use]
38pub fn looks_like_http_status_line(input: &str) -> bool {
39    parse_status_line(input).is_some()
40}
41
42/// Parses a textual HTTP version token.
43#[must_use]
44pub fn parse_http_version(input: &str) -> HttpVersion {
45    match input.trim().to_ascii_uppercase().as_str() {
46        "HTTP/1.0" => HttpVersion::Http10,
47        "HTTP/1.1" => HttpVersion::Http11,
48        "HTTP/2" | "HTTP/2.0" => HttpVersion::Http2,
49        "HTTP/3" | "HTTP/3.0" => HttpVersion::Http3,
50        _ => HttpVersion::Unknown,
51    }
52}
53
54/// Formats an HTTP version token.
55#[must_use]
56pub const fn format_http_version(version: HttpVersion) -> &'static str {
57    match version {
58        HttpVersion::Http10 => "HTTP/1.0",
59        HttpVersion::Http11 => "HTTP/1.1",
60        HttpVersion::Http2 => "HTTP/2",
61        HttpVersion::Http3 => "HTTP/3",
62        HttpVersion::Unknown => "HTTP/?",
63    }
64}
65
66/// Parses a request line in the form `METHOD target HTTP/x`.
67#[must_use]
68pub fn parse_request_line(input: &str) -> Option<HttpRequestLine> {
69    let trimmed = input.trim();
70    let (method, remainder) = split_once_whitespace(trimmed)?;
71    let (target, version_text) = split_once_whitespace(remainder)?;
72    if version_text.chars().any(char::is_whitespace) {
73        return None;
74    }
75
76    let version = parse_http_version(version_text);
77    if matches!(version, HttpVersion::Unknown) {
78        return None;
79    }
80
81    Some(HttpRequestLine {
82        method: method.to_string(),
83        target: target.to_string(),
84        version,
85    })
86}
87
88/// Parses a status line in the form `HTTP/x 200 Reason`.
89#[must_use]
90pub fn parse_status_line(input: &str) -> Option<HttpStatusLine> {
91    let trimmed = input.trim();
92    let (version_text, remainder) = split_once_whitespace(trimmed)?;
93    let version = parse_http_version(version_text);
94    if matches!(version, HttpVersion::Unknown) {
95        return None;
96    }
97
98    let (code_text, reason_text) = match split_once_whitespace(remainder) {
99        Some((code, tail)) => (code, Some(tail.trim())),
100        None => (remainder, None),
101    };
102
103    if code_text.len() != 3
104        || !code_text
105            .chars()
106            .all(|character| character.is_ascii_digit())
107    {
108        return None;
109    }
110
111    Some(HttpStatusLine {
112        version,
113        code: code_text.parse().ok()?,
114        reason: reason_text
115            .filter(|reason| !reason.is_empty())
116            .map(ToString::to_string),
117    })
118}
119
120/// Returns `true` when the input is a recognized textual HTTP version.
121#[must_use]
122pub fn is_http_version(input: &str) -> bool {
123    !matches!(parse_http_version(input), HttpVersion::Unknown)
124}
125
126/// Returns `true` when the target is in origin-form, such as `/items?limit=10`.
127#[must_use]
128pub fn is_request_target_origin_form(input: &str) -> bool {
129    let trimmed = input.trim();
130    trimmed.starts_with('/') && !trimmed.starts_with("//")
131}
132
133/// Returns `true` when the target is in absolute-form, such as `https://example.com/`.
134#[must_use]
135pub fn is_request_target_absolute_form(input: &str) -> bool {
136    let trimmed = input.trim();
137    if let Some(index) = trimmed.find("://") {
138        let scheme = &trimmed[..index];
139        !scheme.is_empty()
140            && scheme.starts_with(|character: char| character.is_ascii_alphabetic())
141            && scheme.chars().all(|character| {
142                character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
143            })
144    } else {
145        false
146    }
147}
148
149/// Returns `true` when the target is in authority-form, such as `example.com:443`.
150#[must_use]
151pub fn is_request_target_authority_form(input: &str) -> bool {
152    let trimmed = input.trim();
153    if trimmed.is_empty()
154        || trimmed.contains(['/', '?', '#'])
155        || trimmed.chars().any(char::is_whitespace)
156    {
157        return false;
158    }
159
160    if let Some(rest) = trimmed.strip_prefix('[') {
161        if let Some(end) = rest.find(']') {
162            let tail = &rest[end + 1..];
163            return matches!(tail.strip_prefix(':'), Some(port) if !port.is_empty() && port.chars().all(|character| character.is_ascii_digit()));
164        }
165        return false;
166    }
167
168    matches!(trimmed.rsplit_once(':'), Some((host, port)) if !host.is_empty() && !port.is_empty() && port.chars().all(|character| character.is_ascii_digit()))
169}
170
171/// Returns `true` when the target is the asterisk-form `*`.
172#[must_use]
173pub fn is_request_target_asterisk_form(input: &str) -> bool {
174    input.trim() == "*"
175}
176
177fn split_once_whitespace(input: &str) -> Option<(&str, &str)> {
178    let index = input
179        .char_indices()
180        .find(|(_, character)| character.is_whitespace())?
181        .0;
182    let head = &input[..index];
183    let tail = input[index..].trim_start();
184    Some((head, tail))
185}