Use /api/v1 with token authentication and typed responses
Closes #38, #39 and #40; adds the client side of #41. - Every request goes to /api/v1. MusicServerClient gets a token with Settings.api_password the first time one is needed, reuses it, and on a 401 gets a new one and sends the request once more. Switching server gets a new token too. Callers never see the token. - Sync is started with POST, as MusicServer 5.0 requires. - The client has every endpoint in the server's OpenAPI spec, including statistics, soundtracks and songs by UUID, version, downloads and tokens. - tools/generate_api_types.gd generates a typed class per schema in server/openapi.json into server/generated/. Song info, statistics and versions are passed to callers as these classes. A list the server sends as null is an empty list. - FakeHttpTransport hands out tokens and answers 401 without one, so every test goes through the token flow. New tests cover the token handling, every endpoint and each generated class.
This commit is contained in:
1 parent
8b71723a34
commit
f7619baf34
44 files changed
+5161
-71
No files matched your search
@@ -5,16 +5,33 @@ extends HttpTransport
|
||||
## Register canned responses per path with [method respond_json] or
|
||||
## [method respond_bytes], then install it with [method install]. Requests to paths
|
||||
## without a response get a 404. Responses are delivered immediately.
|
||||
##
|
||||
## Paths are relative to the API, e.g. [code]/music/list[/code] for
|
||||
## [code]<Settings.default_path>/api/v1/music/list[/code].
|
||||
##
|
||||
## Like the real server it hands out tokens on [code]POST /token[/code] and answers 401
|
||||
## to requests without a valid one. Token requests are counted in
|
||||
## [member token_requests], not in [member requested_paths].
|
||||
|
||||
## Paths requested so far, relative to [code]Settings.default_path[/code].
|
||||
## Paths that need no token, like on the real server.
|
||||
const OPEN_PATHS: Array[String] = ["/token", "/health"]
|
||||
|
||||
## Paths requested so far, relative to the API.
|
||||
var requested_paths: Array[String] = []
|
||||
## HTTP method of each request, in the same order as [member requested_paths].
|
||||
var requested_methods: Array[int] = []
|
||||
## Headers of each request, in the same order as [member requested_paths].
|
||||
var requested_headers: Array[PackedStringArray] = []
|
||||
## How many tokens have been asked for.
|
||||
var token_requests: int = 0
|
||||
## The JSON body of the last token request.
|
||||
var last_token_request: Dictionary = {}
|
||||
|
||||
var _responses: Dictionary = {}
|
||||
var _successes_left: Dictionary = {}
|
||||
var _held_paths: Dictionary = {}
|
||||
var _previous_transport: HttpTransport
|
||||
var _valid_tokens: Array[String] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -25,12 +42,14 @@ func _init() -> void:
|
||||
func install() -> FakeHttpTransport:
|
||||
_previous_transport = MusicServerClient.transport
|
||||
MusicServerClient.transport = self
|
||||
MusicServerClient.clear_token()
|
||||
return self
|
||||
|
||||
|
||||
## Restores the transport that was active before [method install].
|
||||
func uninstall() -> void:
|
||||
MusicServerClient.transport = _previous_transport
|
||||
MusicServerClient.clear_token()
|
||||
|
||||
|
||||
## Answers requests to [param path] with [param data] encoded as JSON.
|
||||
@@ -69,10 +88,32 @@ func count(path: String) -> int:
|
||||
return requested_paths.count(path)
|
||||
|
||||
|
||||
func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
var path: String = url.trim_prefix(Settings.default_path)
|
||||
## Makes every token handed out so far invalid, like after 24 hours or a server restart.
|
||||
func expire_tokens() -> void:
|
||||
_valid_tokens.clear()
|
||||
|
||||
|
||||
func request(
|
||||
url: String,
|
||||
method: int,
|
||||
on_complete: Callable,
|
||||
headers: PackedStringArray = PackedStringArray(),
|
||||
body: String = "",
|
||||
) -> void:
|
||||
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
||||
if path == "/token" && method == HTTPClient.METHOD_POST:
|
||||
token_requests += 1
|
||||
if _held_paths.has(path):
|
||||
_held_paths[path].append(_answer_token_request.bind(body, on_complete))
|
||||
return
|
||||
_answer_token_request(body, on_complete)
|
||||
return
|
||||
requested_paths.append(path)
|
||||
requested_methods.append(method)
|
||||
requested_headers.append(headers)
|
||||
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer())
|
||||
return
|
||||
if _held_paths.has(path):
|
||||
_held_paths[path].append(_answer.bind(path, on_complete))
|
||||
return
|
||||
@@ -89,3 +130,28 @@ func _answer(path: String, on_complete: Callable) -> void:
|
||||
on_complete.call(_responses[path].code, _responses[path].body)
|
||||
else:
|
||||
on_complete.call(404, PackedByteArray())
|
||||
|
||||
|
||||
func _answer_token_request(body: String, on_complete: Callable) -> void:
|
||||
var data: Variant = JSON.parse_string(body)
|
||||
last_token_request = data if data is Dictionary else {}
|
||||
if _responses.has("/token"):
|
||||
# A token from a registered response is valid too
|
||||
var registered: Dictionary = _responses["/token"]
|
||||
var response: Variant = JSON.parse_string(registered.body.get_string_from_utf8())
|
||||
if registered.code == 200 && response is Dictionary:
|
||||
_valid_tokens.append(str(response.get("token", "")))
|
||||
_answer("/token", on_complete)
|
||||
return
|
||||
var token: String = "fake-token-%d" % token_requests
|
||||
_valid_tokens.append(token)
|
||||
on_complete.call(200, JSON.stringify({
|
||||
"token": token, "expires_at": "2026-10-07T12:00:00Z", "client_type": "godot",
|
||||
}).to_utf8_buffer())
|
||||
|
||||
|
||||
func _has_valid_token(headers: PackedStringArray) -> bool:
|
||||
for header: String in headers:
|
||||
if header.begins_with("Authorization: Bearer "):
|
||||
return _valid_tokens.has(header.trim_prefix("Authorization: Bearer "))
|
||||
return false
|
||||
Reference in new issue
Block a user