Fold the v2 API into /v1 and 404 /api

The upstream v2 routers mount under /v1 without collisions. flowweb
answers /api with 404 instead of the SPA shell, and a CI test fails
on any first-party /api/ or /v2/ path in src/.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
This commit is contained in:
2026-09-29 18:34:03 -07:00
parent fe92c6d5f4
commit 5421a384b5
7 changed files with 134 additions and 17 deletions
+1 -1
View File
@@ -50,7 +50,7 @@
<script>
// When the real Vite bundle is embedded, this file is overwritten by the app's
// own index.html. As a standalone landing, nudge signed-in users to the builder.
fetch('/api/v1/auto_login', { credentials: 'include' })
fetch('/v1/auto_login', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d && d.frontend_timeout !== undefined) location.replace('/flows'); })
.catch(() => {});
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"bufio"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// firstParty matches a path literal on flow's own API that names the retired
// /api/ namespace or the folded /v2/ surface: "/api/…", 'api/v1…', `/v2/files`.
var firstParty = regexp.MustCompile("[\"'`(]/?api/|[\"'`(]/v2/(files|mcp|workflows|registration)")
// thirdParty names the files and hosts that call someone else's API, whose
// paths are theirs: Ollama's /api/tags, LangWatch's /api/evaluations, Zep's
// api/v1 base path, OpenRouter's /api/v1.
var thirdParty = []string{
"openrouter.ai", "pokeapi.co", "components/langwatch/", "components/zep/",
"components/ollama/", "base/models/model_utils.py",
}
// TestNoFirstPartyAPIPrefix fails when flow's own source calls or serves a path
// under /api/ or /v2/. The API is /v1 and nothing else.
func TestNoFirstPartyAPIPrefix(t *testing.T) {
root := filepath.Join("..", "..", "..", "src")
dirs := []string{"frontend/src", "backend/base/flow", "lfx/src/lfx", "sdk/src"}
exts := map[string]bool{".py": true, ".ts": true, ".tsx": true, ".js": true, ".jsx": true}
var hits []string
for _, d := range dirs {
err := filepath.WalkDir(filepath.Join(root, d), func(p string, e fs.DirEntry, err error) error {
if err != nil {
return err
}
if e.IsDir() {
switch e.Name() {
case "node_modules", "__tests__", "tests", "_assets":
return filepath.SkipDir
}
return nil
}
if !exts[filepath.Ext(p)] {
return nil
}
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close()
s := bufio.NewScanner(f)
s.Buffer(make([]byte, 1<<20), 1<<24)
for n := 1; s.Scan(); n++ {
line := s.Text()
if !firstParty.MatchString(line) || mentions(filepath.ToSlash(p)+" "+line, thirdParty) {
continue
}
hits = append(hits, p+":"+strconv.Itoa(n)+": "+strings.TrimSpace(line))
}
return s.Err()
})
if err != nil {
t.Fatalf("walk %s: %v", d, err)
}
}
for _, h := range hits {
t.Error(h)
}
}
func mentions(line string, words []string) bool {
l := strings.ToLower(line)
for _, w := range words {
if strings.Contains(l, w) {
return true
}
}
return false
}
+11 -2
View File
@@ -22,13 +22,18 @@ var frontendFS embed.FS
// apiPrefixes go to the backend, never the SPA — an unmatched path here is the
// backend's real JSON 404, never index.html (so a client never gets HTML where it
// expects JSON). The backend mounts its API at /v1 and /v2 (src/backend/base/flow/api/router.py).
// expects JSON). The backend mounts its API at /v1 (src/backend/base/flow/api/router.py).
// Everything else is a client-side route → the SPA shell.
var apiPrefixes = []string{
"/v1/", "/v2/", "/health", "/health_check", "/openapi.json", "/docs", "/redoc",
"/v1/", "/health", "/health_check", "/openapi.json", "/docs", "/redoc",
"/.well-known/", "/logs",
}
// retired is the namespace no route lives under. It answers 404 rather than
// the SPA shell, so a caller still using it fails loudly instead of parsing
// index.html as its reply.
const retired = "/api"
type handler struct {
fsys fs.FS
index []byte
@@ -56,6 +61,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(upath, "/") {
upath = "/" + upath
}
if upath == retired || strings.HasPrefix(upath, retired+"/") {
http.NotFound(w, r)
return
}
// API + backend surfaces → the flow backend verbatim (all methods).
for _, p := range apiPrefixes {
if upath == strings.TrimSuffix(p, "/") || strings.HasPrefix(upath, p) {
+6 -1
View File
@@ -28,7 +28,12 @@ func TestHandlerRoutes(t *testing.T) {
{http.MethodGet, "/v1/version", true, http.StatusOK},
{http.MethodPost, "/v1/login", true, http.StatusOK},
{http.MethodGet, "/v1", true, http.StatusOK},
{http.MethodGet, "/v2/files", true, http.StatusOK},
{http.MethodGet, "/v1/files", true, http.StatusOK},
{http.MethodGet, "/api/v1/version", false, http.StatusNotFound},
{http.MethodPost, "/api/v1/login", false, http.StatusNotFound},
{http.MethodGet, "/api", false, http.StatusNotFound},
{http.MethodGet, "/v2/files", false, http.StatusOK},
{http.MethodGet, "/apis", false, http.StatusOK},
{http.MethodGet, "/health_check", true, http.StatusOK},
{http.MethodGet, "/logs-stream", true, http.StatusOK},
{http.MethodGet, "/", false, http.StatusOK},
+10 -12
View File
@@ -37,10 +37,6 @@ router_v1 = APIRouter(
prefix="/v1",
)
router_v2 = APIRouter(
prefix="/v2",
)
def include_deployment_router(target_router: APIRouter) -> None:
"""Mount deployment routes only when the deployments feature is enabled."""
@@ -86,14 +82,16 @@ def _include_agentic_router():
_include_agentic_router()
router_v2.include_router(files_router_v2)
router_v2.include_router(mcp_router_v2)
router_v2.include_router(registration_router_v2)
router_v2.include_router(workflow_router_v2)
# The upstream v2 surface lives under /v1 too: one version, one prefix. Its
# paths do not collide with v1's — v2 files are /files and /files/{file_id}
# where every v1 files route has two or more segments, and v2 MCP is
# /mcp/servers where v1 MCP is /mcp/sse, /mcp/ and /mcp/streamable.
router_v1.include_router(files_router_v2)
router_v1.include_router(mcp_router_v2)
router_v1.include_router(registration_router_v2)
router_v1.include_router(workflow_router_v2)
# API is mounted at the root — subdomain (api.*) already namespaces the
# service so /api/ on top is redundant double-prefix. /v1 + /v2 hang off
# the root directly, per the global /v1/ canonical-path rule.
# The API hangs off the root at /v1: the host already names the service, so
# an /api/ prefix on top would say it twice.
router = APIRouter()
router.include_router(router_v1)
router.include_router(router_v2)
+1 -1
View File
@@ -622,7 +622,7 @@ def setup_static_files(app: FastAPI, static_files_dir: Path) -> None:
@app.exception_handler(404)
async def custom_404_handler(_request, _exc):
# Return JSON for all API endpoints to prevent HTML responses
if _request.url.path.startswith("/api"):
if _request.url.path.startswith("/v1/"):
# Extract detail from HTTPException if available
detail = _exc.detail if isinstance(_exc, HTTPException) else "Not Found"
return JSONResponse(
@@ -0,0 +1,24 @@
"""Every route flow serves lives under /v1 or at a fixed root path, never /api/ or /v2/."""
from fastapi.routing import APIRoute, APIWebSocketRoute
from flow.api import health_check_router, log_router
from flow.api.router import router
def _paths():
for r in (*router.routes, *health_check_router.routes, *log_router.routes):
if isinstance(r, (APIRoute, APIWebSocketRoute)):
yield r.path
def test_no_route_under_api_or_v2():
bad = sorted(p for p in _paths() if p.startswith(("/api/", "/v2/")) or p in ("/api", "/v2"))
assert bad == []
def test_api_surface_is_v1():
paths = list(_paths())
assert "/v1/version" in paths
assert "/v1/files" in paths # the folded upstream v2 files surface
assert "/v1/mcp/servers" in paths
assert "/v1/workflows" in paths