diff --git a/Makefile b/Makefile index 23d5c75..dacaefc 100644 --- a/Makefile +++ b/Makefile @@ -16,13 +16,25 @@ RESOURCE_DIR := $(CURDIR)/resources/dev GUI_ENV_FILE ?= $(RESOURCE_DIR)/gui.env.example API_ENV_FILE ?= $(RESOURCE_DIR)/api.env.example API_DESI_DATA ?= $(RESOURCE_DIR)/desi_subset.json -# The API binds here for local dev (see api/src/config.rs); `make run` passes -# it through and waits for $(API_HEALTH_URL) to respond before launching the -# GUI, so the renderer never races the API on startup. A bind host of -# 0.0.0.0 is probed via 127.0.0.1. Adjust API_WAIT_TIMEOUT (seconds) if the -# API takes longer than 60s to become healthy on a given machine. +# The API binds here for local dev (see api/src/config.rs); `make run` waits +# for $(API_HEALTH_URL) to respond before launching the GUI, so the renderer +# never races the API on startup. A bind host of 0.0.0.0 is probed via +# 127.0.0.1. Adjust API_WAIT_TIMEOUT (seconds) if the API takes longer than +# 60s to become healthy on a given machine. +# +# API_BIND_ADDR is only forwarded to the API when the user sets it explicitly +# (command line / environment). When it is left unset, the API reads it from +# $(API_ENV_FILE) (dotenvy applies env-file values that aren't already in the +# process environment), so the Makefile must not inject its own default here +# or it would shadow the env file. The effective bind address is read back +# from $(API_ENV_FILE) so the health check always polls the right port. API_BIND_ADDR ?= 127.0.0.1:8080 -API_HEALTH_URL := http://$(subst 0.0.0.0,127.0.0.1,$(API_BIND_ADDR))/health +API_BIND_EXPLICIT := $(filter command line environment,$(origin API_BIND_ADDR)) +API_BIND_FROM_FILE := $(if $(API_BIND_EXPLICIT),,$(shell \ + grep -E '^[[:space:]]*API_BIND_ADDR[[:space:]]*=' '$(API_ENV_FILE)' 2>/dev/null | \ + tail -n 1 | sed 's/^[[:space:]]*API_BIND_ADDR[[:space:]]*=[[:space:]]*//; s/[[:space:]]*#.*$$//')) +API_BIND_EFFECTIVE := $(if $(API_BIND_EXPLICIT),$(API_BIND_ADDR),$(or $(API_BIND_FROM_FILE),$(API_BIND_ADDR))) +API_HEALTH_URL := http://$(subst 0.0.0.0,127.0.0.1,$(API_BIND_EFFECTIVE))/health API_WAIT_TIMEOUT ?= 60 .PHONY: help setup run run-web build build-debug build-web test clean fmt \ @@ -48,7 +60,7 @@ setup: ## Setup all sub-projects (submodules, gui deps, api deps, infra deps) ## ---- Renderer (Odin) ----------------------------------------------------- run: ## Run the native app (gui/); waits for the API to be healthy first - @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \ + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(if $(API_BIND_EXPLICIT),API_BIND_ADDR="$(API_BIND_ADDR)",) $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ $(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \ GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \ @@ -66,7 +78,7 @@ build-web: ## WebAssembly build -> build/web (gui/, needs emscripten) @$(MAKE) -C $(GUI) build-web run-web: ## Start WASM build + API server for web dev (waits for API health) - @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" API_BIND_ADDR="$(API_BIND_ADDR)" $(MAKE) -C $(API) run & api_pid=$$!; \ + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(if $(API_BIND_EXPLICIT),API_BIND_ADDR="$(API_BIND_ADDR)",) $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ $(MAKE) api-wait || { kill $$api_pid 2>/dev/null; exit 1; }; \ GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \ diff --git a/gui/lib/local/http/src/http.odin b/gui/lib/local/http/src/http.odin index 752da5b..df10d03 100644 --- a/gui/lib/local/http/src/http.odin +++ b/gui/lib/local/http/src/http.odin @@ -1,12 +1,14 @@ package http import "core:fmt" +import "core:log" import "core:net" import "core:strconv" import "core:strings" import "core:time" DEFAULT_HTTP_PORT :: 80 +DEFAULT_HTTPS_PORT :: 443 MAX_REDIRECTS :: 5 HTTP_TIMEOUT :: 5 * time.Second @@ -33,27 +35,31 @@ Error_Code :: enum int { Too_Many_Redirects, } -error_make :: proc(code: Error_Code) -> ^Error { +error_make :: proc(code: Error_Code, msg: string = "") -> ^Error { err := new(Error) err.code = int(code) + err.message = msg - 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" + if msg == "" { + 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 } @@ -83,12 +89,12 @@ http_get :: proc(url: string) -> (resp: Response, err: ^Error) { } perform_request :: proc(url: string) -> (next_url: string, resp: Response, err: ^Error) { - host, port, path, parse_ok := parse_http_url(url) + _, 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) + conn, dial_err := net.dial_tcp_from_host_or_endpoint(net.Host{host, port}) if dial_err != nil { return "", {}, error_make(.Dial_Failed) } @@ -214,12 +220,21 @@ parse_status_code :: proc(status_line: string) -> int { 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 +parse_http_url :: proc( + url: string, +) -> ( + scheme: 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://"):] + https := strings.has_prefix(url, "https://") + rest := url[https ? len("https://") : len("http://"):] host_and_port := rest if slash := strings.index_byte(rest, '/'); slash >= 0 { host_and_port = rest[:slash] @@ -229,20 +244,20 @@ parse_http_url :: proc(url: string) -> (host: string, port: int, path: string, o } host = host_and_port - port = DEFAULT_HTTP_PORT + port = https ? DEFAULT_HTTPS_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 + return "", "", 0, "", false } host = host_and_port[:colon] port = parsed } if host == "" { - return "", 0, "", false + return "", "", 0, "", false } - return host, port, path, true + return https ? "https" : "http", host, port, path, true } resolve_redirect :: proc(base_url, location: string) -> string { @@ -308,4 +323,5 @@ fold_eq :: proc(a, b: string) -> bool { 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 index 13868ce..46a6959 100644 --- a/gui/lib/local/http/test/http_test.odin +++ b/gui/lib/local/http/test/http_test.odin @@ -9,7 +9,8 @@ import "core:thread" import http "../src" SERVER_BODY :: "hello world" -SERVER_RESPONSE := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n" + +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 @@ -57,7 +58,9 @@ test_http_get_request :: proc(t: ^testing.T) { return } - args: Serve_Args = {listener = listener} + args: Serve_Args = { + listener = listener, + } server_thread := thread.create_and_start_with_data(&args, serve_proc) defer thread.destroy(server_thread) @@ -66,11 +69,81 @@ test_http_get_request :: proc(t: ^testing.T) { log.infof("requesting %s", url) resp, req_err := http.http_get(url) - if !testing.expect(t, req_err == nil, "http_get returned an error") { + if !testing.expectf(t, req_err == nil, "http_get returned an error: %w", req_err) { return } defer delete(resp.body) testing.expectf(t, resp.status == 200, "expected status 200, got %d", resp.status) testing.expectf(t, resp.body == SERVER_BODY, "expected %q, got %q", SERVER_BODY, resp.body) -} \ No newline at end of file +} + +@(test) +test_http_parse_url :: proc(t: ^testing.T) { + testURL :: struct { + url: string, + expectedScheme: string, + expectedOK: bool, + expectedHost: string, + expectedPort: int, + expectedPath: string, + } + + testURLs := []testURL { + {"http://localhost:8080", "http", true, "localhost", 8080, "/"}, + {"http://localhost", "http", true, "localhost", 80, "/"}, + {"https://localhost:443", "https", true, "localhost", 443, "/"}, + {"https://localhost", "https", true, "localhost", 443, "/"}, + {"http://test:6969", "http", true, "test", 6969, "/"}, + { + "http://localhost:3000/api/v1/catalogs", + "http", + true, + "localhost", + 3000, + "/api/v1/catalogs", + }, + } + + + for tu in testURLs { + scheme, host, port, path, ok := http.parse_http_url(tu.url) + + testing.expectf( + t, + ok == tu.expectedOK, + "expected ok from parsing to be %v but got %v", + tu.expectedOK, + ok, + ) + testing.expectf( + t, + host == tu.expectedHost, + "expected host url to be '%s' but got '%s'", + tu.expectedHost, + host, + ) + testing.expectf( + t, + scheme == tu.expectedScheme, + "expected scheme to be '%s' but got '%s'", + tu.expectedScheme, + scheme, + ) + testing.expectf( + t, + port == tu.expectedPort, + "expected port to be %d but got %d", + tu.expectedPort, + port, + ) + testing.expectf( + t, + path == tu.expectedPath, + "expected path to be '%s' but got '%s'", + tu.expectedPath, + path, + ) + } +} + diff --git a/gui/src/data.odin b/gui/src/data.odin index da6b62c..258615a 100644 --- a/gui/src/data.odin +++ b/gui/src/data.odin @@ -83,7 +83,10 @@ request_json_array :: proc( defer delete(resp.body) if resp.status != 200 { - return nil, json.Null(nil), api_error(resp.status, strings.clone(strings.trim_space(resp.body))), false + return nil, + json.Null(nil), + api_error(resp.status, strings.clone(strings.trim_space(resp.body))), + false } log.debugf("got %s => %s", url, resp.body) @@ -105,6 +108,8 @@ get_catalogs :: proc(base_url: string) -> ([dynamic]Catalog, ^APIError) { url := endpoint(base_url, "/api/v1/catalogs") defer delete(url) + log.infof("getting catalogs from url: %s", url) + arr, val, err, ok := request_json_array(url) if !ok { return nil, err @@ -157,7 +162,7 @@ catalog_from_json :: proc(v: json.Value) -> (c: Catalog, ok: bool) { if !is_obj { return {}, false } - c = Catalog{ + c = Catalog { name = json_string(obj, "name"), release = json_string(obj, "release"), } @@ -177,14 +182,15 @@ catalog_object_from_json :: proc(v: json.Value) -> (o: CatalogObject, ok: bool) if !is_obj { return {}, false } - return CatalogObject{ - id = json_string(obj, "id"), - catalog = json_string(obj, "catalog"), - object_type = json_string(obj, "object_type"), - ra = json_f64(obj, "ra"), - dec = json_f64(obj, "dec"), - redshift = json_f64(obj, "redshift"), - }, true + return CatalogObject { + id = json_string(obj, "id"), + catalog = json_string(obj, "catalog"), + object_type = json_string(obj, "object_type"), + ra = json_f64(obj, "ra"), + dec = json_f64(obj, "dec"), + redshift = json_f64(obj, "redshift"), + }, + true } json_string :: proc(obj: json.Object, key: string) -> string { @@ -218,4 +224,5 @@ json_u64 :: proc(obj: json.Object, key: string) -> (u64, bool) { } } return 0, false -} \ No newline at end of file +} + diff --git a/gui/src/main.odin b/gui/src/main.odin index 92d8079..dc8b750 100644 --- a/gui/src/main.odin +++ b/gui/src/main.odin @@ -2,6 +2,7 @@ package main import "base:runtime" import "core:fmt" +import "core:log" import "core:math" import "core:math/rand" import "core:os" @@ -51,6 +52,8 @@ main :: proc() { c: ^Config err: ^Error + context.logger = log.create_console_logger() + defer log.destroy_console_logger(context.logger) s := os.get_env("GUI_ENV_FILE", context.temp_allocator) if c, err = get_config(&s); err != nil { @@ -82,7 +85,7 @@ main :: proc() { } make_app :: proc(c: ^Config) -> App { - a := App{ + a := App { screen = .Start, selected_catalog = -1, } @@ -90,7 +93,10 @@ make_app :: proc(c: ^Config) -> App { a.region = strings.builder_make() a.status = strings.builder_make() strings.write_string(&a.api_url, c.api_url) - strings.write_string(&a.status, "Set the API URL, press Refresh, pick a catalog, then Explore.") + strings.write_string( + &a.status, + "Set the API URL, press Refresh, pick a catalog, then Explore.", + ) return a } @@ -106,10 +112,10 @@ destroy_app :: proc(a: ^App) { make_camera :: proc() -> rl.Camera3D { return { - position = {0, 220, 220}, - target = {0, 0, 0}, - up = {0, 1, 0}, - fovy = 60, + position = {0, 220, 220}, + target = {0, 0, 0}, + up = {0, 1, 0}, + fovy = 60, projection = .PERSPECTIVE, } } @@ -209,4 +215,5 @@ draw :: proc() { 16, {120, 120, 140, 255}, ) -} \ No newline at end of file +} +