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:
Sansan committed 2026-10-06 22:45:28 +02:00
1 parent 8b71723a34
commit f7619baf34
44 files changed
+5161 -71

No files matched your search

+262
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
uid://cummnobkmb3le