diff --git a/README.md b/README.md index c4dfc8be1..6541cefd4 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,12 @@ Use `--tls-keyfile key.pem --tls-certfile cert.pem` to enable TLS/SSL, the app w > Note: Windows users can use [alexisrolland/docker-openssl](https://github.com/alexisrolland/docker-openssl) or one of the [3rd party binary distributions](https://wiki.openssl.org/index.php/Binaries) to run the command example above.

If you use a container, note that the volume mount `-v` can be a relative path so `... -v ".\:/openssl-certs" ...` would create the key & cert files in the current directory of your command prompt or powershell terminal. +## How to browse the HTTP API? + +Launch with `--enable-api-docs` to serve the OpenAPI specification at `/openapi.yaml` and a browsable API reference at `/api-docs`. + +Both are off by default. The reference page loads its viewer from a CDN, so it needs outbound network access to render; the specification itself is served locally and can be read with any offline viewer, such as `npx @redocly/cli preview-docs openapi.yaml`. + ## Support and dev channel [Discord](https://comfy.org/discord): Try the #help or #feedback channels. diff --git a/app/api_docs.py b/app/api_docs.py new file mode 100644 index 000000000..ee9247624 --- /dev/null +++ b/app/api_docs.py @@ -0,0 +1,111 @@ +"""Browsable API documentation for the ComfyUI HTTP API. + +Renders the repository's ``openapi.yaml`` with Redoc. Redoc is used rather than +Swagger UI because it has no request-execution feature at all: the local server +is unauthenticated by default, so a docs page that could fire requests would +give one-click access to destructive endpoints such as ``/api/interrupt``, +``/api/free`` and ``DELETE /api/userdata/{file}``. + +The viewer bundle is loaded from a CDN, so the page needs outbound network +access to render. Installs without it still get the raw spec from +``/openapi.yaml``; the fallback notice below points there. +""" + +import os + +from aiohttp import web + +# openapi.yaml lives next to server.py at the repository root. Resolve it from +# __file__ rather than the cwd: ComfyUI is routinely launched from other +# directories and through wrappers, and a relative path would 404 unpredictably. +SPEC_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "openapi.yaml" +) + +# Pinned to an exact version rather than a floating tag so a CDN-side release +# cannot change what this page executes. +# +# TODO: add an integrity="sha384-..." attribute. The pinned path is immutable, +# so SRI is worth having; the hash simply could not be computed where this was +# written (no outbound network), and a wrong hash fails the page closed. +REDOC_BUNDLE_URL = "https://cdn.jsdelivr.net/npm/redoc@2.5.0/bundles/redoc.standalone.js" + +# The spec URL is deliberately relative. add_routes() re-registers every route +# under an /api prefix, so this page is reachable at both /api-docs and +# /api/api-docs; a relative URL resolves to the sibling spec in either case. +SPEC_URL = "openapi.yaml" + +# Substituted with str.replace rather than str.format/f-string so the CSS braces +# below stay literal and a future style edit does not have to double them. +API_DOCS_HTML = """ + + + + + ComfyUI API Reference + + + + +
+

API docs viewer unavailable

+

+ The documentation viewer is loaded from a CDN and could not be reached. + This is expected on an offline or air-gapped install. +

+

+ The specification itself is served locally and needs no network access: + openapi.yaml. Render it with any local viewer, + for example npx @redocly/cli preview-docs openapi.yaml. +

+
+ + + +""".replace("__SPEC_URL__", SPEC_URL).replace("__BUNDLE_URL__", REDOC_BUNDLE_URL) + + +def add_api_docs_routes(routes: web.RouteTableDef) -> None: + """Register the spec and docs-page routes on the given route table. + + Registering on PromptServer's route table (rather than on the app directly) + matters twice over: the table is added before the ``web.static('/')`` + catch-all that would otherwise shadow these paths, and every route in it is + also re-registered under an ``/api`` prefix. + """ + + @routes.get("/openapi.yaml") + async def get_openapi_spec(request): + if not os.path.isfile(SPEC_PATH): + return web.Response(status=404, text="openapi.yaml not found") + response = web.FileResponse(SPEC_PATH) + response.headers["Content-Type"] = "application/yaml" + # The cache_control middleware only special-cases js/css/images, so a + # .yaml response falls through untouched. Without this, a user editing + # the spec would keep getting a stale copy from the browser cache. + response.headers["Cache-Control"] = "no-store, must-revalidate" + return response + + @routes.get("/api-docs") + async def get_api_docs(request): + response = web.Response(text=API_DOCS_HTML, content_type="text/html") + response.headers["Cache-Control"] = "no-store, must-revalidate" + return response diff --git a/comfy/cli_args.py b/comfy/cli_args.py index c6660846d..97d977639 100644 --- a/comfy/cli_args.py +++ b/comfy/cli_args.py @@ -66,6 +66,7 @@ parser.add_argument("--tls-keyfile", type=str, help="Path to TLS (SSL) key file. parser.add_argument("--tls-certfile", type=str, help="Path to TLS (SSL) certificate file. Enables TLS, makes app accessible at https://... requires --tls-keyfile to function") parser.add_argument("--enable-cors-header", type=str, default=None, metavar="ORIGIN", nargs="?", const="*", help="Enable CORS (Cross-Origin Resource Sharing) with optional origin or allow all with default '*'.") parser.add_argument("--max-upload-size", type=float, default=100, help="Set the maximum upload size in MB.") +parser.add_argument("--enable-api-docs", action="store_true", help="Serve the OpenAPI spec at /openapi.yaml and browsable API docs at /api-docs. The docs page loads its viewer from a CDN, so it needs outbound network access to render.") parser.add_argument("--base-directory", type=str, default=None, help="Set the ComfyUI base directory for models, custom_nodes, input, output, temp, and user directories.") parser.add_argument("--extra-model-paths-config", type=str, default=None, metavar="PATH", nargs='+', action='append', help="Load one or more extra_model_paths.yaml files.") diff --git a/server.py b/server.py index c9ffcaa0d..b8f7378d5 100644 --- a/server.py +++ b/server.py @@ -42,6 +42,7 @@ from comfy_api import feature_flags from comfy.comfy_api_env import get_environment_overrides import node_helpers from comfyui_version import __version__ +from app.api_docs import add_api_docs_routes from app.frontend_management import FrontendManager, parse_version from comfy_api.internal import _ComfyNodeInternal from app.assets.seeder import asset_seeder @@ -1223,6 +1224,8 @@ class PromptServer(): self.custom_node_manager.add_routes(self.routes, self.app, nodes.LOADED_MODULE_DIRS.items()) self.subgraph_manager.add_routes(self.routes, nodes.LOADED_MODULE_DIRS.items()) self.node_replace_manager.add_routes(self.routes) + if args.enable_api_docs: + add_api_docs_routes(self.routes) self.app.add_subapp('/internal', self.internal_routes.get_app()) # Prefix every route with /api for easier matching for delegation. diff --git a/tests-unit/server_test/test_api_docs.py b/tests-unit/server_test/test_api_docs.py new file mode 100644 index 000000000..42d7f0dd6 --- /dev/null +++ b/tests-unit/server_test/test_api_docs.py @@ -0,0 +1,144 @@ +"""Tests for the OpenAPI spec and API docs routes.""" + +import os + +import pytest +import pytest_asyncio +import yaml +from aiohttp import web + +from app.api_docs import SPEC_PATH, SPEC_URL, add_api_docs_routes + +pytestmark = pytest.mark.asyncio + + +def _build_app(): + """Mirror how PromptServer mounts these routes, including the /api prefix. + + add_routes() walks the route table and re-registers every RouteDef under an + /api prefix, so both the bare and prefixed forms are served. Reproducing + that here keeps the prefix behaviour covered by tests. + """ + app = web.Application() + routes = web.RouteTableDef() + add_api_docs_routes(routes) + + api_routes = web.RouteTableDef() + for route in routes: + if isinstance(route, web.RouteDef): + api_routes.route(route.method, "/api" + route.path)( + route.handler, **route.kwargs + ) + app.add_routes(api_routes) + app.add_routes(routes) + return app + + +@pytest_asyncio.fixture +async def client(aiohttp_client): + return await aiohttp_client(_build_app()) + + +async def test_spec_path_points_at_the_repo_spec(): + """SPEC_PATH must resolve from __file__, not the cwd.""" + assert os.path.isfile(SPEC_PATH) + assert os.path.basename(SPEC_PATH) == "openapi.yaml" + + +async def test_get_spec_returns_yaml(client): + resp = await client.get("/openapi.yaml") + assert resp.status == 200 + assert resp.headers["Content-Type"] == "application/yaml" + assert resp.headers["Cache-Control"] == "no-store, must-revalidate" + + +async def test_spec_body_is_valid_openapi_3(client): + resp = await client.get("/openapi.yaml") + spec = yaml.safe_load(await resp.text()) + assert spec["openapi"].startswith("3.") + assert spec["paths"] + + +async def test_spec_is_also_served_under_the_api_prefix(client): + """Documents the prefix duplication so a refactor cannot silently break it.""" + resp = await client.get("/api/openapi.yaml") + assert resp.status == 200 + assert resp.headers["Content-Type"] == "application/yaml" + + +async def test_missing_spec_returns_404(client, monkeypatch): + monkeypatch.setattr("app.api_docs.SPEC_PATH", "/nonexistent/openapi.yaml") + resp = await client.get("/openapi.yaml") + assert resp.status == 404 + + +async def test_docs_page_returns_html_referencing_the_spec(client): + resp = await client.get("/api-docs") + assert resp.status == 200 + assert resp.content_type == "text/html" + body = await resp.text() + assert SPEC_URL in body + + +async def test_docs_page_spec_url_is_relative(client): + """A relative URL resolves correctly from both /api-docs and /api/api-docs.""" + assert not SPEC_URL.startswith("/") + resp = await client.get("/api/api-docs") + assert resp.status == 200 + + +async def test_docs_page_cannot_execute_requests(client): + """The local server is unauthenticated, so the docs UI must not fire requests. + + Redoc has no request execution at all. Guard against a swap to Swagger UI, + whose "Try it out" would give one-click access to destructive endpoints. + """ + body = (await (await client.get("/api-docs")).text()).lower() + assert "' in body + + +async def test_routes_survive_the_static_catch_all(aiohttp_client, tmp_path): + """web.static('/') is registered last and matches everything. + + Registering on PromptServer's route table is what keeps these paths + reachable; this fails if they are ever moved after the catch-all. + """ + (tmp_path / "index.html").write_text("frontend") + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "node.json").write_text("{}") + + app = _build_app() + app.add_routes([web.static("/docs", tmp_path / "docs")]) + app.add_routes([web.static("/", tmp_path)]) + client = await aiohttp_client(app) + + assert (await client.get("/openapi.yaml")).headers["Content-Type"] == ( + "application/yaml" + ) + assert (await client.get("/api-docs")).content_type == "text/html" + # Embedded node docs and the frontend bundle are untouched. + assert await (await client.get("/docs/node.json")).text() == "{}" + assert await (await client.get("/index.html")).text() == "frontend" + + +async def test_api_docs_flag_is_off_by_default(): + """Both routes are gated on this flag in PromptServer.add_routes().""" + from comfy.cli_args import parser + + assert parser.parse_args([]).enable_api_docs is False + assert parser.parse_args(["--enable-api-docs"]).enable_api_docs is True