lph-11/odin-http/body.odin
2025-03-13 18:14:21 +13:00

309 lines
8.4 KiB
Odin

package http
import "core:bufio"
import "core:io"
import "core:log"
import "core:net"
import "core:strconv"
import "core:strings"
Body :: string
Body_Callback :: #type proc(user_data: rawptr, body: Body, err: Body_Error)
Body_Error :: bufio.Scanner_Error
/*
Retrieves the request's body.
If the request has the chunked Transfer-Encoding header set, the chunks are all read and returned.
Otherwise, the Content-Length header is used to determine what to read and return it.
`max_length` can be used to set a maximum amount of bytes we try to read, once it goes over this,
an error is returned.
Do not call this more than once.
**Tip** If an error is returned, easily respond with an appropriate error code like this, `http.respond(res, http.body_error_status(err))`.
*/
body :: proc(req: ^Request, max_length: int = -1, user_data: rawptr, cb: Body_Callback) {
assert(req._body_ok == nil, "you can only call body once per request")
enc_header, ok := headers_get_unsafe(req.headers, "transfer-encoding")
if ok && strings.has_suffix(enc_header, "chunked") {
_body_chunked(req, max_length, user_data, cb)
} else {
_body_length(req, max_length, user_data, cb)
}
}
/*
Parses a URL encoded body, aka bodies with the 'Content-Type: application/x-www-form-urlencoded'.
Key&value pairs are percent decoded and put in a map.
*/
body_url_encoded :: proc(plain: Body, allocator := context.temp_allocator) -> (res: map[string]string, ok: bool) {
insert :: proc(m: ^map[string]string, plain: string, keys: int, vals: int, end: int, allocator := context.temp_allocator) -> bool {
has_value := vals != -1
key_end := vals - 1 if has_value else end
key := plain[keys:key_end]
val := plain[vals:end] if has_value else ""
// PERF: this could be a hot spot and I don't like that we allocate the decoded key and value here.
keye := (net.percent_decode(key, allocator) or_return) if strings.index_byte(key, '%') > -1 else key
vale := (net.percent_decode(val, allocator) or_return) if has_value && strings.index_byte(val, '%') > -1 else val
m[keye] = vale
return true
}
count := 1
for b in plain {
if b == '&' do count += 1
}
queries := make(map[string]string, count, allocator)
keys := 0
vals := -1
for b, i in plain {
switch b {
case '=':
vals = i + 1
case '&':
insert(&queries, plain, keys, vals, i) or_return
keys = i + 1
vals = -1
}
}
insert(&queries, plain, keys, vals, len(plain)) or_return
return queries, true
}
// Returns an appropriate status code for the given body error.
body_error_status :: proc(e: Body_Error) -> Status {
switch t in e {
case bufio.Scanner_Extra_Error:
switch t {
case .Too_Long: return .Payload_Too_Large
case .Too_Short, .Bad_Read_Count: return .Bad_Request
case .Negative_Advance, .Advanced_Too_Far: return .Internal_Server_Error
case .None: return .OK
case:
return .Internal_Server_Error
}
case io.Error:
switch t {
case .EOF, .Unknown, .No_Progress, .Unexpected_EOF:
return .Bad_Request
case .Empty, .Short_Write, .Buffer_Full, .Short_Buffer,
.Invalid_Write, .Negative_Read, .Invalid_Whence, .Invalid_Offset,
.Invalid_Unread, .Negative_Write, .Negative_Count:
return .Internal_Server_Error
case .None:
return .OK
case:
return .Internal_Server_Error
}
case: unreachable()
}
}
// "Decodes" a request body based on the content length header.
// Meant for internal usage, you should use `http.request_body`.
_body_length :: proc(req: ^Request, max_length: int = -1, user_data: rawptr, cb: Body_Callback) {
req._body_ok = false
len, ok := headers_get_unsafe(req.headers, "content-length")
if !ok {
cb(user_data, "", nil)
return
}
ilen, lenok := strconv.parse_int(len, 10)
if !lenok {
cb(user_data, "", .Bad_Read_Count)
return
}
if max_length > -1 && ilen > max_length {
cb(user_data, "", .Too_Long)
return
}
if ilen == 0 {
req._body_ok = true
cb(user_data, "", nil)
return
}
req._scanner.max_token_size = ilen
req._scanner.split = scan_num_bytes
req._scanner.split_data = rawptr(uintptr(ilen))
req._body_ok = true
scanner_scan(req._scanner, user_data, cb)
}
/*
"Decodes" a chunked transfer encoded request body.
Meant for internal usage, you should use `http.request_body`.
PERF: this could be made non-allocating by writing over the part of the body that contains the
metadata with the rest of the body, and then returning a slice of that, but it is some effort and
I don't think this functionality of HTTP is used that much anyway.
RFC 7230 4.1.3 pseudo-code:
length := 0
read chunk-size, chunk-ext (if any), and CRLF
while (chunk-size > 0) {
read chunk-data and CRLF
append chunk-data to decoded-body
length := length + chunk-size
read chunk-size, chunk-ext (if any), and CRLF
}
read trailer field
while (trailer field is not empty) {
if (trailer field is allowed to be sent in a trailer) {
append trailer field to existing header fields
}
read trailer-field
}
Content-Length := length
Remove "chunked" from Transfer-Encoding
Remove Trailer from existing header fields
*/
_body_chunked :: proc(req: ^Request, max_length: int = -1, user_data: rawptr, cb: Body_Callback) {
req._body_ok = false
on_scan :: proc(s: rawptr, size_line: string, err: bufio.Scanner_Error) {
s := cast(^Chunked_State)s
size_line := size_line
if err != nil {
s.cb(s.user_data, "", err)
return
}
// If there is a semicolon, discard everything after it,
// that would be chunk extensions which we currently have no interest in.
if semi := strings.index_byte(size_line, ';'); semi > -1 {
size_line = size_line[:semi]
}
size, ok := strconv.parse_int(string(size_line), 16)
if !ok {
log.infof("Encountered an invalid chunk size when decoding a chunked body: %q", string(size_line))
s.cb(s.user_data, "", .Bad_Read_Count)
return
}
// start scanning trailer headers.
if size == 0 {
scanner_scan(s.req._scanner, s, on_scan_trailer)
return
}
if s.max_length > -1 && strings.builder_len(s.buf) + size > s.max_length {
s.cb(s.user_data, "", .Too_Long)
return
}
s.req._scanner.max_token_size = size
s.req._scanner.split = scan_num_bytes
s.req._scanner.split_data = rawptr(uintptr(size))
scanner_scan(s.req._scanner, s, on_scan_chunk)
}
on_scan_chunk :: proc(s: rawptr, token: string, err: bufio.Scanner_Error) {
s := cast(^Chunked_State)s
if err != nil {
s.cb(s.user_data, "", err)
return
}
s.req._scanner.max_token_size = bufio.DEFAULT_MAX_SCAN_TOKEN_SIZE
s.req._scanner.split = scan_lines
strings.write_string(&s.buf, token)
on_scan_empty_line :: proc(s: rawptr, token: string, err: bufio.Scanner_Error) {
s := cast(^Chunked_State)s
if err != nil {
s.cb(s.user_data, "", err)
return
}
assert(len(token) == 0)
scanner_scan(s.req._scanner, s, on_scan)
}
scanner_scan(s.req._scanner, s, on_scan_empty_line)
}
on_scan_trailer :: proc(s: rawptr, line: string, err: bufio.Scanner_Error) {
s := cast(^Chunked_State)s
// Headers are done, success.
if err != nil || len(line) == 0 {
headers_delete_unsafe(&s.req.headers, "trailer")
te_header := headers_get_unsafe(s.req.headers, "transfer-encoding")
new_te_header := strings.trim_suffix(te_header, "chunked")
s.req.headers.readonly = false
headers_set_unsafe(&s.req.headers, "transfer-encoding", new_te_header)
s.req.headers.readonly = true
s.req._body_ok = true
s.cb(s.user_data, strings.to_string(s.buf), nil)
return
}
key, ok := header_parse(&s.req.headers, string(line))
if !ok {
log.infof("Invalid header when decoding chunked body: %q", string(line))
s.cb(s.user_data, "", .Unknown)
return
}
// A recipient MUST ignore (or consider as an error) any fields that are forbidden to be sent in a trailer.
if !header_allowed_trailer(key) {
log.infof("Invalid trailer header received, discarding it: %q", key)
headers_delete(&s.req.headers, key)
}
scanner_scan(s.req._scanner, s, on_scan_trailer)
}
Chunked_State :: struct {
req: ^Request,
max_length: int,
user_data: rawptr,
cb: Body_Callback,
buf: strings.Builder,
}
s := new(Chunked_State, context.temp_allocator)
s.buf.buf.allocator = context.temp_allocator
s.req = req
s.max_length = max_length
s.user_data = user_data
s.cb = cb
s.req._scanner.split = scan_lines
scanner_scan(s.req._scanner, s, on_scan)
}