class_name ApiClient extends Node ## Central HTTP client singleton (autoload name: "ApiClient"). ## ## 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. ## ## [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 (null otherwise) ## "detail": String, # human-readable message ## } ## ## 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. ## ## [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://`). ## ## [b]Usage[/b] ## var result: Dictionary = 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" ## 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 "" ## Whether an access token is currently stored. func has_token() -> bool: return _access_token != "" ## 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" # --------------------------------------------------------------------------- # 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: _base_url = str(ProjectSettings.get_setting( "application/agp/api_base_url", "http://127.0.0.1:8000" )).rstrip("/") # --------------------------------------------------------------------------- # Core request primitive # --------------------------------------------------------------------------- ## Generic request primitive. Awaitable via `await`. ## ## 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 the stored bearer token when present. ## @return 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 http := HTTPRequest.new() http.timeout = timeout_seconds http.max_redirects = 8 http.accept_gzip = true add_child(http) 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) var url := _base_url + path var err := http.request(url, headers, method, body) if err != OK: http.queue_free() return _make_result(false, 0, null, "Failed to start request: " + _error_string(err)) # 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() # 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: return _make_result(false, 0, null, "No response received from server.") var json_string := response_body.get_string_from_utf8() var ok := response_code >= 200 and response_code < 300 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) # --------------------------------------------------------------------------- # High-level helpers # --------------------------------------------------------------------------- ## Log in with username/password (OAuth2PasswordRequestForm). ## On success stores the returned access token. func login(username: String, password: String) -> Dictionary: var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode() var result := await request( HTTPClient.METHOD_POST, "/api/auth/login", form, "application/x-www-form-urlencoded", false ) _store_token_from_result(result) return result ## 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 := { "username": username, "email": email, "password": password, "date_of_birth": date_of_birth, } var result := await request( HTTPClient.METHOD_POST, "/api/auth/register", JSON.stringify(payload), "application/json", false ) _store_token_from_result(result) return result ## 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) ## Ping the backend health endpoint (no auth). func health_check() -> Dictionary: return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false) ## 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) # --------------------------------------------------------------------------- # Internals # --------------------------------------------------------------------------- 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) 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": ok, "status": status, "data": data, "detail": detail, } func _transport_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_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: 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