Files
desi_explorer/gui/lib/local/http/src/http.odin
T
sam_oneal de842fdc2d
CI / Detect changed paths (pull_request) Successful in 3s
CI / Odin unit tests and build (pull_request) Successful in 1m4s
CI / API unit tests and lint (pull_request) Failing after 1m14s
CI / Infra unit tests, vet, and preview (pull_request) Successful in 45s
updated http library to return error and response structs; updated how the GUI works to have a 'wait' UI; updated Makefile to ensure API is started before starting GUI
2026-09-13 00:52:02 -06:00

311 lines
6.8 KiB
Odin

package http
import "core:fmt"
import "core:net"
import "core:strconv"
import "core:strings"
import "core:time"
DEFAULT_HTTP_PORT :: 80
MAX_REDIRECTS :: 5
HTTP_TIMEOUT :: 5 * time.Second
Response :: struct {
status: int,
location: string,
body: string,
content_length: int,
chunked: bool,
}
Error :: struct {
code: int,
message: string,
}
Error_Code :: enum int {
Invalid_Url,
Dial_Failed,
Send_Failed,
Invalid_Response,
Invalid_Redirect,
Invalid_Chunked,
Too_Many_Redirects,
}
error_make :: proc(code: Error_Code) -> ^Error {
err := new(Error)
err.code = int(code)
switch code {
case .Invalid_Url:
err.message = "invalid url"
case .Dial_Failed:
err.message = "could not connect to host"
case .Send_Failed:
err.message = "could not send request"
case .Invalid_Response:
err.message = "could not parse response"
case .Invalid_Redirect:
err.message = "invalid redirect"
case .Invalid_Chunked:
err.message = "invalid chunked response"
case .Too_Many_Redirects:
err.message = "too many redirects"
}
return err
}
http_get :: proc(url: string) -> (resp: Response, err: ^Error) {
current := url
owns := false
for _ in 0 ..= MAX_REDIRECTS {
next_url, next_resp, request_err := perform_request(current)
if owns do delete(current)
if request_err != nil {
return {}, request_err
}
if next_url != "" {
current = next_url
owns = true
continue
}
return next_resp, nil
}
if owns do delete(current)
return {}, error_make(.Too_Many_Redirects)
}
perform_request :: proc(url: string) -> (next_url: string, resp: Response, err: ^Error) {
host, port, path, parse_ok := parse_http_url(url)
if !parse_ok {
return "", {}, error_make(.Invalid_Url)
}
conn, dial_err := net.dial_tcp_from_hostname_with_port_override(host, port)
if dial_err != nil {
return "", {}, error_make(.Dial_Failed)
}
defer net.close(conn)
net.set_option(conn, .Receive_Timeout, HTTP_TIMEOUT)
net.set_option(conn, .Send_Timeout, HTTP_TIMEOUT)
host_buf: [256]byte
host_head := host
if port != DEFAULT_HTTP_PORT {
host_head = fmt.bprintf(host_buf[:], "%s:%d", host, port)
}
request_buf: [1024]byte
request := fmt.bprintf(
request_buf[:],
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\nUser-Agent: desi-explorer-http\r\nAccept: */*\r\n\r\n",
path,
host_head,
)
if _, send_err := net.send_tcp(conn, transmute([]u8)request); send_err != nil {
return "", {}, error_make(.Send_Failed)
}
buf: [dynamic]u8
defer delete(buf)
scratch: [4096]byte
for {
n, recv_err := net.recv_tcp(conn, scratch[:])
if n > 0 do append(&buf, ..scratch[:n])
if recv_err != nil || n == 0 do break
}
parsed, response_ok := parse_response(buf[:])
if !response_ok {
return "", {}, error_make(.Invalid_Response)
}
resp = parsed
if resp.status >= 300 && resp.status < 400 && resp.location != "" {
resolved := resolve_redirect(url, resp.location)
if resolved == "" {
return "", {}, error_make(.Invalid_Redirect)
}
resp.body = ""
return resolved, resp, nil
}
if resp.chunked {
decoded, decode_ok := decode_chunked(resp.body)
if !decode_ok {
return "", {}, error_make(.Invalid_Chunked)
}
resp.body = decoded
return "", resp, nil
}
if resp.content_length >= 0 && resp.content_length < len(resp.body) {
resp.body = resp.body[:resp.content_length]
}
resp.body = strings.clone(resp.body)
return "", resp, nil
}
parse_response :: proc(raw: []byte) -> (resp: Response, ok: bool) {
resp.content_length = -1
raw_str := string(raw)
header_end := strings.index(raw_str, "\r\n\r\n")
if header_end < 0 {
return resp, false
}
lines := strings.split(raw_str[:header_end], "\r\n")
defer delete(lines)
if len(lines) == 0 {
return resp, false
}
resp.status = parse_status_code(lines[0])
if resp.status == 0 {
return resp, false
}
for _, i in lines {
if i == 0 do continue
colon := strings.index_byte(lines[i], ':')
if colon < 0 do continue
key := strings.trim_space(lines[i][:colon])
value := strings.trim_space(lines[i][colon + 1:])
switch {
case fold_eq(key, "content-length"):
if n, number_ok := strconv.parse_int(value); number_ok {
resp.content_length = n
}
case fold_eq(key, "transfer-encoding"):
if strings.contains(value, "chunked") {
resp.chunked = true
}
case fold_eq(key, "location"):
resp.location = value
}
}
body_start := header_end + 4
if body_start <= len(raw_str) {
resp.body = raw_str[body_start:]
}
return resp, true
}
parse_status_code :: proc(status_line: string) -> int {
parts := strings.fields(status_line)
defer delete(parts)
if len(parts) < 2 {
return 0
}
code, ok := strconv.parse_int(parts[1])
return code if ok else 0
}
parse_http_url :: proc(url: string) -> (host: string, port: int, path: string, ok: bool) {
if strings.has_prefix(url, "https://") || !strings.has_prefix(url, "http://") {
return "", 0, "", false
}
rest := url[len("http://"):]
host_and_port := rest
if slash := strings.index_byte(rest, '/'); slash >= 0 {
host_and_port = rest[:slash]
path = rest[slash:]
} else {
path = "/"
}
host = host_and_port
port = DEFAULT_HTTP_PORT
if colon := strings.last_index_byte(host_and_port, ':'); colon >= 0 {
parsed, p_ok := strconv.parse_int(host_and_port[colon + 1:])
if !p_ok {
return "", 0, "", false
}
host = host_and_port[:colon]
port = parsed
}
if host == "" {
return "", 0, "", false
}
return host, port, path, true
}
resolve_redirect :: proc(base_url, location: string) -> string {
if strings.has_prefix(location, "http://") || strings.has_prefix(location, "https://") {
return strings.clone(location)
}
if !strings.has_prefix(location, "/") {
return ""
}
rest := base_url[len("http://"):]
if slash := strings.index_byte(rest, '/'); slash >= 0 {
rest = rest[:slash]
}
return strings.concatenate([]string{"http://", rest, location})
}
decode_chunked :: proc(data: string) -> (body: string, ok: bool) {
decoded: [dynamic]u8
defer delete(decoded)
at := 0
for at < len(data) {
nl := strings.index_byte(data[at:], '\n')
if nl < 0 do return "", false
size_line := strings.trim_space(data[at:at + nl])
at += nl + 1
if semi := strings.index_byte(size_line, ';'); semi >= 0 {
size_line = size_line[:semi]
}
size, size_ok := strconv.parse_int(size_line, 16)
if !size_ok || size < 0 {
return "", false
}
if size == 0 {
at += 2
break
}
if at + size > len(data) {
return "", false
}
append(&decoded, ..transmute([]u8)data[at:at + size])
at += size + 2
}
if len(decoded) == 0 {
return "", true
}
return strings.clone(string(decoded[:])), true
}
fold_eq :: proc(a, b: string) -> bool {
if len(a) != len(b) {
return false
}
for i in 0 ..< len(a) {
ca := a[i]
cb := b[i]
if ca >= 'A' && ca <= 'Z' do ca += 'a' - 'A'
if cb >= 'A' && cb <= 'Z' do cb += 'a' - 'A'
if ca != cb do return false
}
return true
}