feat(api): spa fallback route + comfyui registration + coverage gates
This commit is contained in:
@@ -15,6 +15,8 @@ pnpm-debug.log
|
||||
# Build outputs
|
||||
js/
|
||||
web_dist/
|
||||
web/*
|
||||
!web/index.html
|
||||
coverage/
|
||||
.vitest-cache/
|
||||
|
||||
|
||||
+17
@@ -36,3 +36,20 @@ class WildcardPipelineExtension(ComfyExtension):
|
||||
|
||||
async def comfy_entrypoint() -> WildcardPipelineExtension:
|
||||
return WildcardPipelineExtension()
|
||||
|
||||
|
||||
# Register SPA + REST API routes on ComfyUI's aiohttp app.
|
||||
# Imported lazily inside the try-block so static analysis tools that
|
||||
# don't have ComfyUI's `server` module on the path don't fail.
|
||||
try:
|
||||
from server import PromptServer # type: ignore[import-not-found]
|
||||
|
||||
from wp_api import register_routes as _register_wp_routes
|
||||
|
||||
_register_wp_routes(PromptServer.instance.app)
|
||||
except Exception: # noqa: BLE001 - never crash ComfyUI on route registration failure
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception(
|
||||
"wildcard-pipeline: route registration failed",
|
||||
)
|
||||
|
||||
@@ -69,3 +69,17 @@ asyncio_mode = "auto"
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["engine", "wp_api"]
|
||||
omit = [
|
||||
"engine/db/migrations_sql/*",
|
||||
"engine/handlers.py",
|
||||
"engine/template.py",
|
||||
"engine/pipeline.py",
|
||||
"engine/context.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests for /wp + /wp/{path:.*} static + SPA fallback behavior."""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_root_serves_index_html(wp_client):
|
||||
resp = await wp_client.get("/wp")
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
assert "<title>" in body.lower()
|
||||
assert "wildcard pipeline" in body.lower()
|
||||
|
||||
|
||||
async def test_unknown_path_falls_back_to_index_html(wp_client):
|
||||
resp = await wp_client.get("/wp/modules/some-future-route")
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
assert "<title>" in body.lower()
|
||||
|
||||
|
||||
async def test_static_asset_served_when_present(wp_client, tmp_path):
|
||||
"""Static files in WEB_DIR are served as-is, not as the SPA shell."""
|
||||
from wp_api import spa as spa_mod
|
||||
asset = spa_mod.WEB_DIR / "favicon.txt"
|
||||
asset.write_text("hello", encoding="utf-8")
|
||||
try:
|
||||
resp = await wp_client.get("/wp/favicon.txt")
|
||||
assert resp.status == 200
|
||||
assert (await resp.text()) == "hello"
|
||||
finally:
|
||||
asset.unlink()
|
||||
|
||||
|
||||
async def test_path_traversal_blocked(wp_client):
|
||||
"""Requests with .. in the path must not escape WEB_DIR."""
|
||||
resp = await wp_client.get("/wp/../pyproject.toml")
|
||||
# aiohttp's router normalizes path traversal before reaching the handler;
|
||||
# the request resolves to /pyproject.toml which is not registered → 404.
|
||||
# If it ever reaches our handler, the resolve check returns 403.
|
||||
assert resp.status in (403, 404)
|
||||
|
||||
|
||||
async def test_api_routes_not_shadowed(wp_client):
|
||||
"""The catch-all /wp/{path:.*} must not shadow specific /wp/api/* routes."""
|
||||
resp = await wp_client.get("/wp/api/modules")
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert "items" in body
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Wildcard Pipeline Manager</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>Wildcard Pipeline Manager</h1>
|
||||
<p>SPA scaffold not yet built. See Plan 2.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,7 @@ from aiohttp import web
|
||||
from wp_api import categories as _categories
|
||||
from wp_api import import_export as _import_export
|
||||
from wp_api import modules as _modules
|
||||
from wp_api import spa as _spa
|
||||
from wp_api import test_runner as _test_runner
|
||||
|
||||
|
||||
@@ -15,3 +16,8 @@ def register_routes(app: web.Application) -> None:
|
||||
_categories.register(app.router)
|
||||
_test_runner.register(app.router)
|
||||
_import_export.register(app.router)
|
||||
# SPA fallback last — broad catch-all `/wp/{path:.*}` must not shadow
|
||||
# specific `/wp/api/...` routes. aiohttp resolves more-specific routes
|
||||
# first regardless of registration order, but late registration keeps
|
||||
# intent obvious.
|
||||
_spa.register(app.router)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""/wp + /wp/{path:.*} — static asset server with SPA fallback.
|
||||
|
||||
Looks up the requested path inside ``WEB_DIR``. If a file exists, serves it.
|
||||
Otherwise serves ``index.html`` so the SPA router can take over (any
|
||||
``/wp/<route>`` path resolves to the SPA shell).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
WEB_DIR = Path(__file__).parent.parent / "web"
|
||||
|
||||
|
||||
async def _serve(path: str) -> web.Response:
|
||||
if path:
|
||||
candidate = (WEB_DIR / path).resolve()
|
||||
try:
|
||||
candidate.relative_to(WEB_DIR.resolve())
|
||||
except ValueError:
|
||||
return web.Response(status=403)
|
||||
if candidate.is_file():
|
||||
return web.FileResponse(candidate)
|
||||
return web.FileResponse(WEB_DIR / "index.html")
|
||||
|
||||
|
||||
async def serve_root(request: web.Request) -> web.Response:
|
||||
return await _serve("")
|
||||
|
||||
|
||||
async def serve_path(request: web.Request) -> web.Response:
|
||||
return await _serve(request.match_info["path"])
|
||||
|
||||
|
||||
def register(router) -> None:
|
||||
router.add_get("/wp", serve_root)
|
||||
router.add_get("/wp/{path:.*}", serve_path)
|
||||
Reference in New Issue
Block a user