From 4bb4e9fb8af200a1a799c8b76322d6daa44e7187 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 13:14:59 +0000 Subject: [PATCH 1/7] WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) --- scripts/api_client.gd | 365 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 scripts/api_client.gd diff --git a/scripts/api_client.gd b/scripts/api_client.gd new file mode 100644 index 0000000..793cc63 --- /dev/null +++ b/scripts/api_client.gd @@ -0,0 +1,365 @@ +class_name ApiClient +extends Node +## @group Networking +## @description Central HTTP client singleton that wraps Godot's HTTPRequest node. +## +## Every network call flows through this class. Callers use the high-level +## helpers (login, register, get_worlds, etc.) or the generic request() +## primitive. All results conform to the [result-dictionary] contract below. +## +## ## Result Dictionary Contract +## Every public method returns a Dictionary with this shape: +## +## { +## "ok": bool, # true for HTTP 2xx, false otherwise +## "status": int, # HTTP status code (0 on network failure) +## "data": Variant, # parsed JSON body on success +## "detail": String, # human-readable error message +## } +## +## Network failures (timeout, DNS, refused connection) return status=0 with +## a detail describing the failure. Non-2xx responses return the actual status +## code and the backend's "detail" field from the JSON error body. +## +## ## Token Storage +## JWT bearer tokens are kept in memory. The [method set_token] method is +## the single seam for token persistence — override it to save to disk. +## +## ## Usage +## var result = await ApiClient.login("alice", "secret") +## if result.ok: +## print("Logged in!") +## else: +## print("Error: ", result.detail) + +# --------------------------------------------------------------------------- +# Token storage +# --------------------------------------------------------------------------- + +var _access_token: String = "" +var _token_type: String = "Bearer" + +## Current bearer token string (e.g. "Bearer eyJ..."). +## Read-only — use [method set_token] to change. +var bearer_token: String: + get: + return _token_type + " " + _access_token if _access_token else "" + +## Whether a token has been set. +func has_token() -> bool: + return _access_token != "" + +## Set the access token (called after login / token refresh). +## @param access_token The raw JWT string from the server. +## @param token_type Token type, defaults to "Bearer". +func set_token(access_token: String, token_type: String = "Bearer") -> void: + _access_token = access_token + _token_type = token_type + +## Clear stored tokens. +func clear_token() -> void: + _access_token = "" + _token_type = "Bearer" + +# --------------------------------------------------------------------------- +# Internal HTTPRequest node +# --------------------------------------------------------------------------- + +## The internal HTTPRequest node. Created lazily on first use. +var _http_request: HTTPRequest + +## Base URL read from project settings. +var _base_url: String + +# Signal emitted when a request completes. The argument is the result dict. +signal request_completed(result: Dictionary) + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + # Read base URL from project settings, fall back to localhost:8000. + _base_url = ProjectSettings.get_setting( + "application/agp/api_base_url", + "http://127.0.0.1:8000" + ) + # Ensure the setting exists so it appears in the editor. + if not ProjectSettings.has_setting("application/agp/api_base_url"): + ProjectSettings.set_setting("application/agp/api_base_url", _base_url) + ProjectSettings.save() + +func _enter_tree() -> void: + _http_request = HTTPRequest.new() + _http_request.timeout = 10.0 + _http_request.max_redirects = 8 + _http_request.accept_gzip = true + add_child(_http_request) + _http_request.request_completed.connect(_on_http_request_completed) + +# --------------------------------------------------------------------------- +# Core request primitive +# --------------------------------------------------------------------------- + +## Generic request method. +## +## @param method HTTPClient.Method enum value (GET, POST, PUT, DELETE). +## @param path URL path (e.g. "/api/health"). +## @param body Request body as String (JSON or form-encoded). +## @param content_type "application/json" or "application/x-www-form-urlencoded". +## @param require_auth If true, attach bearer token header. +## @return A result dictionary conforming to the contract. +func request( + method: HTTPClient.Method, + path: String, + body: String = "", + content_type: String = "application/json", + require_auth: bool = false +) -> Dictionary: + var url: String = _base_url + path + + # Build headers. + var headers: PackedStringArray = PackedStringArray() + headers.append("Content-Type: " + content_type) + if require_auth and has_token(): + headers.append("Authorization: " + bearer_token) + + # Fire the request. + var err: int = _http_request.request(url, headers, method, body) + if err != OK: + return _network_error(err, "Failed to start request: " + _error_string(err)) + + # Wait for completion. + var result_data = await request_completed + return result_data + +# --------------------------------------------------------------------------- +# Signal handler +# --------------------------------------------------------------------------- + +func _on_http_request_completed( + result: int, + response_code: int, + headers: PackedStringArray, + body: PackedByteArray +) -> void: + # result == HTTPRequest.RESULT_SUCCESS means the transport succeeded. + # response_code == 0 means no response was received (e.g. timeout). + if result != HTTPRequest.RESULT_SUCCESS: + var detail: String = _network_error_detail(result) + var dict: Dictionary = { + "ok": false, + "status": 0, + "data": null, + "detail": detail, + } + request_completed.emit(dict) + return + + if response_code == 0: + var dict: Dictionary = { + "ok": false, + "status": 0, + "data": null, + "detail": "No response received from server.", + } + request_completed.emit(dict) + return + + # Parse JSON body. + var json_string: String = body.get_string_from_utf8() + var data = null + var detail: String = "" + + if response_code >= 200 and response_code < 300: + # Success — try to parse JSON. + var parse_result = JSON.parse_string(json_string) + if parse_result is Dictionary: + data = parse_result + # Extract backend detail if present. + if data.has("detail"): + detail = str(data["detail"]) + else: + detail = "OK" + else: + data = json_string + detail = "OK" + else: + # Error response — try to extract detail from JSON. + var parse_result = JSON.parse_string(json_string) + if parse_result is Dictionary: + detail = str(parse_result.get("detail", "HTTP error %d" % response_code)) + else: + detail = "HTTP error %d" % response_code + + var dict: Dictionary = { + "ok": response_code >= 200 and response_code < 300, + "status": response_code, + "data": data, + "detail": detail, + } + request_completed.emit(dict) + +# --------------------------------------------------------------------------- +# High-level helpers +# --------------------------------------------------------------------------- + +## Login with username and password. +## +## Sends a POST to /api/auth/login with form-encoded credentials +## (OAuth2PasswordRequestForm format). On success, stores the token. +## +## @param username The user's username. +## @param password The user's password. +## @return Result dictionary. +func login(username: String, password: String) -> Dictionary: + var form_data: String = "username=" + username.uri_encode() + "&password=" + password.uri_encode() + var result = await request( + HTTPClient.METHOD_POST, + "/api/auth/login", + form_data, + "application/x-www-form-urlencoded", + false + ) + if result.ok and result.data is Dictionary: + var access_token = result.data.get("access_token", "") + if access_token != "": + set_token(access_token) + return result + +## Register a new user. +## +## Sends a POST to /api/auth/register with JSON body. +## On success, stores the returned token. +## +## @param username The desired username. +## @param email The user's email address. +## @param password The desired password. +## @param date_of_birth Date of birth in YYYY-MM-DD format. +## @return Result dictionary. +func register( + username: String, + email: String, + password: String, + date_of_birth: String +) -> Dictionary: + var payload: Dictionary = { + "username": username, + "email": email, + "password": password, + "date_of_birth": date_of_birth, + } + var json_body: String = JSON.stringify(payload) + var result = await request( + HTTPClient.METHOD_POST, + "/api/auth/register", + json_body, + "application/json", + false + ) + if result.ok and result.data is Dictionary: + var access_token = result.data.get("access_token", "") + if access_token != "": + set_token(access_token) + return result + +## List worlds visible to the current user. +## +## Sends a GET to /api/worlds/ with the bearer token. +## +## @param skip Number of worlds to skip (pagination offset). +## @param limit Maximum number of worlds to return. +## @param visibility Optional filter: "public", "private", or "friends_only". +## @return Result dictionary. +func get_worlds( + skip: int = 0, + limit: int = 50, + visibility: String = "" +) -> Dictionary: + var path: String = "/api/worlds/?skip=%d&limit=%d" % [skip, limit] + if visibility != "": + path += "&visibility=" + visibility.uri_encode() + return await request( + HTTPClient.METHOD_GET, + path, + "", + "application/json", + true + ) + +## Check the server health endpoint. +## +## @return Result dictionary. +func health_check() -> Dictionary: + return await request( + HTTPClient.METHOD_GET, + "/api/health", + "", + "application/json", + false + ) + +## Get current user profile. +## +## @return Result dictionary. +func get_me() -> Dictionary: + return await request( + HTTPClient.METHOD_GET, + "/api/users/me", + "", + "application/json", + true + ) + +# --------------------------------------------------------------------------- +# Persistence seam +# --------------------------------------------------------------------------- + +## Called before the node is freed. Override to persist tokens to disk. +func _exit_tree() -> void: + # TODO: Save tokens to disk (e.g. user://tokens.cfg). + pass + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +func _network_error(err_code: int, fallback: String) -> Dictionary: + return { + "ok": false, + "status": 0, + "data": null, + "detail": fallback, + } + +func _network_error_detail(result_code: int) -> String: + match result_code: + HTTPRequest.RESULT_CANT_CONNECT: + return "Could not connect to server. Is it running?" + HTTPRequest.RESULT_CANT_RESOLVE: + return "Could not resolve server hostname." + HTTPRequest.RESULT_CONNECTION_ERROR: + return "Connection error." + HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR: + return "TLS handshake failed." + HTTPRequest.RESULT_NO_RESPONSE: + return "No response from server." + HTTPRequest.RESULT_TIMEOUT: + return "Request timed out." + _: + return "Network error (code %d)." % result_code + +func _error_string(err_code: int) -> String: + match err_code: + OK: + return "OK" + ERR_UNCONFIGURED: + return "HTTPRequest not configured (not in tree)." + ERR_BUSY: + return "HTTPRequest is busy with a previous request." + ERR_INVALID_PARAMETER: + return "Invalid URL format." + ERR_CANT_CONNECT: + return "Cannot connect to host." + _: + return "Unknown error (%d)." % err_code -- 2.52.0 From 33c5236a2db319979a279da06ad97a853a9b8615 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 13:25:05 +0000 Subject: [PATCH 2/7] WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) --- scripts/api_client.gd | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 793cc63..2f54bf7 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -172,16 +172,17 @@ func _on_http_request_completed( var detail: String = "" if response_code >= 200 and response_code < 300: - # Success — try to parse JSON. + # Success — try to parse JSON (works for both objects and arrays). var parse_result = JSON.parse_string(json_string) - if parse_result is Dictionary: + if parse_result is Dictionary or parse_result is Array: data = parse_result - # Extract backend detail if present. - if data.has("detail"): + # Extract backend detail if present (e.g. {"ok": true, "detail": "OK"}). + if data is Dictionary and data.has("detail"): detail = str(data["detail"]) else: detail = "OK" else: + # Not valid JSON — return raw string. data = json_string detail = "OK" else: -- 2.52.0 From 109c6ef1a0a7e06287186459a9e0d672971aeeec Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 13:38:51 +0000 Subject: [PATCH 3/7] WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) --- project.godot | 3 + scripts/api_client.gd | 358 ++++++++++++++++-------------------------- 2 files changed, 142 insertions(+), 219 deletions(-) diff --git a/project.godot b/project.godot index 8dddf44..a87f3fb 100644 --- a/project.godot +++ b/project.godot @@ -9,9 +9,12 @@ config_version=5 config/name="AGP-Game" run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6.2") +agp/api_base_url="http://127.0.0.1:8000" [autoload] +ApiClient="*res://scripts/api_client.gd" + [display] window/size/viewport_width=1920 diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 2f54bf7..d262026 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -1,32 +1,33 @@ class_name ApiClient extends Node -## @group Networking -## @description Central HTTP client singleton that wraps Godot's HTTPRequest node. +## Central HTTP client singleton (autoload name: "ApiClient"). ## -## Every network call flows through this class. Callers use the high-level -## helpers (login, register, get_worlds, etc.) or the generic request() -## primitive. All results conform to the [result-dictionary] contract below. +## Every network call in the game flows through this class. Callers use the +## high-level helpers ([method login], [method register], [method get_worlds], +## [method health_check], [method get_me]) or the generic [method request] +## primitive. All results conform to the result-dictionary contract below. ## -## ## Result Dictionary Contract -## Every public method returns a Dictionary with this shape: +## [b]Result Dictionary Contract[/b] +## Every public method returns (after `await`) a Dictionary with this shape: ## ## { -## "ok": bool, # true for HTTP 2xx, false otherwise -## "status": int, # HTTP status code (0 on network failure) -## "data": Variant, # parsed JSON body on success -## "detail": String, # human-readable error message +## "ok": bool, # true for HTTP 2xx, false otherwise +## "status": int, # HTTP status code (0 on network failure) +## "data": Variant, # parsed JSON body on success (null otherwise) +## "detail": String, # human-readable message ## } ## -## Network failures (timeout, DNS, refused connection) return status=0 with -## a detail describing the failure. Non-2xx responses return the actual status -## code and the backend's "detail" field from the JSON error body. +## Network failures (timeout / DNS / refused connection) return `status == 0` +## with a `detail` describing the transport failure. Non-2xx responses return +## the real HTTP status and the backend's `detail` field from the JSON error +## body when present. No caller can crash on a failed request. ## -## ## Token Storage -## JWT bearer tokens are kept in memory. The [method set_token] method is -## the single seam for token persistence — override it to save to disk. +## [b]Token storage[/b] +## JWTs are kept in memory. [method set_token] is the single seam for future +## disk persistence (override it to save to `user://`). ## -## ## Usage -## var result = await ApiClient.login("alice", "secret") +## [b]Usage[/b] +## var result: Dictionary = await ApiClient.login("alice", "secret") ## if result.ok: ## print("Logged in!") ## else: @@ -39,76 +40,62 @@ extends Node var _access_token: String = "" var _token_type: String = "Bearer" -## Current bearer token string (e.g. "Bearer eyJ..."). -## Read-only — use [method set_token] to change. +## Full Authorization header value, e.g. "Bearer eyJ...". Empty if no token. var bearer_token: String: get: - return _token_type + " " + _access_token if _access_token else "" + return _token_type + " " + _access_token if _access_token != "" else "" -## Whether a token has been set. + +## Whether an access token is currently stored. func has_token() -> bool: return _access_token != "" -## Set the access token (called after login / token refresh). -## @param access_token The raw JWT string from the server. -## @param token_type Token type, defaults to "Bearer". + +## Set the access token (called after login / register / token refresh). +## This is the persistence seam: override to also write to disk. func set_token(access_token: String, token_type: String = "Bearer") -> void: _access_token = access_token _token_type = token_type + ## Clear stored tokens. func clear_token() -> void: _access_token = "" _token_type = "Bearer" -# --------------------------------------------------------------------------- -# Internal HTTPRequest node -# --------------------------------------------------------------------------- - -## The internal HTTPRequest node. Created lazily on first use. -var _http_request: HTTPRequest - -## Base URL read from project settings. -var _base_url: String - -# Signal emitted when a request completes. The argument is the result dict. -signal request_completed(result: Dictionary) # --------------------------------------------------------------------------- -# Lifecycle +# Configuration # --------------------------------------------------------------------------- +var _base_url: String = "http://127.0.0.1:8000" + +## Request timeout in seconds. 0 disables the timeout. +var timeout_seconds: float = 10.0 + + func _ready() -> void: - # Read base URL from project settings, fall back to localhost:8000. - _base_url = ProjectSettings.get_setting( + _base_url = str(ProjectSettings.get_setting( "application/agp/api_base_url", "http://127.0.0.1:8000" - ) - # Ensure the setting exists so it appears in the editor. - if not ProjectSettings.has_setting("application/agp/api_base_url"): - ProjectSettings.set_setting("application/agp/api_base_url", _base_url) - ProjectSettings.save() + )).rstrip("/") -func _enter_tree() -> void: - _http_request = HTTPRequest.new() - _http_request.timeout = 10.0 - _http_request.max_redirects = 8 - _http_request.accept_gzip = true - add_child(_http_request) - _http_request.request_completed.connect(_on_http_request_completed) # --------------------------------------------------------------------------- # Core request primitive # --------------------------------------------------------------------------- -## Generic request method. +## Generic request primitive. Awaitable via `await`. ## -## @param method HTTPClient.Method enum value (GET, POST, PUT, DELETE). -## @param path URL path (e.g. "/api/health"). -## @param body Request body as String (JSON or form-encoded). +## Uses a dedicated HTTPRequest node per call so concurrent requests can never +## cross-talk, and so the result maps 1:1 to the awaiting caller. +## +## @param method HTTPClient.Method enum value (GET/POST/PUT/PATCH/DELETE). +## @param path URL path, e.g. "/api/health". +## @param body Request body string (JSON or form-encoded). ## @param content_type "application/json" or "application/x-www-form-urlencoded". -## @param require_auth If true, attach bearer token header. -## @return A result dictionary conforming to the contract. +## @param require_auth If true, attach the stored bearer token when present. +## @return Result dictionary conforming to the contract. func request( method: HTTPClient.Method, path: String, @@ -116,224 +103,150 @@ func request( content_type: String = "application/json", require_auth: bool = false ) -> Dictionary: - var url: String = _base_url + path + var http := HTTPRequest.new() + http.timeout = timeout_seconds + http.max_redirects = 8 + http.accept_gzip = true + add_child(http) - # Build headers. - var headers: PackedStringArray = PackedStringArray() - headers.append("Content-Type: " + content_type) + var headers := PackedStringArray() + headers.append("Accept: application/json") + if content_type != "": + headers.append("Content-Type: " + content_type) if require_auth and has_token(): headers.append("Authorization: " + bearer_token) - # Fire the request. - var err: int = _http_request.request(url, headers, method, body) + var url := _base_url + path + var err := http.request(url, headers, method, body) if err != OK: - return _network_error(err, "Failed to start request: " + _error_string(err)) + http.queue_free() + return _make_result(false, 0, null, "Failed to start request: " + _error_string(err)) - # Wait for completion. - var result_data = await request_completed - return result_data + # request_completed emits (result, response_code, headers, body). + var args: Array = await http.request_completed + var result_code: int = args[0] + var response_code: int = args[1] + var response_body: PackedByteArray = args[3] + http.queue_free() -# --------------------------------------------------------------------------- -# Signal handler -# --------------------------------------------------------------------------- - -func _on_http_request_completed( - result: int, - response_code: int, - headers: PackedStringArray, - body: PackedByteArray -) -> void: - # result == HTTPRequest.RESULT_SUCCESS means the transport succeeded. - # response_code == 0 means no response was received (e.g. timeout). - if result != HTTPRequest.RESULT_SUCCESS: - var detail: String = _network_error_detail(result) - var dict: Dictionary = { - "ok": false, - "status": 0, - "data": null, - "detail": detail, - } - request_completed.emit(dict) - return + # Transport-level failure (timeout / DNS / refused / TLS, etc.). + if result_code != HTTPRequest.RESULT_SUCCESS: + return _make_result(false, 0, null, _transport_error_detail(result_code)) + # Server accepted the connection but returned no HTTP status line. if response_code == 0: - var dict: Dictionary = { - "ok": false, - "status": 0, - "data": null, - "detail": "No response received from server.", - } - request_completed.emit(dict) - return + return _make_result(false, 0, null, "No response received from server.") - # Parse JSON body. - var json_string: String = body.get_string_from_utf8() - var data = null - var detail: String = "" + var json_string := response_body.get_string_from_utf8() + var ok := response_code >= 200 and response_code < 300 - if response_code >= 200 and response_code < 300: - # Success — try to parse JSON (works for both objects and arrays). - var parse_result = JSON.parse_string(json_string) - if parse_result is Dictionary or parse_result is Array: - data = parse_result - # Extract backend detail if present (e.g. {"ok": true, "detail": "OK"}). - if data is Dictionary and data.has("detail"): - detail = str(data["detail"]) - else: - detail = "OK" - else: - # Not valid JSON — return raw string. - data = json_string - detail = "OK" - else: - # Error response — try to extract detail from JSON. - var parse_result = JSON.parse_string(json_string) - if parse_result is Dictionary: - detail = str(parse_result.get("detail", "HTTP error %d" % response_code)) - else: - detail = "HTTP error %d" % response_code + if ok: + var data: Variant = _parse_json(json_string) + return _make_result(true, response_code, data, "OK") + + # Non-2xx: surface the backend's "detail" message when available. + var detail := "HTTP error %d" % response_code + var parsed: Variant = _parse_json(json_string) + if parsed is Dictionary and (parsed as Dictionary).has("detail"): + detail = str((parsed as Dictionary)["detail"]) + return _make_result(false, response_code, null, detail) - var dict: Dictionary = { - "ok": response_code >= 200 and response_code < 300, - "status": response_code, - "data": data, - "detail": detail, - } - request_completed.emit(dict) # --------------------------------------------------------------------------- # High-level helpers # --------------------------------------------------------------------------- -## Login with username and password. -## -## Sends a POST to /api/auth/login with form-encoded credentials -## (OAuth2PasswordRequestForm format). On success, stores the token. -## -## @param username The user's username. -## @param password The user's password. -## @return Result dictionary. +## Log in with username/password (OAuth2PasswordRequestForm). +## On success stores the returned access token. func login(username: String, password: String) -> Dictionary: - var form_data: String = "username=" + username.uri_encode() + "&password=" + password.uri_encode() - var result = await request( + var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode() + var result := await request( HTTPClient.METHOD_POST, "/api/auth/login", - form_data, + form, "application/x-www-form-urlencoded", false ) - if result.ok and result.data is Dictionary: - var access_token = result.data.get("access_token", "") - if access_token != "": - set_token(access_token) + _store_token_from_result(result) return result -## Register a new user. -## -## Sends a POST to /api/auth/register with JSON body. -## On success, stores the returned token. -## -## @param username The desired username. -## @param email The user's email address. -## @param password The desired password. -## @param date_of_birth Date of birth in YYYY-MM-DD format. -## @return Result dictionary. + +## Register a new user. On success stores the returned access token. +## @param date_of_birth ISO date string "YYYY-MM-DD". func register( username: String, email: String, password: String, date_of_birth: String ) -> Dictionary: - var payload: Dictionary = { + var payload := { "username": username, "email": email, "password": password, "date_of_birth": date_of_birth, } - var json_body: String = JSON.stringify(payload) - var result = await request( + var result := await request( HTTPClient.METHOD_POST, "/api/auth/register", - json_body, + JSON.stringify(payload), "application/json", false ) - if result.ok and result.data is Dictionary: - var access_token = result.data.get("access_token", "") - if access_token != "": - set_token(access_token) + _store_token_from_result(result) return result -## List worlds visible to the current user. -## -## Sends a GET to /api/worlds/ with the bearer token. -## -## @param skip Number of worlds to skip (pagination offset). -## @param limit Maximum number of worlds to return. -## @param visibility Optional filter: "public", "private", or "friends_only". -## @return Result dictionary. -func get_worlds( - skip: int = 0, - limit: int = 50, - visibility: String = "" -) -> Dictionary: - var path: String = "/api/worlds/?skip=%d&limit=%d" % [skip, limit] + +## List worlds visible to the current user (requires auth). +## @param visibility Optional filter: "public", "private", "friends_only". +func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary: + var path := "/api/worlds/?skip=%d&limit=%d" % [skip, limit] if visibility != "": path += "&visibility=" + visibility.uri_encode() - return await request( - HTTPClient.METHOD_GET, - path, - "", - "application/json", - true - ) + return await request(HTTPClient.METHOD_GET, path, "", "application/json", true) -## Check the server health endpoint. -## -## @return Result dictionary. + +## Ping the backend health endpoint (no auth). func health_check() -> Dictionary: - return await request( - HTTPClient.METHOD_GET, - "/api/health", - "", - "application/json", - false - ) + return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false) -## Get current user profile. -## -## @return Result dictionary. + +## Fetch the current user's profile from the stored JWT (requires auth). func get_me() -> Dictionary: - return await request( - HTTPClient.METHOD_GET, - "/api/users/me", - "", - "application/json", - true - ) + return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true) + # --------------------------------------------------------------------------- -# Persistence seam +# Internals # --------------------------------------------------------------------------- -## Called before the node is freed. Override to persist tokens to disk. -func _exit_tree() -> void: - # TODO: Save tokens to disk (e.g. user://tokens.cfg). - pass +func _store_token_from_result(result: Dictionary) -> void: + if result.ok and result.data is Dictionary: + var token: String = str((result.data as Dictionary).get("access_token", "")) + if token != "": + set_token(token) -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -func _network_error(err_code: int, fallback: String) -> Dictionary: +func _parse_json(text: String) -> Variant: + if text == "": + return null + var parsed: Variant = JSON.parse_string(text) + if parsed == null: + # null is ambiguous (valid JSON "null" vs parse failure). Fall back to + # the raw string so callers never lose the body entirely. + return text if text != "null" else null + return parsed + + +func _make_result(ok: bool, status: int, data: Variant, detail: String) -> Dictionary: return { - "ok": false, - "status": 0, - "data": null, - "detail": fallback, + "ok": ok, + "status": status, + "data": data, + "detail": detail, } -func _network_error_detail(result_code: int) -> String: + +func _transport_error_detail(result_code: int) -> String: match result_code: HTTPRequest.RESULT_CANT_CONNECT: return "Could not connect to server. Is it running?" @@ -345,11 +258,18 @@ func _network_error_detail(result_code: int) -> String: return "TLS handshake failed." HTTPRequest.RESULT_NO_RESPONSE: return "No response from server." + HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED: + return "Response body too large." + HTTPRequest.RESULT_BODY_DECOMPRESS_FAILED: + return "Failed to decompress response body." + HTTPRequest.RESULT_REQUEST_FAILED: + return "Request failed." HTTPRequest.RESULT_TIMEOUT: return "Request timed out." _: return "Network error (code %d)." % result_code + func _error_string(err_code: int) -> String: match err_code: OK: -- 2.52.0 From 6108c63c46172b42f9e280e52d34a008a24f6a61 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 14:11:34 +0000 Subject: [PATCH 4/7] WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) --- .gitignore | 3 +++ scripts/api_client.gd | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 98542a1..d056825 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ export/ # OS .DS_Store Thumbs.db + +# Godot UID files (regenerated from path) +*.uid diff --git a/scripts/api_client.gd b/scripts/api_client.gd index d262026..624af1f 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -1,4 +1,3 @@ -class_name ApiClient extends Node ## Central HTTP client singleton (autoload name: "ApiClient"). ## -- 2.52.0 From 77f9b7428698a0764ceb3588ddf115b0902769f2 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 14:12:56 +0000 Subject: [PATCH 5/7] WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) --- scripts/smoke_test_api.gd | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scripts/smoke_test_api.gd diff --git a/scripts/smoke_test_api.gd b/scripts/smoke_test_api.gd new file mode 100644 index 0000000..1886817 --- /dev/null +++ b/scripts/smoke_test_api.gd @@ -0,0 +1,31 @@ +extends SceneTree +## Headless smoke test for the ApiClient autoload. +## +## Run with: +## godot --headless --path . --script res://scripts/smoke_test_api.gd +## +## Requires the backend to be running (default base URL +## `application/agp/api_base_url` -> http://127.0.0.1:8000). +## +## Calls the real autoload's `health_check()` (which exercises the generic +## `request()` primitive) and exits 0 on a `status == "ok"` response, 1 +## otherwise. This script is intentionally NOT wired into any game scene. + + +func _initialize() -> void: + _run() + + +func _run() -> void: + print("SMOKE: calling ApiClient.health_check() ...") + var result: Dictionary = await ApiClient.health_check() + print("SMOKE: result = ", result) + + if result.ok \ + and result.data is Dictionary \ + and str((result.data as Dictionary).get("status", "")) == "ok": + print("SMOKE RESULT: PASS (status=ok)") + quit(0) + else: + printerr("SMOKE RESULT: FAIL") + quit(1) -- 2.52.0 From e06cd2a23d4c8ff1ed217e8d03fd8d2f5bc7cfcc Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 14:31:35 +0000 Subject: [PATCH 6/7] WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract) --- scripts/api_client.gd | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 624af1f..a483592 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -74,10 +74,17 @@ var timeout_seconds: float = 10.0 func _ready() -> void: - _base_url = str(ProjectSettings.get_setting( - "application/agp/api_base_url", - "http://127.0.0.1:8000" - )).rstrip("/") + # Environment variable override (e.g. for headless smoke tests against a + # backend on a non-default port). Falls back to the ProjectSetting, then + # to the documented default. + var env_url := OS.get_environment("AGP_API_BASE_URL") + if env_url != "": + _base_url = env_url.rstrip("/") + else: + _base_url = str(ProjectSettings.get_setting( + "application/agp/api_base_url", + "http://127.0.0.1:8000" + )).rstrip("/") # --------------------------------------------------------------------------- -- 2.52.0 From ef21bf9d692886cebdef3bf96cd21a7e4af7a81a Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 14:40:51 +0000 Subject: [PATCH 7/7] Fix smoke test: resolve ApiClient via get_node, defer one frame before first request Two Godot --script-mode-only quirks, neither a bug in ApiClient itself (verified working against a real backend, health_check returns 200): 1. Bare autoload identifiers don't resolve at compile time in --script/ SceneTree runs (the node exists at runtime, root.has_node() is true, but referencing it as a plain global name fails to compile) -- use get_node() instead. 2. A freshly add_child()'d HTTPRequest isn't reliably "inside tree" yet at _initialize() time, since --script mode hasn't processed a frame yet -- one process_frame await fixes it. Verified: full smoke test now passes end-to-end against a live backend (docker compose postgres + alembic migrations + uvicorn), confirming ApiClient.health_check() genuinely round-trips. Co-Authored-By: Claude Sonnet 5 --- scripts/smoke_test_api.gd | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/smoke_test_api.gd b/scripts/smoke_test_api.gd index 1886817..70f09a7 100644 --- a/scripts/smoke_test_api.gd +++ b/scripts/smoke_test_api.gd @@ -17,8 +17,23 @@ func _initialize() -> void: func _run() -> void: + # _initialize() fires before the SceneTree has processed its first frame, + # so a freshly add_child()'d HTTPRequest node isn't reliably considered + # "inside tree" yet (request_raw fails with !is_inside_tree()). One frame + # of delay is enough; this only matters for --script mode, not a real + # running game. + await process_frame + + # Godot's --script/SceneTree runner doesn't resolve autoload singletons + # as bare global identifiers at compile time (the node exists at + # runtime -- root.has_node("ApiClient") is true -- but referencing it as + # a plain identifier fails with "Identifier not found: ApiClient" during + # script compilation). get_node sidesteps compile-time resolution + # entirely. This only affects --script mode; a real game scene resolves + # the bare autoload name normally. + var api_client := root.get_node("ApiClient") print("SMOKE: calling ApiClient.health_check() ...") - var result: Dictionary = await ApiClient.health_check() + var result: Dictionary = await api_client.health_check() print("SMOKE: result = ", result) if result.ok \ -- 2.52.0