diff --git a/project.godot b/project.godot index 8dddf44..a87f3fb 100644 --- a/project.godot +++ b/project.godot @@ -9,9 +9,12 @@ config_version=5 config/name="AGP-Game" run/main_scene="res://scenes/main_menu.tscn" config/features=PackedStringArray("4.6.2") +agp/api_base_url="http://127.0.0.1:8000" [autoload] +ApiClient="*res://scripts/api_client.gd" + [display] window/size/viewport_width=1920 diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 2f54bf7..d262026 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -1,32 +1,33 @@ class_name ApiClient extends Node -## @group Networking -## @description Central HTTP client singleton that wraps Godot's HTTPRequest node. +## Central HTTP client singleton (autoload name: "ApiClient"). ## -## 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. +## 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. ## -## ## Result Dictionary Contract -## Every public method returns a Dictionary with this shape: +## [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 -## "detail": String, # human-readable error message +## "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 failure. Non-2xx responses return the actual status -## code and the backend's "detail" field from the JSON error body. +## 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. ## -## ## 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. +## [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://`). ## -## ## Usage -## var result = await ApiClient.login("alice", "secret") +## [b]Usage[/b] +## var result: Dictionary = await ApiClient.login("alice", "secret") ## if result.ok: ## print("Logged in!") ## else: @@ -39,76 +40,62 @@ extends Node 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. +## 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 "" + return _token_type + " " + _access_token if _access_token != "" else "" -## Whether a token has been set. + +## Whether an access token is currently stored. 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". + +## 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" -# --------------------------------------------------------------------------- -# 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 +# 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: - # Read base URL from project settings, fall back to localhost:8000. - _base_url = ProjectSettings.get_setting( + _base_url = str(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() + )).rstrip("/") -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. +## Generic request primitive. Awaitable via `await`. ## -## @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). +## 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 bearer token header. -## @return A result dictionary conforming to the contract. +## @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, @@ -116,224 +103,150 @@ func request( content_type: String = "application/json", require_auth: bool = false ) -> Dictionary: - var url: String = _base_url + path + var http := HTTPRequest.new() + http.timeout = timeout_seconds + http.max_redirects = 8 + http.accept_gzip = true + add_child(http) - # Build headers. - var headers: PackedStringArray = PackedStringArray() - headers.append("Content-Type: " + content_type) + 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) - # Fire the request. - var err: int = _http_request.request(url, headers, method, body) + var url := _base_url + path + var err := http.request(url, headers, method, body) if err != OK: - return _network_error(err, "Failed to start request: " + _error_string(err)) + http.queue_free() + return _make_result(false, 0, null, "Failed to start request: " + _error_string(err)) - # Wait for completion. - var result_data = await request_completed - return result_data + # 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() -# --------------------------------------------------------------------------- -# 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 + # 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: - var dict: Dictionary = { - "ok": false, - "status": 0, - "data": null, - "detail": "No response received from server.", - } - request_completed.emit(dict) - return + return _make_result(false, 0, null, "No response received from server.") - # Parse JSON body. - var json_string: String = body.get_string_from_utf8() - var data = null - var detail: String = "" + var json_string := response_body.get_string_from_utf8() + var ok := response_code >= 200 and response_code < 300 - 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 + 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) - 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. +## Log in with username/password (OAuth2PasswordRequestForm). +## On success stores the returned access token. func login(username: String, password: String) -> Dictionary: - var form_data: String = "username=" + username.uri_encode() + "&password=" + password.uri_encode() - var result = await request( + var form := "username=" + username.uri_encode() + "&password=" + password.uri_encode() + var result := await request( HTTPClient.METHOD_POST, "/api/auth/login", - form_data, + form, "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) + _store_token_from_result(result) 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. + +## 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: Dictionary = { + var payload := { "username": username, "email": email, "password": password, "date_of_birth": date_of_birth, } - var json_body: String = JSON.stringify(payload) - var result = await request( + var result := await request( HTTPClient.METHOD_POST, "/api/auth/register", - json_body, + JSON.stringify(payload), "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) + _store_token_from_result(result) 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] + +## 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 - ) + return await request(HTTPClient.METHOD_GET, path, "", "application/json", true) -## Check the server health endpoint. -## -## @return Result dictionary. + +## Ping the backend health endpoint (no auth). func health_check() -> Dictionary: - return await request( - HTTPClient.METHOD_GET, - "/api/health", - "", - "application/json", - false - ) + return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false) -## Get current user profile. -## -## @return Result dictionary. + +## 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 - ) + return await request(HTTPClient.METHOD_GET, "/api/users/me", "", "application/json", true) + # --------------------------------------------------------------------------- -# Persistence seam +# Internals # --------------------------------------------------------------------------- -## 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 +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) -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -func _network_error(err_code: int, fallback: String) -> Dictionary: +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": false, - "status": 0, - "data": null, - "detail": fallback, + "ok": ok, + "status": status, + "data": data, + "detail": detail, } -func _network_error_detail(result_code: int) -> String: + +func _transport_error_detail(result_code: int) -> String: match result_code: HTTPRequest.RESULT_CANT_CONNECT: return "Could not connect to server. Is it running?" @@ -345,11 +258,18 @@ func _network_error_detail(result_code: int) -> String: 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: