diff --git a/.gitignore b/.gitignore index 98542a1..d056825 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ export/ # OS .DS_Store Thumbs.db + +# Godot UID files (regenerated from path) +*.uid 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 new file mode 100644 index 0000000..a483592 --- /dev/null +++ b/scripts/api_client.gd @@ -0,0 +1,292 @@ +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: + # 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("/") + + +# --------------------------------------------------------------------------- +# 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 diff --git a/scripts/smoke_test_api.gd b/scripts/smoke_test_api.gd new file mode 100644 index 0000000..70f09a7 --- /dev/null +++ b/scripts/smoke_test_api.gd @@ -0,0 +1,46 @@ +extends SceneTree +## Headless smoke test for the ApiClient autoload. +## +## Run with: +## godot --headless --path . --script res://scripts/smoke_test_api.gd +## +## Requires the backend to be running (default base URL +## `application/agp/api_base_url` -> http://127.0.0.1:8000). +## +## Calls the real autoload's `health_check()` (which exercises the generic +## `request()` primitive) and exits 0 on a `status == "ok"` response, 1 +## otherwise. This script is intentionally NOT wired into any game scene. + + +func _initialize() -> void: + _run() + + +func _run() -> void: + # _initialize() fires before the SceneTree has processed its first frame, + # so a freshly add_child()'d HTTPRequest node isn't reliably considered + # "inside tree" yet (request_raw fails with !is_inside_tree()). One frame + # of delay is enough; this only matters for --script mode, not a real + # running game. + await process_frame + + # Godot's --script/SceneTree runner doesn't resolve autoload singletons + # as bare global identifiers at compile time (the node exists at + # runtime -- root.has_node("ApiClient") is true -- but referencing it as + # a plain identifier fails with "Identifier not found: ApiClient" during + # script compilation). get_node sidesteps compile-time resolution + # entirely. This only affects --script mode; a real game scene resolves + # the bare autoload name normally. + var api_client := root.get_node("ApiClient") + print("SMOKE: calling ApiClient.health_check() ...") + var result: Dictionary = await api_client.health_check() + print("SMOKE: result = ", result) + + if result.ok \ + and result.data is Dictionary \ + and str((result.data as Dictionary).get("status", "")) == "ok": + print("SMOKE RESULT: PASS (status=ok)") + quit(0) + else: + printerr("SMOKE RESULT: FAIL") + quit(1)