Skip to content

Commit 6183d2a

Browse files
authored
fasthttp: expand http request parser (related to #26091 part1) (#26104)
1 parent dec1c0b commit 6183d2a

File tree

4 files changed

+244
-57
lines changed

4 files changed

+244
-57
lines changed

vlib/fasthttp/fasthttp.v

Lines changed: 4 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,16 @@ pub:
4848
len int
4949
}
5050

51+
// HttpRequest represents an HTTP request.
52+
// TODO make fields immutable
5153
pub struct HttpRequest {
5254
pub mut:
5355
buffer []u8 // A V slice of the read buffer for convenience
5456
method Slice
5557
path Slice
5658
version Slice
59+
header_fields Slice
60+
body Slice
5761
client_conn_fd int
5862
user_data voidptr // User-defined context data
5963
}
@@ -66,55 +70,3 @@ pub:
6670
handler fn (HttpRequest) ![]u8 @[required]
6771
user_data voidptr
6872
}
69-
70-
@[direct_array_access]
71-
fn parse_request_line(buffer []u8) !HttpRequest {
72-
mut req := HttpRequest{
73-
buffer: buffer
74-
}
75-
76-
mut i := 0
77-
// Parse HTTP method
78-
for i < buffer.len && buffer[i] != ` ` {
79-
i++
80-
}
81-
req.method = Slice{
82-
start: 0
83-
len: i
84-
}
85-
i++
86-
87-
// Parse path
88-
mut path_start := i
89-
for i < buffer.len && buffer[i] != ` ` {
90-
i++
91-
}
92-
req.path = Slice{
93-
start: path_start
94-
len: i - path_start
95-
}
96-
i++
97-
98-
// Parse HTTP version
99-
mut version_start := i
100-
for i < buffer.len && buffer[i] != `\r` {
101-
i++
102-
}
103-
req.version = Slice{
104-
start: version_start
105-
len: i - version_start
106-
}
107-
108-
// Move to the end of the request line
109-
if i + 1 < buffer.len && buffer[i] == `\r` && buffer[i + 1] == `\n` {
110-
i += 2
111-
} else {
112-
return error('Invalid HTTP request line')
113-
}
114-
115-
return req
116-
}
117-
118-
fn decode_http_request(buffer []u8) !HttpRequest {
119-
return parse_request_line(buffer)
120-
}

vlib/fasthttp/fasthttp_test.v

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ fn test_fasthttp_example_compiles() {
2525
fn test_parse_request_line() {
2626
// Test basic GET request
2727
request := 'GET / HTTP/1.1\r\n'.bytes()
28-
req := parse_request_line(request) or {
28+
req := decode_http_request(request) or {
2929
assert false, 'Failed to parse valid request: ${err}'
3030
return
3131
}
@@ -50,7 +50,7 @@ fn test_parse_request_line() {
5050
fn test_parse_request_line_with_path() {
5151
// Test GET request with path
5252
request := 'GET /users/123 HTTP/1.1\r\n'.bytes()
53-
req := parse_request_line(request) or {
53+
req := decode_http_request(request) or {
5454
assert false, 'Failed to parse valid request: ${err}'
5555
return
5656
}
@@ -62,7 +62,7 @@ fn test_parse_request_line_with_path() {
6262
fn test_parse_request_line_post() {
6363
// Test POST request
6464
request := 'POST /api/data HTTP/1.1\r\n'.bytes()
65-
req := parse_request_line(request) or {
65+
req := decode_http_request(request) or {
6666
assert false, 'Failed to parse valid request: ${err}'
6767
return
6868
}
@@ -77,8 +77,8 @@ fn test_parse_request_line_post() {
7777
fn test_parse_request_line_invalid() {
7878
// Test invalid request (missing \r\n)
7979
request := 'GET / HTTP/1.1'.bytes()
80-
parse_request_line(request) or {
81-
assert err.msg() == 'Invalid HTTP request line'
80+
decode_http_request(request) or {
81+
assert err.msg() == 'Invalid HTTP request line: Missing CR'
8282
return
8383
}
8484
assert false, 'Should have failed to parse invalid request'

vlib/fasthttp/request_parser.v

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
module fasthttp
2+
3+
const empty_space = u8(` `)
4+
const cr_char = u8(`\r`)
5+
const lf_char = u8(`\n`)
6+
7+
// libc memchr is AVX2-accelerated via glibc IFUNC
8+
@[inline]
9+
fn find_byte(buf &u8, len int, c u8) int {
10+
unsafe {
11+
p := C.memchr(buf, c, len)
12+
if p == voidptr(nil) {
13+
return -1
14+
}
15+
return int(&u8(p) - buf)
16+
}
17+
}
18+
19+
// parse_http1_request_line parses the request line of an HTTP/1.1 request.
20+
// spec: https://datatracker.ietf.org/doc/rfc9112/
21+
// request-line is the start-line for for requests
22+
// According to RFC 9112, the request line is structured as:
23+
// `request-line = method SP request-target SP HTTP-version`
24+
// where:
25+
// METHOD is the HTTP method (e.g., GET, POST)
26+
// SP is a single space character
27+
// REQUEST-TARGET is the path or resource being requested
28+
// HTTP-VERSION is the version of HTTP being used (e.g., HTTP/1.1)
29+
// CRLF is a carriage return followed by a line feed
30+
// returns the position after the CRLF on success
31+
@[direct_array_access]
32+
pub fn parse_http1_request_line(mut req HttpRequest) !int {
33+
unsafe {
34+
buf := &req.buffer[0]
35+
len := req.buffer.len
36+
37+
if len < 12 {
38+
return error('Too short')
39+
}
40+
41+
// METHOD
42+
pos1 := find_byte(buf, len, empty_space)
43+
if pos1 <= 0 {
44+
return error('Invalid method')
45+
}
46+
req.method = Slice{0, pos1}
47+
48+
// PATH - skip any extra spaces
49+
mut pos2 := pos1 + 1
50+
for pos2 < len && buf[pos2] == empty_space {
51+
pos2++
52+
}
53+
if pos2 >= len {
54+
return error('Missing path')
55+
}
56+
57+
path_start := pos2
58+
space_pos := find_byte(buf + pos2, len - pos2, empty_space)
59+
cr_pos := find_byte(buf + pos2, len - pos2, cr_char)
60+
61+
if space_pos < 0 && cr_pos < 0 {
62+
return error('Invalid request line')
63+
}
64+
65+
// pick earliest delimiter
66+
mut path_len := 0
67+
mut delim_pos := 0
68+
if space_pos >= 0 && (cr_pos < 0 || space_pos < cr_pos) {
69+
path_len = space_pos
70+
delim_pos = pos2 + space_pos
71+
} else {
72+
path_len = cr_pos
73+
delim_pos = pos2 + cr_pos
74+
}
75+
76+
req.path = Slice{path_start, path_len}
77+
78+
// VERSION
79+
if buf[delim_pos] == cr_char {
80+
// No HTTP version specified
81+
req.version = Slice{delim_pos, 0}
82+
} else {
83+
version_start := delim_pos + 1
84+
cr := find_byte(buf + version_start, len - version_start, cr_char)
85+
if cr < 0 {
86+
return error('Invalid HTTP request line: Missing CR')
87+
}
88+
req.version = Slice{version_start, cr}
89+
delim_pos = version_start + cr
90+
}
91+
92+
// Validate CRLF
93+
if delim_pos + 1 >= len || buf[delim_pos + 1] != lf_char {
94+
return error('Invalid CRLF')
95+
}
96+
97+
return delim_pos + 2 // Return position after CRLF
98+
}
99+
}
100+
101+
// decode_http_request parses a raw HTTP request from the given byte buffer
102+
pub fn decode_http_request(buffer []u8) !HttpRequest {
103+
mut req := HttpRequest{
104+
buffer: buffer
105+
}
106+
107+
// header_start is the byte index immediately after the request line's \r\n
108+
header_start := parse_http1_request_line(mut req)!
109+
110+
// Find the end of the header block (\r\n\r\n)
111+
mut body_start := -1
112+
for i := header_start; i <= buffer.len - 4; i++ {
113+
if buffer[i] == cr_char && buffer[i + 1] == lf_char && buffer[i + 2] == cr_char
114+
&& buffer[i + 3] == lf_char {
115+
body_start = i + 4
116+
117+
// The header fields slice covers everything from header_start
118+
// up to (but not including) the final double CRLF
119+
req.header_fields = Slice{
120+
start: header_start
121+
len: i - header_start
122+
}
123+
break
124+
}
125+
}
126+
127+
if body_start != -1 {
128+
req.body = Slice{
129+
start: body_start
130+
len: buffer.len - body_start
131+
}
132+
} else {
133+
// If no body delimiter found, assume headers go to end or body is missing
134+
req.header_fields = Slice{header_start, buffer.len - header_start - 2}
135+
req.body = Slice{0, 0}
136+
}
137+
138+
return req
139+
}
140+
141+
// Helper function to convert Slice to string for debugging
142+
fn (slice Slice) to_string(buffer []u8) string {
143+
if slice.len <= 0 {
144+
return ''
145+
}
146+
return buffer[slice.start..slice.start + slice.len].bytestr()
147+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
module fasthttp
2+
3+
fn test_parse_http1_request_line_valid_request() {
4+
buffer := 'GET /path/to/resource HTTP/1.1\r\n'.bytes()
5+
mut req := HttpRequest{
6+
buffer: buffer
7+
}
8+
9+
parse_http1_request_line(mut req) or { panic(err) }
10+
11+
assert req.method.to_string(req.buffer) == 'GET'
12+
assert req.path.to_string(req.buffer) == '/path/to/resource'
13+
assert req.version.to_string(req.buffer) == 'HTTP/1.1'
14+
}
15+
16+
fn test_parse_http1_request_line_invalid_request() {
17+
buffer := 'INVALID REQUEST LINE'.bytes()
18+
mut req := HttpRequest{
19+
buffer: buffer
20+
}
21+
22+
mut has_error := false
23+
parse_http1_request_line(mut req) or {
24+
has_error = true
25+
assert err.msg() == 'Invalid HTTP request line: Missing CR'
26+
}
27+
assert has_error, 'Expected error for invalid request line'
28+
}
29+
30+
fn test_decode_http_request_valid_request() {
31+
buffer := 'POST /api/resource HTTP/1.0\r\n'.bytes()
32+
req := decode_http_request(buffer) or { panic(err) }
33+
34+
assert req.method.to_string(req.buffer) == 'POST'
35+
assert req.path.to_string(req.buffer) == '/api/resource'
36+
assert req.version.to_string(req.buffer) == 'HTTP/1.0'
37+
}
38+
39+
fn test_decode_http_request_invalid_request() {
40+
buffer := 'INVALID REQUEST LINE'.bytes()
41+
42+
mut has_error := false
43+
decode_http_request(buffer) or {
44+
has_error = true
45+
assert err.msg() == 'Invalid HTTP request line: Missing CR'
46+
}
47+
assert has_error, 'Expected error for invalid request'
48+
}
49+
50+
fn test_decode_http_request_with_headers_and_body() {
51+
raw := 'POST /submit HTTP/1.1\r\n' + 'Host: localhost\r\n' +
52+
'Content-Type: application/json\r\n' + 'Content-Length: 18\r\n' + '\r\n' +
53+
'{"status": "ok"}'
54+
55+
buffer := raw.bytes()
56+
req := decode_http_request(buffer) or { panic(err) }
57+
58+
assert req.method.to_string(req.buffer) == 'POST'
59+
assert req.path.to_string(req.buffer) == '/submit'
60+
61+
// Verify Header Fields block
62+
// Should contain everything between the first \r\n and the \r\n\r\n
63+
header_str := req.header_fields.to_string(req.buffer)
64+
assert header_str == 'Host: localhost\r\nContent-Type: application/json\r\nContent-Length: 18'
65+
66+
// Verify Body
67+
assert req.body.to_string(req.buffer) == '{"status": "ok"}'
68+
}
69+
70+
fn test_decode_http_request_no_body() {
71+
// A GET request usually ends with \r\n\r\n and no body
72+
buffer := 'GET /index.html HTTP/1.1\r\nUser-Agent: V\r\n\r\n'.bytes()
73+
req := decode_http_request(buffer) or { panic(err) }
74+
75+
assert req.header_fields.to_string(req.buffer) == 'User-Agent: V'
76+
assert req.body.len == 0
77+
}
78+
79+
fn test_decode_http_request_malformed_no_double_crlf() {
80+
// Request that never finishes headers
81+
buffer := 'GET / HTTP/1.1\r\nHost: example.com\r\n'.bytes()
82+
req := decode_http_request(buffer) or { panic(err) }
83+
84+
// Based on our implementation, if no \r\n\r\n is found,
85+
// body should be empty and headers go to the end.
86+
assert req.body.len == 0
87+
assert req.header_fields.to_string(req.buffer) == 'Host: example.com'
88+
}

0 commit comments

Comments
 (0)