Serve the OpenAPI spec and browsable API docs behind a flag

openapi.yaml has been linted in CI but never reachable over HTTP. Add
--enable-api-docs (off by default) to serve it at /openapi.yaml and render
it at /api-docs.

The viewer is Redoc rather than Swagger UI specifically because it has no
request-execution feature. The local server is unauthenticated by default,
so a docs page with "Try it out" would give one-click access to
/api/interrupt, /api/free and DELETE /api/userdata/{file}.

Notes on the implementation:

- Routes are registered on PromptServer's route table, not on the app, so
  they land before the web.static('/') catch-all that would shadow them.
  This also means they are served at both /openapi.yaml and
  /api/openapi.yaml; the docs page references the spec by a relative URL so
  it resolves from either mount point.
- The spec path resolves from __file__, since ComfyUI is routinely launched
  from other directories.
- Cache headers are set explicitly: the cache_control middleware only
  special-cases js/css/images, so a .yaml response would otherwise be
  served stale after an edit.
- /docs is left alone; it already serves embedded node help content.

The Redoc bundle comes from a pinned CDN URL, so the page needs outbound
network access. Offline installs still get the spec itself, and the page
degrades to a notice pointing at it.

Claude-Session: https://claude.ai/code/session_01BvUveU9ofyGrSz3QxYeecB
This commit is contained in:
Claude 2026-08-14 23:40:51 +00:00
parent 55b6a9b11d
commit 91ec18778a
No known key found for this signature in database
5 changed files with 265 additions and 0 deletions

View File

@ -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.
<br/><br/>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.

111
app/api_docs.py Normal file
View File

@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ComfyUI API Reference</title>
<style>
body { margin: 0; padding: 0; font-family: system-ui, sans-serif; }
#fallback {
display: none;
margin: 3rem auto;
max-width: 40rem;
padding: 0 1.5rem;
line-height: 1.6;
color: #333;
}
#fallback code {
background: #f2f2f2;
border-radius: 3px;
padding: 0.1em 0.35em;
}
</style>
</head>
<body>
<redoc spec-url="__SPEC_URL__"></redoc>
<div id="fallback">
<h1>API docs viewer unavailable</h1>
<p>
The documentation viewer is loaded from a CDN and could not be reached.
This is expected on an offline or air-gapped install.
</p>
<p>
The specification itself is served locally and needs no network access:
<a href="__SPEC_URL__">openapi.yaml</a>. Render it with any local viewer,
for example <code>npx @redocly/cli preview-docs openapi.yaml</code>.
</p>
</div>
<script
src="__BUNDLE_URL__"
onerror="document.getElementById('fallback').style.display='block';"
></script>
</body>
</html>
""".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

View File

@ -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.")

View File

@ -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.

View File

@ -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 "<redoc" in body
assert "swagger" not in body
async def test_docs_page_degrades_when_the_cdn_is_unreachable(client):
"""Offline installs must get the fallback notice, not a blank page.
The viewer bundle is the only part that needs network, so the page carries
an onerror hook that reveals a notice pointing at the locally served spec.
"""
body = await (await client.get("/api-docs")).text()
assert 'onerror=' in body
assert "getElementById('fallback')" in body
assert 'id="fallback"' in body
# The notice has to link the spec, which is served locally.
assert f'<a href="{SPEC_URL}">' 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