From 6c1666659c52b787f448c3ca5f33d746cb4e7414 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 3 Nov 2025 16:10:36 +0100 Subject: [PATCH] API docs tweaks --- CLAUDE.md | 56 + music_assistant/controllers/webserver.py | 55 +- music_assistant/helpers/api_docs.py | 1643 +++++++++++++++-- .../helpers/resources/api_docs.html | 84 +- .../helpers/resources/redoc_ui.html | 28 - .../helpers/resources/swagger_ui.html | 94 +- pyproject.toml | 3 +- 7 files changed, 1748 insertions(+), 215 deletions(-) delete mode 100644 music_assistant/helpers/resources/redoc_ui.html diff --git a/CLAUDE.md b/CLAUDE.md index a62f2962..16d846a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,62 @@ These demo/example implementations have a lot of docstrings and comments to help - Docker-based deployment (not standalone pip package) - VS Code launch configurations provided for debugging +## Code Style Guidelines + +### Docstring Format + +Music Assistant uses **Sphinx-style docstrings** with `:param:` syntax for documenting function parameters. This is the standard format used throughout the codebase. + +**Correct format:** +```python +def my_function(param1: str, param2: int, param3: bool = False) -> str: + """Brief one-line description of the function. + + Optional longer description providing more context about what the function does, + why it exists, and any important implementation details. + + :param param1: Description of what param1 is used for. + :param param2: Description of what param2 is used for. + :param param3: Description of what param3 is used for. + """ +``` + +**Key points:** +- Use `:param param_name: description` format for all parameters +- Brief summary on first line, followed by blank line +- Optional detailed description before parameters section +- No need to document types in docstring (use type hints instead) +- No need to document return types in docstring (use type hints instead) + +**Incorrect formats to avoid:** +```python +# ❌ Bullet-style (being phased out) +"""Function description. + +- param1: Description +- param2: Description +""" + +# ❌ Google-style +"""Function description. + +Args: + param1: Description + param2: Description +""" +``` + +**For simple functions**, a single-line docstring is acceptable: +```python +def get_item(self, item_id: str) -> Item: + """Get an item by its ID.""" +``` + +**Enforcement:** +- Ruff with pydocstyle rules enforces basic docstring structure +- Pre-commit hooks check docstring format +- The API documentation generator parses Sphinx-style docstrings for the web interface + ## Branching Strategy ### Branch Structure diff --git a/music_assistant/controllers/webserver.py b/music_assistant/controllers/webserver.py index 30d8ca9e..1b2570c6 100644 --- a/music_assistant/controllers/webserver.py +++ b/music_assistant/controllers/webserver.py @@ -33,7 +33,11 @@ from music_assistant_models.errors import InvalidCommand from music_assistant.constants import CONF_BIND_IP, CONF_BIND_PORT, VERBOSE_LOG_LEVEL from music_assistant.helpers.api import APICommandHandler, parse_arguments -from music_assistant.helpers.api_docs import generate_openapi_spec +from music_assistant.helpers.api_docs import ( + generate_commands_reference, + generate_openapi_spec, + generate_schemas_reference, +) from music_assistant.helpers.audio import get_preview_stream from music_assistant.helpers.json import json_dumps, json_loads from music_assistant.helpers.util import get_ip_addresses @@ -157,9 +161,14 @@ class WebserverController(CoreController): routes.append(("POST", "/api", self._handle_jsonrpc_api_command)) # add api documentation routes.append(("GET", "/api-docs", self._handle_api_intro)) + routes.append(("GET", "/api-docs/", self._handle_api_intro)) + routes.append(("GET", "/api-docs/commands", self._handle_commands_reference)) + routes.append(("GET", "/api-docs/commands/", self._handle_commands_reference)) + routes.append(("GET", "/api-docs/schemas", self._handle_schemas_reference)) + routes.append(("GET", "/api-docs/schemas/", self._handle_schemas_reference)) routes.append(("GET", "/api-docs/openapi.json", self._handle_openapi_spec)) routes.append(("GET", "/api-docs/swagger", self._handle_swagger_ui)) - routes.append(("GET", "/api-docs/redoc", self._handle_redoc_ui)) + routes.append(("GET", "/api-docs/swagger/", self._handle_swagger_ui)) # start the webserver all_ip_addresses = await get_ip_addresses() default_publish_ip = all_ip_addresses[0] @@ -265,14 +274,23 @@ class WebserverController(CoreController): error = f"Invalid Command: {command_msg.command}" self.logger.error("Unhandled JSONRPC API error: %s", error) return web.Response(status=400, text=error) - args = parse_arguments(handler.signature, handler.type_hints, command_msg.args) - result = handler.target(**args) - if hasattr(result, "__anext__"): - # handle async generator (for really large listings) - result = [item async for item in result] - elif asyncio.iscoroutine(result): - result = await result - return web.json_response(result, dumps=json_dumps) + + try: + args = parse_arguments(handler.signature, handler.type_hints, command_msg.args) + result = handler.target(**args) + if hasattr(result, "__anext__"): + # handle async generator (for really large listings) + result = [item async for item in result] + elif asyncio.iscoroutine(result): + result = await result + return web.json_response(result, dumps=json_dumps) + except Exception as e: + # Return clean error message without stacktrace + error_type = type(e).__name__ + error_msg = str(e) + error = f"{error_type}: {error_msg}" + self.logger.error("Error executing command %s: %s", command_msg.command, error) + return web.Response(status=500, text=error) async def _handle_application_log(self, request: web.Request) -> web.Response: """Handle request to get the application log.""" @@ -302,6 +320,16 @@ class WebserverController(CoreController): ) return web.json_response(spec) + async def _handle_commands_reference(self, request: web.Request) -> web.Response: + """Handle request for commands reference page (generated on-the-fly).""" + html = generate_commands_reference(self.mass.command_handlers, server_url=self.base_url) + return web.Response(text=html, content_type="text/html") + + async def _handle_schemas_reference(self, request: web.Request) -> web.Response: + """Handle request for schemas reference page (generated on-the-fly).""" + html = generate_schemas_reference(self.mass.command_handlers) + return web.Response(text=html, content_type="text/html") + async def _handle_swagger_ui(self, request: web.Request) -> web.Response: """Handle request for Swagger UI.""" swagger_html_path = os.path.join( @@ -309,13 +337,6 @@ class WebserverController(CoreController): ) return await self._server.serve_static(swagger_html_path, request) - async def _handle_redoc_ui(self, request: web.Request) -> web.Response: - """Handle request for ReDoc UI.""" - redoc_html_path = os.path.join( - os.path.dirname(__file__), "..", "helpers", "resources", "redoc_ui.html" - ) - return await self._server.serve_static(redoc_html_path, request) - class WebsocketClientHandler: """Handle an active websocket client connection.""" diff --git a/music_assistant/helpers/api_docs.py b/music_assistant/helpers/api_docs.py index 0a6a9d9b..1e561b1a 100644 --- a/music_assistant/helpers/api_docs.py +++ b/music_assistant/helpers/api_docs.py @@ -74,12 +74,18 @@ def _get_type_schema( # noqa: PLR0911, PLR0915 # Check if it looks like a simple class name (no special chars, starts with uppercase) # Examples: "PlayerType", "DeviceInfo", "PlaybackState" - if type_hint.isidentifier() and type_hint[0].isupper(): + # Exclude generic types like "Any", "Union", "Optional", etc. + excluded_types = {"Any", "Union", "Optional", "List", "Dict", "Tuple", "Set"} + if type_hint.isidentifier() and type_hint[0].isupper() and type_hint not in excluded_types: # Create a schema reference for this type if type_hint not in definitions: definitions[type_hint] = {"type": "object"} return {"$ref": f"#/components/schemas/{type_hint}"} + # If it's "Any", return generic object without creating a schema + if type_hint == "Any": + return {"type": "object"} + # For complex type expressions like "str | None", "list[str]", return generic object return {"type": "object"} @@ -338,6 +344,21 @@ def _parse_docstring( # noqa: PLR0915 current_param = None continue + # Detect bullet-style params even without explicit section header + # Format: "- param_name: description" + if stripped.startswith("- ") and ":" in stripped: + # This is likely a bullet-style parameter + current_section = "params" + content = stripped[2:] # Remove "- " + parts = content.split(":", 1) + param_name = parts[0].strip() + desc_part = parts[1].strip() if len(parts) > 1 else "" + if param_name and not param_name.startswith(("return", "yield", "raise")): + current_param = param_name + if desc_part: + param_descriptions[current_param] = desc_part + continue + # In params section, detect param lines (indented or starting with name) if current_section == "params" and stripped: # Google/NumPy style: "param_name: description" or "param_name (type): description" @@ -394,97 +415,95 @@ def generate_openapi_spec( server_url: str = "http://localhost:8095", version: str = "1.0.0", ) -> dict[str, Any]: - """Generate OpenAPI 3.0 specification from API command handlers.""" - definitions: dict[str, Any] = {} - paths: dict[str, Any] = {} - - for command, handler in sorted(command_handlers.items()): - # Parse docstring - summary, description, param_descriptions = _parse_docstring(handler.target) + """Generate simplified OpenAPI 3.0 specification focusing on data models. - # Build request body - request_body_properties = {} - request_body_required = [] + This spec documents the single /api endpoint and all data models/schemas. + For detailed command documentation, see the Commands Reference page. + """ + definitions: dict[str, Any] = {} - for param_name, param in handler.signature.parameters.items(): + # Build all schemas from command handlers (this populates definitions) + for handler in command_handlers.values(): + # Build parameter schemas + for param_name in handler.signature.parameters: if param_name == "self": continue - param_type = handler.type_hints.get(param_name, Any) - param_schema = _get_type_schema(param_type, definitions) - param_description = param_descriptions.get(param_name, "") - - # Check if parameter is required - is_required = param.default is inspect.Parameter.empty - - # Add default value if present - if not is_required: - # Try to serialize the default value - try: - if param.default is None: - param_schema["default"] = None - elif isinstance(param.default, (str, int, float, bool)): - param_schema["default"] = param.default - elif isinstance(param.default, Enum): - param_schema["default"] = param.default.value - elif isinstance(param.default, (list, dict)): - param_schema["default"] = param.default - except Exception: # noqa: S110 - # If we can't serialize it, just skip the default - pass - - # Add to request body properties - request_body_properties[param_name] = { - **param_schema, - "description": param_description, - } - if is_required: - request_body_required.append(param_name) + # Skip Any types as they don't provide useful schema information + if param_type is not Any and str(param_type) != "typing.Any": + _get_type_schema(param_type, definitions) - # Build response + # Build return type schema return_type = handler.type_hints.get("return", Any) - response_schema = _get_type_schema(return_type, definitions) + # Skip Any types as they don't provide useful schema information + if return_type is not Any and str(return_type) != "typing.Any": + _get_type_schema(return_type, definitions) - # Build path item - path = f"/{command}" - paths[path] = { + # Build a single /api endpoint with generic request/response + paths = { + "/api": { "post": { - "summary": summary or command, - "description": description, - "operationId": command.replace("/", "_"), - "tags": [command.split("/")[0]] if "/" in command else ["general"], + "summary": "Execute API command", + "description": ( + "Execute any Music Assistant API command.\n\n" + "See the **Commands Reference** page for a complete list of available " + "commands with examples." + ), + "operationId": "execute_command", "requestBody": { "required": True, "content": { "application/json": { "schema": { "type": "object", - "properties": request_body_properties, - **( - {"required": request_body_required} - if request_body_required - else {} - ), - } + "required": ["command"], + "properties": { + "command": { + "type": "string", + "description": ( + "The command to execute (e.g., 'players/all')" + ), + "example": "players/all", + }, + "args": { + "type": "object", + "description": "Command arguments (varies by command)", + "additionalProperties": True, + "example": {}, + }, + }, + }, + "examples": { + "get_players": { + "summary": "Get all players", + "value": {"command": "players/all", "args": {}}, + }, + "play_media": { + "summary": "Play media on a player", + "value": { + "command": "players/cmd/play", + "args": {"player_id": "player123"}, + }, + }, + }, } }, - } - if request_body_properties - else None, + }, "responses": { "200": { - "description": "Successful response", - "content": {"application/json": {"schema": response_schema}}, + "description": "Successful command execution", + "content": { + "application/json": { + "schema": {"description": "Command result (varies by command)"} + } + }, }, - "400": {"description": "Bad request - invalid parameters"}, + "400": {"description": "Bad request - invalid command or parameters"}, "500": {"description": "Internal server error"}, }, } } - - # Remove requestBody if empty - if not request_body_properties: - del paths[path]["post"]["requestBody"] + } # Build OpenAPI spec return { @@ -492,67 +511,13 @@ def generate_openapi_spec( "info": { "title": "Music Assistant API", "version": version, - "description": """ -# Music Assistant API Documentation - -Music Assistant provides two ways to interact with the API: - -## WebSocket API (Recommended) -- **Endpoint:** `ws://{server}/ws` -- **Features:** - - Full API access to all commands - - Real-time event updates - - Bi-directional communication - - Best for applications that need live updates - -### WebSocket Message Format -Send commands as JSON messages: -```json -{ - "message_id": "unique-id", - "command": "command/name", - "args": { - "param1": "value1", - "param2": "value2" - } -} -``` - -Receive responses: -```json -{ - "message_id": "unique-id", - "result": { ... } -} -``` - -## REST API (Simple) -- **Endpoint:** `POST /api` -- **Features:** - - Simple HTTP POST requests - - JSON request/response - - Best for simple, incidental commands - -### REST Message Format -Send POST request to `/api` with JSON body: -```json -{ - "command": "command/name", - "args": { - "param1": "value1", - "param2": "value2" - } -} -``` - -Receive JSON response with result. - -## Authentication -Authentication is not yet implemented but will be added in a future release. - -## API Commands -All commands listed below are available via both WebSocket and REST interfaces. - """.strip(), + "description": ( + "Music Assistant API provides control over your music library, " + "players, and playback.\n\n" + "This specification documents the API structure and data models. " + "For a complete list of available commands with examples, " + "see the Commands Reference page." + ), "contact": { "name": "Music Assistant", "url": "https://music-assistant.io", @@ -564,6 +529,1426 @@ All commands listed below are available via both WebSocket and REST interfaces. } +def _split_union_type(type_str: str) -> list[str]: + """Split a union type on | but respect brackets and parentheses. + + This ensures that list[A | B] and (A | B) are not split at the inner |. + """ + parts = [] + current_part = "" + bracket_depth = 0 + paren_depth = 0 + i = 0 + while i < len(type_str): + char = type_str[i] + if char == "[": + bracket_depth += 1 + current_part += char + elif char == "]": + bracket_depth -= 1 + current_part += char + elif char == "(": + paren_depth += 1 + current_part += char + elif char == ")": + paren_depth -= 1 + current_part += char + elif char == "|" and bracket_depth == 0 and paren_depth == 0: + # Check if this is a union separator (has space before and after) + if ( + i > 0 + and i < len(type_str) - 1 + and type_str[i - 1] == " " + and type_str[i + 1] == " " + ): + parts.append(current_part.strip()) + current_part = "" + i += 1 # Skip the space after |, the loop will handle incrementing i + else: + current_part += char + else: + current_part += char + i += 1 + if current_part.strip(): + parts.append(current_part.strip()) + return parts + + +def _python_type_to_json_type(type_str: str, _depth: int = 0) -> str: + """Convert Python type string to JSON/JavaScript type string. + + Args: + type_str: The type string to convert + _depth: Internal recursion depth tracker (do not set manually) + """ + import re # noqa: PLC0415 + + # Prevent infinite recursion + if _depth > 50: + return "any" + + # Remove typing module prefix and class markers + type_str = type_str.replace("typing.", "").replace("", "") + + # Remove module paths from type names (e.g., "music_assistant.models.Artist" -> "Artist") + type_str = re.sub(r"[\w.]+\.(\w+)", r"\1", type_str) + + # Map Python types to JSON types + type_mappings = { + "str": "string", + "int": "integer", + "float": "number", + "bool": "boolean", + "dict": "object", + "Dict": "object", + "None": "null", + "NoneType": "null", + } + + # Check for List/list/UniqueList with type parameter BEFORE checking for union types + # This is important because list[A | B] contains " | " but should be handled as a list first + # We need to match list[...] where the brackets are balanced + if type_str.startswith(("list[", "List[", "UniqueList[")): # codespell:ignore + # Find the matching closing bracket + bracket_count = 0 + start_idx = type_str.index("[") + 1 + end_idx = -1 + for i in range(start_idx, len(type_str)): + if type_str[i] == "[": + bracket_count += 1 + elif type_str[i] == "]": + if bracket_count == 0: + end_idx = i + break + bracket_count -= 1 + + # Check if this is a complete list type (ends with the closing bracket) + if end_idx == len(type_str) - 1: + inner_type = type_str[start_idx:end_idx].strip() + # Recursively convert the inner type + inner_json_type = _python_type_to_json_type(inner_type, _depth + 1) + # For list[A | B], wrap in parentheses to keep it as one unit + # This prevents "Array of A | B" from being split into separate union parts + if " | " in inner_json_type: + return f"Array of ({inner_json_type})" + return f"Array of {inner_json_type}" + + # Handle Union types by splitting on | and recursively processing each part + if " | " in type_str: + # Use helper to split on | but respect brackets + parts = _split_union_type(type_str) + + # Filter out None types + parts = [part for part in parts if part != "None"] + + # If splitting didn't help (only one part or same as input), avoid infinite recursion + if not parts or (len(parts) == 1 and parts[0] == type_str): + # Can't split further, return as-is or "any" + return type_str if parts else "any" + + if parts: + converted_parts = [_python_type_to_json_type(part, _depth + 1) for part in parts] + # Remove duplicates while preserving order + seen = set() + unique_parts = [] + for part in converted_parts: + if part not in seen: + seen.add(part) + unique_parts.append(part) + return " | ".join(unique_parts) + return "any" + + # Check for Union/Optional types with brackets + if "Union[" in type_str or "Optional[" in type_str: + # Extract content from Union[...] or Optional[...] + union_match = re.search(r"(?:Union|Optional)\[([^\]]+)\]", type_str) + if union_match: + inner = union_match.group(1) + # Recursively process the union content + return _python_type_to_json_type(inner, _depth + 1) + + # Direct mapping for basic types + for py_type, json_type in type_mappings.items(): + if type_str == py_type: + return json_type + + # Check if it's a complex type (starts with capital letter) + complex_match = re.search(r"^([A-Z][a-zA-Z0-9_]*)$", type_str) + if complex_match: + return complex_match.group(1) + + # Default to the original string if no mapping found + return type_str + + +def _make_type_links(type_str: str, server_url: str, as_list: bool = False) -> str: + """Convert type string to HTML with links to schemas reference for complex types. + + Args: + type_str: The type string to convert + server_url: Base server URL for building links + as_list: If True and type contains |, format as "Any of:" bullet list + """ + import re # noqa: PLC0415 + from re import Match # noqa: PLC0415 + + # Find all complex types (capitalized words that aren't basic types) + def replace_type(match: Match[str]) -> str: + type_name = match.group(0) + # Check if it's a complex type (starts with capital letter) + # Exclude basic types and "Array" (which is used in "Array of Type") + excluded = {"Union", "Optional", "List", "Dict", "Array"} + if type_name[0].isupper() and type_name not in excluded: + # Create link to our schemas reference page + schema_url = f"{server_url}/api-docs/schemas#schema-{type_name}" + return f'{type_name}' + return type_name + + # If it's a union type with multiple options and as_list is True, format as bullet list + if as_list and " | " in type_str: + # Use the bracket/parenthesis-aware splitter + parts = _split_union_type(type_str) + # Only use list format if there are 3+ options + if len(parts) >= 3: + html = '
Any of:
    ' + for part in parts: + linked_part = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, part) + html += f"
  • {linked_part}
  • " + html += "
" + return html + + # Replace complex type names with links + result: str = re.sub(r"\b[A-Z][a-zA-Z0-9_]*\b", replace_type, type_str) + return result + + +def generate_commands_reference( # noqa: PLR0915 + command_handlers: dict[str, APICommandHandler], + server_url: str = "http://localhost:8095", +) -> str: + """Generate HTML commands reference page with all available commands.""" + import json # noqa: PLC0415 + + # Group commands by category + categories: dict[str, list[tuple[str, APICommandHandler]]] = {} + for command, handler in sorted(command_handlers.items()): + category = command.split("/")[0] if "/" in command else "general" + if category not in categories: + categories[category] = [] + categories[category].append((command, handler)) + + html = """ + + + + + Music Assistant API - Commands Reference + + + +
+

Commands Reference

+

Complete list of Music Assistant API commands

+
+ + + +
+""" + + for category, commands in sorted(categories.items()): + category_display = category.replace("_", " ").title() + html += f'
\n' + html += f'
{category_display}
\n' + html += '
\n' + + for command, handler in commands: + # Parse docstring + summary, description, param_descriptions = _parse_docstring(handler.target) + + # Get return type + return_type = handler.type_hints.get("return", Any) + return_type_str = _python_type_to_json_type(str(return_type)) + + html += f'
\n' + html += ( + '
\n' + ) + html += '
\n' + html += f'
{command}
\n' + if summary: + summary_escaped = summary.replace("<", "<").replace(">", ">") + html += ( + f'
' + f"{summary_escaped}
\n" + ) + html += "
\n" + html += '
▼
\n' + html += "
\n" + + # Command details (collapsed by default) + html += '
\n' + + if description and description != summary: + desc_escaped = description.replace("<", "<").replace(">", ">") + html += ( + f'
' + f"{desc_escaped}
\n" + ) + + # Return type with links + return_type_html = _make_type_links(return_type_str, server_url) + html += '
\n' + html += ' Returns:\n' + html += f' {return_type_html}\n' # noqa: E501 + html += "
\n" + + # Parameters + params = [] + for param_name, param in handler.signature.parameters.items(): + if param_name == "self": + continue + is_required = param.default is inspect.Parameter.empty + param_type = handler.type_hints.get(param_name, Any) + type_str = str(param_type) + json_type_str = _python_type_to_json_type(type_str) + param_desc = param_descriptions.get(param_name, "") + params.append((param_name, is_required, json_type_str, param_desc)) + + if params: + html += '
\n' + html += '
Parameters:
\n' + for param_name, is_required, type_str, param_desc in params: + # Convert type to HTML with links (use list format for unions) + type_html = _make_type_links(type_str, server_url, as_list=True) + html += '
\n' + html += ( + f' ' + f"{param_name}\n" + ) + if is_required: + html += ( + ' ' + "REQUIRED\n" + ) + # If it's a list format, display it differently + if "
    " in type_html: + html += ( + '
    ' + f"{type_html}
    \n" + ) + else: + html += ( + f' ' + f"{type_html}\n" + ) + if param_desc: + html += ( + f'
    ' + f"{param_desc}
    \n" + ) + html += "
\n" + html += "
\n" + + # Build example curl command with JSON types + example_args: dict[str, Any] = {} + for param_name, is_required, type_str, _ in params: + # Include optional params if few params + if is_required or len(params) <= 2: + if type_str == "string": + example_args[param_name] = "example_value" + elif type_str == "integer": + example_args[param_name] = 0 + elif type_str == "number": + example_args[param_name] = 0.0 + elif type_str == "boolean": + example_args[param_name] = True + elif type_str == "object": + example_args[param_name] = {} + elif type_str == "null": + example_args[param_name] = None + elif type_str.startswith("Array of "): + # Array type with item type specified (e.g., "Array of Artist") + item_type = type_str[9:] # Remove "Array of " + if item_type in {"string", "integer", "number", "boolean"}: + example_args[param_name] = [] + else: + # Complex type array + example_args[param_name] = [ + {"_comment": f"See {item_type} schema in Swagger UI"} + ] + else: + # Complex type (Artist, Player, etc.) - use placeholder object + # Extract the primary type if it's a union (e.g., "Artist | string") + primary_type = type_str.split(" | ")[0] if " | " in type_str else type_str + example_args[param_name] = { + "_comment": f"See {primary_type} schema in Swagger UI" + } + + request_body: dict[str, Any] = {"command": command} + if example_args: + request_body["args"] = example_args + + curl_cmd = ( + f"curl -X POST {server_url}/api \\\n" + ' -H "Content-Type: application/json" \\\n' + f" -d '{json.dumps(request_body, indent=2)}'" + ) + + # Add tabs for curl example and try it + html += '
\n' + html += '
\n' + html += ( + ' \n" + ) + html += ( + ' \n" # noqa: E501 + ) + html += "
\n" + + # cURL tab + html += f'
\n' # noqa: E501 + html += '
\n' + html += ( + ' \n' + ) + html += f"
{curl_cmd}
\n" + html += "
\n" + html += "
\n" + + # Try It tab + html += f'
\n' # noqa: E501 + html += '
\n' + # HTML-escape the JSON for the textarea + json_str = json.dumps(request_body, indent=2) + # Escape HTML entities + json_str_escaped = ( + json_str.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) + html += f' \n' # noqa: E501 + html += ( + f' \n" + ) + html += '
\n' + html += "
\n" + html += "
\n" + + html += "
\n" + # Close command-details div + html += "
\n" + # Close command div + html += "
\n" + + html += "
\n" + html += "
\n" + + html += """
+ + + + +""" + + return html + + +def generate_schemas_reference( # noqa: PLR0915 + command_handlers: dict[str, APICommandHandler], +) -> str: + """Generate HTML schemas reference page with all data models.""" + # Collect all unique schemas from commands + schemas: dict[str, Any] = {} + + for handler in command_handlers.values(): + # Collect schemas from parameters + for param_name in handler.signature.parameters: + if param_name == "self": + continue + param_type = handler.type_hints.get(param_name, Any) + if param_type is not Any and str(param_type) != "typing.Any": + _get_type_schema(param_type, schemas) + + # Collect schemas from return type + return_type = handler.type_hints.get("return", Any) + if return_type is not Any and str(return_type) != "typing.Any": + _get_type_schema(return_type, schemas) + + # Build HTML + html = """ + + + + + Music Assistant API - Schemas Reference + + + +
+

Schemas Reference

+

Data models and types used in the Music Assistant API

+
+ + + +
+ ← Back to API Documentation +""" + + # Add each schema + for schema_name in sorted(schemas.keys()): + schema_def = schemas[schema_name] + html += ( + f'
\n' + ) + html += '
\n' + html += f'
{schema_name}
\n' + html += '
▼
\n' + html += "
\n" + html += '
\n' + + # Add description if available + if "description" in schema_def: + desc = schema_def["description"] + html += f'
{desc}
\n' + + # Add properties if available + if "properties" in schema_def: + html += '
\n' + html += '
Properties:
\n' + + # Get required fields list + required_fields = schema_def.get("required", []) + + for prop_name, prop_def in schema_def["properties"].items(): + html += '
\n' + html += f' {prop_name}\n' + + # Check if field is required + is_required = prop_name in required_fields + + # Check if field is nullable (type is "null" or has null in anyOf/oneOf) + is_nullable = False + if "type" in prop_def and prop_def["type"] == "null": + is_nullable = True + elif "anyOf" in prop_def: + is_nullable = any(item.get("type") == "null" for item in prop_def["anyOf"]) + elif "oneOf" in prop_def: + is_nullable = any(item.get("type") == "null" for item in prop_def["oneOf"]) + + # Add required/optional badge + if is_required: + html += ( + ' REQUIRED\n' + ) + else: + html += ( + ' OPTIONAL\n' + ) + + # Add nullable badge if applicable + if is_nullable: + html += ( + ' NULLABLE\n' + ) + + # Add type + if "type" in prop_def: + prop_type = prop_def["type"] + html += ( + f' {prop_type}\n' + ) + elif "$ref" in prop_def: + # Extract type name from $ref + ref_type = prop_def["$ref"].split("/")[-1] + html += ( + f' ' + f'' + f"{ref_type}\n" + ) + + # Add description + if "description" in prop_def: + prop_desc = prop_def["description"] + html += ( + f'
' + f"{prop_desc}
\n" + ) + + # Add enum values if present + if "enum" in prop_def: + html += '
\n' + html += ( + '
' + "Possible values:
\n" + ) + for enum_val in prop_def["enum"]: + html += ( + f' ' + f"{enum_val}\n" + ) + html += "
\n" + + html += "
\n" + + html += "
\n" + + html += "
\n" + html += "
\n" + + html += """ +
+ + 📄 Download OpenAPI Spec + +
+
+ + + + +""" + + return html + + def generate_html_docs( # noqa: PLR0915 command_handlers: dict[str, APICommandHandler], server_url: str = "http://localhost:8095", diff --git a/music_assistant/helpers/resources/api_docs.html b/music_assistant/helpers/resources/api_docs.html index ee3ee26c..04bfc12e 100644 --- a/music_assistant/helpers/resources/api_docs.html +++ b/music_assistant/helpers/resources/api_docs.html @@ -30,59 +30,59 @@ .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; - padding: 60px 40px; + padding: 40px 40px; text-align: center; display: flex; flex-direction: column; align-items: center; - gap: 20px; + gap: 15px; } .header .logo { - width: 80px; - height: 80px; + width: 60px; + height: 60px; } .header h1 { - font-size: 3em; + font-size: 2.2em; margin: 0; - font-weight: 700; + font-weight: 600; } .header .version { - font-size: 1.2em; + font-size: 1em; opacity: 0.9; font-weight: 300; } .content { - padding: 50px 40px; + padding: 35px 40px; } .section { - margin-bottom: 50px; + margin-bottom: 35px; } .section h2 { color: #667eea; - font-size: 2em; - margin-bottom: 20px; - padding-bottom: 10px; - border-bottom: 3px solid #667eea; + font-size: 1.6em; + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 2px solid #667eea; } .section h3 { color: #764ba2; - font-size: 1.5em; - margin: 30px 0 15px 0; + font-size: 1.3em; + margin: 25px 0 12px 0; } .section p { - margin-bottom: 15px; - font-size: 1.05em; - line-height: 1.8; + margin-bottom: 12px; + font-size: 1em; + line-height: 1.6; } .api-boxes { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 25px; - margin: 30px 0; + gap: 20px; + margin: 20px 0; } .api-box { background: #f8f9fa; - padding: 30px; + padding: 25px; border-radius: 12px; border-left: 5px solid #667eea; transition: transform 0.2s, box-shadow 0.2s; @@ -93,8 +93,8 @@ } .api-box h4 { color: #667eea; - font-size: 1.3em; - margin-bottom: 12px; + font-size: 1.2em; + margin-bottom: 10px; } .api-box p { color: #666; @@ -247,25 +247,25 @@
-

API Explorers

+

API Documentation

- Interactive documentation where you can test all API endpoints directly in your browser: + Explore the Music Assistant API with our documentation tools:

-

📘 Swagger UI

-

Test endpoints interactively, see request/response examples, and try out the API

- Open Swagger UI +

📚 Commands Reference

+

Complete list of all available commands with parameters, descriptions, working curl examples, and interactive testing

+ View Commands
-

📗 ReDoc

-

Beautiful, detailed documentation with a clean three-panel layout

- Open ReDoc +

📊 Schemas Reference

+

All data models and types with their properties, descriptions, and relationships

+ View Schemas
-

📄 OpenAPI Spec

-

Download the OpenAPI 3.0 specification for automated client generation

- Download JSON +

📘 Swagger UI

+

Interactive API explorer with OpenAPI specification and "Try it out" functionality

+ Open Swagger UI
@@ -278,11 +278,15 @@ The WebSocket API provides real-time bidirectional communication and automatic event notifications. Perfect for applications that need live updates.

+
+ Note: WebSocket messages require a message_id field to match requests with responses. + This allows multiple concurrent requests over the same connection. +
# Connect to WebSocket ws://{SERVER_HOST}/ws -# Send a command +# Send a command (message_id is REQUIRED) { "message_id": "unique-id-123", "command": "players/all", @@ -302,10 +306,16 @@ ws://{SERVER_HOST}/ws }
-

REST API (Simple)

+

HTTP API (RPC)

- For simple, one-off commands without needing real-time updates. + The HTTP API provides a simple RPC-like interface for executing commands. + This allows you to call the same commands available via WebSocket over a simple HTTP POST endpoint. + Perfect for one-off commands without needing real-time updates.

+
+ Note: The message_id field is optional for HTTP requests + since each HTTP request is isolated. The response returns the command result directly. +
# Get all players curl -X POST {BASE_URL}/api \ diff --git a/music_assistant/helpers/resources/redoc_ui.html b/music_assistant/helpers/resources/redoc_ui.html deleted file mode 100644 index ed67b298..00000000 --- a/music_assistant/helpers/resources/redoc_ui.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - Music Assistant API Documentation - - - - - - - - diff --git a/music_assistant/helpers/resources/swagger_ui.html b/music_assistant/helpers/resources/swagger_ui.html index 3eed9dcf..872a6a70 100644 --- a/music_assistant/helpers/resources/swagger_ui.html +++ b/music_assistant/helpers/resources/swagger_ui.html @@ -3,7 +3,7 @@ - Music Assistant API Explorer + Music Assistant API - Swagger UI