1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum HttpVersion {
7 Http10,
8 Http11,
9 Http2,
10 Http3,
11 Unknown,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct HttpRequestLine {
17 pub method: String,
18 pub target: String,
19 pub version: HttpVersion,
20}
21
22#[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#[must_use]
32pub fn looks_like_http_request_line(input: &str) -> bool {
33 parse_request_line(input).is_some()
34}
35
36#[must_use]
38pub fn looks_like_http_status_line(input: &str) -> bool {
39 parse_status_line(input).is_some()
40}
41
42#[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#[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#[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#[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#[must_use]
122pub fn is_http_version(input: &str) -> bool {
123 !matches!(parse_http_version(input), HttpVersion::Unknown)
124}
125
126#[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#[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#[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#[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}