From 4bb4e9fb8af200a1a799c8b76322d6daa44e7187 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Wed, 19 Aug 2026 13:14:59 +0000 Subject: [PATCH] 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