mirror of
https://github.com/hanzoai/flow.git
synced 2026-10-17 01:29:18 +00:00
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:
@@ -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(() => {});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user