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 (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 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