From 39cb53ea8a5e94591ebb293d574f16cf645992c5 Mon Sep 17 00:00:00 2001 From: Samuel O'Neal Date: Sat, 12 Sep 2026 13:55:14 -0600 Subject: [PATCH] added http package; updated data.odin --- gui/lib/local/http/src/http.odin | 273 +++++++++++++++++++++++++ gui/lib/local/http/test/http_test.odin | 75 +++++++ gui/src/data.odin | 14 +- 3 files changed, 357 insertions(+), 5 deletions(-) create mode 100644 gui/lib/local/http/src/http.odin create mode 100644 gui/lib/local/http/test/http_test.odin diff --git a/gui/lib/local/http/src/http.odin b/gui/lib/local/http/src/http.odin new file mode 100644 index 0000000..e7a63f3 --- /dev/null +++ b/gui/lib/local/http/src/http.odin @@ -0,0 +1,273 @@ +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, +} + +http_get :: proc(url: string) -> (data: string, ok: bool) { + current := url + owns := false + + for _ in 0 ..= MAX_REDIRECTS { + next_url, body, _, request_ok := perform_request(current) + if owns do delete(current) + + if !request_ok { + if next_url != "" do delete(next_url) + if body != "" do delete(body) + return "", false + } + + if next_url != "" { + if body != "" do delete(body) + current = next_url + owns = true + continue + } + + return body, true + } + + return "", false +} + +perform_request :: proc(url: string) -> (next_url: string, body: string, status: int, ok: bool) { + host, port, path, parse_ok := parse_http_url(url) + if !parse_ok { + return "", "", 0, false + } + + conn, dial_err := net.dial_tcp_from_hostname_with_port_override(host, port) + if dial_err != nil { + return "", "", 0, false + } + 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 "", "", 0, false + } + + 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 + } + + resp, parsed := parse_response(buf[:]) + if !parsed { + return "", "", 0, false + } + + if resp.status >= 300 && resp.status < 400 && resp.location != "" { + resolved := resolve_redirect(url, resp.location) + if resolved == "" { + return "", "", resp.status, false + } + return resolved, "", resp.status, true + } + + if resp.chunked { + decoded, decode_ok := decode_chunked(resp.body) + if !decode_ok { + return "", "", resp.status, false + } + return "", decoded, resp.status, true + } + + if resp.content_length >= 0 && resp.content_length < len(resp.body) { + resp.body = resp.body[:resp.content_length] + } + if resp.body == "" { + return "", "", resp.status, true + } + return "", strings.clone(resp.body), resp.status, true +} + +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 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 +} \ No newline at end of file diff --git a/gui/lib/local/http/test/http_test.odin b/gui/lib/local/http/test/http_test.odin new file mode 100644 index 0000000..758e688 --- /dev/null +++ b/gui/lib/local/http/test/http_test.odin @@ -0,0 +1,75 @@ +package http_test + +import "core:fmt" +import "core:log" +import "core:net" +import "core:testing" +import "core:thread" + +import http "../src" + +SERVER_BODY :: "hello world" +SERVER_RESPONSE := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n" + + "Content-Length: 11\r\n" + + "Connection: close\r\n\r\n" + + SERVER_BODY + +Serve_Args :: struct { + listener: net.TCP_Socket, +} + +serve_proc :: proc(data: rawptr) { + args := (^Serve_Args)(data) + + conn, _, err := net.accept_tcp(args.listener) + if err != nil { + log.errorf("test server: accept failed: %v", err) + return + } + defer net.close(conn) + + request: [4096]byte + if _, err := net.recv_tcp(conn, request[:]); err != nil { + log.errorf("test server: recv failed: %v", err) + return + } + + if _, err := net.send_tcp(conn, transmute([]u8)SERVER_RESPONSE); err != nil { + log.errorf("test server: send failed: %v", err) + return + } +} + +@(test) +test_http_get_request :: proc(t: ^testing.T) { + listener, err := net.listen_tcp({net.IP4_Loopback, 0}) + if err != nil { + log.errorf("could not start test server: %v", err) + testing.fail(t) + return + } + defer net.close(listener) + + bound, berr := net.bound_endpoint(listener) + if berr != nil { + log.errorf("could not read test server port: %v", berr) + testing.fail(t) + return + } + + args: Serve_Args = {listener = listener} + server_thread := thread.create_and_start_with_data(&args, serve_proc) + defer thread.destroy(server_thread) + + url_buf: [64]byte + url := fmt.bprintf(url_buf[:], "http://127.0.0.1:%d/", bound.port) + + log.infof("requesting %s", url) + body, ok := http.http_get(url) + if !testing.expect(t, ok, "http_get returned ok=false") { + return + } + defer delete(body) + + testing.expectf(t, body == SERVER_BODY, "expected %q, got %q", SERVER_BODY, body) +} \ No newline at end of file diff --git a/gui/src/data.odin b/gui/src/data.odin index e4da099..faccd0d 100644 --- a/gui/src/data.odin +++ b/gui/src/data.odin @@ -1,7 +1,6 @@ package main -import "core:net" -import "vendor:curl" +import http "lib:http/src" Catalog :: struct { name: string, @@ -25,10 +24,15 @@ APIError :: struct { } get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) { - ucurl := curl.url() + data, ok := http.http_get(url) + if !ok { + err := new(APIError) + err.code = 1 + err.message = "request failed" + return nil, err + } + defer delete(data) - - defer curl.url_cleanup(ucurl) return nil, nil }