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" ## Read-only access to the configured backend base URL (no trailing slash). ## Lets other client-side code that needs to build its own request outside ## the [method request] primitive -- e.g. [HttpChunkSource], which needs raw ## response bytes and custom headers `request()`'s JSON-only contract can't ## give it -- target the same backend without duplicating the env var / ## ProjectSettings resolution logic in `_ready()`. var base_url: String: get: return _base_url ## Request timeout in seconds. 0 disables the timeout. var timeout_seconds: float = 10.0 ## Real-time WebSocket gateway client instance var ws: WsGatewayClient = null func _ready() -> void: # 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("/") ws = WsGatewayClient.new() ws.name = "WsGatewayClient" add_child(ws) ## Connect to the WebSocket gateway using the current base_url and stored token. func connect_ws_gateway(token: String = "") -> Error: if ws == null: return ERR_UNCONFIGURED var tok := token if token != "" else _access_token return ws.connect_gateway(_base_url, tok) ## Disconnect from the WebSocket gateway. func disconnect_ws_gateway() -> void: if ws != null: ws.disconnect_gateway() # --------------------------------------------------------------------------- # 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 "YYYY-MM-DD". The backend computes age from it ## and rejects under-18s (403). 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) ## Fetch the current user's saved avatar loadout (requires auth). ## On success, result.data is {"data": , "is_default": bool} -- ## `is_default` true means the user has never customised and this is the ## server default. func get_avatar_loadout() -> Dictionary: return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true) ## Ask the backend for a dedicated server to host [param world_id] and return ## its address. On success result.data is {"world_id", "host", "port"}. ## 503 if this backend has no game-server orchestration. func join_world(world_id: int) -> Dictionary: return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true) ## Save (replace) the current user's avatar loadout (requires auth). ## @param loadout A dict in the AvatarLoadout.to_json() shape ## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}). func save_avatar_loadout(loadout: Dictionary) -> Dictionary: var body := JSON.stringify({"data": loadout}) return await request(HTTPClient.METHOD_PUT, "/api/avatar/loadout", body, "application/json", true) # --------------------------------------------------------------------------- # Social Features: Friends, Follow & Block # --------------------------------------------------------------------------- ## Send a friend request to target user_id. func send_friend_request(user_id: int) -> Dictionary: return await SocialApiClient.send_friend_request(self, user_id) ## Get pending incoming friend requests. func get_friend_requests() -> Dictionary: return await SocialApiClient.get_friend_requests(self) ## Accept an incoming friend request. func accept_friend_request(request_id: int) -> Dictionary: return await SocialApiClient.accept_friend_request(self, request_id) ## Decline an incoming friend request. func decline_friend_request(request_id: int) -> Dictionary: return await SocialApiClient.decline_friend_request(self, request_id) ## Remove an existing friend. func unfriend(user_id: int) -> Dictionary: return await SocialApiClient.unfriend(self, user_id) ## Get list of mutual friends. func get_friends() -> Dictionary: return await SocialApiClient.get_friends(self) ## Follow a user. func follow_user(user_id: int) -> Dictionary: return await SocialApiClient.follow_user(self, user_id) ## Unfollow a user. func unfollow_user(user_id: int) -> Dictionary: return await SocialApiClient.unfollow_user(self, user_id) ## Get list of users followed by current user. func get_following() -> Dictionary: return await SocialApiClient.get_following(self) ## Block a user. func block_user(user_id: int) -> Dictionary: return await SocialApiClient.block_user(self, user_id) ## Unblock a user. func unblock_user(user_id: int) -> Dictionary: return await SocialApiClient.unblock_user(self, user_id) ## Get list of blocked users. func get_blocked() -> Dictionary: return await SocialApiClient.get_blocked(self) # --------------------------------------------------------------------------- # Social Features: Presence # --------------------------------------------------------------------------- ## Send presence heartbeat with optional custom status. func send_presence_heartbeat(custom_status: String = "") -> Dictionary: return await SocialApiClient.send_presence_heartbeat(self, custom_status) ## Get presence status of mutual friends. func get_friends_presence() -> Dictionary: return await SocialApiClient.get_friends_presence(self) ## Get presence status of a specific user. func get_user_presence(user_id: int) -> Dictionary: return await SocialApiClient.get_user_presence(self, user_id) # --------------------------------------------------------------------------- # Social Features: Profiles # --------------------------------------------------------------------------- ## Get current user's own profile. func get_my_profile() -> Dictionary: return await SocialApiClient.get_my_profile(self) ## Update current user's bio, avatar asset, or privacy settings. func update_my_profile(bio: Variant = null, avatar_asset_id: Variant = null, privacy: Variant = null) -> Dictionary: return await SocialApiClient.update_my_profile(self, bio, avatar_asset_id, privacy) ## Get current user's favorited world IDs. func get_my_favorites() -> Dictionary: return await SocialApiClient.get_my_favorites(self) ## Replace current user's favorited world IDs. func replace_my_favorites(world_ids: Array) -> Dictionary: return await SocialApiClient.replace_my_favorites(self, world_ids) ## Get public profile of another user. func get_public_profile(user_id: int) -> Dictionary: return await SocialApiClient.get_public_profile(self, user_id) # --------------------------------------------------------------------------- # Social Features: Groups # --------------------------------------------------------------------------- ## Create a new group. func create_group(name: String, description: String = "") -> Dictionary: return await SocialApiClient.create_group(self, name, description) ## List groups. func list_groups(page: int = 1, page_size: int = 50, mine: bool = false) -> Dictionary: return await SocialApiClient.list_groups(self, page, page_size, mine) ## Get group details and member list. func get_group(group_id: int) -> Dictionary: return await SocialApiClient.get_group(self, group_id) ## Update group name or description. func update_group(group_id: int, name: Variant = null, description: Variant = null) -> Dictionary: return await SocialApiClient.update_group(self, group_id, name, description) ## Delete a group (owner only). func delete_group(group_id: int) -> Dictionary: return await SocialApiClient.delete_group(self, group_id) ## Join a group. func join_group(group_id: int) -> Dictionary: return await SocialApiClient.join_group(self, group_id) ## Leave a group. func leave_group(group_id: int) -> Dictionary: return await SocialApiClient.leave_group(self, group_id) ## Set a member's role in a group. func set_group_member_role(group_id: int, user_id: int, role: String) -> Dictionary: return await SocialApiClient.set_group_member_role(self, group_id, user_id, role) ## Transfer ownership of a group. func transfer_group_ownership(group_id: int, new_owner_id: int) -> Dictionary: return await SocialApiClient.transfer_group_ownership(self, group_id, new_owner_id) # --------------------------------------------------------------------------- # Social Features: Chat & Moderation # --------------------------------------------------------------------------- ## List DM conversations for the authenticated user. func get_dm_conversations() -> Dictionary: return await SocialApiClient.get_dm_conversations(self) ## Start or get existing DM conversation with target user. func start_dm_conversation(target_user_id: int) -> Dictionary: return await SocialApiClient.start_dm_conversation(self, target_user_id) ## Get DM messages for a conversation. func get_dm_messages(conversation_id: int, before_id: int = 0, limit: int = 50) -> Dictionary: return await SocialApiClient.get_dm_messages(self, conversation_id, before_id, limit) ## Send a DM message to a conversation. func send_dm_message(conversation_id: int, content: String) -> Dictionary: return await SocialApiClient.send_dm_message(self, conversation_id, content) ## Get messages in world chat. func get_world_chat_messages(world_id: int, before_id: int = 0, limit: int = 50) -> Dictionary: return await SocialApiClient.get_world_chat_messages(self, world_id, before_id, limit) ## Send a message to world chat. func send_world_chat_message(world_id: int, content: String) -> Dictionary: return await SocialApiClient.send_world_chat_message(self, world_id, content) ## Get messages in room chat. func get_room_chat_messages(room_id: String, before_id: int = 0, limit: int = 50) -> Dictionary: return await SocialApiClient.get_room_chat_messages(self, room_id, before_id, limit) ## Send a message to room chat. func send_room_chat_message(room_id: String, content: String) -> Dictionary: return await SocialApiClient.send_room_chat_message(self, room_id, content) ## Report a message to the moderation queue. func report_message(message_id: int, conversation_type: String, reason: String, context_text: String = "") -> Dictionary: return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text) # --------------------------------------------------------------------------- # 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