Compare commits
3
Commits
2c3e97190d
...
973ebf224b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973ebf224b | ||
|
|
0ec2425f78 | ||
|
|
bc7d406e41 |
No files matched your search
@@ -6,7 +6,7 @@ tmp_dir = "tmp"
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ./cmd"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "swagger", "cmd/swagger"]
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
|
||||
-1780
File diff suppressed because it is too large.
Load diff
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
def load_openapi_spec(path: str) -> dict:
|
||||
"""Load OpenAPI spec from YAML or JSON file."""
|
||||
try:
|
||||
import yaml
|
||||
with open(path, "r") as f:
|
||||
return yaml.safe_load(f)
|
||||
except ImportError:
|
||||
# Fallback to JSON if PyYAML is not installed
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def map_type(openapi_type: str) -> str:
|
||||
"""Map OpenAPI types to GDScript types."""
|
||||
type_mapping = {
|
||||
"integer": "int",
|
||||
"number": "float",
|
||||
"boolean": "bool",
|
||||
"array": "Array",
|
||||
"object": "Dictionary",
|
||||
}
|
||||
return type_mapping.get(openapi_type, "String")
|
||||
|
||||
def default_value(openapi_type: str):
|
||||
"""Return default values for GDScript types."""
|
||||
default_mapping = {
|
||||
"integer": "0",
|
||||
"number": "0.0",
|
||||
"boolean": "false",
|
||||
"array": "[]",
|
||||
"object": "{}",
|
||||
}
|
||||
return default_mapping.get(openapi_type, '""')
|
||||
|
||||
def sanitize_class_name(name: str) -> str:
|
||||
"""Convert a name to a valid GDScript class name."""
|
||||
# Replace invalid characters with underscores
|
||||
name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
||||
# Capitalize first letter
|
||||
return name[0].upper() + name[1:] if name else "Model"
|
||||
|
||||
def generate_model_class(class_name: str, schema: dict) -> str:
|
||||
"""Generate a GDScript class for a model."""
|
||||
lines = [
|
||||
f"class_name {class_name}",
|
||||
"extends RefCounted",
|
||||
"",
|
||||
]
|
||||
|
||||
# Add properties
|
||||
properties = schema.get("properties", {})
|
||||
for prop_name, prop_schema in properties.items():
|
||||
prop_type = map_type(prop_schema.get("type", "string"))
|
||||
lines.append(f"var {prop_name}: {prop_type}")
|
||||
|
||||
# Add _init method
|
||||
lines.extend([
|
||||
"",
|
||||
"func _init(data: Dictionary):",
|
||||
])
|
||||
for prop_name in properties:
|
||||
prop_type = map_type(properties[prop_name].get("type", "string"))
|
||||
default = default_value(properties[prop_name].get("type", "string"))
|
||||
lines.append(f' {prop_name} = data.get("{prop_name}", {default})')
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_api_client(path: str, method: str, endpoint: dict) -> str:
|
||||
"""Generate a GDScript API client for an endpoint."""
|
||||
# Sanitize path for class name
|
||||
class_name = sanitize_class_name(path.replace("/", "_").replace("{", "").replace("}", "")) + method.capitalize()
|
||||
# Format URL (replace {param} with %s for Godot's string formatting)
|
||||
url = path.replace("{", "%").replace("}", "s")
|
||||
full_url = f'"{BASE_URL}{url}"'
|
||||
|
||||
lines = [
|
||||
f"class_name {class_name}",
|
||||
"extends RefCounted",
|
||||
"",
|
||||
"var http_request: HTTPRequest",
|
||||
"",
|
||||
"func _init(node: Node):",
|
||||
" http_request = HTTPRequest.new()",
|
||||
" node.add_child(http_request)",
|
||||
' http_request.connect("request_completed", self, "_on_request_completed")',
|
||||
"",
|
||||
f"func call(params: Dictionary, callback: Callable):",
|
||||
f" var url := {full_url}",
|
||||
' var headers = ["User-Agent: MyGodotApp"]',
|
||||
" var error := http_request.request(url, headers)",
|
||||
" if error != OK:",
|
||||
' push_error("HTTP request failed.")',
|
||||
" return",
|
||||
" http_request.set_meta(\"callback\", callback)",
|
||||
"",
|
||||
"func _on_request_completed(result: int, response_code: int, headers: PoolStringArray, body: PoolByteArray):",
|
||||
" var callback := http_request.get_meta(\"callback\")",
|
||||
" if callback:",
|
||||
" var response_body = body.get_string_from_utf8()",
|
||||
" var json = JSON.new()",
|
||||
" if json.parse(response_body) == OK:",
|
||||
" callback.call(json.get_data())",
|
||||
" else:",
|
||||
" callback.call(null)",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_tag_client(tag: str, endpoints: list) -> str:
|
||||
"""Generate a GDScript API client for all endpoints with a given tag."""
|
||||
class_name = sanitize_class_name(tag) + "API"
|
||||
|
||||
lines = [
|
||||
f"class_name {class_name}",
|
||||
"extends RefCounted",
|
||||
"",
|
||||
"var http_request: HTTPRequest",
|
||||
"var base_url: String",
|
||||
"",
|
||||
"func _init(node: Node, base_url_param: String):",
|
||||
" http_request = HTTPRequest.new()",
|
||||
" node.add_child(http_request)",
|
||||
' http_request.connect("request_completed", self, "_on_request_completed")',
|
||||
" base_url = base_url_param",
|
||||
"",
|
||||
]
|
||||
|
||||
# Generate a method for each endpoint
|
||||
for path, method, endpoint in endpoints:
|
||||
# Sanitize method name for GDScript
|
||||
method_name = method.lower()
|
||||
# Create a valid function name from the path
|
||||
func_name = "call_" + path.replace("/", "_").replace("{", "").replace("}", "").replace("-", "_")
|
||||
|
||||
# Format URL (replace {param} with %s for Godot's string formatting)
|
||||
url = path.replace("{", "%").replace("}", "s")
|
||||
|
||||
lines.extend([
|
||||
f"func {func_name}(params: Dictionary = {{}}, callback: Callable):",
|
||||
f' var url := base_url + "{url}"',
|
||||
' var headers = ["User-Agent: MyGodotApp"]',
|
||||
f" var error := http_request.request(url, headers, false, HTTPClient.METHOD_{method.upper()})",
|
||||
" if error != OK:",
|
||||
' push_error("HTTP request failed.")',
|
||||
" return",
|
||||
" http_request.set_meta(\"callback\", callback)",
|
||||
"",
|
||||
])
|
||||
|
||||
# Add the completion handler
|
||||
lines.extend([
|
||||
"func _on_request_completed(result: int, response_code: int, headers: PoolStringArray, body: PoolByteArray):",
|
||||
" var callback := http_request.get_meta(\"callback\")",
|
||||
" if callback:",
|
||||
" var response_body = body.get_string_from_utf8()",
|
||||
" var json = JSON.new()",
|
||||
" if json.parse(response_body) == OK:",
|
||||
" callback.call(json.get_data())",
|
||||
" else:",
|
||||
" callback.call(null)",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_code(spec: dict, output_dir: str):
|
||||
"""Generate all GDScript files from the OpenAPI spec."""
|
||||
# Create output directory
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate models
|
||||
schemas = spec.get("definitions", {}) # Swagger 2.0 uses "definitions"
|
||||
if not schemas:
|
||||
schemas = spec.get("components", {}).get("schemas", {})
|
||||
|
||||
for schema_name, schema in schemas.items():
|
||||
class_name = sanitize_class_name(schema_name)
|
||||
code = generate_model_class(class_name, schema)
|
||||
output_path = Path(output_dir) / f"{class_name}.gd"
|
||||
with open(output_path, "w") as f:
|
||||
f.write(code)
|
||||
print(f"Generated model: {output_path}")
|
||||
|
||||
# Group endpoints by tag
|
||||
paths = spec.get("paths", {})
|
||||
tag_endpoints = defaultdict(list)
|
||||
|
||||
for path, methods in paths.items():
|
||||
for method, endpoint in methods.items():
|
||||
tags = endpoint.get("tags", ["default"])
|
||||
for tag in tags:
|
||||
tag_endpoints[tag].append((path, method, endpoint))
|
||||
|
||||
# Generate one file per tag
|
||||
for tag, endpoints in tag_endpoints.items():
|
||||
code = generate_tag_client(tag, endpoints)
|
||||
class_name = sanitize_class_name(tag) + "API"
|
||||
output_path = Path(output_dir) / f"{class_name}.gd"
|
||||
with open(output_path, "w") as f:
|
||||
f.write(code)
|
||||
print(f"Generated API client for tag '{tag}': {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate Godot API clients from OpenAPI spec")
|
||||
parser.add_argument("input", help="Path to the OpenAPI JSON/YAML file")
|
||||
parser.add_argument("-o", "--output", default="godot_generated", help="Output directory for GDScript files")
|
||||
args = parser.parse_args()
|
||||
|
||||
spec = load_openapi_spec(args.input)
|
||||
generate_code(spec, args.output)
|
||||
print("Done!")
|
||||
print("Note: When initializing the API classes, pass the base URL as a parameter:")
|
||||
print(" var music_api = MusicAPI.new()")
|
||||
print(" music_api._init(get_node('/root'), 'http://localhost:8080')")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -8,28 +8,27 @@ require (
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/labstack/echo/v5 v5.1.1
|
||||
github.com/labstack/echo/v5 v5.3.0
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/echo-swagger/v2 v2.0.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
github.com/testcontainers/testcontainers-go v0.42.0
|
||||
go.uber.org/zap v1.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1
|
||||
github.com/getkin/kin-openapi v0.149.0
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sync v0.22.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
@@ -38,7 +37,6 @@ require (
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
@@ -50,25 +48,20 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.19.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.15.4 // indirect
|
||||
github.com/labstack/gommon v0.5.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.54.1 // indirect
|
||||
@@ -79,20 +72,19 @@ require (
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/natefinch/atomic v1.0.1 // indirect
|
||||
github.com/oasdiff/yaml v0.1.1 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.14 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sv-tools/openapi v0.4.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.5 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
@@ -100,20 +92,14 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/a-h/templ/cmd/templ
|
||||
github.com/swaggo/swag/cmd/swag
|
||||
)
|
||||
tool github.com/a-h/templ/cmd/templ
|
||||
@@ -4,8 +4,6 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/MShekow/directory-checksum v1.4.18 h1:1nPPVl7uREa6WMTAPKoWW/GylhnASs0C9C+GPiwLwXA=
|
||||
github.com/MShekow/directory-checksum v1.4.18/go.mod h1:iUupsPb0X0BumQQymLrpD5Pkqe/CbV13OSgosw1oFc4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -14,8 +12,8 @@ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ6
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -32,10 +30,10 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g=
|
||||
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -43,6 +41,8 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
@@ -57,6 +57,12 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
|
||||
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -66,33 +72,12 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
||||
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
|
||||
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
|
||||
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
|
||||
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
|
||||
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
|
||||
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
@@ -102,6 +87,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -112,25 +99,28 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v5 v5.1.1 h1:4QkvKoS8ps5ch49t8b72QS9Z581ytgxhTzxuB/CBA2I=
|
||||
github.com/labstack/echo/v5 v5.1.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
|
||||
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
|
||||
github.com/labstack/echo/v5 v5.3.0 h1:KT74Mprk053PQEHwSZdeCDIz1BigTZOZhavMD0c9Fjs=
|
||||
github.com/labstack/echo/v5 v5.3.0/go.mod h1:Q3j2+clBRgJr0O3DDONQeXNsM7RHgSwUhcuo47unqm8=
|
||||
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
|
||||
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
|
||||
@@ -153,6 +143,10 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -166,8 +160,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
@@ -181,26 +175,20 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sv-tools/openapi v0.4.0 h1:UhD9DVnGox1hfTePNclpUzUFgos57FvzT2jmcAuTOJ4=
|
||||
github.com/sv-tools/openapi v0.4.0/go.mod h1:kD/dG+KP0+Fom1r6nvcj/ORtLus8d8enXT6dyRZDirE=
|
||||
github.com/swaggo/echo-swagger/v2 v2.0.1 h1:jKR3QiK+ciGjxE0+7qZ/azjtlx/pTVls7pJFJqdJoJI=
|
||||
github.com/swaggo/echo-swagger/v2 v2.0.1/go.mod h1:BbgiO9XKX6yYU5Rq4ejqVlQI0mVRv6ziFKd0XgdztnQ=
|
||||
github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU=
|
||||
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5 h1:fK7d6ET9rrEsdB8IyuwXREWMcyQN3N7gawGFbbrjgHk=
|
||||
github.com/swaggo/swag/v2 v2.0.0-rc5/go.mod h1:kCL8Fu4Zl8d5tB2Bgj96b8wRowwrwk175bZHXfuGVFI=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
|
||||
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
|
||||
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -223,33 +211,29 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -261,5 +245,3 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
@@ -28,7 +28,7 @@ func (c *CharacterService) List() ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var characters []string
|
||||
characters := []string{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && isImage(entry.Name()) {
|
||||
characters = append(characters, entry.Name())
|
||||
|
||||
@@ -198,7 +198,7 @@ func (m *MusicService) SongInfo(ctx context.Context) SongInfo {
|
||||
|
||||
// PlayedSongs returns all songs in the queue.
|
||||
func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo {
|
||||
var songList []SongInfo
|
||||
songList := []SongInfo{}
|
||||
for i, song := range m.queue {
|
||||
soundtrack := m.soundtrackFor(ctx, song)
|
||||
songList = append(songList, SongInfo{
|
||||
|
||||
@@ -66,7 +66,7 @@ func toSoundtracks[R soundtrackRow](rows []R, err error) ([]SoundtrackWithSongs,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []SoundtrackWithSongs
|
||||
result := []SoundtrackWithSongs{}
|
||||
for _, r := range rows {
|
||||
row := repository.GetMostPlayedSoundtracksWithSongsRow(r)
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
@@ -89,7 +89,7 @@ func toSongs[R songRow](rows []R, err error) ([]SongInfoForStats, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []SongInfoForStats
|
||||
result := []SongInfoForStats{}
|
||||
for _, r := range rows {
|
||||
result = append(result, SongInfoForStats(repository.GetMostPlayedSongsWithSoundtrackRow(r)))
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (s *StatisticsService) NeverPlayedSoundtracks(ctx context.Context) ([]Sound
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []SoundtrackWithSongs
|
||||
result := []SoundtrackWithSongs{}
|
||||
for _, row := range rows {
|
||||
result = append(result, SoundtrackWithSongs{
|
||||
SoundtrackID: row.SoundtrackID,
|
||||
|
||||
@@ -136,11 +136,23 @@ func (s *SyncService) Progress() ProgressResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Result returns the result of the last finished sync.
|
||||
// Result returns the result of the last finished sync. Lists are empty, not nil, before the first sync.
|
||||
func (s *SyncService) Result() SyncResponse {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.result
|
||||
return s.result.withEmptyLists()
|
||||
}
|
||||
|
||||
func (r SyncResponse) withEmptyLists() SyncResponse {
|
||||
for _, list := range []*[]string{&r.SoundtracksAdded, &r.SoundtracksReAdded, &r.SoundtracksChangedContent, &r.SoundtracksRemoved, &r.CatchedErrors} {
|
||||
if *list == nil {
|
||||
*list = []string{}
|
||||
}
|
||||
}
|
||||
if r.SoundtracksChangedTitle == nil {
|
||||
r.SoundtracksChangedTitle = map[string]string{}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ResetDB deletes all songs and soundtracks.
|
||||
|
||||
@@ -371,7 +371,14 @@ func TestSyncStartOnlyOnce(t *testing.T) {
|
||||
func TestSyncProgressBeforeFirstSync(t *testing.T) {
|
||||
s := NewSyncService(fakedb.New(), t.TempDir(), nil)
|
||||
assert.Equal(t, "0", s.Progress().Progress)
|
||||
assert.Equal(t, SyncResponse{}, s.Result())
|
||||
assert.Equal(t, SyncResponse{
|
||||
SoundtracksAdded: []string{},
|
||||
SoundtracksReAdded: []string{},
|
||||
SoundtracksChangedTitle: map[string]string{},
|
||||
SoundtracksChangedContent: []string{},
|
||||
SoundtracksRemoved: []string{},
|
||||
CatchedErrors: []string{},
|
||||
}, s.Result(), "lists are empty, not null, so the JSON matches the API schema")
|
||||
}
|
||||
|
||||
func TestSyncCallsOnCompleteBeforeFinishing(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
apiPrefix = "/api/v1"
|
||||
bearerAuthName = "bearer"
|
||||
)
|
||||
|
||||
var bearerAuth = []map[string][]string{{bearerAuthName: {}}}
|
||||
|
||||
// APIError is the body of every error response.
|
||||
type APIError struct {
|
||||
status int
|
||||
Message string `json:"error" example:"Not Found" doc:"What went wrong"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Message }
|
||||
func (e *APIError) GetStatus() int { return e.status }
|
||||
|
||||
func init() {
|
||||
// The API always returns [] for empty lists, never null.
|
||||
huma.DefaultArrayNullable = false
|
||||
|
||||
// Huma creates all its errors (validation, 404, ...) through NewError.
|
||||
huma.NewError = func(status int, msg string, errs ...error) huma.StatusError {
|
||||
var details []string
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
details = append(details, err.Error())
|
||||
}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
msg += ": " + strings.Join(details, "; ")
|
||||
}
|
||||
return &APIError{status: status, Message: msg}
|
||||
}
|
||||
}
|
||||
|
||||
// apis holds the documented /api/v1 API and the hidden, deprecated legacy routes.
|
||||
type apis struct {
|
||||
v1 huma.API
|
||||
legacy huma.API
|
||||
}
|
||||
|
||||
func newAPIs(e *echo.Echo) apis {
|
||||
config := huma.DefaultConfig("MusicServer API", backend.GetLatestVersion().Version)
|
||||
// No $schema fields or Link headers in responses.
|
||||
config.CreateHooks = nil
|
||||
config.DocsRenderer = huma.DocsRendererScalar
|
||||
config.Info.Description = "API for the MusicServer application.\n\n" +
|
||||
"Get a token with `POST /api/v1/token` and send it as `Authorization: Bearer <token>`.\n\n" +
|
||||
"The same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token " +
|
||||
"and are not documented here. They will be removed."
|
||||
config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
|
||||
bearerAuthName: {Type: "http", Scheme: "bearer", Description: "Token from POST /api/v1/token"},
|
||||
}
|
||||
|
||||
legacyConfig := huma.DefaultConfig("MusicServer legacy API", backend.GetLatestVersion().Version)
|
||||
legacyConfig.CreateHooks = nil
|
||||
legacyConfig.OpenAPIPath = ""
|
||||
legacyConfig.DocsPath = ""
|
||||
legacyConfig.SchemasPath = ""
|
||||
|
||||
return apis{v1: humaecho.New(e, config), legacy: humaecho.New(e, legacyConfig)}
|
||||
}
|
||||
|
||||
// route describes an endpoint registered on /api/v1 and, optionally, as a legacy route.
|
||||
type route struct {
|
||||
op huma.Operation
|
||||
// public endpoints need no token.
|
||||
public bool
|
||||
// legacyPath is the deprecated path without /api/v1 (empty: no legacy route).
|
||||
legacyPath string
|
||||
// locked endpoints answer 423 while a sync is running.
|
||||
locked bool
|
||||
}
|
||||
|
||||
// register adds the route's operation to the v1 API and, if it has one, its hidden legacy route.
|
||||
func register[I, O any](s *Server, a apis, r route, handler func(context.Context, *I) (*O, error)) {
|
||||
op := r.op
|
||||
op.Path = apiPrefix + r.op.Path
|
||||
if !r.public {
|
||||
op.Security = bearerAuth
|
||||
op.Errors = append(op.Errors, http.StatusUnauthorized)
|
||||
}
|
||||
if r.locked {
|
||||
op.Middlewares = append(op.Middlewares, s.syncLock(a.v1))
|
||||
op.Errors = append(op.Errors, http.StatusLocked)
|
||||
}
|
||||
huma.Register(a.v1, op, handler)
|
||||
|
||||
if r.legacyPath == "" {
|
||||
return
|
||||
}
|
||||
legacy := r.op
|
||||
legacy.OperationID = "legacy-" + r.op.OperationID
|
||||
legacy.Path = r.legacyPath
|
||||
legacy.Hidden = true
|
||||
legacy.Deprecated = true
|
||||
if r.locked {
|
||||
legacy.Middlewares = append(legacy.Middlewares, s.syncLock(a.legacy))
|
||||
}
|
||||
huma.Register(a.legacy, legacy, handler)
|
||||
}
|
||||
|
||||
// syncLock answers 423 Locked while a sync is running.
|
||||
func (s *Server) syncLock(api huma.API) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if s.sync.IsSyncing() {
|
||||
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
||||
_ = huma.WriteErr(api, ctx, http.StatusLocked, "Syncing is in progress")
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// apiError maps backend errors to API errors.
|
||||
func apiError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
|
||||
return huma.Error404NotFound(err.Error())
|
||||
case errors.Is(err, backend.ErrInvalidName):
|
||||
return huma.Error400BadRequest(err.Error())
|
||||
case errors.Is(err, backend.ErrUpstream):
|
||||
return huma.Error502BadGateway(err.Error())
|
||||
default:
|
||||
logging.GetLogger().Error("Request failed", zap.Error(err))
|
||||
return huma.Error500InternalServerError("Internal Server Error")
|
||||
}
|
||||
}
|
||||
|
||||
// echoContext returns the Echo context behind a Huma context.
|
||||
func echoContext(ctx huma.Context) *echo.Context {
|
||||
return humaecho.Unwrap(ctx)
|
||||
}
|
||||
|
||||
// noBody is the output of operations that only return a status.
|
||||
type noBody struct{}
|
||||
|
||||
// jsonBody wraps a JSON response body.
|
||||
type jsonBody[T any] struct {
|
||||
Body T
|
||||
}
|
||||
|
||||
func ok[T any](body T, err error) (*jsonBody[T], error) {
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &jsonBody[T]{Body: body}, nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type CharacterHandler struct {
|
||||
characters *backend.CharacterService
|
||||
}
|
||||
|
||||
func NewCharacterHandler(characters *backend.CharacterService) *CharacterHandler {
|
||||
return &CharacterHandler{characters: characters}
|
||||
}
|
||||
|
||||
// GetCharacterList godoc
|
||||
// @Summary Get list of characters
|
||||
// @Description Returns a list of all available characters
|
||||
// @Tags characters
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Router /characters [get]
|
||||
func (c *CharacterHandler) GetCharacterList(ctx *echo.Context) error {
|
||||
characters, err := c.characters.List()
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, characters)
|
||||
}
|
||||
|
||||
// GetCharacter godoc
|
||||
// @Summary Get character image
|
||||
// @Description Returns the image for a specific character
|
||||
// @Tags characters
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Param name query string true "Character file name"
|
||||
// @Success 200 {file} file
|
||||
// @Failure 400 {string} string "invalid name"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Router /character [get]
|
||||
func (c *CharacterHandler) GetCharacter(ctx *echo.Context) error {
|
||||
f, err := c.characters.Open(ctx.QueryParam("name"))
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return serveFile(ctx, f, "")
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type DownloadHandler struct {
|
||||
releases *backend.ReleaseService
|
||||
}
|
||||
|
||||
func NewDownloadHandler(releases *backend.ReleaseService) *DownloadHandler {
|
||||
return &DownloadHandler{releases: releases}
|
||||
}
|
||||
|
||||
// CheckLatest godoc
|
||||
// @Summary Check for latest version
|
||||
// @Description Returns the latest version of the client application
|
||||
// @Tags download
|
||||
// @Produce json
|
||||
// @Success 200 {string} string
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download [get]
|
||||
func (d *DownloadHandler) CheckLatest(ctx *echo.Context) error {
|
||||
latest, err := d.releases.LatestVersion(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, latest)
|
||||
}
|
||||
|
||||
// ListAssetsOfLatest godoc
|
||||
// @Summary List assets of latest version
|
||||
// @Description Lists all assets available for the latest version
|
||||
// @Tags download
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/list [get]
|
||||
func (d *DownloadHandler) ListAssetsOfLatest(ctx *echo.Context) error {
|
||||
assets, err := d.releases.AssetNames(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, assets)
|
||||
}
|
||||
|
||||
// DownloadLatestWindows godoc
|
||||
// @Summary Download latest Windows version
|
||||
// @Description Redirects to download the latest Windows version
|
||||
// @Tags download
|
||||
// @Success 302 {string} string
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/windows [get]
|
||||
func (d *DownloadHandler) DownloadLatestWindows(ctx *echo.Context) error {
|
||||
return d.redirectToAsset(ctx, ".exe")
|
||||
}
|
||||
|
||||
// DownloadLatestLinux godoc
|
||||
// @Summary Download latest Linux version
|
||||
// @Description Redirects to download the latest Linux version
|
||||
// @Tags download
|
||||
// @Success 302 {string} string
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 502 {string} string "upstream service unavailable"
|
||||
// @Router /download/linux [get]
|
||||
func (d *DownloadHandler) DownloadLatestLinux(ctx *echo.Context) error {
|
||||
return d.redirectToAsset(ctx, ".x86_64")
|
||||
}
|
||||
|
||||
func (d *DownloadHandler) redirectToAsset(ctx *echo.Context, suffix string) error {
|
||||
url, err := d.releases.AssetURL(ctx.Request().Context(), suffix)
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.Redirect(http.StatusFound, url)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// httpErrorHandler writes errors from Echo (unknown routes, panics, ...) as an APIError, like Huma does.
|
||||
func httpErrorHandler(c *echo.Context, err error) {
|
||||
if r, _ := echo.UnwrapResponse(c.Response()); r != nil && r.Committed {
|
||||
return
|
||||
}
|
||||
|
||||
code := http.StatusInternalServerError
|
||||
message := http.StatusText(code)
|
||||
var he *echo.HTTPError
|
||||
var sc echo.HTTPStatusCoder
|
||||
switch {
|
||||
case errors.As(err, &he):
|
||||
code = he.Code
|
||||
message = he.Message
|
||||
case errors.As(err, &sc):
|
||||
code = sc.StatusCode()
|
||||
}
|
||||
if code >= http.StatusInternalServerError && he == nil {
|
||||
logging.GetLogger().Error("Unhandled error", zap.String("path", c.Request().URL.Path), zap.Error(err))
|
||||
}
|
||||
if message == "" {
|
||||
message = http.StatusText(code)
|
||||
}
|
||||
|
||||
if c.Request().Method == http.MethodHead {
|
||||
_ = c.NoContent(code)
|
||||
return
|
||||
}
|
||||
_ = c.JSON(code, &APIError{Message: message})
|
||||
}
|
||||
+39
-40
@@ -1,61 +1,60 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// serveFile sends a file with support for Range requests and caching headers.
|
||||
// contentType may be empty to detect it from the file extension.
|
||||
func serveFile(c *echo.Context, f *os.File, contentType string) error {
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
// fileResponses documents an endpoint that returns a file with Range support.
|
||||
func fileResponses(description string, contentTypes ...string) map[string]*huma.Response {
|
||||
content := map[string]*huma.MediaType{}
|
||||
for _, contentType := range contentTypes {
|
||||
content[contentType] = &huma.MediaType{Schema: &huma.Schema{Type: "string", Format: "binary"}}
|
||||
}
|
||||
if info.IsDir() {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
return map[string]*huma.Response{
|
||||
"200": {Description: description, Content: content},
|
||||
"206": {Description: "Part of the file, for a Range request", Content: content},
|
||||
}
|
||||
if contentType != "" {
|
||||
c.Response().Header().Set(echo.HeaderContentType, contentType)
|
||||
}
|
||||
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveSong streams a song. The write deadline is lifted because songs can take long to send to slow clients.
|
||||
func serveSong(c *echo.Context, path string, err error) error {
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
// streamFile opens path and returns a response that sends it with Range support.
|
||||
// contentType may be empty to detect it from the file extension. Audio has no write
|
||||
// deadline because songs can take long to send to slow clients.
|
||||
func streamFile(path, contentType string, audio bool) (*huma.StreamResponse, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Not Found")
|
||||
return nil, huma.Error404NotFound("Not Found")
|
||||
}
|
||||
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
|
||||
return serveFile(c, f, "audio/mpeg")
|
||||
return streamOpenFile(f, contentType, audio)
|
||||
}
|
||||
|
||||
// songError maps backend errors to HTTP errors.
|
||||
func songError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, backend.ErrQueueEmpty), errors.Is(err, backend.ErrNoSongs), errors.Is(err, backend.ErrNotFound):
|
||||
return echo.NewHTTPError(http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, backend.ErrInvalidName):
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, backend.ErrUpstream):
|
||||
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
|
||||
default:
|
||||
logging.GetLogger().Error("Request failed", zap.Error(err))
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error")
|
||||
func streamOpenFile(f *os.File, contentType string, audio bool) (*huma.StreamResponse, error) {
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
f.Close()
|
||||
return nil, huma.Error404NotFound("Not Found")
|
||||
}
|
||||
return &huma.StreamResponse{Body: func(ctx huma.Context) {
|
||||
defer f.Close()
|
||||
c := echoContext(ctx)
|
||||
if contentType != "" {
|
||||
c.Response().Header().Set("Content-Type", contentType)
|
||||
}
|
||||
if audio {
|
||||
_ = http.NewResponseController(c.Response()).SetWriteDeadline(time.Time{})
|
||||
}
|
||||
http.ServeContent(c.Response(), c.Request(), filepath.Base(f.Name()), info.ModTime(), f)
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// streamSong streams the song at path, or returns err mapped to an API error.
|
||||
func streamSong(path string, err error) (*huma.StreamResponse, error) {
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return streamFile(path, "audio/mpeg", true)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestMusicRandomAndQueue(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/music/played?song=0").Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodPut, "/music/played?song=x").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/music/played?song=x").Code)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/music/reset").Code)
|
||||
}
|
||||
|
||||
@@ -60,12 +60,13 @@ func TestMusicEmptyStates(t *testing.T) {
|
||||
for _, path := range []string{"/music/next", "/music/previous", "/music?song=0", "/music/rand", "/music/rand/low", "/music/rand/classic"} {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music?song=abc").Code)
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/music").Code)
|
||||
// Invalid or missing parameters fail validation.
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music?song=abc").Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/music").Code)
|
||||
|
||||
rec := e.do(http.MethodGet, "/music/list")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "null\n", rec.Body.String())
|
||||
assert.JSONEq(t, "[]", rec.Body.String())
|
||||
}
|
||||
|
||||
func TestMusicSoundCheck(t *testing.T) {
|
||||
@@ -118,7 +119,8 @@ func TestCharacterEndpoints(t *testing.T) {
|
||||
|
||||
func TestCharacterPathTraversal(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd", ""} {
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/character?name=").Code, "name is required")
|
||||
for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} {
|
||||
rec := e.do(http.MethodGet, "/character?name="+name)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, name)
|
||||
assert.NotContains(t, rec.Body.String(), "secret", name)
|
||||
@@ -262,6 +264,10 @@ func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatist
|
||||
return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err
|
||||
}
|
||||
|
||||
func statsService() *backend.StatisticsService {
|
||||
return backend.NewStatisticsService(statsStore{})
|
||||
}
|
||||
|
||||
func TestStatisticsEndpoints(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.server.stats = backend.NewStatisticsService(statsStore{})
|
||||
@@ -279,7 +285,7 @@ func TestStatisticsEndpoints(t *testing.T) {
|
||||
assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped")
|
||||
|
||||
for _, limit := range []string{"0", "-1", "abc"} {
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit)
|
||||
}
|
||||
|
||||
var summary backend.StatisticsSummary
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// HealthChecker reports the health of a dependency.
|
||||
type HealthChecker interface {
|
||||
Health(ctx context.Context) map[string]string
|
||||
}
|
||||
|
||||
type HealthHandler struct {
|
||||
db HealthChecker
|
||||
}
|
||||
|
||||
func NewHealthHandler(db HealthChecker) *HealthHandler {
|
||||
return &HealthHandler{db: db}
|
||||
}
|
||||
|
||||
// HealthCheck godoc
|
||||
//
|
||||
// @Summary Check server health
|
||||
// @Description Returns the health status of the server
|
||||
// @Tags health
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /health [get]
|
||||
func (h *HealthHandler) HealthCheck(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, h.db.Health(ctx.Request().Context()))
|
||||
}
|
||||
@@ -1,111 +1,59 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type LibraryHandler struct {
|
||||
library *backend.LibraryService
|
||||
type uuidInput struct {
|
||||
UUID string `path:"uuid" format:"uuid" doc:"UUID of the soundtrack or song"`
|
||||
}
|
||||
|
||||
func NewLibraryHandler(library *backend.LibraryService) *LibraryHandler {
|
||||
return &LibraryHandler{library: library}
|
||||
func (in *uuidInput) id() uuid.UUID {
|
||||
// The format is validated by Huma before the handler runs.
|
||||
return uuid.MustParse(in.UUID)
|
||||
}
|
||||
|
||||
// uuidParam parses the :uuid path parameter.
|
||||
func uuidParam(ctx *echo.Context) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(ctx.Param("uuid"))
|
||||
if err != nil {
|
||||
return uuid.Nil, echo.NewHTTPError(http.StatusBadRequest, "invalid uuid")
|
||||
func (s *Server) registerLibrary(a apis) {
|
||||
l := s.library
|
||||
op := func(id, path, summary, description string, errs ...int) huma.Operation {
|
||||
return huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"library"},
|
||||
Errors: errs,
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetSoundtracks godoc
|
||||
//
|
||||
// @Summary List soundtracks
|
||||
// @Description Returns all soundtracks with their UUIDs, sorted by name
|
||||
// @Tags library
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.Soundtrack
|
||||
// @Router /api/v1/soundtracks [get]
|
||||
func (l *LibraryHandler) GetSoundtracks(ctx *echo.Context) error {
|
||||
soundtracks, err := l.library.Soundtracks(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, soundtracks)
|
||||
}
|
||||
register(s, a, route{op: op("list-soundtracks", "/soundtracks", "List soundtracks",
|
||||
"Returns all soundtracks, sorted by name.")},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.Soundtrack], error) {
|
||||
return ok(l.Soundtracks(ctx))
|
||||
})
|
||||
register(s, a, route{op: op("get-soundtrack", "/soundtracks/{uuid}", "Get soundtrack",
|
||||
"Returns a soundtrack with its songs, sorted by name.", http.StatusNotFound)},
|
||||
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
|
||||
return ok(l.Soundtrack(ctx, in.id()))
|
||||
})
|
||||
register(s, a, route{op: op("get-song", "/songs/{uuid}", "Get song",
|
||||
"Returns information about a song.", http.StatusNotFound)},
|
||||
func(ctx context.Context, in *uuidInput) (*jsonBody[backend.Song], error) {
|
||||
return ok(l.Song(ctx, in.id()))
|
||||
})
|
||||
|
||||
// GetSoundtrack godoc
|
||||
//
|
||||
// @Summary Get soundtrack
|
||||
// @Description Returns a soundtrack with its songs
|
||||
// @Tags library
|
||||
// @Produce json
|
||||
// @Param uuid path string true "Soundtrack UUID"
|
||||
// @Success 200 {object} backend.SoundtrackWithSongList
|
||||
// @Failure 400 {string} string "invalid uuid"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Router /api/v1/soundtracks/{uuid} [get]
|
||||
func (l *LibraryHandler) GetSoundtrack(ctx *echo.Context) error {
|
||||
id, err := uuidParam(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
soundtrack, err := l.library.Soundtrack(ctx.Request().Context(), id)
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, soundtrack)
|
||||
}
|
||||
|
||||
// GetSong godoc
|
||||
//
|
||||
// @Summary Get song
|
||||
// @Description Returns information about a song
|
||||
// @Tags library
|
||||
// @Produce json
|
||||
// @Param uuid path string true "Song UUID"
|
||||
// @Success 200 {object} backend.Song
|
||||
// @Failure 400 {string} string "invalid uuid"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Router /api/v1/songs/{uuid} [get]
|
||||
func (l *LibraryHandler) GetSong(ctx *echo.Context) error {
|
||||
id, err := uuidParam(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
song, err := l.library.Song(ctx.Request().Context(), id)
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, song)
|
||||
}
|
||||
|
||||
// GetSongFile godoc
|
||||
//
|
||||
// @Summary Stream song
|
||||
// @Description Returns the audio file of a song. Supports Range requests.
|
||||
// @Tags library
|
||||
// @Produce audio/mpeg
|
||||
// @Param uuid path string true "Song UUID"
|
||||
// @Success 200 {file} file
|
||||
// @Success 206 {file} file
|
||||
// @Failure 400 {string} string "invalid uuid"
|
||||
// @Failure 404 {string} string "not found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /api/v1/songs/{uuid}/file [get]
|
||||
func (l *LibraryHandler) GetSongFile(ctx *echo.Context) error {
|
||||
id, err := uuidParam(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := l.library.SongPath(ctx.Request().Context(), id)
|
||||
return serveSong(ctx, path, err)
|
||||
file := op("get-song-file", "/songs/{uuid}/file", "Stream song",
|
||||
"Returns the audio file of a song.", http.StatusNotFound)
|
||||
file.Responses = fileResponses("The song (MP3)", audioMPEG)
|
||||
register(s, a, route{op: file, locked: true},
|
||||
func(ctx context.Context, in *uuidInput) (*huma.StreamResponse, error) {
|
||||
return streamSong(l.SongPath(ctx, in.id()))
|
||||
})
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestLibraryEndpointErrors(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
for _, path := range []string{"/api/v1/soundtracks/not-a-uuid", "/api/v1/songs/123", "/api/v1/songs/123/file"} {
|
||||
assert.Equal(t, http.StatusBadRequest, e.do(http.MethodGet, path).Code, path)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
import "github.com/danielgtaylor/huma/v2"
|
||||
|
||||
// DeprecationMiddleware adds deprecation warning to responses
|
||||
// for old endpoints that are being phased out in favor of /api/v1/*
|
||||
func DeprecationMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
// Add deprecation warning header
|
||||
c.Response().Header().Add("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
|
||||
c.Response().Header().Add("Deprecation", "true")
|
||||
return next(c)
|
||||
}
|
||||
// Deprecation marks responses from legacy routes as deprecated in favour of /api/v1.
|
||||
func Deprecation(ctx huma.Context, next func(huma.Context)) {
|
||||
ctx.AppendHeader("Warning", `299 - "Deprecated: This endpoint is deprecated. Use /api/v1/ endpoints instead."`)
|
||||
ctx.AppendHeader("Deprecation", "true")
|
||||
next(ctx)
|
||||
}
|
||||
@@ -1,2 +1,15 @@
|
||||
// Package middleware provides Echo middleware for the MusicServer application.
|
||||
// Package middleware provides Huma middleware for the MusicServer API.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/adapters/humaecho"
|
||||
)
|
||||
|
||||
// Middleware is a Huma middleware function.
|
||||
type Middleware = func(ctx huma.Context, next func(huma.Context))
|
||||
|
||||
// clientIP returns the client IP as resolved by Echo (respecting trusted proxies).
|
||||
func clientIP(ctx huma.Context) string {
|
||||
return humaecho.Unwrap(ctx).RealIP()
|
||||
}
|
||||
@@ -6,26 +6,25 @@ import (
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RateLimit allows each client IP limit requests per window, with bursts up to limit.
|
||||
func RateLimit(limit int, window time.Duration) echo.MiddlewareFunc {
|
||||
config := echoMiddleware.DefaultRateLimiterConfig
|
||||
config.Store = echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
|
||||
func RateLimit(api huma.API, limit int, window time.Duration) Middleware {
|
||||
store := echoMiddleware.NewRateLimiterMemoryStoreWithConfig(echoMiddleware.RateLimiterMemoryStoreConfig{
|
||||
Rate: float64(limit) / window.Seconds(),
|
||||
Burst: limit,
|
||||
ExpiresIn: window,
|
||||
})
|
||||
config.DenyHandler = func(c *echo.Context, identifier string, err error) error {
|
||||
logging.GetLogger().Warn("Rate limit exceeded",
|
||||
zap.String("ip", identifier),
|
||||
zap.String("path", c.Request().URL.Path))
|
||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||
"error": "Rate limit exceeded. Please try again later.",
|
||||
})
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
ip := clientIP(ctx)
|
||||
if allowed, _ := store.Allow(ip); !allowed {
|
||||
logging.GetLogger().Warn("Rate limit exceeded", zap.String("ip", ip), zap.String("path", ctx.URL().Path))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusTooManyRequests, "Rate limit exceeded. Please try again later.")
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
return echoMiddleware.RateLimiterWithConfig(config)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
// SyncCheck blocks requests with HTTP 423 Locked while a sync is in progress.
|
||||
func SyncCheck(isSyncing func() bool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
if isSyncing() {
|
||||
logging.GetLogger().Info("Syncing is in progress - request blocked")
|
||||
return c.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,40 +9,49 @@ import (
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type tokenKey struct{}
|
||||
|
||||
// Token returns the session token of an authenticated request.
|
||||
func Token(ctx context.Context) string {
|
||||
token, _ := ctx.Value(tokenKey{}).(string)
|
||||
return token
|
||||
}
|
||||
|
||||
// BearerToken extracts the token from an "Authorization: Bearer <token>" header.
|
||||
func BearerToken(c *echo.Context) (string, bool) {
|
||||
token, ok := strings.CutPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||
func BearerToken(header string) (string, bool) {
|
||||
token, ok := strings.CutPrefix(header, "Bearer ")
|
||||
return token, ok && token != "" && !strings.Contains(token, " ")
|
||||
}
|
||||
|
||||
// TokenAuth validates session tokens. When disabled is true all requests are let through.
|
||||
func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
if disabled {
|
||||
return next(c)
|
||||
// TokenAuth validates the session token for operations that declare a security requirement.
|
||||
// When disabled is true all requests are let through.
|
||||
func TokenAuth(api huma.API, q repository.Querier, disabled bool) Middleware {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if disabled || len(ctx.Operation().Security) == 0 {
|
||||
next(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
header := ctx.Header("Authorization")
|
||||
if header == "" {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Authorization header required")
|
||||
return
|
||||
}
|
||||
token, ok := BearerToken(c)
|
||||
token, ok := BearerToken(header)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer <token>"})
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid authorization format. Use: Bearer <token>")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
session, err := q.GetSession(ctx, token)
|
||||
session, err := q.GetSession(ctx.Context(), token)
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("Invalid token attempt",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("error", err.Error()),
|
||||
)
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"})
|
||||
logging.GetLogger().Warn("Invalid token attempt", zap.String("ip", clientIP(ctx)), zap.Error(err))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt.Time) {
|
||||
@@ -51,12 +60,30 @@ func TokenAuth(q repository.Querier, disabled bool) echo.MiddlewareFunc {
|
||||
if err := q.DeleteSession(ctx, token); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete expired session", zap.Error(err))
|
||||
}
|
||||
}(context.WithoutCancel(ctx))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"})
|
||||
}(context.WithoutCancel(ctx.Context()))
|
||||
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "Token expired")
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("session", session)
|
||||
return next(c)
|
||||
next(huma.WithValue(ctx, tokenKey{}, token))
|
||||
}
|
||||
}
|
||||
|
||||
type clientKey struct{}
|
||||
|
||||
// Client holds request details that handlers need but that are not part of the API input.
|
||||
type Client struct {
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// ClientInfo stores the client's IP and user agent in the request context.
|
||||
func ClientInfo(ctx huma.Context, next func(huma.Context)) {
|
||||
next(huma.WithValue(ctx, clientKey{}, Client{IP: clientIP(ctx), UserAgent: ctx.Header("User-Agent")}))
|
||||
}
|
||||
|
||||
// ClientFrom returns the client details stored by ClientInfo.
|
||||
func ClientFrom(ctx context.Context) Client {
|
||||
client, _ := ctx.Value(clientKey{}).(Client)
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
type characterInput struct {
|
||||
Name string `query:"name" required:"true" doc:"File name of the character image, from the character list"`
|
||||
}
|
||||
|
||||
type redirectOutput struct {
|
||||
Status int
|
||||
Location string `header:"Location" doc:"Download URL of the asset"`
|
||||
}
|
||||
|
||||
type healthOutput struct {
|
||||
Body map[string]string
|
||||
}
|
||||
|
||||
func (s *Server) registerMisc(a apis) {
|
||||
get := func(id, path, tag, summary, description string, errs ...int) huma.Operation {
|
||||
return huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{tag},
|
||||
Errors: errs,
|
||||
}
|
||||
}
|
||||
|
||||
register(s, a, route{op: get("get-health", "/health", "server", "Health check",
|
||||
"Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error."),
|
||||
public: true, legacyPath: "/health"},
|
||||
func(ctx context.Context, _ *struct{}) (*healthOutput, error) {
|
||||
return &healthOutput{Body: s.db.Health(ctx)}, nil
|
||||
})
|
||||
register(s, a, route{op: get("get-version", "/version", "server", "Latest server version",
|
||||
"Returns the server version and its changelog."), legacyPath: "/version"},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.VersionData], error) {
|
||||
return ok(backend.GetLatestVersion(), nil)
|
||||
})
|
||||
register(s, a, route{op: get("get-version-history", "/version/history", "server", "Version history",
|
||||
"Returns all server versions with their changelogs, newest first."), legacyPath: "/version/history"},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.VersionData], error) {
|
||||
return ok(backend.GetVersionHistory(), nil)
|
||||
})
|
||||
|
||||
register(s, a, route{op: get("list-characters", "/character/list", "characters", "List characters",
|
||||
"Returns the file names of all character images."), legacyPath: "/characters"},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(s.characters.List())
|
||||
})
|
||||
image := get("get-character", "/character", "characters", "Character image",
|
||||
"Returns a character image.", http.StatusBadRequest, http.StatusNotFound)
|
||||
image.Responses = fileResponses("The image", "image/png", "image/jpeg")
|
||||
register(s, a, route{op: image, legacyPath: "/character"},
|
||||
func(ctx context.Context, in *characterInput) (*huma.StreamResponse, error) {
|
||||
f, err := s.characters.Open(in.Name)
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return streamOpenFile(f, "", false)
|
||||
})
|
||||
|
||||
r := s.releases
|
||||
register(s, a, route{op: get("get-latest-client-version", "/download", "download", "Latest client version",
|
||||
"Returns the name of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download"},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
return ok(r.LatestVersion(ctx))
|
||||
})
|
||||
register(s, a, route{op: get("list-client-assets", "/download/list", "download", "Latest client assets",
|
||||
"Returns the file names of the latest MusicPlayer release.", http.StatusBadGateway), legacyPath: "/download/list"},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(r.AssetNames(ctx))
|
||||
})
|
||||
download := func(id, path, platform, suffix string) {
|
||||
op := get(id, path, "download", "Download the latest client for "+platform,
|
||||
"Redirects to the download URL of the latest MusicPlayer release for "+platform+".",
|
||||
http.StatusNotFound, http.StatusBadGateway)
|
||||
op.DefaultStatus = http.StatusFound
|
||||
register(s, a, route{op: op, legacyPath: path},
|
||||
func(ctx context.Context, _ *struct{}) (*redirectOutput, error) {
|
||||
url, err := r.AssetURL(ctx, suffix)
|
||||
if err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return &redirectOutput{Status: http.StatusFound, Location: url}, nil
|
||||
})
|
||||
}
|
||||
download("download-client-windows", "/download/windows", "Windows", ".exe")
|
||||
download("download-client-linux", "/download/linux", "Linux", ".x86_64")
|
||||
}
|
||||
+134
-218
@@ -1,240 +1,156 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type MusicHandler struct {
|
||||
music *backend.MusicService
|
||||
type songNumberInput struct {
|
||||
Song int `query:"song" required:"true" doc:"Position of the song in the queue (SongNo)"`
|
||||
}
|
||||
|
||||
func NewMusicHandler(music *backend.MusicService) *MusicHandler {
|
||||
return &MusicHandler{music: music}
|
||||
const audioMPEG = "audio/mpeg"
|
||||
|
||||
func (s *Server) registerMusic(a apis) {
|
||||
m := s.music
|
||||
audio := func(id, path, summary, description string, errs ...int) route {
|
||||
return route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"music"},
|
||||
Responses: fileResponses("The song (MP3)", audioMPEG),
|
||||
Errors: errs,
|
||||
},
|
||||
legacyPath: path,
|
||||
locked: true,
|
||||
}
|
||||
}
|
||||
status := func(id, method, path, summary, description string) route {
|
||||
return route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: method,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"music"},
|
||||
DefaultStatus: http.StatusOK,
|
||||
},
|
||||
legacyPath: path,
|
||||
locked: true,
|
||||
}
|
||||
}
|
||||
list := func(id, path, summary, description string, locked bool) route {
|
||||
return route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"music"},
|
||||
},
|
||||
legacyPath: path,
|
||||
locked: locked,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSong godoc
|
||||
// @Summary Get a specific song
|
||||
// @Description Returns the song at the given position in the queue
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Param song query int true "Song number in the queue"
|
||||
// @Success 200 {file} file
|
||||
// @Success 206 {file} file
|
||||
// @Failure 400 {string} string "song must be a number"
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music [get]
|
||||
func (m *MusicHandler) GetSong(ctx *echo.Context) error {
|
||||
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||
if err != nil {
|
||||
return ctx.String(http.StatusBadRequest, "song must be a number")
|
||||
}
|
||||
path, err := m.music.Song(song)
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
register(s, a, audio("get-queue-song", "/music", "Play a song from the queue",
|
||||
"Jumps to the song at the given queue position (clamped to the queue) and returns it.", http.StatusNotFound),
|
||||
func(ctx context.Context, in *songNumberInput) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.Song(in.Song))
|
||||
})
|
||||
register(s, a, audio("get-sound-test-song", "/music/soundTest", "Sound check song",
|
||||
"Returns a random song for checking the volume.", http.StatusNotFound),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.SoundCheckSong())
|
||||
})
|
||||
register(s, a, audio("get-random-song", "/music/rand", "Random song",
|
||||
"Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", http.StatusNotFound),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.RandomSong(ctx))
|
||||
})
|
||||
register(s, a, audio("get-random-song-low-chance", "/music/rand/low", "Random song, favouring rarely played",
|
||||
"Picks a random song, favouring soundtracks that have been played less than average.", http.StatusNotFound),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.RandomSongLowChance(ctx))
|
||||
})
|
||||
register(s, a, audio("get-random-song-classic", "/music/rand/classic", "Random song, every song equal",
|
||||
"Picks a random song where every song has the same chance, regardless of soundtrack.", http.StatusNotFound),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.RandomSongClassic(ctx))
|
||||
})
|
||||
register(s, a, audio("get-next-song", "/music/next", "Next song in the queue",
|
||||
"Moves to the next song in the queue and returns it. Stays on the last song at the end.", http.StatusNotFound),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.NextSong())
|
||||
})
|
||||
register(s, a, audio("get-previous-song", "/music/previous", "Previous song in the queue",
|
||||
"Moves to the previous song in the queue and returns it. Stays on the first song at the start.", http.StatusNotFound),
|
||||
func(ctx context.Context, _ *struct{}) (*huma.StreamResponse, error) {
|
||||
return streamSong(m.PreviousSong())
|
||||
})
|
||||
|
||||
// GetSoundCheckSong godoc
|
||||
// @Summary Get sound check song
|
||||
// @Description Returns the sound check song
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/soundTest [get]
|
||||
func (m *MusicHandler) GetSoundCheckSong(ctx *echo.Context) error {
|
||||
path, err := m.music.SoundCheckSong()
|
||||
return serveSong(ctx, path, err)
|
||||
register(s, a, list("get-song-info", "/music/info", "Current song", "Returns the current song in the queue.", false),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.SongInfo], error) {
|
||||
return ok(m.SongInfo(ctx), nil)
|
||||
})
|
||||
register(s, a, list("get-played-songs", "/music/list", "Queue", "Returns all songs in the queue.", false),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SongInfo], error) {
|
||||
return ok(m.PlayedSongs(ctx), nil)
|
||||
})
|
||||
randomNames := func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(m.SoundtrackNamesRandom(ctx))
|
||||
}
|
||||
register(s, a, list("get-soundtrack-names-random", "/music/all", "Soundtrack names, random order",
|
||||
"Returns the names of all soundtracks in random order.", true), randomNames)
|
||||
register(s, a, list("get-soundtrack-names-random-alias", "/music/all/random", "Soundtrack names, random order",
|
||||
"Same as /music/all.", true), randomNames)
|
||||
register(s, a, list("get-soundtrack-names", "/music/all/order", "Soundtrack names, sorted",
|
||||
"Returns the names of all soundtracks in alphabetical order.", true),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) {
|
||||
return ok(m.SoundtrackNames(ctx))
|
||||
})
|
||||
|
||||
// ResetMusic godoc
|
||||
// @Summary Reset music state
|
||||
// @Description Resets the music state
|
||||
// @Tags music
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/reset [get]
|
||||
func (m *MusicHandler) ResetMusic(ctx *echo.Context) error {
|
||||
if err := m.music.Reset(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
register(s, a, status("reset-queue", http.MethodGet, "/music/reset", "Reset the queue",
|
||||
"Clears the queue and reloads the soundtrack list."),
|
||||
func(ctx context.Context, _ *struct{}) (*noBody, error) {
|
||||
if err := m.Reset(ctx); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
return &noBody{}, nil
|
||||
})
|
||||
register(s, a, status("add-latest-to-queue", http.MethodGet, "/music/addQue", "Add the last random song to the queue",
|
||||
"Adds the song returned by the last random song request to the end of the queue."),
|
||||
func(ctx context.Context, _ *struct{}) (*noBody, error) {
|
||||
m.AddLatestToQueue()
|
||||
return &noBody{}, nil
|
||||
})
|
||||
register(s, a, status("mark-current-played", http.MethodGet, "/music/addPlayed", "Mark the current song as played",
|
||||
"Increments the play counters of the current song and its soundtrack."),
|
||||
func(ctx context.Context, _ *struct{}) (*noBody, error) {
|
||||
if err := m.AddLatestPlayed(ctx); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
|
||||
// GetRandomSong godoc
|
||||
// @Summary Get random song
|
||||
// @Description Returns a random song
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand [get]
|
||||
func (m *MusicHandler) GetRandomSong(ctx *echo.Context) error {
|
||||
path, err := m.music.RandomSong(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
return &noBody{}, nil
|
||||
})
|
||||
register(s, a, status("mark-song-played", http.MethodPut, "/music/played", "Mark a queued song as played",
|
||||
"Increments the play counters of the song at the given queue position. Positions outside the queue are ignored."),
|
||||
func(ctx context.Context, in *songNumberInput) (*noBody, error) {
|
||||
logging.GetLogger().Info("Marking song as played", zap.Int("song_no", in.Song))
|
||||
if err := m.SetPlayed(ctx, in.Song); err != nil {
|
||||
return nil, apiError(err)
|
||||
}
|
||||
|
||||
// GetRandomSongLowChance godoc
|
||||
// @Summary Get random song with low chance
|
||||
// @Description Returns a random song, favouring soundtracks that have been played less
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand/low [get]
|
||||
func (m *MusicHandler) GetRandomSongLowChance(ctx *echo.Context) error {
|
||||
path, err := m.music.RandomSongLowChance(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetRandomSongClassic godoc
|
||||
// @Summary Get random classic song
|
||||
// @Description Returns a random song where every song has the same chance
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/rand/classic [get]
|
||||
func (m *MusicHandler) GetRandomSongClassic(ctx *echo.Context) error {
|
||||
path, err := m.music.RandomSongClassic(ctx.Request().Context())
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetSongInfo godoc
|
||||
// @Summary Get current song info
|
||||
// @Description Returns information about the current song
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.SongInfo
|
||||
// @Router /music/info [get]
|
||||
func (m *MusicHandler) GetSongInfo(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, m.music.SongInfo(ctx.Request().Context()))
|
||||
}
|
||||
|
||||
// GetPlayedSongs godoc
|
||||
// @Summary Get played songs list
|
||||
// @Description Returns a list of played songs
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.SongInfo
|
||||
// @Router /music/list [get]
|
||||
func (m *MusicHandler) GetPlayedSongs(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, m.music.PlayedSongs(ctx.Request().Context()))
|
||||
}
|
||||
|
||||
// GetNextSong godoc
|
||||
// @Summary Get next song
|
||||
// @Description Returns the next song in the queue
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/next [get]
|
||||
func (m *MusicHandler) GetNextSong(ctx *echo.Context) error {
|
||||
path, err := m.music.NextSong()
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetPreviousSong godoc
|
||||
// @Summary Get previous song
|
||||
// @Description Returns the previous song in the queue
|
||||
// @Tags music
|
||||
// @Produce audio/mpeg
|
||||
// @Success 200 {file} file
|
||||
// @Failure 404 {string} string "Not Found"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/previous [get]
|
||||
func (m *MusicHandler) GetPreviousSong(ctx *echo.Context) error {
|
||||
path, err := m.music.PreviousSong()
|
||||
return serveSong(ctx, path, err)
|
||||
}
|
||||
|
||||
// GetAllSoundtracks godoc
|
||||
// @Summary Get all soundtracks
|
||||
// @Description Returns a list of all soundtracks in order
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/order [get]
|
||||
func (m *MusicHandler) GetAllSoundtracks(ctx *echo.Context) error {
|
||||
names, err := m.music.SoundtrackNames(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, names)
|
||||
}
|
||||
|
||||
// GetAllSoundtracksRandom godoc
|
||||
// @Summary Get all soundtracks random
|
||||
// @Description Returns a list of all soundtracks in random order
|
||||
// @Tags music
|
||||
// @Produce json
|
||||
// @Success 200 {array} string
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/all/random [get]
|
||||
func (m *MusicHandler) GetAllSoundtracksRandom(ctx *echo.Context) error {
|
||||
names, err := m.music.SoundtrackNamesRandom(ctx.Request().Context())
|
||||
if err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, names)
|
||||
}
|
||||
|
||||
// PutPlayed godoc
|
||||
// @Summary Mark song as played
|
||||
// @Description Marks the song at the given queue position as played
|
||||
// @Tags music
|
||||
// @Param song query int true "Song number in the queue"
|
||||
// @Success 200
|
||||
// @Failure 400 {string} string "Bad Request"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/played [put]
|
||||
func (m *MusicHandler) PutPlayed(ctx *echo.Context) error {
|
||||
song, err := strconv.Atoi(ctx.QueryParam("song"))
|
||||
if err != nil {
|
||||
return ctx.JSON(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
logging.GetLogger().Info("Marking song as played", zap.Int("song_id", song))
|
||||
if err := m.music.SetPlayed(ctx.Request().Context(), song); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// AddLatestToQue godoc
|
||||
// @Summary Add latest to queue
|
||||
// @Description Adds the latest song to the queue
|
||||
// @Tags music
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/addQue [get]
|
||||
func (m *MusicHandler) AddLatestToQue(ctx *echo.Context) error {
|
||||
m.music.AddLatestToQueue()
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
// AddLatestPlayed godoc
|
||||
// @Summary Add latest to played
|
||||
// @Description Marks the current song as played
|
||||
// @Tags music
|
||||
// @Success 200
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /music/addPlayed [get]
|
||||
func (m *MusicHandler) AddLatestPlayed(ctx *echo.Context) error {
|
||||
if err := m.music.AddLatestPlayed(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
return &noBody{}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"music-server/internal/config"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// loadSpec loads the OpenAPI 3.0 version of the spec the server publishes.
|
||||
func loadSpec(t *testing.T, e *testEnv) *openapi3.T {
|
||||
t.Helper()
|
||||
rec := e.do(http.MethodGet, "/openapi-3.0.json")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
spec, err := openapi3.NewLoader().LoadFromData(rec.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
return spec
|
||||
}
|
||||
|
||||
func TestOpenAPISpecIsValid(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
spec := loadSpec(t, e)
|
||||
require.NoError(t, spec.Validate(context.Background()))
|
||||
|
||||
// The 3.1 spec is valid JSON with the same paths.
|
||||
var spec31 struct {
|
||||
OpenAPI string `json:"openapi"`
|
||||
Paths map[string]any `json:"paths"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/openapi.json").Body.Bytes(), &spec31))
|
||||
assert.Equal(t, "3.1.0", spec31.OpenAPI)
|
||||
assert.Len(t, spec31.Paths, spec.Paths.Len())
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentsTheV1API(t *testing.T) {
|
||||
spec := loadSpec(t, newTestEnv(t))
|
||||
|
||||
var operations []string
|
||||
for path, item := range spec.Paths.Map() {
|
||||
assert.True(t, strings.HasPrefix(path, "/api/v1/"), "only /api/v1 is documented: %s", path)
|
||||
for method, op := range item.Operations() {
|
||||
operations = append(operations, method+" "+path)
|
||||
|
||||
public := path == "/api/v1/health" || (path == "/api/v1/token" && method == http.MethodPost)
|
||||
if public {
|
||||
assert.Nil(t, op.Security, "%s %s needs no token", method, path)
|
||||
} else {
|
||||
require.NotNil(t, op.Security, "%s %s needs a token", method, path)
|
||||
assert.Equal(t, openapi3.SecurityRequirements{{bearerAuthName: {}}}, *op.Security)
|
||||
assert.Contains(t, op.Responses.Map(), "401", "%s %s documents 401", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(operations)
|
||||
assert.Equal(t, []string{
|
||||
"DELETE /api/v1/token",
|
||||
"GET /api/v1/character",
|
||||
"GET /api/v1/character/list",
|
||||
"GET /api/v1/download",
|
||||
"GET /api/v1/download/linux",
|
||||
"GET /api/v1/download/list",
|
||||
"GET /api/v1/download/windows",
|
||||
"GET /api/v1/health",
|
||||
"GET /api/v1/music",
|
||||
"GET /api/v1/music/addPlayed",
|
||||
"GET /api/v1/music/addQue",
|
||||
"GET /api/v1/music/all",
|
||||
"GET /api/v1/music/all/order",
|
||||
"GET /api/v1/music/all/random",
|
||||
"GET /api/v1/music/info",
|
||||
"GET /api/v1/music/list",
|
||||
"GET /api/v1/music/next",
|
||||
"GET /api/v1/music/previous",
|
||||
"GET /api/v1/music/rand",
|
||||
"GET /api/v1/music/rand/classic",
|
||||
"GET /api/v1/music/rand/low",
|
||||
"GET /api/v1/music/reset",
|
||||
"GET /api/v1/music/soundTest",
|
||||
"GET /api/v1/songs/{uuid}",
|
||||
"GET /api/v1/songs/{uuid}/file",
|
||||
"GET /api/v1/soundtracks",
|
||||
"GET /api/v1/soundtracks/{uuid}",
|
||||
"GET /api/v1/statistics/songs/least-played",
|
||||
"GET /api/v1/statistics/songs/most-played",
|
||||
"GET /api/v1/statistics/soundtracks/last-played",
|
||||
"GET /api/v1/statistics/soundtracks/least-played",
|
||||
"GET /api/v1/statistics/soundtracks/most-played",
|
||||
"GET /api/v1/statistics/soundtracks/never-played",
|
||||
"GET /api/v1/statistics/soundtracks/oldest-played",
|
||||
"GET /api/v1/statistics/summary",
|
||||
"GET /api/v1/sync/progress",
|
||||
"GET /api/v1/version",
|
||||
"GET /api/v1/version/history",
|
||||
"POST /api/v1/sync",
|
||||
"POST /api/v1/sync/full",
|
||||
"POST /api/v1/token",
|
||||
"POST /api/v1/token/cleanup",
|
||||
"PUT /api/v1/music/played",
|
||||
}, operations, "/sync/reset is hidden and legacy routes are not documented")
|
||||
|
||||
assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type)
|
||||
assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme)
|
||||
}
|
||||
|
||||
// specValidator checks responses against the published spec.
|
||||
type specValidator struct {
|
||||
t *testing.T
|
||||
e *testEnv
|
||||
router routers.Router
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
func newSpecValidator(t *testing.T, e *testEnv) *specValidator {
|
||||
spec := loadSpec(t, e)
|
||||
spec.Servers = openapi3.Servers{{URL: "http://example.com"}}
|
||||
router, err := gorillamux.NewRouter(spec)
|
||||
require.NoError(t, err)
|
||||
return &specValidator{t: t, e: e, router: router, seen: map[string]bool{}}
|
||||
}
|
||||
|
||||
// check sends a request and validates the response status, headers and body against the spec.
|
||||
func (v *specValidator) check(method, path, body string, wantStatus int, headers ...string) *httptest.ResponseRecorder {
|
||||
v.t.Helper()
|
||||
rec := v.e.doBody(method, path, body, headers...)
|
||||
require.Equal(v.t, wantStatus, rec.Code, "%s %s: %s", method, path, rec.Body.String())
|
||||
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
route, params, err := v.router.FindRoute(req)
|
||||
require.NoError(v.t, err, "%s %s is not in the spec", method, path)
|
||||
v.seen[method+" "+route.Path] = true
|
||||
|
||||
input := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route},
|
||||
Status: rec.Code,
|
||||
Header: rec.Header(),
|
||||
Options: &openapi3filter.Options{IncludeResponseStatus: true},
|
||||
}
|
||||
input.SetBodyBytes(rec.Body.Bytes())
|
||||
if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "image/") {
|
||||
input.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
input.Options.ExcludeResponseBody = true
|
||||
}
|
||||
assert.NoError(v.t, openapi3filter.ValidateResponse(context.Background(), input), "%s %s", method, path)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestResponsesMatchOpenAPI(t *testing.T) {
|
||||
gitea := newGitea(t, http.StatusOK)
|
||||
e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea })
|
||||
v := newSpecValidator(t, e)
|
||||
song := e.addSong("Game A", "Song", "0123456789")
|
||||
songURL := "/api/v1/songs/" + song.Uuid.String()
|
||||
|
||||
// Empty states and errors.
|
||||
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music?song=x", "", http.StatusUnprocessableEntity)
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
|
||||
// Music.
|
||||
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/addQue", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/addPlayed", "", http.StatusOK)
|
||||
v.check(http.MethodPut, "/api/v1/music/played?song=0", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music?song=0", "", http.StatusPartialContent, "Range", "bytes=0-1")
|
||||
v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/previous", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand/low", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand/classic", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/soundTest", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all/random", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/all/order", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/music/reset", "", http.StatusOK)
|
||||
|
||||
// Library.
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, songURL, "", http.StatusOK)
|
||||
v.check(http.MethodGet, songURL+"/file", "", http.StatusOK)
|
||||
|
||||
// Characters, downloads, server info.
|
||||
v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest)
|
||||
v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/download", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/download/list", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/download/windows", "", http.StatusFound)
|
||||
v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound)
|
||||
v.check(http.MethodGet, "/api/v1/health", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK)
|
||||
|
||||
// Statistics (empty database).
|
||||
for _, path := range []string{
|
||||
"/soundtracks/most-played", "/soundtracks/least-played", "/soundtracks/last-played",
|
||||
"/soundtracks/oldest-played", "/songs/most-played", "/songs/least-played",
|
||||
} {
|
||||
v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK)
|
||||
}
|
||||
v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK)
|
||||
v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
|
||||
|
||||
// Sync, including the locked state and the result after it finishes.
|
||||
release := e.blockSync()
|
||||
v.check(http.MethodPost, "/api/v1/sync", "", http.StatusOK)
|
||||
v.check(http.MethodPost, "/api/v1/sync/full", "", http.StatusLocked)
|
||||
v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusLocked)
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
release()
|
||||
v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK)
|
||||
|
||||
// Tokens and authentication.
|
||||
auth := newTestEnv(t, withAuth("secret"))
|
||||
va := newSpecValidator(t, auth)
|
||||
va.check(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`, http.StatusUnauthorized)
|
||||
rec := va.check(http.MethodPost, "/api/v1/token", `{"password":"secret","client_type":"api"}`, http.StatusOK)
|
||||
var token TokenResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &token))
|
||||
bearer := []string{"Authorization", "Bearer " + token.Token}
|
||||
va.check(http.MethodGet, "/api/v1/version", "", http.StatusUnauthorized)
|
||||
va.check(http.MethodPost, "/api/v1/token/cleanup", "", http.StatusOK, bearer...)
|
||||
va.check(http.MethodDelete, "/api/v1/token", "", http.StatusOK, bearer...)
|
||||
|
||||
// Statistics with data, through the canned statistics store.
|
||||
stats := newTestEnv(t)
|
||||
stats.server.stats = statsService()
|
||||
stats.handler = stats.server.RegisterRoutes()
|
||||
vs := newSpecValidator(t, stats)
|
||||
vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK)
|
||||
vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK)
|
||||
|
||||
// Every documented operation was checked at least once (statistics and tokens use other environments).
|
||||
spec := loadSpec(t, e)
|
||||
for path, item := range spec.Paths.Map() {
|
||||
for method := range item.Operations() {
|
||||
key := method + " " + path
|
||||
assert.True(t, v.seen[key] || va.seen[key] || vs.seen[key], "no response check for %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-129
@@ -2,9 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "music-server/cmd/docs" // registers the OpenAPI spec
|
||||
"music-server/cmd/web"
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
@@ -12,53 +10,12 @@ import (
|
||||
"github.com/a-h/templ"
|
||||
"github.com/labstack/echo/v5"
|
||||
echoMiddleware "github.com/labstack/echo/v5/middleware"
|
||||
echoSwagger "github.com/swaggo/echo-swagger/v2"
|
||||
"github.com/swaggo/swag"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// @title MusicServer API
|
||||
// @version 1.0
|
||||
// @description API for the MusicServer application
|
||||
// @termsOfService http://sanplex.xyz/terms/
|
||||
|
||||
// @contact.name Sebastian Olsson
|
||||
// @contact.email zarnor91@gmail.com
|
||||
|
||||
// @license.name MIT
|
||||
// @license.url http://opensource.org/licenses/MIT
|
||||
|
||||
// @BasePath /
|
||||
|
||||
// handlers holds all HTTP handlers.
|
||||
type handlers struct {
|
||||
health *HealthHandler
|
||||
version *VersionHandler
|
||||
character *CharacterHandler
|
||||
download *DownloadHandler
|
||||
sync *SyncHandler
|
||||
music *MusicHandler
|
||||
library *LibraryHandler
|
||||
statistics *StatisticsHandler
|
||||
token *TokenHandler
|
||||
}
|
||||
|
||||
func (s *Server) handlers() handlers {
|
||||
return handlers{
|
||||
health: NewHealthHandler(s.db),
|
||||
version: NewVersionHandler(),
|
||||
character: NewCharacterHandler(s.characters),
|
||||
download: NewDownloadHandler(s.releases),
|
||||
sync: NewSyncHandler(s.sync),
|
||||
music: NewMusicHandler(s.music),
|
||||
library: NewLibraryHandler(s.library),
|
||||
statistics: NewStatisticsHandler(s.stats),
|
||||
token: NewTokenHandler(s.q, s.cfg.APIPassword, s.cfg.DisableSessionAuth),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes() http.Handler {
|
||||
e := echo.New()
|
||||
e.HTTPErrorHandler = httpErrorHandler
|
||||
if len(s.cfg.TrustedProxies) > 0 {
|
||||
var trust []echo.TrustOption
|
||||
for _, ipNet := range s.cfg.TrustedProxies {
|
||||
@@ -77,102 +34,35 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
e.GET("/openapi", serveOpenAPI)
|
||||
e.GET("/swagger/*", echoSwagger.WrapHandler)
|
||||
// The API: /api/v1 is documented at /docs and /openapi.json; legacy routes are hidden.
|
||||
a := newAPIs(e)
|
||||
a.v1.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a.v1, s.q, s.cfg.DisableSessionAuth))
|
||||
a.legacy.UseMiddleware(middleware.ClientInfo, middleware.Deprecation)
|
||||
s.registerToken(a)
|
||||
s.registerMisc(a)
|
||||
s.registerSync(a)
|
||||
s.registerMusic(a)
|
||||
s.registerLibrary(a)
|
||||
s.registerStatistics(a)
|
||||
|
||||
// Older links to the spec and docs.
|
||||
e.GET("/openapi", redirect("/openapi.json"))
|
||||
e.GET("/swagger/*", redirect("/docs"))
|
||||
|
||||
// Search page and frontend.
|
||||
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
|
||||
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
|
||||
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
|
||||
e.Static("/", s.cfg.FrontendPath)
|
||||
|
||||
h := s.handlers()
|
||||
syncLock := middleware.SyncCheck(s.sync.IsSyncing)
|
||||
|
||||
// Legacy endpoints (deprecated - use /api/v1/ instead)
|
||||
legacy := e.Group("", middleware.DeprecationMiddleware)
|
||||
registerCommon(legacy, h, syncLock)
|
||||
legacy.GET("/health", h.health.HealthCheck)
|
||||
legacy.GET("/characters", h.character.GetCharacterList)
|
||||
|
||||
// API v1
|
||||
apiV1 := e.Group("/api/v1")
|
||||
tokenGroup := apiV1.Group("/token")
|
||||
tokenGroup.POST("", h.token.CreateTokenHandler, middleware.RateLimit(10, time.Minute))
|
||||
tokenGroup.DELETE("", h.token.DeleteTokenHandler)
|
||||
|
||||
protected := apiV1.Group("", middleware.TokenAuth(s.q, s.cfg.DisableSessionAuth))
|
||||
protected.POST("/token/cleanup", h.token.CleanupExpiredSessionsHandler)
|
||||
registerCommon(protected, h, syncLock)
|
||||
protected.GET("/character/list", h.character.GetCharacterList)
|
||||
|
||||
protected.GET("/soundtracks", h.library.GetSoundtracks)
|
||||
protected.GET("/soundtracks/:uuid", h.library.GetSoundtrack)
|
||||
protected.GET("/songs/:uuid", h.library.GetSong)
|
||||
protected.GET("/songs/:uuid/file", h.library.GetSongFile, syncLock)
|
||||
|
||||
stats := protected.Group("/statistics")
|
||||
stats.GET("/soundtracks/most-played", h.statistics.GetMostPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/least-played", h.statistics.GetLeastPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/never-played", h.statistics.GetNeverPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/last-played", h.statistics.GetLastPlayedSoundtracks)
|
||||
stats.GET("/soundtracks/oldest-played", h.statistics.GetOldestPlayedSoundtracks)
|
||||
stats.GET("/songs/most-played", h.statistics.GetMostPlayedSongs)
|
||||
stats.GET("/songs/least-played", h.statistics.GetLeastPlayedSongs)
|
||||
stats.GET("/summary", h.statistics.GetStatisticsSummary)
|
||||
|
||||
for _, r := range e.Router().Routes() {
|
||||
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// registerCommon registers the routes shared by the legacy and the v1 API.
|
||||
// syncLock is applied to routes that must not run during a sync.
|
||||
func registerCommon(g *echo.Group, h handlers, syncLock echo.MiddlewareFunc) {
|
||||
g.GET("/version", h.version.GetLatestVersion)
|
||||
g.GET("/version/history", h.version.GetVersionHistory)
|
||||
|
||||
g.GET("/character", h.character.GetCharacter)
|
||||
|
||||
g.GET("/download", h.download.CheckLatest)
|
||||
g.GET("/download/list", h.download.ListAssetsOfLatest)
|
||||
g.GET("/download/windows", h.download.DownloadLatestWindows)
|
||||
g.GET("/download/linux", h.download.DownloadLatestLinux)
|
||||
|
||||
g.GET("/sync/progress", h.sync.SyncProgress)
|
||||
for _, path := range []string{"/sync", "/sync/new", "/sync/quick"} {
|
||||
g.POST(path, h.sync.SyncSoundtracksNewOnlyChanges, syncLock)
|
||||
func redirect(to string) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
return c.Redirect(http.StatusMovedPermanently, to)
|
||||
}
|
||||
for _, path := range []string{"/sync/full", "/sync/new/full"} {
|
||||
g.POST(path, h.sync.SyncSoundtracksNewFull, syncLock)
|
||||
}
|
||||
g.POST("/sync/reset", h.sync.ResetDB, syncLock)
|
||||
|
||||
music := g.Group("/music", syncLock)
|
||||
music.GET("", h.music.GetSong)
|
||||
music.GET("/soundTest", h.music.GetSoundCheckSong)
|
||||
music.GET("/reset", h.music.ResetMusic)
|
||||
music.GET("/rand", h.music.GetRandomSong)
|
||||
music.GET("/rand/low", h.music.GetRandomSongLowChance)
|
||||
music.GET("/rand/classic", h.music.GetRandomSongClassic)
|
||||
music.GET("/next", h.music.GetNextSong)
|
||||
music.GET("/previous", h.music.GetPreviousSong)
|
||||
music.GET("/all", h.music.GetAllSoundtracksRandom)
|
||||
music.GET("/all/order", h.music.GetAllSoundtracks)
|
||||
music.GET("/all/random", h.music.GetAllSoundtracksRandom)
|
||||
music.PUT("/played", h.music.PutPlayed)
|
||||
music.GET("/addQue", h.music.AddLatestToQue)
|
||||
music.GET("/addPlayed", h.music.AddLatestPlayed)
|
||||
// Info and list are read-only and stay available during a sync.
|
||||
g.GET("/music/info", h.music.GetSongInfo)
|
||||
g.GET("/music/list", h.music.GetPlayedSongs)
|
||||
}
|
||||
|
||||
// serveOpenAPI serves the embedded OpenAPI spec.
|
||||
func serveOpenAPI(c *echo.Context) error {
|
||||
doc, err := swag.ReadDoc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Blob(http.StatusOK, echo.MIMEApplicationJSON, []byte(doc))
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.addSong("Game A", "Song", "a")
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
for _, path := range []string{"/sync", "/sync/new", "/sync/quick", "/sync/full", "/sync/new/full", "/sync/reset"} {
|
||||
for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} {
|
||||
// GET falls through to the frontend's static files, which answer 404.
|
||||
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+path)
|
||||
}
|
||||
@@ -99,21 +99,33 @@ func TestHealthEndpoint(t *testing.T) {
|
||||
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestOpenAPIAndSwagger(t *testing.T) {
|
||||
func TestSyncAliasesAreRemoved(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
|
||||
for _, path := range []string{"/openapi", "/swagger/doc.json"} {
|
||||
rec := e.do(http.MethodGet, path)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
var spec struct {
|
||||
Paths map[string]map[string]any `json:"paths"`
|
||||
for _, prefix := range []string{"", "/api/v1"} {
|
||||
for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} {
|
||||
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path)
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec), path)
|
||||
assert.Contains(t, spec.Paths, "/api/v1/token", path)
|
||||
assert.Contains(t, spec.Paths["/sync/reset"], "post", path)
|
||||
}
|
||||
assert.False(t, e.server.sync.IsSyncing())
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/swagger/index.html").Code)
|
||||
func TestV1HealthEndpoint(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
rec := e.do(http.MethodGet, "/api/v1/health")
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.JSONEq(t, `{"status":"up"}`, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestDocsAndSpecLinks(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code)
|
||||
for _, path := range []string{"/openapi.json", "/openapi.yaml", "/openapi-3.0.json"} {
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path)
|
||||
}
|
||||
rec := e.do(http.MethodGet, "/openapi")
|
||||
assert.Equal(t, http.StatusMovedPermanently, rec.Code)
|
||||
assert.Equal(t, "/openapi.json", rec.Header().Get("Location"))
|
||||
assert.Equal(t, "/docs", e.do(http.MethodGet, "/swagger/index.html").Header().Get("Location"))
|
||||
}
|
||||
|
||||
func TestSearchPageAndFrontend(t *testing.T) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// Database is the database connection used by the server.
|
||||
type Database interface {
|
||||
HealthChecker
|
||||
Health(ctx context.Context) map[string]string
|
||||
Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,188 +1,71 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultStatsLimit = 10
|
||||
maxStatsLimit = 100
|
||||
)
|
||||
const maxStatsLimit = 100
|
||||
|
||||
// StatisticsHandler handles statistics-related HTTP requests
|
||||
type StatisticsHandler struct {
|
||||
stats *backend.StatisticsService
|
||||
type limitInput struct {
|
||||
Limit int `query:"limit" default:"10" minimum:"1" doc:"Number of results. Values above 100 are capped to 100."`
|
||||
}
|
||||
|
||||
// NewStatisticsHandler creates a new StatisticsHandler
|
||||
func NewStatisticsHandler(stats *backend.StatisticsService) *StatisticsHandler {
|
||||
return &StatisticsHandler{stats: stats}
|
||||
func (in *limitInput) capped() int32 {
|
||||
return int32(min(in.Limit, maxStatsLimit))
|
||||
}
|
||||
|
||||
// parseLimit reads the "limit" query parameter, defaulting to 10 and capping at 100.
|
||||
func parseLimit(ctx *echo.Context) (int32, bool) {
|
||||
limitStr := ctx.QueryParam("limit")
|
||||
if limitStr == "" {
|
||||
return defaultStatsLimit, true
|
||||
func (s *Server) registerStatistics(a apis) {
|
||||
st := s.stats
|
||||
op := func(id, path, summary, description string) route {
|
||||
return route{op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodGet,
|
||||
Path: "/statistics" + path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"statistics"},
|
||||
}}
|
||||
}
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 {
|
||||
return 0, false
|
||||
soundtracks := func(id, path, summary, description string,
|
||||
query func(context.Context, int32) ([]backend.SoundtrackWithSongs, error)) {
|
||||
register(s, a, op(id, path, summary, description),
|
||||
func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
|
||||
return ok(query(ctx, in.capped()))
|
||||
})
|
||||
}
|
||||
return int32(min(limit, maxStatsLimit)), true
|
||||
}
|
||||
|
||||
// withLimit runs a statistics query that takes a limit and writes the result as JSON.
|
||||
func withLimit[T any](ctx *echo.Context, name string, query func(*echo.Context, int32) (T, error)) error {
|
||||
limit, ok := parseLimit(ctx)
|
||||
if !ok {
|
||||
return ctx.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid limit parameter"})
|
||||
}
|
||||
result, err := query(ctx, limit)
|
||||
return statsResult(ctx, name, result, err)
|
||||
}
|
||||
|
||||
func statsResult[T any](ctx *echo.Context, name string, result T, err error) error {
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get statistics", zap.String("statistic", name), zap.Error(err))
|
||||
return ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get statistics"})
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetMostPlayedSoundtracks returns top N most played soundtracks with songs
|
||||
//
|
||||
// @Summary Get most played soundtracks
|
||||
// @Description Returns the top N most played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/most-played [get]
|
||||
func (h *StatisticsHandler) GetMostPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "most played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.MostPlayedSoundtracks(c.Request().Context(), limit)
|
||||
songs := func(id, path, summary, description string,
|
||||
query func(context.Context, int32) ([]backend.SongInfoForStats, error)) {
|
||||
register(s, a, op(id, path, summary, description),
|
||||
func(ctx context.Context, in *limitInput) (*jsonBody[[]backend.SongInfoForStats], error) {
|
||||
return ok(query(ctx, in.capped()))
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeastPlayedSoundtracks returns top N least played soundtracks with songs
|
||||
//
|
||||
// @Summary Get least played soundtracks
|
||||
// @Description Returns the top N least played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/least-played [get]
|
||||
func (h *StatisticsHandler) GetLeastPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "least played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.LeastPlayedSoundtracks(c.Request().Context(), limit)
|
||||
soundtracks("get-most-played-soundtracks", "/soundtracks/most-played", "Most played soundtracks",
|
||||
"Returns the most played soundtracks with their songs.", st.MostPlayedSoundtracks)
|
||||
soundtracks("get-least-played-soundtracks", "/soundtracks/least-played", "Least played soundtracks",
|
||||
"Returns the least played soundtracks with their songs.", st.LeastPlayedSoundtracks)
|
||||
soundtracks("get-last-played-soundtracks", "/soundtracks/last-played", "Recently played soundtracks",
|
||||
"Returns the most recently played soundtracks with their songs.", st.LastPlayedSoundtracks)
|
||||
soundtracks("get-oldest-played-soundtracks", "/soundtracks/oldest-played", "Least recently played soundtracks",
|
||||
"Returns the soundtracks played longest ago (played at least once) with their songs.", st.OldestPlayedSoundtracks)
|
||||
songs("get-most-played-songs", "/songs/most-played", "Most played songs",
|
||||
"Returns the most played songs with their soundtrack.", st.MostPlayedSongs)
|
||||
songs("get-least-played-songs", "/songs/least-played", "Least played songs",
|
||||
"Returns the least played songs with their soundtrack.", st.LeastPlayedSongs)
|
||||
|
||||
register(s, a, op("get-never-played-soundtracks", "/soundtracks/never-played", "Never played soundtracks",
|
||||
"Returns all soundtracks that have never been played, with their songs."),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.SoundtrackWithSongs], error) {
|
||||
return ok(st.NeverPlayedSoundtracks(ctx))
|
||||
})
|
||||
register(s, a, op("get-statistics-summary", "/summary", "Statistics summary", "Returns overall play statistics."),
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[backend.StatisticsSummary], error) {
|
||||
return ok(st.Summary(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// GetMostPlayedSongs returns top N most played songs
|
||||
//
|
||||
// @Summary Get most played songs
|
||||
// @Description Returns the top N most played songs with their soundtrack info
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SongInfoForStats
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/songs/most-played [get]
|
||||
func (h *StatisticsHandler) GetMostPlayedSongs(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "most played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||
return h.stats.MostPlayedSongs(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetLeastPlayedSongs returns top N least played songs
|
||||
//
|
||||
// @Summary Get least played songs
|
||||
// @Description Returns the top N least played songs with their soundtrack info
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SongInfoForStats
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/songs/least-played [get]
|
||||
func (h *StatisticsHandler) GetLeastPlayedSongs(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "least played songs", func(c *echo.Context, limit int32) ([]backend.SongInfoForStats, error) {
|
||||
return h.stats.LeastPlayedSongs(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetLastPlayedSoundtracks returns the most recently played soundtracks
|
||||
//
|
||||
// @Summary Get last played soundtracks
|
||||
// @Description Returns the most recently played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/last-played [get]
|
||||
func (h *StatisticsHandler) GetLastPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "last played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.LastPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetOldestPlayedSoundtracks returns the least recently played soundtracks
|
||||
//
|
||||
// @Summary Get oldest played soundtracks
|
||||
// @Description Returns the least recently played soundtracks with their songs
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Param limit query int false "Number of results (default: 10, max: 100)"
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/oldest-played [get]
|
||||
func (h *StatisticsHandler) GetOldestPlayedSoundtracks(ctx *echo.Context) error {
|
||||
return withLimit(ctx, "oldest played soundtracks", func(c *echo.Context, limit int32) ([]backend.SoundtrackWithSongs, error) {
|
||||
return h.stats.OldestPlayedSoundtracks(c.Request().Context(), limit)
|
||||
})
|
||||
}
|
||||
|
||||
// GetNeverPlayedSoundtracks returns soundtracks that have never been played
|
||||
//
|
||||
// @Summary Get never played soundtracks
|
||||
// @Description Returns all soundtracks that have never been played
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.SoundtrackWithSongs
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/soundtracks/never-played [get]
|
||||
func (h *StatisticsHandler) GetNeverPlayedSoundtracks(ctx *echo.Context) error {
|
||||
result, err := h.stats.NeverPlayedSoundtracks(ctx.Request().Context())
|
||||
return statsResult(ctx, "never played soundtracks", result, err)
|
||||
}
|
||||
|
||||
// GetStatisticsSummary returns overall statistics
|
||||
//
|
||||
// @Summary Get statistics summary
|
||||
// @Description Returns overall play statistics
|
||||
// @Tags statistics
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.StatisticsSummary
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/statistics/summary [get]
|
||||
func (h *StatisticsHandler) GetStatisticsSummary(ctx *echo.Context) error {
|
||||
result, err := h.stats.Summary(ctx.Request().Context())
|
||||
return statsResult(ctx, "summary", result, err)
|
||||
}
|
||||
@@ -1,80 +1,86 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"music-server/internal/backend"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
type SyncHandler struct {
|
||||
sync *backend.SyncService
|
||||
func (s *Server) registerSync(a apis) {
|
||||
registry := a.v1.OpenAPI().Components.Schemas
|
||||
progress := huma.Operation{
|
||||
OperationID: "get-sync-progress",
|
||||
Method: http.MethodGet,
|
||||
Path: "/sync/progress",
|
||||
Summary: "Sync progress or result",
|
||||
Description: "While a sync is running, returns its progress. Otherwise returns the result of the last sync.",
|
||||
Tags: []string{"sync"},
|
||||
Responses: map[string]*huma.Response{
|
||||
"200": {
|
||||
Description: "Progress of the running sync, or the result of the last sync",
|
||||
Content: map[string]*huma.MediaType{"application/json": {Schema: &huma.Schema{OneOf: []*huma.Schema{
|
||||
registry.Schema(reflect.TypeFor[backend.ProgressResponse](), true, ""),
|
||||
registry.Schema(reflect.TypeFor[backend.SyncResponse](), true, ""),
|
||||
}}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func NewSyncHandler(sync *backend.SyncService) *SyncHandler {
|
||||
return &SyncHandler{sync: sync}
|
||||
}
|
||||
|
||||
// SyncProgress godoc
|
||||
// @Summary Get sync progress
|
||||
// @Description Returns the progress of the running sync, or the result of the last sync
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.SyncResponse
|
||||
// @Router /sync/progress [get]
|
||||
func (s *SyncHandler) SyncProgress(ctx *echo.Context) error {
|
||||
register(s, a, route{op: progress, legacyPath: "/sync/progress"},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[any], error) {
|
||||
if s.sync.IsSyncing() {
|
||||
return ctx.JSON(http.StatusOK, s.sync.Progress())
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, s.sync.Result())
|
||||
return ok[any](s.sync.Progress(), nil)
|
||||
}
|
||||
return ok[any](s.sync.Result(), nil)
|
||||
})
|
||||
|
||||
// SyncSoundtracksNewOnlyChanges godoc
|
||||
// @Summary Sync soundtracks with only changes
|
||||
// @Description Starts syncing soundtracks that have changed
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing soundtracks"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync [post]
|
||||
func (s *SyncHandler) SyncSoundtracksNewOnlyChanges(ctx *echo.Context) error {
|
||||
return s.start(ctx, false, "Start syncing soundtracks")
|
||||
start := func(id, path, summary, description string, full bool) {
|
||||
register(s, a, route{
|
||||
op: huma.Operation{
|
||||
OperationID: id,
|
||||
Method: http.MethodPost,
|
||||
Path: path,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Tags: []string{"sync"},
|
||||
Errors: []int{http.StatusLocked},
|
||||
},
|
||||
legacyPath: path,
|
||||
}, func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
message := "Start syncing soundtracks"
|
||||
if full {
|
||||
message += " full"
|
||||
}
|
||||
|
||||
// SyncSoundtracksNewFull godoc
|
||||
// @Summary Sync all soundtracks fully
|
||||
// @Description Starts a full sync of all soundtracks
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Start syncing soundtracks full"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/full [post]
|
||||
func (s *SyncHandler) SyncSoundtracksNewFull(ctx *echo.Context) error {
|
||||
return s.start(ctx, true, "Start syncing soundtracks full")
|
||||
}
|
||||
|
||||
func (s *SyncHandler) start(ctx *echo.Context, full bool, message string) error {
|
||||
if !s.sync.Start(full) {
|
||||
return ctx.JSON(http.StatusLocked, "Syncing is in progress")
|
||||
return nil, huma.Error423Locked("Syncing is in progress")
|
||||
}
|
||||
logging.GetLogger().Info(message)
|
||||
return ctx.JSON(http.StatusOK, message)
|
||||
return ok(message, nil)
|
||||
})
|
||||
}
|
||||
start("start-sync", "/sync", "Sync changed soundtracks",
|
||||
"Starts syncing the music folder in the background, skipping soundtracks that have not changed. "+
|
||||
"Poll /sync/progress for the result.", false)
|
||||
start("start-full-sync", "/sync/full", "Sync all soundtracks",
|
||||
"Starts a full sync in the background that re-reads every soundtrack and removes songs whose files are gone. "+
|
||||
"Poll /sync/progress for the result.", true)
|
||||
|
||||
// ResetDB godoc
|
||||
// @Summary Reset soundtracks database
|
||||
// @Description Resets the soundtracks database by deleting all soundtracks and songs
|
||||
// @Tags sync
|
||||
// @Produce json
|
||||
// @Success 200 {string} string "Soundtracks and songs are deleted from the database"
|
||||
// @Failure 423 {string} string "Syncing is in progress"
|
||||
// @Router /sync/reset [post]
|
||||
func (s *SyncHandler) ResetDB(ctx *echo.Context) error {
|
||||
reset := huma.Operation{
|
||||
OperationID: "reset-library",
|
||||
Method: http.MethodPost,
|
||||
Path: "/sync/reset",
|
||||
Summary: "Delete all soundtracks and songs",
|
||||
Description: "For debugging only.",
|
||||
Tags: []string{"sync"},
|
||||
Hidden: true,
|
||||
}
|
||||
register(s, a, route{op: reset, legacyPath: "/sync/reset", locked: true},
|
||||
func(ctx context.Context, _ *struct{}) (*jsonBody[string], error) {
|
||||
logging.GetLogger().Info("Resetting soundtracks database")
|
||||
if err := s.sync.ResetDB(ctx.Request().Context()); err != nil {
|
||||
return songError(err)
|
||||
}
|
||||
return ctx.JSON(http.StatusOK, "Soundtracks and songs are deleted from the database")
|
||||
return ok("Soundtracks and songs are deleted from the database", s.sync.ResetDB(ctx))
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
@@ -11,8 +12,8 @@ import (
|
||||
"music-server/internal/logging"
|
||||
"music-server/internal/server/middleware"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -20,27 +21,23 @@ const tokenLifetime = 24 * time.Hour
|
||||
|
||||
// TokenRequest represents a request to generate a new token
|
||||
type TokenRequest struct {
|
||||
Password string `json:"password"` // API password, required when API_PASSWORD is set
|
||||
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||
Password string `json:"password,omitempty" doc:"API password, required when the server has one configured"`
|
||||
ClientType string `json:"client_type,omitempty" doc:"Kind of client, for example web, godot or api" default:"web"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the response with a new token
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Token string `json:"token" doc:"Send as Authorization: Bearer <token>"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClientType string `json:"client_type"`
|
||||
}
|
||||
|
||||
// TokenHandler creates and removes session tokens.
|
||||
type TokenHandler struct {
|
||||
q repository.Querier
|
||||
apiPassword string
|
||||
disableSessionAuth bool
|
||||
type tokenInput struct {
|
||||
Body TokenRequest `required:"false"`
|
||||
}
|
||||
|
||||
// NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password.
|
||||
func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler {
|
||||
return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth}
|
||||
type statusMessage struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
@@ -51,115 +48,84 @@ func generateToken() (string, error) {
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (h *TokenHandler) passwordValid(password string) bool {
|
||||
if h.disableSessionAuth || h.apiPassword == "" {
|
||||
func (s *Server) passwordValid(password string) bool {
|
||||
if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" {
|
||||
return true
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
|
||||
}
|
||||
|
||||
// CreateTokenHandler creates a new session token
|
||||
//
|
||||
// @Summary Create session token
|
||||
// @Description Returns a new session token for API access. Requires the API password when one is configured.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body TokenRequest true "Password and client type"
|
||||
// @Success 200 {object} TokenResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 429 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [post]
|
||||
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
var req TokenRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||
func (s *Server) registerToken(a apis) {
|
||||
create := huma.Operation{
|
||||
OperationID: "create-token",
|
||||
Method: http.MethodPost,
|
||||
Path: "/token",
|
||||
Summary: "Create session token",
|
||||
Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " +
|
||||
"Requires the API password when the server has one. Limited to 10 requests per minute per client.",
|
||||
Tags: []string{"auth"},
|
||||
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
|
||||
Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)},
|
||||
}
|
||||
|
||||
if !h.passwordValid(req.Password) {
|
||||
register(s, a, route{op: create, public: true},
|
||||
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
|
||||
client := middleware.ClientFrom(ctx)
|
||||
if !s.passwordValid(in.Body.Password) {
|
||||
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("user_agent", c.Request().UserAgent()))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||
zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent))
|
||||
return nil, huma.Error401Unauthorized("Invalid password")
|
||||
}
|
||||
|
||||
if req.ClientType == "" {
|
||||
req.ClientType = "web"
|
||||
clientType := in.Body.ClientType
|
||||
if clientType == "" {
|
||||
clientType = "web"
|
||||
}
|
||||
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
|
||||
return nil, huma.Error500InternalServerError("Failed to generate token")
|
||||
}
|
||||
|
||||
session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{
|
||||
session, err := s.q.CreateSession(ctx, repository.CreateSessionParams{
|
||||
Token: token,
|
||||
IpAddress: c.RealIP(),
|
||||
UserAgent: c.Request().UserAgent(),
|
||||
ClientType: &req.ClientType,
|
||||
IpAddress: client.IP,
|
||||
UserAgent: client.UserAgent,
|
||||
ClientType: &clientType,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to create session", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"})
|
||||
return nil, huma.Error500InternalServerError("Failed to create session")
|
||||
}
|
||||
return ok(TokenResponse{Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType}, nil)
|
||||
})
|
||||
|
||||
clientType := req.ClientType
|
||||
if session.ClientType != nil {
|
||||
clientType = *session.ClientType
|
||||
register(s, a, route{op: huma.Operation{
|
||||
OperationID: "delete-token",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/token",
|
||||
Summary: "Invalidate session token",
|
||||
Description: "Deletes the token used for this request.",
|
||||
Tags: []string{"auth"},
|
||||
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
|
||||
if err := s.q.DeleteSession(ctx, middleware.Token(ctx)); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
||||
return nil, huma.Error500InternalServerError("Failed to invalidate token")
|
||||
}
|
||||
return c.JSON(http.StatusOK, TokenResponse{
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt.Time,
|
||||
ClientType: clientType,
|
||||
return ok(statusMessage{Status: "token invalidated"}, nil)
|
||||
})
|
||||
|
||||
register(s, a, route{op: huma.Operation{
|
||||
OperationID: "cleanup-tokens",
|
||||
Method: http.MethodPost,
|
||||
Path: "/token/cleanup",
|
||||
Summary: "Remove expired tokens",
|
||||
Description: "Deletes all expired session tokens.",
|
||||
Tags: []string{"auth"},
|
||||
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
|
||||
if err := s.q.DeleteExpiredSessions(ctx); err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
||||
return nil, huma.Error500InternalServerError("Failed to cleanup sessions")
|
||||
}
|
||||
return ok(statusMessage{Status: "cleanup complete"}, nil)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTokenHandler invalidates a session token
|
||||
//
|
||||
// @Summary Invalidate session token
|
||||
// @Description Deletes the current session token
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [delete]
|
||||
func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error {
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"})
|
||||
}
|
||||
token, ok := middleware.BearerToken(c)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"})
|
||||
}
|
||||
|
||||
if err := h.q.DeleteSession(c.Request().Context(), token); err != nil {
|
||||
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"})
|
||||
}
|
||||
|
||||
// CleanupExpiredSessionsHandler removes all expired sessions
|
||||
//
|
||||
// @Summary Cleanup expired sessions
|
||||
// @Description Removes all expired session tokens from the database
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Param Authorization header string true "Bearer token"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token/cleanup [post]
|
||||
func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error {
|
||||
if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil {
|
||||
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"music-server/internal/backend"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type VersionHandler struct{}
|
||||
|
||||
func NewVersionHandler() *VersionHandler {
|
||||
return &VersionHandler{}
|
||||
}
|
||||
|
||||
// GetVersionHistory godoc
|
||||
//
|
||||
// @Summary Getting the version history of the backend
|
||||
// @Description get version history
|
||||
// @Tags version
|
||||
// @Produce json
|
||||
// @Success 200 {array} backend.VersionData
|
||||
// @Router /version/history [get]
|
||||
func (v *VersionHandler) GetVersionHistory(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, backend.GetVersionHistory())
|
||||
}
|
||||
|
||||
// GetLatestVersion godoc
|
||||
//
|
||||
// @Summary Getting the latest version of the backend
|
||||
// @Description get latest version info
|
||||
// @Tags version
|
||||
// @Produce json
|
||||
// @Success 200 {object} backend.VersionData
|
||||
// @Router /version [get]
|
||||
func (v *VersionHandler) GetLatestVersion(ctx *echo.Context) error {
|
||||
return ctx.JSON(http.StatusOK, backend.GetLatestVersion())
|
||||
}
|
||||
@@ -372,3 +372,37 @@ func (s *Store) HasSession(token string) bool {
|
||||
_, ok := s.Sessions[token]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Statistics queries return empty results; tests that need data use their own fakes.
|
||||
|
||||
func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetLeastPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetLeastPlayedSoundtracksWithSongsRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetLastPlayedSoundtracks(context.Context, int32) ([]repository.GetLastPlayedSoundtracksRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetOldestPlayedSoundtracks(context.Context, int32) ([]repository.GetOldestPlayedSoundtracksRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetMostPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetMostPlayedSongsWithSoundtrackRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetLeastPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetLeastPlayedSongsWithSoundtrackRow, error) {
|
||||
return nil, s.Err
|
||||
}
|
||||
|
||||
func (s *Store) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) {
|
||||
return repository.GetStatisticsSummaryRow{}, s.Err
|
||||
}
|
||||
@@ -15,12 +15,7 @@ sqlc-generate:
|
||||
migrate-create name:
|
||||
@migrate create -ext sql -dir internal/db/migrations -seq {{name}}
|
||||
|
||||
# Generate OpenAPI docs (version pinned in go.mod)
|
||||
swag-generate:
|
||||
@echo "Generating OpenAPI docs..."
|
||||
@go tool swag init -g internal/server/routes.go -o cmd/docs --parseInternal
|
||||
|
||||
build: sqlc-generate templ-build swag-generate
|
||||
build: sqlc-generate templ-build
|
||||
@echo "Building..."
|
||||
@go build -o main cmd/main.go
|
||||
|
||||
|
||||
Reference in new issue
Block a user