Files
modbison/scripts/api_client.gd
T

676 lines
24 KiB
GDScript

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"
## Authenticated user identity
var current_user_id: int = 0
var current_username: String = ""
## 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"
current_user_id = 0
current_username = ""
# ---------------------------------------------------------------------------
# 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,
custom_headers: PackedStringArray = PackedStringArray(),
custom_timeout: float = 0.0
) -> Dictionary:
var http := HTTPRequest.new()
http.timeout = custom_timeout if custom_timeout > 0.0 else 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)
for ch in custom_headers:
headers.append(ch)
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"):
var raw_detail = (parsed as Dictionary)["detail"]
if raw_detail is Array and not raw_detail.is_empty():
var parts: Array[String] = []
for val_err in raw_detail:
if val_err is Dictionary and val_err.has("msg"):
var loc_arr = val_err.get("loc", [])
var loc_str := ""
if loc_arr is Array:
var filtered_loc: Array[String] = []
for x in loc_arr:
if str(x) != "body":
filtered_loc.append(str(x))
loc_str = ".".join(filtered_loc)
if loc_str != "":
parts.append("%s: %s" % [loc_str, str(val_err.get("msg"))])
else:
parts.append(str(val_err.get("msg")))
else:
parts.append(str(val_err))
detail = ", ".join(parts)
else:
detail = str(raw_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)
if result.ok:
current_username = username
var me := await get_me()
if me.ok and me.data is Dictionary:
current_user_id = int((me.data as Dictionary).get("id", 0))
current_username = str((me.data as Dictionary).get("username", username))
return result
## Register a new user. On success stores the returned access token.
## ModBison is an 18+ platform. Supports over_18 boolean or optional legacy DOB string.
func register(
username: String,
email: String,
password: String,
p4: Variant = true,
p5: String = ""
) -> Dictionary:
var over_18: bool = true
var date_of_birth: String = ""
if p4 is bool:
over_18 = p4
date_of_birth = p5
elif p4 is String:
date_of_birth = p4
over_18 = true
var payload: Dictionary = {
"username": username,
"email": email,
"password": password,
"over_18": over_18,
}
if not date_of_birth.is_empty():
payload["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)
if result.ok:
current_username = username
var me := await get_me()
if me.ok and me.data is Dictionary:
current_user_id = int((me.data as Dictionary).get("id", 0))
current_username = str((me.data as Dictionary).get("username", username))
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)
## Create a new world owned by the current user (requires auth).
## On success, result.data is the created World object containing {"id", "name", ...}.
func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = [], content_guidelines: String = "") -> Dictionary:
var payload: Dictionary = {
"name": world_name,
"description": description,
"visibility": visibility,
"category": category,
"tags": tags,
"content_guidelines": content_guidelines,
}
return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "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:
var result := await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true)
if result.ok and result.data is Dictionary:
current_user_id = int((result.data as Dictionary).get("id", 0))
current_username = str((result.data as Dictionary).get("username", current_username))
return result
## Search active users by username substring (requires auth).
func search_users(query: String, limit: int = 20) -> Dictionary:
var path := "/api/users/search?q=%s&limit=%d" % [query.uri_encode(), limit]
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
## Get active user summary by exact username (requires auth).
func get_user_by_username(username: String) -> Dictionary:
var path := "/api/users/by-username/%s" % username.strip_edges().uri_encode()
return await request(HTTPClient.METHOD_GET, path, "", "application/json", true)
## Fetch the current user's saved avatar loadout (requires auth).
## On success, result.data is {"data": <loadout dict>, "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, PackedStringArray(), 25.0)
## 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 (int) or username (String).
func send_friend_request(target: Variant) -> Dictionary:
var payload: Dictionary = {}
if target is int:
payload = {"user_id": target}
elif target is String:
if (target as String).is_valid_int():
payload = {"user_id": int(target)}
else:
payload = {"username": (target as String).strip_edges()}
else:
payload = {"user_id": int(target)}
return await request(HTTPClient.METHOD_POST, "/api/friends/requests", JSON.stringify(payload), "application/json", true)
## 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 by ID or username.
func follow_user(target: Variant) -> Dictionary:
if target is String and not (target as String).is_valid_int():
var lookup := await get_user_by_username((target as String).strip_edges())
if not lookup.ok:
return lookup
target = int(lookup.data["id"])
return await request(HTTPClient.METHOD_POST, "/api/friends/%d/follow" % int(target), "", "application/json", true)
## 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 by ID or username.
func block_user(target: Variant) -> Dictionary:
if target is String and not (target as String).is_valid_int():
var lookup := await get_user_by_username((target as String).strip_edges())
if not lookup.ok:
return lookup
target = int(lookup.data["id"])
return await request(HTTPClient.METHOD_POST, "/api/friends/%d/block" % int(target), "", "application/json", true)
## 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)
## Submit a moderation report for a world, user, or chat message.
func submit_moderation_report(target_type: String, target_id: int, reason: String, category: String = "general") -> Dictionary:
var payload := {
"target_type": target_type,
"target_id": target_id,
"reason": reason,
"category": category,
}
return await request(
HTTPClient.METHOD_POST,
"/api/moderation/reports",
JSON.stringify(payload),
"application/json",
true
)
# ---------------------------------------------------------------------------
# Discovery Features
# ---------------------------------------------------------------------------
## List public worlds for discovery with optional search, tags, category, and sort.
func get_discovery_worlds(
q: String = "",
tags: Array = [],
category: String = "",
sort: String = "newest",
skip: int = 0,
limit: int = 50
) -> Dictionary:
return await DiscoveryApiClient.get_worlds(self, q, tags, category, sort, skip, limit)
## Get distinct categories of public worlds.
func get_discovery_categories() -> Dictionary:
return await DiscoveryApiClient.get_categories(self)
## Record a visit to a world.
func record_world_visit(world_id: int, anonymous_token: String = "") -> Dictionary:
return await DiscoveryApiClient.record_visit(self, world_id, anonymous_token)
# ---------------------------------------------------------------------------
# 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