diff --git a/tests/remote/unit/test_routes.py b/tests/remote/unit/test_routes.py index 0cba1688..4ac3b00a 100644 --- a/tests/remote/unit/test_routes.py +++ b/tests/remote/unit/test_routes.py @@ -347,3 +347,13 @@ def test_setup_routes_cdm_only_keeps_health_and_dashboard_only() -> None: assert "/api/health" in paths assert any(path.startswith("/api/dashboard/") for path in paths) assert all(path == "/api/health" or path.startswith("/api/dashboard/") for path in paths) + + +def test_setup_routes_with_docs_registers_each_route_once() -> None: + """Swagger registers the routes itself, so docs add none twice and drop none.""" + plain, documented = web.Application(), web.Application() + setup_routes(plain, dashboard=True) + setup_routes(documented, dashboard=True, docs=True) + routes_with_docs = Counter((r.method, r.resource.canonical) for r in documented.router.routes()) + assert max(routes_with_docs.values()) == 1 + assert {route for route in routes_with_docs if not route[1].startswith("/api/docs")} == set(collect_paths(plain)) diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index b4412621..f6084c23 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -16,7 +16,7 @@ from aiohttp import web import unshackle.core.config as config_module from unshackle.core import binaries -from unshackle.core.api import cors_middleware, no_5xx_middleware, setup_routes, setup_swagger +from unshackle.core.api import cors_middleware, no_5xx_middleware, setup_routes from unshackle.core.api.compression import compression_middleware from unshackle.core.api.events import publish_refresh_events, publish_service_event from unshackle.core.api.handlers import ( @@ -493,29 +493,17 @@ def serve( serve_config, playready_config = _build_cdm_config(config.serve, api_secret, serve_widevine, serve_playready) prd_devices = playready_config["devices"] + base_middlewares = [cors_middleware, no_5xx_middleware, stats_middleware] if api_only: log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)") - if no_key: - app = web.Application( - middlewares=[ - cors_middleware, - no_5xx_middleware, - stats_middleware, - dashboard_authentication, - compression_middleware, - ] - ) - else: - app = web.Application( - middlewares=[ - cors_middleware, - no_5xx_middleware, - stats_middleware, - dashboard_authentication, - api_key_authentication, - compression_middleware, - ] - ) + app = web.Application( + middlewares=[ + *base_middlewares, + dashboard_authentication, + *([] if no_key else [api_key_authentication]), + compression_middleware, + ] + ) app["config"] = serve_config app["debug_api"] = debug_api @@ -523,9 +511,8 @@ def serve( _install_service_refresh(app) _install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready) - setup_routes(app, remote_only=remote_only, dashboard=dashboard) + setup_routes(app, remote_only=remote_only, dashboard=dashboard, docs=not remote_only) if not remote_only: - setup_swagger(app, dashboard=dashboard) log.info(f"REST API endpoints available at http://{host}:{port}/api/") log.info(f"Swagger UI available at http://{host}:{port}/api/docs/") else: @@ -546,18 +533,17 @@ def serve( secret_key = request_secret_key(request) if request.path.startswith(DASHBOARD_PREFIX): return await handler(request) - if ( - request.path != "/api/health" - and secret_key - and any(hmac.compare_digest(secret_key, k) for k in request.app["config"]["users"]) - ): + known = bool(secret_key) and any( + hmac.compare_digest(secret_key, k) for k in request.app["config"]["users"] + ) + if request.path != "/api/health" and secret_key and known: limited = rate_limit_response(secret_key) if limited is not None: return limited if serve_playready_flag and request.path in ("/playready", "/playready/"): response = await handler(request) elif secret_key and not request.headers.get("X-Secret-Key"): - if not any(hmac.compare_digest(secret_key, k) for k in request.app["config"]["users"]): + if not known: return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401) response = await handler(request) else: @@ -574,30 +560,15 @@ def serve( return serve_authentication - if no_key: - app = web.Application( - middlewares=[ - cors_middleware, - no_5xx_middleware, - stats_middleware, - cdm_log_middleware, - dashboard_authentication, - compression_middleware, - ] - ) - else: - serve_auth = create_serve_authentication(serve_playready and bool(prd_devices)) - app = web.Application( - middlewares=[ - cors_middleware, - no_5xx_middleware, - stats_middleware, - cdm_log_middleware, - dashboard_authentication, - serve_auth, - compression_middleware, - ] - ) + app = web.Application( + middlewares=[ + *base_middlewares, + cdm_log_middleware, + dashboard_authentication, + *([] if no_key else [create_serve_authentication(serve_playready and bool(prd_devices))]), + compression_middleware, + ] + ) app["config"] = serve_config app["debug_api"] = debug_api @@ -637,7 +608,9 @@ def serve( elif serve_playready: log.info("No PlayReady devices found, skipping PlayReady CDM endpoints") - setup_routes(app, remote_only=remote_only, dashboard=dashboard, cdm_only=cdm_only) + setup_routes( + app, remote_only=remote_only, dashboard=dashboard, cdm_only=cdm_only, docs=not (remote_only or cdm_only) + ) if serve_widevine: log.info(f"Widevine CDM endpoints available at http://{host}:{port}/{{device}}/open") @@ -646,7 +619,6 @@ def serve( elif remote_only: log.info(f"Remote service endpoints available at http://{host}:{port}/api/session/") else: - setup_swagger(app, dashboard=dashboard) log.info(f"REST API endpoints available at http://{host}:{port}/api/") log.info(f"Swagger UI available at http://{host}:{port}/api/docs/") log.info("(Press CTRL+C to quit)") diff --git a/unshackle/core/api/__init__.py b/unshackle/core/api/__init__.py index b38b32ef..692222be 100644 --- a/unshackle/core/api/__init__.py +++ b/unshackle/core/api/__init__.py @@ -1,3 +1,3 @@ -from unshackle.core.api.routes import cors_middleware, no_5xx_middleware, setup_routes, setup_swagger +from unshackle.core.api.routes import cors_middleware, no_5xx_middleware, setup_routes -__all__ = ["setup_routes", "setup_swagger", "cors_middleware", "no_5xx_middleware"] +__all__ = ["setup_routes", "cors_middleware", "no_5xx_middleware"] diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 42fbb1eb..c02a2b4d 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -913,7 +913,7 @@ class DownloadQueueManager: jobs_to_remove = [] for job_id, job in self._jobs.items(): - if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]: + if job.status in TERMINAL_STATUSES: if job.completed_time and job.completed_time < cutoff_time: jobs_to_remove.append(job_id) elif not job.completed_time and job.created_time < cutoff_time: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index dc11c3de..649984d4 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -312,24 +312,29 @@ def server_login_material( """ from unshackle.commands.dl import dl from unshackle.core.api.stats import stats - from unshackle.core.credential import Credential if account: return server_account_cookies(normalized_service, profile), dl.get_credentials(normalized_service, profile) if stats.mode != "remote_only": return dl.get_cookie_jar(normalized_service, profile), dl.get_credentials(normalized_service, profile) - credential = None + return load_client_cookies(data.get("cookies")), client_credential(data) + + +def client_credential(data: Dict[str, Any]) -> Any: + """The Credential a client sent as ``credentials``, or None when it sent no username.""" + from unshackle.core.credential import Credential + cred_data = data.get("credentials") - if isinstance(cred_data, dict) and cred_data.get("username") is not None: - credential = Credential( - username=cred_data["username"], password=cred_data.get("password") or "", extra=cred_data.get("extra") - ) - return load_client_cookies(data.get("cookies")), credential + if not isinstance(cred_data, dict) or cred_data.get("username") is None: + return None + return Credential( + username=cred_data["username"], password=cred_data.get("password") or "", extra=cred_data.get("extra") + ) def api_key_namespace(request: Optional[web.Request]) -> str: """Name of the caller's cache directories, derived from its API key so no caller can guess another's.""" - return key_namespace(request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous") + return key_namespace(caller_key(request)) @functools.lru_cache(maxsize=256) @@ -1266,19 +1271,7 @@ def server_accounts_allowed(request: Optional[web.Request] = None, service: Opti full access. Combine with ``server_account_spec``: the server treats an API key without the opt-in as a client-managed remote session for that service. """ - if not request: - return True - user_config = config.serve.get("users", {}).get(request_secret_key(request)) - if user_config is None: - return True - allowed = user_config.get("server_accounts", False) - if isinstance(allowed, str): - allowed = [allowed] - if isinstance(allowed, (list, tuple, set)): - return service is not None and Services.get_tag(service).upper() in { - Services.get_tag(s).upper() for s in allowed - } - return bool(allowed) + return key_grant_allows(request, service, "server_accounts") def resolve_server_account(request: Optional[web.Request], service: str, region: Any) -> Optional[str]: @@ -1781,6 +1774,20 @@ def serialize_track_common(track: Any) -> Dict[str, Any]: } +def serialize_tracks(tracks: Tracks, title: Any, include_url: bool = False) -> Dict[str, List[Dict[str, Any]]]: + """The ``video``, ``audio`` and ``subtitles`` lists of a title's tracks, video and audio highest bitrate first.""" + video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True) + audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True) + original_ids = original_audio_ids(audio_tracks, title) + return { + "video": [serialize_video_track(t, include_url=include_url) for t in video_tracks], + "audio": [ + serialize_audio_track(t, include_url=include_url, is_original=t.id in original_ids) for t in audio_tracks + ], + "subtitles": [serialize_subtitle_track(t, include_url=include_url) for t in tracks.subtitles], + } + + def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]: """Convert video track to JSON-serializable dict.""" codec_name = enum_name(track.codec) @@ -2102,19 +2109,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques for title in sorted_titles: try: tracks = await call(probed_tracks, service_instance, title) - video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True) - audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True) - - original_ids = original_audio_ids(audio_tracks, title) - episode_data = { - "title": serialize_title(title), - "video": [serialize_video_track(t) for t in video_tracks], - "audio": [ - serialize_audio_track(t, is_original=t.id in original_ids) for t in audio_tracks - ], - "subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles], - } - episodes_data.append(episode_data) + episodes_data.append({"title": serialize_title(title), **serialize_tracks(tracks, title)}) log.debug(f"Successfully got tracks for {title.season}x{title.number}") except SystemExit: # Service calls sys.exit() for unavailable episodes - catch and skip @@ -2149,17 +2144,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques first_title = titles tracks = await call(probed_tracks, service_instance, first_title) - - video_tracks = sorted(tracks.videos, key=lambda t: t.bitrate or 0, reverse=True) - audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True) - - original_ids = original_audio_ids(audio_tracks, first_title) - response = { - "title": serialize_title(first_title), - "video": [serialize_video_track(t) for t in video_tracks], - "audio": [serialize_audio_track(t, is_original=t.id in original_ids) for t in audio_tracks], - "subtitles": [serialize_subtitle_track(t) for t in tracks.subtitles], - } + response = {"title": serialize_title(first_title), **serialize_tracks(tracks, first_title)} return await request_cache.json_response(response) @@ -2476,9 +2461,28 @@ def service_dl_defaults(service: str) -> Dict[str, Any]: return result +async def enqueue_job( + service: str, title_id: str, params: Dict[str, Any], request: Optional[web.Request], gate_service: str +) -> web.Response: + """Check the download gates for ``gate_service``, then queue a job with ``params`` and answer 202. + + ``server_proxy`` is always stamped here, so a client-sent value cannot grant the server's proxy providers. + """ + from unshackle.core.api.download_manager import get_download_manager + + await asyncio.to_thread(enforce_download_gates, {**params, "service": gate_service}, request) + manager = get_download_manager() + await manager.start_workers() + job = manager.create_job( + service, title_id, owner_key=caller_key(request), **{**params, "server_proxy": server_proxy_allowed(request)} + ) + return web.json_response( + {"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202 + ) + + async def download_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: """Answer the download request: make a download job and add it to the queue.""" - from unshackle.core.api.download_manager import get_download_manager require_fields(data, "service", "title_id") service_tag = data["service"] @@ -2538,19 +2542,10 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request] } if service_params is not None: params_with_defaults["service_params"] = service_params - await asyncio.to_thread(enforce_download_gates, {**params_with_defaults, "service": service_tag}, request) - - manager = get_download_manager() - await manager.start_workers() - # The download worker reads this to decide whether dl may load the server's proxy - # providers. Always stamped server-side so a client-sent value cannot grant it. - params_with_defaults["server_proxy"] = server_proxy_allowed(request) - params_with_defaults["client_keys"] = sorted(params_with_defaults if server else filtered_params) - job = manager.create_job(normalized_service, title_id, owner_key=caller_key(request), **params_with_defaults) - - return web.json_response( - {"job_id": job.job_id, "status": job.status.value, "created_time": job.created_time.isoformat()}, status=202 + params_with_defaults["client_keys"] = sorted( + [*params_with_defaults, "server_proxy"] if server else filtered_params ) + return await enqueue_job(normalized_service, title_id, params_with_defaults, request, gate_service=service_tag) except APIError: raise @@ -3309,26 +3304,8 @@ async def retry_download_job_handler(job_id: str, request: Optional[web.Request] f"Invalid or unavailable service: {job.service}", details={"service": job.service}, ) - await asyncio.to_thread(enforce_download_gates, {**job.parameters, "service": job.service}, request) - - await manager.start_workers() - # Reuse the raw in-memory parameters; redaction only ever applies to serialized copies. - new_job = manager.create_job( - job.service, - job.title_id, - owner_key=caller_key(request), - **{**job.parameters, "server_proxy": server_proxy_allowed(request)}, - ) - - return web.json_response( - { - "job_id": new_job.job_id, - "status": new_job.status.value, - "created_time": new_job.created_time.isoformat(), - }, - status=202, - ) + return await enqueue_job(job.service, job.title_id, job.parameters, request, gate_service=job.service) except APIError: raise @@ -3819,7 +3796,6 @@ def create_service_instance( own accounts. """ from unshackle.commands.dl import dl - from unshackle.core.credential import Credential from unshackle.core.tracks import Video server_config = service_config = load_service_yaml(normalized_service) @@ -3872,14 +3848,7 @@ def create_service_instance( cookies = server_account_cookies(normalized_service, profile) credential = dl.get_credentials(normalized_service, profile) else: - credential = None - cred_data = data.get("credentials") - if cred_data and isinstance(cred_data, dict): - credential = Credential( - username=cred_data["username"], - password=cred_data["password"], - extra=cred_data.get("extra"), - ) + credential = client_credential(data) cookies = load_client_cookies(data.get("cookies")) extra_params["cookies_supplied"] = cookies is not None @@ -3939,7 +3908,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req import uuid as uuid_mod session_id = str(uuid_mod.uuid4()) - api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" + api_key = caller_key(request) session_cache_dir = f"_sessions/{api_key_namespace(request)}/{session_id}/{normalized_service}" session_cache_tag: Optional[str] = session_cache_dir @@ -4240,7 +4209,7 @@ async def session_tracks_handler( from unshackle.core.config import config as app_config - api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" + api_key = caller_key(request) user_cfg = serve_user_config(api_key) has_wv = bool(user_cfg.get("devices")) has_pr = bool(user_cfg.get("playready_devices")) @@ -4271,13 +4240,11 @@ async def session_tracks_handler( def serialize() -> tuple[list, list, list, list]: """The track and attachment lists, off the event loop: an attachment can be a 20 MB file.""" - original_ids = original_audio_ids(audio_tracks, title) - video = [serialize_video_track(t, include_url=True) for t in video_tracks] - audio = [serialize_audio_track(t, include_url=True, is_original=t.id in original_ids) for t in audio_tracks] - subtitles = [serialize_subtitle_track(t, include_url=True) for t in tracks.subtitles] - for d in video + audio + subtitles: + lists = serialize_tracks(tracks, title, include_url=True) + for d in lists["video"] + lists["audio"] + lists["subtitles"]: d["data"] = scrub_track_data(d.get("data"), session) - return video, audio, subtitles, [a for a in map(serialize_attachment, tracks.attachments) if a] + attachments = [a for a in map(serialize_attachment, tracks.attachments) if a] + return lists["video"], lists["audio"], lists["subtitles"], attachments video, audio, subtitles, attachments = await asyncio.to_thread(serialize) @@ -5149,24 +5116,14 @@ def handle_single_server_cdm( raise APIError(APIErrorCode.INVALID_INPUT, "No PSSH available for server_cdm licensing") if vault_only: - from unshackle.core.drm import PlayReady, Widevine - - if drm_type == "playready": - from pyplayready.system.pssh import PSSH as PlayReadyPSSH - - kids = PlayReady(pssh=PlayReadyPSSH(base64.b64decode(pssh_b64)), pssh_b64=pssh_b64).kids - else: - from pywidevine.pssh import PSSH as WvPSSH - - kids = Widevine(pssh=WvPSSH(pssh_b64)).kids - vault_hit = check_vaults(kids, service_name, shared=shared) + vault_hit = check_vaults(list(pssh_kids(pssh_b64, drm_type)), service_name, shared=shared) if vault_hit: if sources is not None: sources.update(vault_hit[1]) return vault_hit[0] raise refusal or APIError(APIErrorCode.NO_CONTENT, "The server vault holds no content key for this track") - api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" + api_key = caller_key(request) user_config = serve_user_config(api_key) if drm_type == "playready": @@ -5448,7 +5405,7 @@ def license_response( ) if mode == "server_cdm" and track_ids: - api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" + api_key = caller_key(request) user_config = serve_user_config(api_key) service = session.service_instance service_tag = session.service_tag @@ -5629,7 +5586,7 @@ def license_response( if mode == "server_cdm": # The server's config.cdm mapping decides the DRM system, as in the batch path. # The client only knows its own local device, which the server never uses here. - api_key = request.headers.get("X-Secret-Key", "anonymous") if request else "anonymous" + api_key = caller_key(request) candidates = ( vault_candidates(drm_type) if vault_only diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 2cf6d3b1..5d35fd98 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -142,6 +142,14 @@ def api_handler(handler: Handler) -> Handler: return wrapper +async def json_body(request: web.Request) -> Any: + """The request's JSON body; raises INVALID_INPUT when it does not parse.""" + try: + return await request.json() + except Exception as e: + raise APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}) from e + + HEARTBEAT_INTERVAL = 30.0 @@ -624,17 +632,7 @@ async def search(request: web.Request) -> web.Response: '400': description: Invalid request """ - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError( - APIErrorCode.INVALID_INPUT, - "Invalid JSON request body", - details={"error": str(e)}, - ), - request.app.get("debug_api", False), - ) + data = await json_body(request) try: return await search_handler(data, request) @@ -792,17 +790,7 @@ async def list_titles(request: web.Request) -> web.Response: type: string format: date-time """ - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError( - APIErrorCode.INVALID_INPUT, - "Invalid JSON request body", - details={"error": str(e)}, - ), - request.app.get("debug_api", False), - ) + data = await json_body(request) return await list_titles_handler(data, request) @@ -884,17 +872,7 @@ async def list_tracks(request: web.Request) -> web.Response: '400': description: Invalid request """ - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError( - APIErrorCode.INVALID_INPUT, - "Invalid JSON request body", - details={"error": str(e)}, - ), - request.app.get("debug_api", False), - ) + data = await json_body(request) return await list_tracks_handler(data, request) @@ -1215,17 +1193,7 @@ async def download(request: web.Request) -> web.Response: A gated parameter is not permitted (FORBIDDEN), or the job goes above the server_cdm_max_height of the API key (SERVER_CDM_CAPPED) """ - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError( - APIErrorCode.INVALID_INPUT, - "Invalid JSON request body", - details={"error": str(e)}, - ), - request.app.get("debug_api", False), - ) + data = await json_body(request) return await download_handler(data, request) @@ -1557,10 +1525,7 @@ async def download_job_input(request: web.Request) -> web.Response: description: No prompt pending for this job """ job_id = request.match_info["job_id"] - try: - data = await request.json() - except Exception as e: - raise APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}) from e + data = await json_body(request) return await download_job_input_handler(data, job_id, request) @@ -2066,13 +2031,7 @@ async def session_create(request: web.Request) -> web.Response: '401': description: Authentication failed """ - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}), - request.app.get("debug_api", False), - ) + data = await json_body(request) try: return await session_create_handler(data, request) except Exception as e: @@ -2202,13 +2161,7 @@ async def session_tracks(request: web.Request) -> web.Response: description: Remote session or title not found """ session_id = request.match_info["session_id"] - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}), - request.app.get("debug_api", False), - ) + data = await json_body(request) try: return await session_tracks_handler(data, session_id, request) except Exception as e: @@ -2252,13 +2205,7 @@ async def session_segments(request: web.Request) -> web.Response: description: Remote session or track not found """ session_id = request.match_info["session_id"] - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}), - request.app.get("debug_api", False), - ) + data = await json_body(request) try: return await session_segments_handler(data, session_id, request) except Exception as e: @@ -2303,13 +2250,7 @@ async def session_segment_filter(request: web.Request) -> web.Response: description: Remote session or track not found """ session_id = request.match_info["session_id"] - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}), - request.app.get("debug_api", False), - ) + data = await json_body(request) try: return await session_segment_filter_handler(data, session_id, request) except Exception as e: @@ -2425,13 +2366,7 @@ async def session_license(request: web.Request) -> web.Response: description: Remote session or track not found """ session_id = request.match_info["session_id"] - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}), - request.app.get("debug_api", False), - ) + data = await json_body(request) try: return await session_license_handler(data, session_id, request) except Exception as e: @@ -2486,13 +2421,7 @@ async def session_bad_key(request: web.Request) -> web.Response: description: Remote session not found """ session_id = request.match_info["session_id"] - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}), - request.app.get("debug_api", False), - ) + data = await json_body(request) return await session_bad_key_handler(data, session_id, request) @@ -2695,13 +2624,7 @@ async def session_prompt_submit(request: web.Request) -> web.Response: description: Remote session not found """ session_id = request.match_info["session_id"] - try: - data = await request.json() - except Exception as e: - return build_error_response( - APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}), - request.app.get("debug_api", False), - ) + data = await json_body(request) try: return await session_prompt_post_handler(data, session_id, request) except Exception as e: @@ -3398,16 +3321,13 @@ async def remote_login_input(request: web.Request) -> web.Response: '409': description: The login is not asking anything """ - try: - data = await request.json() - except Exception as e: - raise APIError(APIErrorCode.INVALID_INPUT, "Invalid JSON request body", details={"error": str(e)}) from e + data = await json_body(request) return await remote_login_input_handler(request.match_info["login_id"], data, request) # Single source of truth for all API routes. `remote` marks endpoints exposed in -# --remote-only server mode; full mode registers every route. Both setup_routes and -# setup_swagger derive from this table so the live routes and the docs cannot drift. +# --remote-only server mode; full mode registers every route. setup_routes registers the +# routes and their docs from this table, so the live routes and the docs cannot drift. ROUTES: list[tuple[str, str, Handler, bool]] = [ ("GET", "/api/health", health, True), ("GET", "/api/services", services, True), @@ -3467,15 +3387,21 @@ DASHBOARD_ROUTES: list[tuple[str, str, Handler, bool]] = [ def setup_routes( - app: web.Application, remote_only: bool = False, dashboard: bool = False, cdm_only: bool = False + app: web.Application, + remote_only: bool = False, + dashboard: bool = False, + cdm_only: bool = False, + docs: bool = False, ) -> None: """Setup API routes. When remote_only=True, only the remote session endpoints operate. When cdm_only=True, only /api/health operates. When dashboard=True, this also registers - the /api/dashboard/ routes.""" + the /api/dashboard/ routes. When docs=True, each route goes through Swagger so it is + documented at /api/docs/; Swagger request validation stays off.""" + router: Any = swagger_docs(app) if docs else app.router add: dict[str, Callable[..., Any]] = { - "GET": app.router.add_get, - "POST": app.router.add_post, - "DELETE": app.router.add_delete, + "GET": router.add_get, + "POST": router.add_post, + "DELETE": router.add_delete, } api_routes = [r for r in ROUTES if r[1] == "/api/health"] if cdm_only else ROUTES for method, path, handler, remote in api_routes + (DASHBOARD_ROUTES if dashboard else []): @@ -3485,10 +3411,11 @@ def setup_routes( app.on_shutdown.append(close_tunnels) -def setup_swagger(app: web.Application, dashboard: bool = False) -> None: - """Setup Swagger UI documentation.""" - swagger = SwaggerDocs( +def swagger_docs(app: web.Application) -> SwaggerDocs: + """The Swagger UI at /api/docs/, which registers the routes added through it.""" + return SwaggerDocs( app, + validate=False, swagger_ui_settings=SwaggerUiSettings(path="/api/docs/"), info=SwaggerInfo( title="Unshackle REST API", @@ -3496,8 +3423,3 @@ def setup_swagger(app: web.Application, dashboard: bool = False) -> None: description="REST API for Unshackle - Modular Movie, TV, and Music Archival Software", ), ) - - route: dict[str, Callable[..., Any]] = {"GET": web.get, "POST": web.post, "DELETE": web.delete} - swagger.add_routes( - [route[method](path, handler) for method, path, handler, _ in ROUTES + (DASHBOARD_ROUTES if dashboard else [])] - )