WIP checkpoint: Architecture: ApiClient autoload (HTTP + JWT + error contract)

This commit is contained in:
2026-08-19 13:14:59 +00:00
parent ffbc4849a7
commit 4bb4e9fb8a
+365
View File
@@ -0,0 +1,365 @@
class_name ApiClient
extends Node
## @group Networking
## @description Central HTTP client singleton that wraps Godot's HTTPRequest node.
##
## 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.
##
## ## Result Dictionary Contract
## Every public method returns 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
## }
##
## 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.
##
## ## 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.
##
## ## Usage
## var result = 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"
## Current bearer token string (e.g. "Bearer eyJ...").
## Read-only — use [method set_token] to change.
var bearer_token: String:
get:
return _token_type + " " + _access_token if _access_token else ""
## Whether a token has been set.
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".
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
# ---------------------------------------------------------------------------
func _ready() -> void:
# Read base URL from project settings, fall back to localhost:8000.
_base_url = 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()
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.
##
## @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).
## @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.
func request(
method: HTTPClient.Method,
path: String,
body: String = "",
content_type: String = "application/json",
require_auth: bool = false
) -> Dictionary:
var url: String = _base_url + path
# Build headers.
var headers: PackedStringArray = PackedStringArray()
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)
if err != OK:
return _network_error(err, "Failed to start request: " + _error_string(err))
# Wait for completion.
var result_data = await request_completed
return result_data
# ---------------------------------------------------------------------------
# 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
if response_code == 0:
var dict: Dictionary = {
"ok": false,
"status": 0,
"data": null,
"detail": "No response received from server.",
}
request_completed.emit(dict)
return
# Parse JSON body.
var json_string: String = body.get_string_from_utf8()
var data = null
var detail: String = ""
if response_code >= 200 and response_code < 300:
# Success — try to parse JSON.
var parse_result = JSON.parse_string(json_string)
if parse_result is Dictionary:
data = parse_result
# Extract backend detail if present.
if data.has("detail"):
detail = str(data["detail"])
else:
detail = "OK"
else:
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
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.
func login(username: String, password: String) -> Dictionary:
var form_data: String = "username=" + username.uri_encode() + "&password=" + password.uri_encode()
var result = await request(
HTTPClient.METHOD_POST,
"/api/auth/login",
form_data,
"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)
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.
func register(
username: String,
email: String,
password: String,
date_of_birth: String
) -> Dictionary:
var payload: Dictionary = {
"username": username,
"email": email,
"password": password,
"date_of_birth": date_of_birth,
}
var json_body: String = JSON.stringify(payload)
var result = await request(
HTTPClient.METHOD_POST,
"/api/auth/register",
json_body,
"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)
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]
if visibility != "":
path += "&visibility=" + visibility.uri_encode()
return await request(
HTTPClient.METHOD_GET,
path,
"",
"application/json",
true
)
## Check the server health endpoint.
##
## @return Result dictionary.
func health_check() -> Dictionary:
return await request(
HTTPClient.METHOD_GET,
"/api/health",
"",
"application/json",
false
)
## Get current user profile.
##
## @return Result dictionary.
func get_me() -> Dictionary:
return await request(
HTTPClient.METHOD_GET,
"/api/users/me",
"",
"application/json",
true
)
# ---------------------------------------------------------------------------
# Persistence seam
# ---------------------------------------------------------------------------
## 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
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _network_error(err_code: int, fallback: String) -> Dictionary:
return {
"ok": false,
"status": 0,
"data": null,
"detail": fallback,
}
func _network_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_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