Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3496d8c67 |
@@ -8,6 +8,3 @@ export/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Godot UID files (regenerated from path)
|
|
||||||
*.uid
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from sqlalchemy.dialects.postgresql import ENUM
|
|||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
import enum
|
import enum
|
||||||
|
import json
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
@@ -38,3 +40,32 @@ class Asset(Base):
|
|||||||
|
|
||||||
owner = relationship("User", back_populates="assets")
|
owner = relationship("User", back_populates="assets")
|
||||||
world = relationship("World", back_populates="assets")
|
world = relationship("World", back_populates="assets")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Computed properties
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_gltf_metadata(self) -> bool:
|
||||||
|
"""Return True when metadata_json contains glTF-specific keys.
|
||||||
|
|
||||||
|
glTF metadata is considered present when the stored JSON contains at
|
||||||
|
least one of the keys that *extract_gltf_metadata* produces
|
||||||
|
(mesh_count, vertex_count, has_animation, texture_refs,
|
||||||
|
bounding_box_min, bounding_box_max).
|
||||||
|
"""
|
||||||
|
if not self.metadata_json:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data: dict[str, Any] = json.loads(self.metadata_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return False
|
||||||
|
gltf_keys = {
|
||||||
|
"mesh_count",
|
||||||
|
"vertex_count",
|
||||||
|
"has_animation",
|
||||||
|
"texture_refs",
|
||||||
|
"bounding_box_min",
|
||||||
|
"bounding_box_max",
|
||||||
|
}
|
||||||
|
return bool(gltf_keys & data.keys())
|
||||||
|
|||||||
@@ -9,12 +9,9 @@ config_version=5
|
|||||||
config/name="AGP-Game"
|
config/name="AGP-Game"
|
||||||
run/main_scene="res://scenes/main_menu.tscn"
|
run/main_scene="res://scenes/main_menu.tscn"
|
||||||
config/features=PackedStringArray("4.6.2")
|
config/features=PackedStringArray("4.6.2")
|
||||||
agp/api_base_url="http://127.0.0.1:8000"
|
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
ApiClient="*res://scripts/api_client.gd"
|
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=1920
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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)
|
|
||||||
Reference in New Issue
Block a user