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
+5154
-64
No files matched your search
+21
-1
@@ -48,7 +48,27 @@ assert_int(server.count("/music/rand")).is_equal(1)
|
||||
server.uninstall()
|
||||
```
|
||||
|
||||
Paths without a response get a 404, so the code under test sees a failed request.
|
||||
Paths are relative to the API (`/music/list` is `<server>/api/v1/music/list`). Paths
|
||||
without a response get a 404, so the code under test sees a failed request.
|
||||
|
||||
Like the real server, the fake hands out tokens on `POST /token` and answers 401 to
|
||||
requests without a valid one, so the client's token handling runs in every test. Token
|
||||
requests are counted in `server.token_requests`, not in `requested_paths`. Use
|
||||
`server.expire_tokens()` to make the client renew its token, `server.hold("/token")` to
|
||||
keep it waiting for one, and `server.respond_json("/token", {...}, 401)` for a refused
|
||||
password.
|
||||
|
||||
## API classes
|
||||
|
||||
The classes in `server/generated/` (`SongInfo`, `StatisticsSummary`, ...) are generated
|
||||
from the server's OpenAPI spec, committed as `server/openapi.json`. To update them, copy
|
||||
a new spec there (`just openapi` in MusicServer, or `<server>/openapi.json`) and run:
|
||||
|
||||
```bash
|
||||
godot --headless --path . -s tools/generate_api_types.gd
|
||||
```
|
||||
|
||||
`tests/unit/test_api_types.gd` parses a sample response with each class.
|
||||
|
||||
## Writing tests
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ extends Node
|
||||
const MAX_NUMBER_OF_PLAYERS: int = 7
|
||||
|
||||
var default_path: String = "https://music.sanplex.xyz"
|
||||
## Password for getting an API token from the server. Any password works when the
|
||||
## server has none set.
|
||||
var api_password: String = ""
|
||||
var selected_server: int = 0
|
||||
var is_local: bool = false
|
||||
var is_debug: bool = false
|
||||
|
||||
+9
-12
@@ -22,19 +22,16 @@ var retry_seconds: float = 5.0
|
||||
## Preloading waits for the list, because loading the list clears the playlist.
|
||||
func load_song_list() -> void:
|
||||
GameLog.debug("load_song_list")
|
||||
var song_list_fetched: Callable = func song_list_fetched(data: Variant) -> void:
|
||||
var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void:
|
||||
Playlist.clear_song_list()
|
||||
if typeof(data) == TYPE_ARRAY:
|
||||
for d: Dictionary in data:
|
||||
for info: SongInfo in songs:
|
||||
var new_song_object: SongObject = SongObject.new()
|
||||
new_song_object.song_number = d.SongNo
|
||||
new_song_object.soundtrack_title = d.Soundtrack
|
||||
new_song_object.song_title = d.Song
|
||||
new_song_object.song_number = info.song_no
|
||||
new_song_object.soundtrack_title = info.soundtrack
|
||||
new_song_object.song_title = info.song
|
||||
new_song_object.is_answered = true
|
||||
new_song_object.has_played = true
|
||||
Playlist.add_to_playlist(new_song_object)
|
||||
elif data != null:
|
||||
GameLog.warning("Unexpected data")
|
||||
Playlist.update_last_played_song()
|
||||
song_list_loaded.emit()
|
||||
fetch_song_if_idle()
|
||||
@@ -80,11 +77,11 @@ func _add_song_to_queue(new_song_object: SongObject) -> void:
|
||||
|
||||
|
||||
func _fetch_song_info(new_song_object: SongObject) -> void:
|
||||
var song_info_fetched: Callable = func song_info_fetched(data_received: Dictionary) -> void:
|
||||
var song_info_fetched: Callable = func song_info_fetched(info: SongInfo) -> void:
|
||||
GameLog.debug("song_info_fetched")
|
||||
new_song_object.song_number = data_received.SongNo
|
||||
new_song_object.soundtrack_title = data_received.Soundtrack
|
||||
new_song_object.song_title = data_received.Song
|
||||
new_song_object.song_number = info.song_no
|
||||
new_song_object.soundtrack_title = info.soundtrack
|
||||
new_song_object.song_title = info.song
|
||||
GameLog.debug("new_song_object: ", new_song_object.to_string())
|
||||
Playlist.add_to_playlist(new_song_object)
|
||||
if Playlist.should_preload_more_songs():
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name ApiError
|
||||
extends RefCounted
|
||||
## The [code]APIError[/code] schema of the MusicServer API.
|
||||
|
||||
## What went wrong
|
||||
var error: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> ApiError:
|
||||
var result: ApiError = ApiError.new()
|
||||
result.error = ApiJson.get_string(data, "error", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"error": error,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://4ak2e86vltp2
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name ApiJson
|
||||
extends RefCounted
|
||||
## Reads fields from parsed JSON for the generated API classes. A field that is
|
||||
## missing, null or of the wrong type gives the default instead.
|
||||
|
||||
|
||||
static func get_string(data: Dictionary, key: String, default: String = "") -> String:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is String else default
|
||||
|
||||
|
||||
static func get_int(data: Dictionary, key: String, default: int = 0) -> int:
|
||||
var value: Variant = data.get(key)
|
||||
return int(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_float(data: Dictionary, key: String, default: float = 0.0) -> float:
|
||||
var value: Variant = data.get(key)
|
||||
return float(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_bool(data: Dictionary, key: String, default: bool = false) -> bool:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is bool else default
|
||||
|
||||
|
||||
static func get_array(data: Dictionary, key: String) -> Array:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Array else []
|
||||
|
||||
|
||||
static func get_dictionary(data: Dictionary, key: String) -> Dictionary:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Dictionary else {}
|
||||
@@ -0,0 +1 @@
|
||||
uid://kpihxs4wsldn
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name ProgressResponse
|
||||
extends RefCounted
|
||||
## The [code]ProgressResponse[/code] schema of the MusicServer API.
|
||||
|
||||
var progress: String
|
||||
var time_spent: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> ProgressResponse:
|
||||
var result: ProgressResponse = ProgressResponse.new()
|
||||
result.progress = ApiJson.get_string(data, "progress", "")
|
||||
result.time_spent = ApiJson.get_string(data, "time_spent", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"progress": progress,
|
||||
"time_spent": time_spent,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://blka8xmsytbw1
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name Song
|
||||
extends RefCounted
|
||||
## The [code]Song[/code] schema of the MusicServer API.
|
||||
|
||||
var file_name: String
|
||||
var name: String
|
||||
var soundtrack_name: String
|
||||
var soundtrack_uuid: String
|
||||
var times_played: int
|
||||
var uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> Song:
|
||||
var result: Song = Song.new()
|
||||
result.file_name = ApiJson.get_string(data, "file_name", "")
|
||||
result.name = ApiJson.get_string(data, "name", "")
|
||||
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
result.uuid = ApiJson.get_string(data, "uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"file_name": file_name,
|
||||
"name": name,
|
||||
"soundtrack_name": soundtrack_name,
|
||||
"soundtrack_uuid": soundtrack_uuid,
|
||||
"times_played": times_played,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmsr3kl148gth
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SongInfo
|
||||
extends RefCounted
|
||||
## The [code]SongInfo[/code] schema of the MusicServer API.
|
||||
|
||||
var currently_playing: bool
|
||||
var song: String
|
||||
var song_no: int
|
||||
var song_played: int
|
||||
var song_uuid: String
|
||||
var soundtrack: String
|
||||
var soundtrack_played: int
|
||||
var soundtrack_uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SongInfo:
|
||||
var result: SongInfo = SongInfo.new()
|
||||
result.currently_playing = ApiJson.get_bool(data, "CurrentlyPlaying", false)
|
||||
result.song = ApiJson.get_string(data, "Song", "")
|
||||
result.song_no = ApiJson.get_int(data, "SongNo", 0)
|
||||
result.song_played = ApiJson.get_int(data, "SongPlayed", 0)
|
||||
result.song_uuid = ApiJson.get_string(data, "SongUuid", "")
|
||||
result.soundtrack = ApiJson.get_string(data, "Soundtrack", "")
|
||||
result.soundtrack_played = ApiJson.get_int(data, "SoundtrackPlayed", 0)
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "SoundtrackUuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"CurrentlyPlaying": currently_playing,
|
||||
"Song": song,
|
||||
"SongNo": song_no,
|
||||
"SongPlayed": song_played,
|
||||
"SongUuid": song_uuid,
|
||||
"Soundtrack": soundtrack,
|
||||
"SoundtrackPlayed": soundtrack_played,
|
||||
"SoundtrackUuid": soundtrack_uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://blybmr7a2rm6a
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SongInfoForStats
|
||||
extends RefCounted
|
||||
## The [code]SongInfoForStats[/code] schema of the MusicServer API.
|
||||
|
||||
var file_name: String
|
||||
var path: String
|
||||
var song_name: String
|
||||
var song_uuid: String
|
||||
var soundtrack_id: int
|
||||
var soundtrack_name: String
|
||||
var soundtrack_uuid: String
|
||||
var times_played: int
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SongInfoForStats:
|
||||
var result: SongInfoForStats = SongInfoForStats.new()
|
||||
result.file_name = ApiJson.get_string(data, "file_name", "")
|
||||
result.path = ApiJson.get_string(data, "path", "")
|
||||
result.song_name = ApiJson.get_string(data, "song_name", "")
|
||||
result.song_uuid = ApiJson.get_string(data, "song_uuid", "")
|
||||
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
|
||||
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"file_name": file_name,
|
||||
"path": path,
|
||||
"song_name": song_name,
|
||||
"song_uuid": song_uuid,
|
||||
"soundtrack_id": soundtrack_id,
|
||||
"soundtrack_name": soundtrack_name,
|
||||
"soundtrack_uuid": soundtrack_uuid,
|
||||
"times_played": times_played,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://chexehl48yklr
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name Soundtrack
|
||||
extends RefCounted
|
||||
## The [code]Soundtrack[/code] schema of the MusicServer API.
|
||||
|
||||
var added: String
|
||||
var last_played: String
|
||||
var name: String
|
||||
var times_played: int
|
||||
var uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> Soundtrack:
|
||||
var result: Soundtrack = Soundtrack.new()
|
||||
result.added = ApiJson.get_string(data, "added", "")
|
||||
result.last_played = ApiJson.get_string(data, "last_played", "")
|
||||
result.name = ApiJson.get_string(data, "name", "")
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
result.uuid = ApiJson.get_string(data, "uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"added": added,
|
||||
"last_played": last_played,
|
||||
"name": name,
|
||||
"times_played": times_played,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdbaxmn66tuv7
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SoundtrackWithSongList
|
||||
extends RefCounted
|
||||
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
|
||||
|
||||
var added: String
|
||||
var last_played: String
|
||||
var name: String
|
||||
var songs: Array[Song]
|
||||
var times_played: int
|
||||
var uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
|
||||
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
|
||||
result.added = ApiJson.get_string(data, "added", "")
|
||||
result.last_played = ApiJson.get_string(data, "last_played", "")
|
||||
result.name = ApiJson.get_string(data, "name", "")
|
||||
for item: Variant in ApiJson.get_array(data, "songs"):
|
||||
if item is Dictionary:
|
||||
result.songs.append(Song.from_dict(item))
|
||||
result.times_played = ApiJson.get_int(data, "times_played", 0)
|
||||
result.uuid = ApiJson.get_string(data, "uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"added": added,
|
||||
"last_played": last_played,
|
||||
"name": name,
|
||||
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
|
||||
"times_played": times_played,
|
||||
"uuid": uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj4rx6m53oarm
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SoundtrackWithSongs
|
||||
extends RefCounted
|
||||
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
|
||||
|
||||
var songs: Array[SongInfoForStats]
|
||||
var soundtrack_id: int
|
||||
var soundtrack_last_played: String
|
||||
var soundtrack_name: String
|
||||
var soundtrack_played: int
|
||||
var soundtrack_uuid: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
|
||||
var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
|
||||
for item: Variant in ApiJson.get_array(data, "songs"):
|
||||
if item is Dictionary:
|
||||
result.songs.append(SongInfoForStats.from_dict(item))
|
||||
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
|
||||
result.soundtrack_last_played = ApiJson.get_string(data, "soundtrack_last_played", "")
|
||||
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
|
||||
result.soundtrack_played = ApiJson.get_int(data, "soundtrack_played", 0)
|
||||
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
|
||||
"soundtrack_id": soundtrack_id,
|
||||
"soundtrack_last_played": soundtrack_last_played,
|
||||
"soundtrack_name": soundtrack_name,
|
||||
"soundtrack_played": soundtrack_played,
|
||||
"soundtrack_uuid": soundtrack_uuid,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://yxxvcbbr5ioi
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name StatisticsSummary
|
||||
extends RefCounted
|
||||
## The [code]StatisticsSummary[/code] schema of the MusicServer API.
|
||||
|
||||
var avg_soundtrack_plays: float
|
||||
var max_soundtrack_plays: int
|
||||
var min_soundtrack_plays: int
|
||||
var never_played_soundtracks: int
|
||||
var played_soundtracks: int
|
||||
var total_soundtrack_plays: int
|
||||
var total_soundtracks: int
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> StatisticsSummary:
|
||||
var result: StatisticsSummary = StatisticsSummary.new()
|
||||
result.avg_soundtrack_plays = ApiJson.get_float(data, "avg_soundtrack_plays", 0.0)
|
||||
result.max_soundtrack_plays = ApiJson.get_int(data, "max_soundtrack_plays", 0)
|
||||
result.min_soundtrack_plays = ApiJson.get_int(data, "min_soundtrack_plays", 0)
|
||||
result.never_played_soundtracks = ApiJson.get_int(data, "never_played_soundtracks", 0)
|
||||
result.played_soundtracks = ApiJson.get_int(data, "played_soundtracks", 0)
|
||||
result.total_soundtrack_plays = ApiJson.get_int(data, "total_soundtrack_plays", 0)
|
||||
result.total_soundtracks = ApiJson.get_int(data, "total_soundtracks", 0)
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"avg_soundtrack_plays": avg_soundtrack_plays,
|
||||
"max_soundtrack_plays": max_soundtrack_plays,
|
||||
"min_soundtrack_plays": min_soundtrack_plays,
|
||||
"never_played_soundtracks": never_played_soundtracks,
|
||||
"played_soundtracks": played_soundtracks,
|
||||
"total_soundtrack_plays": total_soundtrack_plays,
|
||||
"total_soundtracks": total_soundtracks,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2acyameyuhv2
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name StatusMessage
|
||||
extends RefCounted
|
||||
## The [code]StatusMessage[/code] schema of the MusicServer API.
|
||||
|
||||
var status: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> StatusMessage:
|
||||
var result: StatusMessage = StatusMessage.new()
|
||||
result.status = ApiJson.get_string(data, "status", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"status": status,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://5inwlixefu0m
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name SyncResponse
|
||||
extends RefCounted
|
||||
## The [code]SyncResponse[/code] schema of the MusicServer API.
|
||||
|
||||
var catched_errors: Array[String]
|
||||
var soundtracks_added: Array[String]
|
||||
var soundtracks_changed_content: Array[String]
|
||||
var soundtracks_changed_title: Dictionary[String, String]
|
||||
var soundtracks_re_added: Array[String]
|
||||
var soundtracks_removed: Array[String]
|
||||
var total_time: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> SyncResponse:
|
||||
var result: SyncResponse = SyncResponse.new()
|
||||
result.catched_errors.assign(ApiJson.get_array(data, "catched_errors"))
|
||||
result.soundtracks_added.assign(ApiJson.get_array(data, "soundtracks_added"))
|
||||
result.soundtracks_changed_content.assign(
|
||||
ApiJson.get_array(data, "soundtracks_changed_content"))
|
||||
result.soundtracks_changed_title.assign(
|
||||
ApiJson.get_dictionary(data, "soundtracks_changed_title"))
|
||||
result.soundtracks_re_added.assign(ApiJson.get_array(data, "soundtracks_re_added"))
|
||||
result.soundtracks_removed.assign(ApiJson.get_array(data, "soundtracks_removed"))
|
||||
result.total_time = ApiJson.get_string(data, "total_time", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"catched_errors": catched_errors,
|
||||
"soundtracks_added": soundtracks_added,
|
||||
"soundtracks_changed_content": soundtracks_changed_content,
|
||||
"soundtracks_changed_title": soundtracks_changed_title,
|
||||
"soundtracks_re_added": soundtracks_re_added,
|
||||
"soundtracks_removed": soundtracks_removed,
|
||||
"total_time": total_time,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2fcfuusfbhah
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name TokenRequest
|
||||
extends RefCounted
|
||||
## The [code]TokenRequest[/code] schema of the MusicServer API.
|
||||
|
||||
## Kind of client, for example web, godot or api
|
||||
var client_type: String = "web"
|
||||
## API password, required when the server has one configured
|
||||
var password: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> TokenRequest:
|
||||
var result: TokenRequest = TokenRequest.new()
|
||||
result.client_type = ApiJson.get_string(data, "client_type", "web")
|
||||
result.password = ApiJson.get_string(data, "password", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"client_type": client_type,
|
||||
"password": password,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c83b66bi6vpmp
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name TokenResponse
|
||||
extends RefCounted
|
||||
## The [code]TokenResponse[/code] schema of the MusicServer API.
|
||||
|
||||
var client_type: String
|
||||
var expires_at: String
|
||||
## Send as Authorization: Bearer <token>
|
||||
var token: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> TokenResponse:
|
||||
var result: TokenResponse = TokenResponse.new()
|
||||
result.client_type = ApiJson.get_string(data, "client_type", "")
|
||||
result.expires_at = ApiJson.get_string(data, "expires_at", "")
|
||||
result.token = ApiJson.get_string(data, "token", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"client_type": client_type,
|
||||
"expires_at": expires_at,
|
||||
"token": token,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://4a44v85naqw0
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by tools/generate_api_types.gd from server/openapi.json.
|
||||
# Do not edit; change the server and generate again.
|
||||
class_name VersionData
|
||||
extends RefCounted
|
||||
## The [code]VersionData[/code] schema of the MusicServer API.
|
||||
|
||||
var changelog: Array[String]
|
||||
var version: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> VersionData:
|
||||
var result: VersionData = VersionData.new()
|
||||
result.changelog.assign(ApiJson.get_array(data, "changelog"))
|
||||
result.version = ApiJson.get_string(data, "version", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"changelog": changelog,
|
||||
"version": version,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://no7af2fycku5
|
||||
@@ -17,8 +17,15 @@ func _init(owner: Node) -> void:
|
||||
|
||||
## Requests [param url] and calls [param on_complete] with
|
||||
## [code](response_code: int, body: PackedByteArray)[/code]. Connection errors and
|
||||
## timeouts are reported with response code 0.
|
||||
func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
## timeouts are reported with response code 0. [param headers] are sent as-is; a
|
||||
## [param body] is sent as JSON.
|
||||
func request(
|
||||
url: String,
|
||||
method: int,
|
||||
on_complete: Callable,
|
||||
headers: PackedStringArray = PackedStringArray(),
|
||||
body: String = "",
|
||||
) -> void:
|
||||
var http_request: HTTPRequest = HTTPRequest.new()
|
||||
http_request.timeout = timeout_seconds
|
||||
_owner.add_child(http_request)
|
||||
@@ -27,15 +34,16 @@ func request(url: String, method: int, on_complete: Callable) -> void:
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
body: PackedByteArray,
|
||||
response_body: PackedByteArray,
|
||||
) -> void:
|
||||
http_request.queue_free()
|
||||
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0, body)
|
||||
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0,
|
||||
response_body)
|
||||
)
|
||||
var headers: PackedStringArray = []
|
||||
if method == HTTPClient.METHOD_PUT:
|
||||
headers.append("Content-Type: application/json")
|
||||
var error: int = http_request.request(url, headers, method)
|
||||
var all_headers: PackedStringArray = headers.duplicate()
|
||||
if method == HTTPClient.METHOD_PUT || !body.is_empty():
|
||||
all_headers.append("Content-Type: application/json")
|
||||
var error: int = http_request.request(url, all_headers, method, body)
|
||||
if error != OK:
|
||||
http_request.queue_free()
|
||||
push_error("Could not send request to %s (error %d)" % [url, error])
|
||||
|
||||
+380
-24
@@ -1,25 +1,47 @@
|
||||
extends Node
|
||||
## Client for the MusicServer REST API.
|
||||
## Client for the MusicServer REST API ([code]/api/v1[/code]).
|
||||
##
|
||||
## Registered as the [code]MusicServerClient[/code] autoload, so every script can call it
|
||||
## directly, e.g. [code]MusicServerClient.fetch_song_list(on_done)[/code]. All HTTP traffic
|
||||
## goes through [member transport]; tests replace it with a fake to answer requests
|
||||
## without a running server.
|
||||
##
|
||||
## Requests need a token, which the client gets with [code]Settings.api_password[/code]
|
||||
## the first time it is needed and then reuses. When the server answers 401 (the token
|
||||
## expired, or the server restarted), it gets a new token and sends the request once
|
||||
## more. Callers never see the token.
|
||||
##
|
||||
## JSON objects are handed to [code]on_done[/code] as the classes generated from the
|
||||
## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed
|
||||
## Arrays. [code]on_failed[/code] is called with no arguments when a request fails,
|
||||
## including when no token could be had.
|
||||
|
||||
## Path of the API on the server, added between [code]Settings.default_path[/code] and
|
||||
## each endpoint.
|
||||
const API_PATH: String = "/api/v1"
|
||||
## Sent when asking for a token, so the server can tell its clients apart.
|
||||
const CLIENT_TYPE: String = "godot"
|
||||
|
||||
## Sends the actual HTTP requests. Swap it out in tests.
|
||||
|
||||
var transport: HttpTransport
|
||||
|
||||
var _token: String = ""
|
||||
# The server the token is for; switching server in the settings needs a new token
|
||||
var _token_server: String = ""
|
||||
# [on_token, on_failed] pairs waiting for the token request that is under way
|
||||
var _token_waiters: Array[Array] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if transport == null:
|
||||
transport = HttpTransport.new(self)
|
||||
|
||||
|
||||
## Fetches the songs already in the server playlist, as an Array of
|
||||
## [code]{SongNo, Soundtrack, Song}[/code] dictionaries.
|
||||
# Queue
|
||||
|
||||
## Fetches the songs already in the server playlist as an [code]Array[SongInfo][/code].
|
||||
func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/music/list", on_done, on_failed)
|
||||
get_json_list("/music/list", SongInfo, on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a random song as MP3 bytes. With [param low_played] the server prefers
|
||||
@@ -29,14 +51,20 @@ func fetch_random_song(
|
||||
get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a random song as MP3 bytes, picked the way the server did before low played
|
||||
## mode existed: any soundtrack is as likely as any other.
|
||||
func fetch_random_song_classic(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/rand/classic", on_done, on_failed)
|
||||
|
||||
|
||||
## Adds the most recently fetched random song to the server playlist.
|
||||
func add_to_queue(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/music/addQue", on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches [code]{SongNo, Soundtrack, Song}[/code] for the most recently queued song.
|
||||
## Fetches the [SongInfo] of the most recently queued song.
|
||||
func fetch_song_info(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/music/info", on_done, on_failed)
|
||||
get_json_object("/music/info", SongInfo, on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a song from the server playlist as MP3 bytes.
|
||||
@@ -44,6 +72,16 @@ func fetch_song(song_no: int, on_done: Callable, on_failed: Callable = Callable(
|
||||
get_bytes("/music?song=" + str(song_no), on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the song after the current one in the server playlist as MP3 bytes.
|
||||
func fetch_next_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/next", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the song before the current one in the server playlist as MP3 bytes.
|
||||
func fetch_previous_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/previous", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the sound test song as MP3 bytes.
|
||||
func fetch_sound_test_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/music/soundTest", on_done, on_failed)
|
||||
@@ -54,7 +92,19 @@ func reset_playlist(on_done: Callable, on_failed: Callable = Callable()) -> void
|
||||
send("/music/reset", on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches every soundtrack name as an Array of Strings.
|
||||
## Records in the server statistics that a song has been played.
|
||||
func mark_song_played(song_no: int) -> void:
|
||||
send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT)
|
||||
|
||||
|
||||
## Records in the server statistics that the current song has been played.
|
||||
func mark_current_song_played(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/music/addPlayed", on_done, on_failed)
|
||||
|
||||
|
||||
# Soundtracks and songs
|
||||
|
||||
## Fetches every soundtrack name, shuffled, as an Array of Strings.
|
||||
func fetch_all_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/music/all", on_done, on_failed)
|
||||
|
||||
@@ -64,14 +114,93 @@ func fetch_all_soundtracks_ordered(on_done: Callable, on_failed: Callable = Call
|
||||
get_json("/music/all/order", on_done, on_failed)
|
||||
|
||||
|
||||
## Records in the server statistics that a song has been played.
|
||||
func mark_song_played(song_no: int) -> void:
|
||||
send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT)
|
||||
## Fetches every soundtrack as an [code]Array[Soundtrack][/code].
|
||||
func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/soundtracks", Soundtrack, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
|
||||
func fetch_soundtrack(
|
||||
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/soundtracks/" + uuid.uri_encode(), SoundtrackWithSongList, on_done,
|
||||
on_failed)
|
||||
|
||||
|
||||
## Fetches a song as a [Song].
|
||||
func fetch_song_by_uuid(
|
||||
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/songs/" + uuid.uri_encode(), Song, on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a song by its UUID as MP3 bytes.
|
||||
func fetch_song_file(uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/songs/%s/file" % uuid.uri_encode(), on_done, on_failed)
|
||||
|
||||
|
||||
# Statistics
|
||||
|
||||
## Fetches the play statistics of the whole library as a [StatisticsSummary].
|
||||
func fetch_statistics_summary(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/statistics/summary", StatisticsSummary, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] most played soundtracks as an
|
||||
## [code]Array[SoundtrackWithSongs][/code]. The server allows a limit of 1 to 100.
|
||||
func fetch_most_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("most-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] least played soundtracks as an
|
||||
## [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_least_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("least-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] most recently played soundtracks as an
|
||||
## [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_last_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("last-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] soundtracks that were played longest ago, leaving out those
|
||||
## never played, as an [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_oldest_played_soundtracks(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_fetch_soundtrack_statistics("oldest-played", limit, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches every soundtrack that has never been played as an
|
||||
## [code]Array[SoundtrackWithSongs][/code].
|
||||
func fetch_never_played_soundtracks(
|
||||
on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/statistics/soundtracks/never-played", SoundtrackWithSongs, on_done,
|
||||
on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] most played songs as an
|
||||
## [code]Array[SongInfoForStats][/code].
|
||||
func fetch_most_played_songs(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/statistics/songs/most-played?limit=%d" % limit, SongInfoForStats,
|
||||
on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the [param limit] least played songs as an
|
||||
## [code]Array[SongInfoForStats][/code].
|
||||
func fetch_least_played_songs(
|
||||
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/statistics/songs/least-played?limit=%d" % limit, SongInfoForStats,
|
||||
on_done, on_failed)
|
||||
|
||||
|
||||
# Characters
|
||||
|
||||
## Fetches the names of all character images.
|
||||
func fetch_character_list(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/characters", on_done, on_failed)
|
||||
get_json("/character/list", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads a character image as PNG bytes.
|
||||
@@ -80,9 +209,17 @@ func fetch_character_image(
|
||||
get_bytes("/character?name=" + character_name.uri_encode(), on_done, on_failed)
|
||||
|
||||
|
||||
## Starts syncing the server's soundtrack library with its music folder.
|
||||
# Sync
|
||||
|
||||
## Starts syncing the server's soundtrack library with its music folder, looking only
|
||||
## at what changed. The server answers 423 if a sync is already running.
|
||||
func start_sync(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/sync", on_done, on_failed)
|
||||
send("/sync", on_done, on_failed, HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
## Starts syncing every soundtrack again, changed or not.
|
||||
func start_full_sync(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/sync/full", on_done, on_failed, HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
## Fetches the sync state: [code]{progress, time_spent}[/code] while running, or the
|
||||
@@ -91,17 +228,115 @@ func fetch_sync_progress(on_done: Callable, on_failed: Callable = Callable()) ->
|
||||
get_json("/sync/progress", on_done, on_failed)
|
||||
|
||||
|
||||
# Server
|
||||
|
||||
## Checks that the server is up, without a token. Calls [param on_done] with its status
|
||||
## Dictionary.
|
||||
func fetch_health(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_send_once("/health", HTTPClient.METHOD_GET, "",
|
||||
_parse_json.bind("/health", on_done, on_failed), on_failed)
|
||||
|
||||
|
||||
## Fetches the server version and its changes as a [VersionData].
|
||||
func fetch_version(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_object("/version", VersionData, on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches every server version as an [code]Array[VersionData][/code].
|
||||
func fetch_version_history(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json_list("/version/history", VersionData, on_done, on_failed)
|
||||
|
||||
|
||||
# Game downloads
|
||||
|
||||
## Fetches the name of the newest MusicPlayer release.
|
||||
func fetch_latest_client_version(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/download", on_done, on_failed)
|
||||
|
||||
|
||||
## Fetches the file names in the newest MusicPlayer release.
|
||||
func fetch_client_assets(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_json("/download/list", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the newest MusicPlayer for Windows.
|
||||
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/download/windows", on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the newest MusicPlayer for Linux.
|
||||
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/download/linux", on_done, on_failed)
|
||||
|
||||
|
||||
# Tokens
|
||||
|
||||
## Tells the server to forget the token, e.g. when quitting. The next request gets a
|
||||
## new one.
|
||||
func delete_token(on_done: Callable = Callable(), on_failed: Callable = Callable()) -> void:
|
||||
if _token.is_empty():
|
||||
_call_if_valid(on_done)
|
||||
return
|
||||
var token: String = _token
|
||||
clear_token()
|
||||
_send_once("/token", HTTPClient.METHOD_DELETE, token, func(_body: PackedByteArray) -> void:
|
||||
_call_if_valid(on_done)
|
||||
, on_failed)
|
||||
|
||||
|
||||
## Asks the server to remove expired tokens of all clients.
|
||||
func cleanup_tokens(on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
send("/token/cleanup", on_done, on_failed, HTTPClient.METHOD_POST)
|
||||
|
||||
|
||||
## Forgets the token without telling the server, so the next request gets a new one.
|
||||
func clear_token() -> void:
|
||||
_token = ""
|
||||
_token_server = ""
|
||||
|
||||
|
||||
# Generic requests
|
||||
|
||||
## Requests [param path] and calls [param on_done] with the parsed JSON.
|
||||
## [param on_failed] is called with no arguments if the request fails or the body is
|
||||
## not valid JSON.
|
||||
func get_json(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
_request(path, HTTPClient.METHOD_GET, func(body: PackedByteArray) -> void:
|
||||
var json: JSON = JSON.new()
|
||||
if json.parse(body.get_string_from_utf8()) != OK:
|
||||
push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()])
|
||||
_request(path, HTTPClient.METHOD_GET, _parse_json.bind(path, on_done, on_failed),
|
||||
on_failed)
|
||||
|
||||
|
||||
## Requests [param path], which answers with a JSON object, and calls [param on_done]
|
||||
## with it read into [param type], one of the generated API classes.
|
||||
func get_json_object(
|
||||
path: String, type: GDScript, on_done: Callable, on_failed: Callable = Callable(),
|
||||
) -> void:
|
||||
get_json(path, func(data: Variant) -> void:
|
||||
if !(data is Dictionary):
|
||||
push_warning("Expected a JSON object from " + path)
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
on_done.call(json.data)
|
||||
on_done.call(type.from_dict(data))
|
||||
, on_failed)
|
||||
|
||||
|
||||
## Requests [param path], which answers with a JSON array of objects, and calls
|
||||
## [param on_done] with a typed Array of [param type], one of the generated API classes.
|
||||
## The server sends [code]null[/code] for an empty list, which gives an empty Array.
|
||||
func get_json_list(
|
||||
path: String, type: GDScript, on_done: Callable, on_failed: Callable = Callable(),
|
||||
) -> void:
|
||||
get_json(path, func(data: Variant) -> void:
|
||||
if data == null:
|
||||
data = []
|
||||
if !(data is Array):
|
||||
push_warning("Expected a JSON array from " + path)
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
var items: Array = Array([], TYPE_OBJECT, "RefCounted", type)
|
||||
for item: Variant in data:
|
||||
if item is Dictionary:
|
||||
items.append(type.from_dict(item))
|
||||
on_done.call(items)
|
||||
, on_failed)
|
||||
|
||||
|
||||
@@ -124,15 +359,136 @@ func send(
|
||||
, on_failed)
|
||||
|
||||
|
||||
func _request(path: String, method: int, on_success: Callable, on_failed: Callable) -> void:
|
||||
var url: String = Settings.default_path + path
|
||||
transport.request(url, method, func(response_code: int, body: PackedByteArray) -> void:
|
||||
func _fetch_soundtrack_statistics(
|
||||
list_name: String, limit: int, on_done: Callable, on_failed: Callable) -> void:
|
||||
get_json_list("/statistics/soundtracks/%s?limit=%d" % [list_name, limit],
|
||||
SoundtrackWithSongs, on_done, on_failed)
|
||||
|
||||
|
||||
func _parse_json(
|
||||
body: PackedByteArray, path: String, on_done: Callable, on_failed: Callable) -> void:
|
||||
var json: JSON = JSON.new()
|
||||
if json.parse(body.get_string_from_utf8()) != OK:
|
||||
push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()])
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
on_done.call(json.data)
|
||||
|
||||
|
||||
# Sends the request with the token, and once more with a new token if the server
|
||||
# answers 401
|
||||
func _request(
|
||||
path: String,
|
||||
method: int,
|
||||
on_success: Callable,
|
||||
on_failed: Callable,
|
||||
may_retry: bool = true,
|
||||
) -> void:
|
||||
_with_token(func(token: String) -> void:
|
||||
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
|
||||
if response_code == 401 && may_retry:
|
||||
GameLog.debug("Token refused by the server, getting a new one")
|
||||
if _token == token:
|
||||
clear_token()
|
||||
_request(path, method, on_success, on_failed, false)
|
||||
return
|
||||
_handle_response(path, response_code, body, on_success, on_failed)
|
||||
)
|
||||
, on_failed)
|
||||
|
||||
|
||||
# Sends the request once, with [param token] if it is not empty
|
||||
func _send_once(
|
||||
path: String, method: int, token: String, on_success: Callable, on_failed: Callable,
|
||||
) -> void:
|
||||
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
|
||||
_handle_response(path, response_code, body, on_success, on_failed)
|
||||
)
|
||||
|
||||
|
||||
func _send(
|
||||
path: String, method: int, token: String, on_complete: Callable, body: String = "",
|
||||
) -> void:
|
||||
var headers: PackedStringArray = []
|
||||
if !token.is_empty():
|
||||
headers.append("Authorization: Bearer " + token)
|
||||
transport.request(Settings.default_path + API_PATH + path, method, on_complete, headers,
|
||||
body)
|
||||
|
||||
|
||||
func _handle_response(
|
||||
path: String,
|
||||
response_code: int,
|
||||
body: PackedByteArray,
|
||||
on_success: Callable,
|
||||
on_failed: Callable,
|
||||
) -> void:
|
||||
if response_code != 200:
|
||||
push_warning("Request to %s failed with response code %d" % [url, response_code])
|
||||
push_warning("Request to %s failed with response code %d%s"
|
||||
% [path, response_code, _error_message(body)])
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
on_success.call(body)
|
||||
)
|
||||
|
||||
|
||||
# Calls [param on_token] with the token, asking the server for one first if needed.
|
||||
# Requests made while asking wait for the same token instead of asking again.
|
||||
func _with_token(on_token: Callable, on_failed: Callable) -> void:
|
||||
if _token_server != Settings.default_path:
|
||||
clear_token()
|
||||
if !_token.is_empty():
|
||||
on_token.call(_token)
|
||||
return
|
||||
_token_waiters.append([on_token, on_failed])
|
||||
if _token_waiters.size() == 1:
|
||||
_request_token()
|
||||
|
||||
|
||||
func _request_token() -> void:
|
||||
var token_request: TokenRequest = TokenRequest.new()
|
||||
token_request.password = Settings.api_password
|
||||
token_request.client_type = CLIENT_TYPE
|
||||
var server: String = Settings.default_path
|
||||
_send("/token", HTTPClient.METHOD_POST, "",
|
||||
func(response_code: int, body: PackedByteArray) -> void:
|
||||
var waiters: Array[Array] = _token_waiters.duplicate()
|
||||
_token_waiters.clear()
|
||||
var token: String = _read_token(response_code, body)
|
||||
if token.is_empty():
|
||||
for waiter: Array in waiters:
|
||||
_call_if_valid(waiter[1])
|
||||
return
|
||||
_token = token
|
||||
_token_server = server
|
||||
for waiter: Array in waiters:
|
||||
waiter[0].call(token)
|
||||
, JSON.stringify(token_request.to_dict()))
|
||||
|
||||
|
||||
func _read_token(response_code: int, body: PackedByteArray) -> String:
|
||||
if response_code != 200:
|
||||
push_warning("Could not get an API token, response code %d%s"
|
||||
% [response_code, _error_message(body)])
|
||||
return ""
|
||||
var data: Variant = _parse_or_null(body)
|
||||
var token: String = TokenResponse.from_dict(data).token if data is Dictionary else ""
|
||||
if token.is_empty():
|
||||
push_warning("The server answered without an API token")
|
||||
return token
|
||||
|
||||
|
||||
# The server's {"error": "..."} message, for logging
|
||||
static func _error_message(body: PackedByteArray) -> String:
|
||||
var data: Variant = _parse_or_null(body)
|
||||
if data is Dictionary && data.has("error"):
|
||||
return ": " + ApiError.from_dict(data).error
|
||||
return ""
|
||||
|
||||
|
||||
# Error pages are not always JSON, so failing to parse is not worth a warning here
|
||||
static func _parse_or_null(body: PackedByteArray) -> Variant:
|
||||
var json: JSON = JSON.new()
|
||||
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
|
||||
|
||||
|
||||
static func _call_if_valid(callback: Callable) -> void:
|
||||
|
||||
+3285
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -30,7 +30,7 @@ func before_test() -> void:
|
||||
_server.respond_json("/music/all", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/all/order", ["Chrono Trigger", "Final Fantasy VI"])
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.respond_json("/characters", [])
|
||||
_server.respond_json("/character/list", [])
|
||||
_server.respond_bytes("/music/rand", mp3)
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.respond_json("/music/info", SONG_INFO[0])
|
||||
|
||||
@@ -57,7 +57,7 @@ static func make_server() -> FakeHttpTransport:
|
||||
server.respond_json("/music/all/order", SOUNDTRACKS)
|
||||
server.respond_json("/music/list", [])
|
||||
var characters: Array[String] = character_names()
|
||||
server.respond_json("/characters", characters)
|
||||
server.respond_json("/character/list", characters)
|
||||
for character_name: String in characters:
|
||||
server.respond_bytes("/character?name=" + character_name.uri_encode(),
|
||||
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name)))
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
extends GdUnitTestSuite
|
||||
## The classes generated from the MusicServer OpenAPI spec read the server's JSON.
|
||||
##
|
||||
## Each sample is a response the way the server sends it, parsed from text like the
|
||||
## client does, so numbers arrive as floats.
|
||||
|
||||
|
||||
func _parse(json: String) -> Dictionary:
|
||||
return JSON.parse_string(json)
|
||||
|
||||
|
||||
func test_song_info() -> void:
|
||||
var info: SongInfo = SongInfo.from_dict(_parse("""{
|
||||
"SoundtrackUuid": "st-1", "Soundtrack": "Chrono Trigger", "SoundtrackPlayed": 4,
|
||||
"SongUuid": "so-1", "Song": "Corridors of Time", "SongPlayed": 2,
|
||||
"CurrentlyPlaying": true, "SongNo": 3}"""))
|
||||
|
||||
assert_str(info.soundtrack_uuid).is_equal("st-1")
|
||||
assert_str(info.soundtrack).is_equal("Chrono Trigger")
|
||||
assert_int(info.soundtrack_played).is_equal(4)
|
||||
assert_str(info.song_uuid).is_equal("so-1")
|
||||
assert_str(info.song).is_equal("Corridors of Time")
|
||||
assert_int(info.song_played).is_equal(2)
|
||||
assert_bool(info.currently_playing).is_true()
|
||||
assert_int(info.song_no).is_equal(3)
|
||||
|
||||
|
||||
func test_song() -> void:
|
||||
var song: Song = Song.from_dict(_parse("""{"uuid": "so-1", "name": "Dancing Mad",
|
||||
"times_played": 7, "soundtrack_uuid": "st-2", "soundtrack_name": "Final Fantasy VI",
|
||||
"file_name": "dancing_mad.mp3"}"""))
|
||||
|
||||
assert_str(song.uuid).is_equal("so-1")
|
||||
assert_str(song.name).is_equal("Dancing Mad")
|
||||
assert_int(song.times_played).is_equal(7)
|
||||
assert_str(song.soundtrack_uuid).is_equal("st-2")
|
||||
assert_str(song.soundtrack_name).is_equal("Final Fantasy VI")
|
||||
assert_str(song.file_name).is_equal("dancing_mad.mp3")
|
||||
|
||||
|
||||
func test_soundtrack() -> void:
|
||||
var soundtrack: Soundtrack = Soundtrack.from_dict(_parse("""{"uuid": "st-1",
|
||||
"name": "Terranigma", "times_played": 3, "added": "2026-01-02T10:00:00Z",
|
||||
"last_played": "2026-10-01T20:00:00Z"}"""))
|
||||
|
||||
assert_str(soundtrack.uuid).is_equal("st-1")
|
||||
assert_str(soundtrack.name).is_equal("Terranigma")
|
||||
assert_int(soundtrack.times_played).is_equal(3)
|
||||
assert_str(soundtrack.added).is_equal("2026-01-02T10:00:00Z")
|
||||
assert_str(soundtrack.last_played).is_equal("2026-10-01T20:00:00Z")
|
||||
|
||||
|
||||
func test_soundtrack_with_song_list() -> void:
|
||||
var soundtrack: SoundtrackWithSongList = SoundtrackWithSongList.from_dict(_parse("""{
|
||||
"uuid": "st-1", "name": "Lufia II", "times_played": 1, "added": "2026-01-02T10:00:00Z",
|
||||
"songs": [{"uuid": "so-1", "name": "Battle #1", "times_played": 1,
|
||||
"soundtrack_uuid": "st-1", "soundtrack_name": "Lufia II"}]}"""))
|
||||
|
||||
assert_str(soundtrack.name).is_equal("Lufia II")
|
||||
assert_str(soundtrack.last_played).is_empty()
|
||||
assert_int(soundtrack.songs.size()).is_equal(1)
|
||||
assert_str(soundtrack.songs[0].name).is_equal("Battle #1")
|
||||
|
||||
|
||||
func test_soundtrack_with_songs() -> void:
|
||||
var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{
|
||||
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
|
||||
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
|
||||
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
|
||||
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/music/onett.mp3",
|
||||
"times_played": 2, "file_name": "onett.mp3"}]}"""))
|
||||
|
||||
assert_str(soundtrack.soundtrack_uuid).is_equal("st-1")
|
||||
assert_str(soundtrack.soundtrack_name).is_equal("Earthbound")
|
||||
assert_int(soundtrack.soundtrack_played).is_equal(6)
|
||||
assert_str(soundtrack.soundtrack_last_played).is_equal("2026-10-01T20:00:00Z")
|
||||
assert_str(soundtrack.songs[0].song_name).is_equal("Onett")
|
||||
|
||||
|
||||
func test_soundtrack_with_songs_tolerates_null_songs() -> void:
|
||||
var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{
|
||||
"soundtrack_uuid": "st-1", "songs": null}"""))
|
||||
|
||||
assert_array(soundtrack.songs).is_empty()
|
||||
|
||||
|
||||
func test_song_info_for_stats() -> void:
|
||||
var song: SongInfoForStats = SongInfoForStats.from_dict(_parse("""{"soundtrack_id": 5,
|
||||
"soundtrack_uuid": "st-1", "song_uuid": "so-1", "soundtrack_name": "Super Metroid",
|
||||
"song_name": "Brinstar", "path": "/music/brinstar.mp3", "times_played": 9,
|
||||
"file_name": "brinstar.mp3"}"""))
|
||||
|
||||
assert_str(song.song_uuid).is_equal("so-1")
|
||||
assert_str(song.soundtrack_name).is_equal("Super Metroid")
|
||||
assert_str(song.song_name).is_equal("Brinstar")
|
||||
assert_str(song.path).is_equal("/music/brinstar.mp3")
|
||||
assert_int(song.times_played).is_equal(9)
|
||||
assert_str(song.file_name).is_equal("brinstar.mp3")
|
||||
|
||||
|
||||
func test_statistics_summary() -> void:
|
||||
var summary: StatisticsSummary = StatisticsSummary.from_dict(_parse("""{
|
||||
"total_soundtracks": 120, "played_soundtracks": 100, "never_played_soundtracks": 20,
|
||||
"total_soundtrack_plays": 250, "avg_soundtrack_plays": 2.5,
|
||||
"max_soundtrack_plays": 12, "min_soundtrack_plays": 1}"""))
|
||||
|
||||
assert_int(summary.total_soundtracks).is_equal(120)
|
||||
assert_int(summary.played_soundtracks).is_equal(100)
|
||||
assert_int(summary.never_played_soundtracks).is_equal(20)
|
||||
assert_int(summary.total_soundtrack_plays).is_equal(250)
|
||||
assert_float(summary.avg_soundtrack_plays).is_equal(2.5)
|
||||
assert_int(summary.max_soundtrack_plays).is_equal(12)
|
||||
assert_int(summary.min_soundtrack_plays).is_equal(1)
|
||||
|
||||
|
||||
func test_sync_response() -> void:
|
||||
var result: SyncResponse = SyncResponse.from_dict(_parse("""{
|
||||
"soundtracks_added": ["Terranigma"], "soundtracks_re_added": [],
|
||||
"soundtracks_changed_title": {"FF6": "Final Fantasy VI"},
|
||||
"soundtracks_changed_content": ["Chrono Trigger"], "soundtracks_removed": ["Old"],
|
||||
"catched_errors": ["Could not read x.mp3"], "total_time": "00:00:03"}"""))
|
||||
|
||||
assert_array(result.soundtracks_added).contains_exactly(["Terranigma"])
|
||||
assert_array(result.soundtracks_re_added).is_empty()
|
||||
assert_dict(result.soundtracks_changed_title).is_equal({"FF6": "Final Fantasy VI"})
|
||||
assert_array(result.soundtracks_changed_content).contains_exactly(["Chrono Trigger"])
|
||||
assert_array(result.soundtracks_removed).contains_exactly(["Old"])
|
||||
assert_array(result.catched_errors).contains_exactly(["Could not read x.mp3"])
|
||||
assert_str(result.total_time).is_equal("00:00:03")
|
||||
|
||||
|
||||
func test_progress_response() -> void:
|
||||
var progress: ProgressResponse = ProgressResponse.from_dict(_parse("""{
|
||||
"progress": "40", "time_spent": "00:00:02"}"""))
|
||||
|
||||
assert_str(progress.progress).is_equal("40")
|
||||
assert_str(progress.time_spent).is_equal("00:00:02")
|
||||
|
||||
|
||||
func test_version_data() -> void:
|
||||
var version: VersionData = VersionData.from_dict(_parse("""{"version": "5.0.0",
|
||||
"changelog": ["Initial release", "Bug fixes"]}"""))
|
||||
|
||||
assert_str(version.version).is_equal("5.0.0")
|
||||
assert_array(version.changelog).contains_exactly(["Initial release", "Bug fixes"])
|
||||
|
||||
|
||||
func test_token_response() -> void:
|
||||
var response: TokenResponse = TokenResponse.from_dict(_parse("""{"token": "abc",
|
||||
"expires_at": "2026-10-07T12:00:00Z", "client_type": "godot"}"""))
|
||||
|
||||
assert_str(response.token).is_equal("abc")
|
||||
assert_str(response.expires_at).is_equal("2026-10-07T12:00:00Z")
|
||||
assert_str(response.client_type).is_equal("godot")
|
||||
|
||||
|
||||
func test_token_request_defaults_to_web_client() -> void:
|
||||
var request: TokenRequest = TokenRequest.from_dict({})
|
||||
|
||||
assert_str(request.client_type).is_equal("web")
|
||||
assert_str(request.password).is_empty()
|
||||
|
||||
|
||||
func test_status_message() -> void:
|
||||
assert_str(StatusMessage.from_dict(_parse("""{"status": "ok"}""")).status).is_equal("ok")
|
||||
|
||||
|
||||
func test_api_error() -> void:
|
||||
assert_str(ApiError.from_dict(_parse("""{"error": "Not Found"}""")).error) \
|
||||
.is_equal("Not Found")
|
||||
|
||||
|
||||
func test_missing_and_wrong_fields_give_defaults() -> void:
|
||||
var info: SongInfo = SongInfo.from_dict({"SongNo": "three", "Song": null})
|
||||
|
||||
assert_int(info.song_no).is_equal(0)
|
||||
assert_str(info.song).is_empty()
|
||||
assert_bool(info.currently_playing).is_false()
|
||||
|
||||
|
||||
func test_to_dict_gives_back_the_json() -> void:
|
||||
var json: Dictionary = {
|
||||
"soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound",
|
||||
"soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z",
|
||||
"songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1",
|
||||
"soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/onett.mp3",
|
||||
"times_played": 2, "file_name": "onett.mp3"}],
|
||||
}
|
||||
|
||||
assert_dict(SoundtrackWithSongs.from_dict(json).to_dict()).is_equal(json)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbuyu23x8rxbv
|
||||
@@ -1,5 +1,6 @@
|
||||
extends GdUnitTestSuite
|
||||
## MusicServerClient: which paths it asks for and how it handles the answers.
|
||||
## MusicServerClient: which paths it asks for, how it handles the answers and how it
|
||||
## gets and renews its API token.
|
||||
|
||||
var _server: FakeHttpTransport
|
||||
|
||||
@@ -12,16 +13,50 @@ func after_test() -> void:
|
||||
_server.uninstall()
|
||||
|
||||
|
||||
func test_fetch_song_list_passes_parsed_json() -> void:
|
||||
func test_fetch_song_list_passes_song_infos() -> void:
|
||||
var songs: Array = [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}]
|
||||
_server.respond_json("/music/list", songs)
|
||||
var received: Array = []
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_list(func(data: Array) -> void: received.append_array(data))
|
||||
MusicServerClient.fetch_song_list(
|
||||
func(data: Array[SongInfo]) -> void: received.append_array(data))
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_int(int(received[0].SongNo)).is_equal(0)
|
||||
assert_str(received[0].Soundtrack).is_equal("Chrono Trigger")
|
||||
assert_int(received[0].song_no).is_equal(0)
|
||||
assert_str(received[0].soundtrack).is_equal("Chrono Trigger")
|
||||
assert_str(received[0].song).is_equal("Corridors of Time")
|
||||
|
||||
|
||||
func test_fetch_song_info_passes_song_info() -> void:
|
||||
_server.respond_json("/music/info", {"SongNo": 7, "Soundtrack": "Terranigma",
|
||||
"Song": "Underworld", "SoundtrackUuid": "st-1", "SongUuid": "so-1"})
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info))
|
||||
|
||||
assert_int(received[0].song_no).is_equal(7)
|
||||
assert_str(received[0].song_uuid).is_equal("so-1")
|
||||
|
||||
|
||||
func test_empty_song_list_sent_as_null_gives_empty_array() -> void:
|
||||
_server.respond_bytes("/music/list", "null".to_utf8_buffer())
|
||||
var received: Array = []
|
||||
|
||||
MusicServerClient.fetch_song_list(func(data: Array[SongInfo]) -> void: received.append(data))
|
||||
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_array(received[0]).is_empty()
|
||||
|
||||
|
||||
func test_json_list_that_is_not_an_array_calls_on_failed() -> void:
|
||||
_server.respond_json("/music/list", {"error": "not a list"})
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_list(
|
||||
func(_data: Array[SongInfo]) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_fetch_random_song_uses_normal_endpoint() -> void:
|
||||
@@ -59,7 +94,7 @@ func test_invalid_json_does_not_call_back() -> void:
|
||||
_server.respond_bytes("/music/info", "not json".to_utf8_buffer())
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: Variant) -> void: called[0] = true)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_false()
|
||||
|
||||
@@ -80,7 +115,7 @@ func test_invalid_json_calls_on_failed() -> void:
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: Variant) -> void: outcome.append("done"),
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
@@ -122,17 +157,370 @@ func test_character_name_is_url_encoded() -> void:
|
||||
assert_array(_server.requested_paths).contains_exactly(["/character?name=Paper%20Mario.png"])
|
||||
|
||||
|
||||
func test_requests_go_to_selected_server() -> void:
|
||||
func test_start_sync_sends_post() -> void:
|
||||
_server.respond_json("/sync", "Sync started")
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.start_sync(func() -> void: called[0] = true)
|
||||
|
||||
assert_array(_server.requested_paths).contains_exactly(["/sync"])
|
||||
assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST])
|
||||
assert_bool(called[0]).is_true()
|
||||
|
||||
|
||||
func test_start_full_sync_sends_post() -> void:
|
||||
MusicServerClient.start_full_sync(func() -> void: pass)
|
||||
|
||||
assert_array(_server.requested_paths).contains_exactly(["/sync/full"])
|
||||
assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST])
|
||||
|
||||
|
||||
func test_sync_already_running_calls_on_failed() -> void:
|
||||
_server.respond_json("/sync", {"error": "Sync is already running"}, 423)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.start_sync(
|
||||
func() -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_requests_go_to_selected_server_under_api_path() -> void:
|
||||
var previous_path: String = Settings.default_path
|
||||
Settings.default_path = "http://localhost:8080"
|
||||
var requested_urls: Array[String] = []
|
||||
var recording_transport: HttpTransport = _RecordingTransport.new(requested_urls)
|
||||
MusicServerClient.transport = recording_transport
|
||||
MusicServerClient.transport = _RecordingTransport.new(requested_urls)
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: Variant) -> void: pass)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.default_path = previous_path
|
||||
assert_array(requested_urls).contains_exactly(["http://localhost:8080/music/info"])
|
||||
assert_array(requested_urls).contains_exactly([
|
||||
"http://localhost:8080/api/v1/token",
|
||||
"http://localhost:8080/api/v1/music/info",
|
||||
])
|
||||
|
||||
|
||||
# Tokens
|
||||
|
||||
func test_request_sends_token_in_authorization_header() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(_server.requested_headers[0]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1"])
|
||||
|
||||
|
||||
func test_token_request_sends_password_and_client_type() -> void:
|
||||
var previous_password: String = Settings.api_password
|
||||
Settings.api_password = "secret"
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.api_password = previous_password
|
||||
assert_dict(_server.last_token_request) \
|
||||
.is_equal({"password": "secret", "client_type": "godot"})
|
||||
|
||||
|
||||
func test_token_is_reused() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
_server.respond_json("/music/list", [])
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(_server.requested_paths) \
|
||||
.contains_exactly(["/music/info", "/music/list", "/music/info"])
|
||||
|
||||
|
||||
func test_requests_while_getting_a_token_share_it() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.hold("/token")
|
||||
var done: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: done.append("info"))
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: done.append("list"))
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
_server.release("/token")
|
||||
|
||||
assert_int(_server.token_requests).is_equal(1)
|
||||
assert_array(done).contains_exactly(["info", "list"])
|
||||
|
||||
|
||||
func test_expired_token_is_renewed_and_request_sent_again() -> void:
|
||||
_server.respond_json("/music/info", {"SongNo": 3})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
_server.expire_tokens()
|
||||
var received: Array[SongInfo] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_array(_server.requested_paths) \
|
||||
.contains_exactly(["/music/info", "/music/info", "/music/info"])
|
||||
assert_array(_server.requested_headers[2]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-2"])
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_int(received[0].song_no).is_equal(3)
|
||||
|
||||
|
||||
func test_request_is_sent_again_only_once() -> void:
|
||||
_server.respond_json("/music/info", {"error": "Unauthorized"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_int(_server.count("/music/info")).is_equal(2)
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_refused_password_calls_on_failed_without_sending_request() -> void:
|
||||
_server.respond_json("/token", {"error": "Invalid password"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.fetch_song_info(
|
||||
func(_data: SongInfo) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_rate_limited_token_request_calls_on_failed() -> void:
|
||||
_server.respond_json("/token", {"error": "Too many requests"}, 429)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
MusicServerClient.add_to_queue(
|
||||
func() -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed"])
|
||||
|
||||
|
||||
func test_new_token_after_a_failed_token_request() -> void:
|
||||
_server.respond_json("/token", {"error": "Too many requests"}, 429)
|
||||
MusicServerClient.add_to_queue(func() -> void: pass)
|
||||
_server.respond_json("/token", {"token": "second", "expires_at": "", "client_type": "godot"})
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.expire_tokens()
|
||||
|
||||
MusicServerClient.add_to_queue(func() -> void: pass)
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
assert_array(_server.requested_headers.back()) \
|
||||
.contains_exactly(["Authorization: Bearer second"])
|
||||
|
||||
|
||||
func test_changing_server_gets_a_new_token() -> void:
|
||||
var previous_path: String = Settings.default_path
|
||||
_server.respond_json("/music/info", {})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
|
||||
Settings.default_path = "http://localhost:8080"
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
Settings.default_path = previous_path
|
||||
|
||||
assert_int(_server.token_requests).is_equal(2)
|
||||
|
||||
|
||||
func test_health_needs_no_token() -> void:
|
||||
_server.respond_json("/health", {"status": "ok"})
|
||||
var received: Array = []
|
||||
|
||||
MusicServerClient.fetch_health(func(data: Dictionary) -> void: received.append(data))
|
||||
|
||||
assert_int(_server.token_requests).is_equal(0)
|
||||
assert_array(_server.requested_headers[0]).is_empty()
|
||||
assert_array(received).contains_exactly([{"status": "ok"}])
|
||||
|
||||
|
||||
func test_delete_token_sends_token_and_forgets_it() -> void:
|
||||
_server.respond_json("/music/info", {})
|
||||
MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
|
||||
_server.respond_json("/token", {"status": "deleted"})
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.delete_token(func() -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_true()
|
||||
assert_int(_server.requested_methods.back()).is_equal(HTTPClient.METHOD_DELETE)
|
||||
assert_array(_server.requested_headers.back()) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1"])
|
||||
|
||||
|
||||
func test_delete_token_without_token_sends_nothing() -> void:
|
||||
var called: Array[bool] = [false]
|
||||
|
||||
MusicServerClient.delete_token(func() -> void: called[0] = true)
|
||||
|
||||
assert_bool(called[0]).is_true()
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
|
||||
|
||||
# Other endpoints
|
||||
|
||||
func test_endpoint_paths_and_methods() -> void:
|
||||
var noop: Callable = func(_data: Variant = null) -> void: pass
|
||||
var calls: Array[Array] = [
|
||||
[func() -> void: MusicServerClient.fetch_random_song_classic(noop),
|
||||
"/music/rand/classic", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_next_song(noop),
|
||||
"/music/next", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_previous_song(noop),
|
||||
"/music/previous", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_sound_test_song(noop),
|
||||
"/music/soundTest", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.mark_current_song_played(noop),
|
||||
"/music/addPlayed", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_all_soundtracks(noop),
|
||||
"/music/all", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_all_soundtracks_ordered(noop),
|
||||
"/music/all/order", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_soundtracks(noop),
|
||||
"/soundtracks", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_soundtrack("a b", noop),
|
||||
"/soundtracks/a%20b", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_song_by_uuid("so-1", noop),
|
||||
"/songs/so-1", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_song_file("so-1", noop),
|
||||
"/songs/so-1/file", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_character_list(noop),
|
||||
"/character/list", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_sync_progress(noop),
|
||||
"/sync/progress", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_version(noop),
|
||||
"/version", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_version_history(noop),
|
||||
"/version/history", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_latest_client_version(noop),
|
||||
"/download", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_client_assets(noop),
|
||||
"/download/list", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.download_client_windows(noop),
|
||||
"/download/windows", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.download_client_linux(noop),
|
||||
"/download/linux", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.cleanup_tokens(noop),
|
||||
"/token/cleanup", HTTPClient.METHOD_POST],
|
||||
[func() -> void: MusicServerClient.fetch_statistics_summary(noop),
|
||||
"/statistics/summary", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_most_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/most-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_least_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/least-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_last_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/last-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_oldest_played_soundtracks(5, noop),
|
||||
"/statistics/soundtracks/oldest-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_never_played_soundtracks(noop),
|
||||
"/statistics/soundtracks/never-played", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_most_played_songs(5, noop),
|
||||
"/statistics/songs/most-played?limit=5", HTTPClient.METHOD_GET],
|
||||
[func() -> void: MusicServerClient.fetch_least_played_songs(5, noop),
|
||||
"/statistics/songs/least-played?limit=5", HTTPClient.METHOD_GET],
|
||||
]
|
||||
for entry: Array in calls:
|
||||
_server.requested_paths.clear()
|
||||
_server.requested_methods.clear()
|
||||
entry[0].call()
|
||||
assert_array(_server.requested_paths).contains_exactly([entry[1]])
|
||||
assert_array(_server.requested_methods).contains_exactly([entry[2]])
|
||||
|
||||
|
||||
func test_statistics_summary_is_typed() -> void:
|
||||
_server.respond_json("/statistics/summary", {"total_soundtracks": 120,
|
||||
"played_soundtracks": 100, "avg_soundtrack_plays": 2.5})
|
||||
var received: Array[StatisticsSummary] = []
|
||||
|
||||
MusicServerClient.fetch_statistics_summary(
|
||||
func(summary: StatisticsSummary) -> void: received.append(summary))
|
||||
|
||||
assert_int(received[0].total_soundtracks).is_equal(120)
|
||||
assert_int(received[0].played_soundtracks).is_equal(100)
|
||||
assert_float(received[0].avg_soundtrack_plays).is_equal(2.5)
|
||||
|
||||
|
||||
func test_most_played_soundtracks_are_typed_with_their_songs() -> void:
|
||||
_server.respond_json("/statistics/soundtracks/most-played?limit=2", [
|
||||
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger",
|
||||
"soundtrack_played": 9, "songs": [{"song_name": "Frog's Theme"}]},
|
||||
{"soundtrack_uuid": "st-2", "soundtrack_name": "Lufia II", "soundtrack_played": 4,
|
||||
"songs": []},
|
||||
])
|
||||
var received: Array[SoundtrackWithSongs] = []
|
||||
|
||||
MusicServerClient.fetch_most_played_soundtracks(2,
|
||||
func(list: Array[SoundtrackWithSongs]) -> void: received.append_array(list))
|
||||
|
||||
assert_int(received.size()).is_equal(2)
|
||||
assert_str(received[0].soundtrack_name).is_equal("Chrono Trigger")
|
||||
assert_int(received[0].soundtrack_played).is_equal(9)
|
||||
assert_str(received[0].songs[0].song_name).is_equal("Frog's Theme")
|
||||
assert_array(received[1].songs).is_empty()
|
||||
|
||||
|
||||
func test_most_played_songs_are_typed() -> void:
|
||||
_server.respond_json("/statistics/songs/most-played?limit=1", [
|
||||
{"song_uuid": "so-1", "song_name": "Dancing Mad", "times_played": 12},
|
||||
])
|
||||
var received: Array[SongInfoForStats] = []
|
||||
|
||||
MusicServerClient.fetch_most_played_songs(1,
|
||||
func(list: Array[SongInfoForStats]) -> void: received.append_array(list))
|
||||
|
||||
assert_str(received[0].song_name).is_equal("Dancing Mad")
|
||||
assert_int(received[0].times_played).is_equal(12)
|
||||
|
||||
|
||||
func test_statistics_calls_on_failed_on_server_error() -> void:
|
||||
var outcome: Array[String] = []
|
||||
var on_done: Callable = func(_data: Variant) -> void: outcome.append("done")
|
||||
var on_failed: Callable = func() -> void: outcome.append("failed")
|
||||
for path: String in ["/statistics/summary",
|
||||
"/statistics/soundtracks/most-played?limit=3",
|
||||
"/statistics/soundtracks/least-played?limit=3",
|
||||
"/statistics/soundtracks/last-played?limit=3",
|
||||
"/statistics/soundtracks/oldest-played?limit=3",
|
||||
"/statistics/soundtracks/never-played",
|
||||
"/statistics/songs/most-played?limit=3",
|
||||
"/statistics/songs/least-played?limit=3"]:
|
||||
_server.respond_json(path, {"error": "Internal Server Error"}, 500)
|
||||
|
||||
_call_every_statistics_endpoint(on_done, on_failed)
|
||||
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed",
|
||||
"failed", "failed", "failed"])
|
||||
|
||||
|
||||
func test_statistics_calls_on_failed_when_token_is_refused() -> void:
|
||||
_server.respond_json("/token", {"error": "Invalid password"}, 401)
|
||||
var outcome: Array[String] = []
|
||||
|
||||
_call_every_statistics_endpoint(func(_data: Variant) -> void: outcome.append("done"),
|
||||
func() -> void: outcome.append("failed"))
|
||||
|
||||
assert_array(_server.requested_paths).is_empty()
|
||||
assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed",
|
||||
"failed", "failed", "failed"])
|
||||
|
||||
|
||||
func _call_every_statistics_endpoint(on_done: Callable, on_failed: Callable) -> void:
|
||||
MusicServerClient.fetch_statistics_summary(on_done, on_failed)
|
||||
MusicServerClient.fetch_most_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_least_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_last_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_oldest_played_soundtracks(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_never_played_soundtracks(on_done, on_failed)
|
||||
MusicServerClient.fetch_most_played_songs(3, on_done, on_failed)
|
||||
MusicServerClient.fetch_least_played_songs(3, on_done, on_failed)
|
||||
|
||||
|
||||
class _RecordingTransport:
|
||||
@@ -144,5 +532,13 @@ class _RecordingTransport:
|
||||
super(null)
|
||||
_urls = urls
|
||||
|
||||
func request(url: String, _method: int, _on_complete: Callable) -> void:
|
||||
func request(
|
||||
url: String,
|
||||
_method: int,
|
||||
on_complete: Callable,
|
||||
_headers: PackedStringArray = PackedStringArray(),
|
||||
_body: String = "",
|
||||
) -> void:
|
||||
_urls.append(url)
|
||||
if url.ends_with("/token"):
|
||||
on_complete.call(200, JSON.stringify({"token": "t"}).to_utf8_buffer())
|
||||
@@ -0,0 +1,262 @@
|
||||
extends SceneTree
|
||||
## Generates a typed class for every schema in the MusicServer OpenAPI spec.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . -s tools/generate_api_types.gd [-- <spec>]
|
||||
##
|
||||
## [code]<spec>[/code] defaults to [code]server/openapi.json[/code]. To update it, run
|
||||
## [code]just openapi[/code] in MusicServer, or save [code]<server>/openapi.json[/code]
|
||||
## from a running server. Each schema becomes [code]server/generated/<name>.gd[/code]
|
||||
## with typed properties, [code]from_dict()[/code] and [code]to_dict()[/code]. Files
|
||||
## for schemas that no longer exist are removed. The output is sorted, so running it
|
||||
## again without API changes gives no diff.
|
||||
|
||||
const DEFAULT_SPEC_PATH: String = "res://server/openapi.json"
|
||||
const OUTPUT_DIR: String = "res://server/generated"
|
||||
const HELPER_CLASS: String = "ApiJson"
|
||||
const MAX_LINE_LENGTH: int = 100
|
||||
const HEADER: String = "# Generated by tools/generate_api_types.gd from server/openapi.json.\n" \
|
||||
+ "# Do not edit; change the server and generate again.\n"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var args: PackedStringArray = OS.get_cmdline_user_args()
|
||||
var spec_path: String = args[0] if !args.is_empty() else DEFAULT_SPEC_PATH
|
||||
var spec: Variant = JSON.parse_string(FileAccess.get_file_as_string(spec_path))
|
||||
if !(spec is Dictionary) || !spec.has("components"):
|
||||
push_error("Could not read an OpenAPI spec from " + spec_path)
|
||||
quit(1)
|
||||
return
|
||||
var schemas: Dictionary = spec.components.get("schemas", {})
|
||||
DirAccess.make_dir_recursive_absolute(OUTPUT_DIR)
|
||||
|
||||
var written: Array[String] = [_file_name(HELPER_CLASS)]
|
||||
_write(_file_name(HELPER_CLASS), _helper_source())
|
||||
var schema_names: Array = schemas.keys()
|
||||
schema_names.sort()
|
||||
for schema_name: String in schema_names:
|
||||
var file_name: String = _file_name(schema_name)
|
||||
_write(file_name, _class_source(schema_name, schemas[schema_name]))
|
||||
written.append(file_name)
|
||||
_remove_stale_files(written)
|
||||
print("Wrote %d classes to %s" % [written.size(), OUTPUT_DIR])
|
||||
quit()
|
||||
|
||||
|
||||
func _class_source(schema_name: String, schema: Dictionary) -> String:
|
||||
var class_id: String = _class_name(schema_name)
|
||||
var properties: Dictionary = schema.get("properties", {})
|
||||
var keys: Array = properties.keys()
|
||||
keys.sort()
|
||||
|
||||
var lines: PackedStringArray = [
|
||||
HEADER + "class_name " + class_id,
|
||||
"extends RefCounted",
|
||||
"## The [code]%s[/code] schema of the MusicServer API." % schema_name,
|
||||
"",
|
||||
]
|
||||
for key: String in keys:
|
||||
var property: Dictionary = properties[key]
|
||||
if property.has("description"):
|
||||
lines.append("## " + property.description)
|
||||
lines.append("var %s: %s%s" % [_property_name(key), _gd_type(property),
|
||||
_initial_value(property)])
|
||||
|
||||
lines.append_array([
|
||||
"",
|
||||
"",
|
||||
"## Reads the fields from parsed JSON. Missing fields keep their defaults.",
|
||||
"static func from_dict(data: Dictionary) -> %s:" % class_id,
|
||||
"\tvar result: %s = %s.new()" % [class_id, class_id],
|
||||
])
|
||||
for key: String in keys:
|
||||
lines.append_array(_read_lines(key, properties[key]))
|
||||
lines.append("\treturn result")
|
||||
|
||||
lines.append_array([
|
||||
"",
|
||||
"",
|
||||
"## Returns the fields under their JSON names, ready for [method JSON.stringify].",
|
||||
"func to_dict() -> Dictionary:",
|
||||
])
|
||||
if keys.is_empty():
|
||||
lines.append("\treturn {}")
|
||||
else:
|
||||
lines.append("\treturn {")
|
||||
for key: String in keys:
|
||||
lines.append("\t\t\"%s\": %s," % [key, _write_value(key, properties[key])])
|
||||
lines.append("\t}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
func _read_lines(key: String, property: Dictionary) -> PackedStringArray:
|
||||
var target: String = "result." + _property_name(key)
|
||||
var quoted_key: String = "\"%s\"" % key
|
||||
if property.has("$ref"):
|
||||
return ["\t%s = %s.from_dict(%s.get_dictionary(data, %s))"
|
||||
% [target, _ref_class(property), HELPER_CLASS, quoted_key]]
|
||||
match property.get("type"):
|
||||
"array":
|
||||
var items: Dictionary = property.get("items", {})
|
||||
if items.has("$ref"):
|
||||
return [
|
||||
"\tfor item: Variant in %s.get_array(data, %s):" % [HELPER_CLASS, quoted_key],
|
||||
"\t\tif item is Dictionary:",
|
||||
"\t\t\t%s.append(%s.from_dict(item))" % [target, _ref_class(items)],
|
||||
]
|
||||
return ["\t%s.assign(%s.get_array(data, %s))" % [target, HELPER_CLASS, quoted_key]]
|
||||
"object":
|
||||
return ["\t%s.assign(%s.get_dictionary(data, %s))"
|
||||
% [target, HELPER_CLASS, quoted_key]]
|
||||
"integer":
|
||||
return ["\t%s = %s.get_int(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
"number":
|
||||
return ["\t%s = %s.get_float(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
"boolean":
|
||||
return ["\t%s = %s.get_bool(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
_:
|
||||
return ["\t%s = %s.get_string(data, %s, %s)"
|
||||
% [target, HELPER_CLASS, quoted_key, _default(property)]]
|
||||
|
||||
|
||||
func _write_value(key: String, property: Dictionary) -> String:
|
||||
var value: String = _property_name(key)
|
||||
if property.has("$ref"):
|
||||
return value + ".to_dict()"
|
||||
if property.get("type") == "array" && property.get("items", {}).has("$ref"):
|
||||
return "%s.map(func(item: %s) -> Dictionary: return item.to_dict())" \
|
||||
% [value, _ref_class(property.items)]
|
||||
return value
|
||||
|
||||
|
||||
func _gd_type(property: Dictionary) -> String:
|
||||
if property.has("$ref"):
|
||||
return _ref_class(property)
|
||||
match property.get("type"):
|
||||
"array":
|
||||
return "Array[%s]" % _gd_type(property.get("items", {}))
|
||||
"object":
|
||||
var values: Variant = property.get("additionalProperties")
|
||||
if values is Dictionary && !values.is_empty():
|
||||
return "Dictionary[String, %s]" % _gd_type(values)
|
||||
return "Dictionary"
|
||||
"integer":
|
||||
return "int"
|
||||
"number":
|
||||
return "float"
|
||||
"boolean":
|
||||
return "bool"
|
||||
"string":
|
||||
return "String"
|
||||
return "Variant"
|
||||
|
||||
|
||||
func _initial_value(property: Dictionary) -> String:
|
||||
if property.has("$ref"):
|
||||
return " = %s.new()" % _ref_class(property)
|
||||
if property.has("default"):
|
||||
return " = " + _default(property)
|
||||
return ""
|
||||
|
||||
|
||||
# The default value as GDScript source
|
||||
func _default(property: Dictionary) -> String:
|
||||
var type: String = _gd_type(property)
|
||||
if property.has("default"):
|
||||
var value: Variant = property.default
|
||||
if type == "int":
|
||||
return str(int(value))
|
||||
return JSON.stringify(value)
|
||||
match type:
|
||||
"int":
|
||||
return "0"
|
||||
"float":
|
||||
return "0.0"
|
||||
"bool":
|
||||
return "false"
|
||||
return "\"\""
|
||||
|
||||
|
||||
func _ref_class(property: Dictionary) -> String:
|
||||
return _class_name(String(property["$ref"]).get_file())
|
||||
|
||||
|
||||
func _class_name(schema_name: String) -> String:
|
||||
return schema_name.to_snake_case().to_pascal_case()
|
||||
|
||||
|
||||
func _property_name(key: String) -> String:
|
||||
return key.to_snake_case()
|
||||
|
||||
|
||||
func _file_name(schema_name: String) -> String:
|
||||
return schema_name.to_snake_case() + ".gd"
|
||||
|
||||
|
||||
func _helper_source() -> String:
|
||||
return HEADER + """class_name ApiJson
|
||||
extends RefCounted
|
||||
## Reads fields from parsed JSON for the generated API classes. A field that is
|
||||
## missing, null or of the wrong type gives the default instead.
|
||||
|
||||
|
||||
static func get_string(data: Dictionary, key: String, default: String = "") -> String:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is String else default
|
||||
|
||||
|
||||
static func get_int(data: Dictionary, key: String, default: int = 0) -> int:
|
||||
var value: Variant = data.get(key)
|
||||
return int(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_float(data: Dictionary, key: String, default: float = 0.0) -> float:
|
||||
var value: Variant = data.get(key)
|
||||
return float(value) if value is int || value is float else default
|
||||
|
||||
|
||||
static func get_bool(data: Dictionary, key: String, default: bool = false) -> bool:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is bool else default
|
||||
|
||||
|
||||
static func get_array(data: Dictionary, key: String) -> Array:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Array else []
|
||||
|
||||
|
||||
static func get_dictionary(data: Dictionary, key: String) -> Dictionary:
|
||||
var value: Variant = data.get(key)
|
||||
return value if value is Dictionary else {}
|
||||
"""
|
||||
|
||||
|
||||
func _write(file_name: String, source: String) -> void:
|
||||
var lines: PackedStringArray = []
|
||||
for line: String in source.split("\n"):
|
||||
lines.append(_wrap(line))
|
||||
var file: FileAccess = FileAccess.open(OUTPUT_DIR.path_join(file_name), FileAccess.WRITE)
|
||||
file.store_string("\n".join(lines))
|
||||
file.close()
|
||||
|
||||
|
||||
# Breaks a statement longer than the style guide's 100 characters after its first
|
||||
# opening parenthesis, with the continuation two levels deeper
|
||||
func _wrap(line: String) -> String:
|
||||
var indent: int = line.length() - line.lstrip("\t").length()
|
||||
var split_at: int = line.find("(")
|
||||
if line.replace("\t", " ").length() <= MAX_LINE_LENGTH || split_at < 0 \
|
||||
|| line.strip_edges().begins_with("#"):
|
||||
return line
|
||||
return line.substr(0, split_at + 1) + "\n" + "\t".repeat(indent + 2) \
|
||||
+ line.substr(split_at + 1)
|
||||
|
||||
|
||||
func _remove_stale_files(written: Array[String]) -> void:
|
||||
for file_name: String in DirAccess.get_files_at(OUTPUT_DIR):
|
||||
var script_name: String = file_name.trim_suffix(".uid")
|
||||
if script_name.ends_with(".gd") && !written.has(script_name):
|
||||
DirAccess.remove_absolute(OUTPUT_DIR.path_join(file_name))
|
||||
@@ -0,0 +1 @@
|
||||
uid://cummnobkmb3le
|
||||
Reference in new issue
Block a user