diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 6e27e0192..e142a0af8 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -3980,6 +3980,36 @@ } } }, + "/panel/api/openapi.json": { + "get": { + "tags": [ + "Server" + ], + "summary": "Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.", + "operationId": "get_panel_api_openapi_json", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/server/status": { "get": { "tags": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index db4b3bced..f7e4c2213 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -254,6 +254,11 @@ export const sections: readonly Section[] = [ description: 'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.', endpoints: [ + { + method: 'GET', + path: '/panel/api/openapi.json', + summary: 'Serve this API description as an OpenAPI 3 document — the same file that powers the API Docs page. Requires a session or Bearer token like the rest of /panel/api. Useful for generating clients or importing into API tooling.', + }, { method: 'GET', path: '/panel/api/server/status', diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index a4b4a83b4..cf8033162 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -78,6 +78,8 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { api.Use(middleware.ConfigEnvelopeMiddleware()) api.Use(middleware.CSRFMiddleware()) + api.GET("/openapi.json", ServeOpenAPISpec) + // Inbounds API inbounds := api.Group("/inbounds") a.inboundController = NewInboundController(inbounds) diff --git a/internal/web/controller/api_docs_test.go b/internal/web/controller/api_docs_test.go deleted file mode 100644 index b41e146a7..000000000 --- a/internal/web/controller/api_docs_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package controller - -import ( - "os" - "path/filepath" - "regexp" - "strings" - "testing" -) - -type routeDef struct { - Method string - Path string -} - -// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler) -var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`) - -// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts. -var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`) - -// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the -// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription -// placeholders (paths starting with /{...}) are skipped because they aren't -// registered on the main Gin engine. -func buildDocSet(t *testing.T) map[string]bool { - t.Helper() - controllerDir, err := filepath.Abs(".") - if err != nil { - t.Fatalf("failed to get current dir: %v", err) - } - endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts") - data, err := os.ReadFile(endpointsPath) - if err != nil { - t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err) - } - docSet := make(map[string]bool) - for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) { - method, path := m[1], m[2] - if method == "WS" { - continue - } - if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") { - continue - } - docSet[method+" "+path] = true - } - if len(docSet) == 0 { - t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath) - } - return docSet -} - -func TestAPIRoutesDocumented(t *testing.T) { - docSet := buildDocSet(t) - - controllerDir, err := filepath.Abs(".") - if err != nil { - t.Fatalf("failed to get current dir: %v", err) - } - - var allRoutes []routeDef - - entries, err := os.ReadDir(controllerDir) - if err != nil { - t.Fatalf("failed to read controller dir: %v", err) - } - - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { - continue - } - data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name())) - if err != nil { - t.Fatalf("failed to read %s: %v", entry.Name(), err) - } - src := string(data) - - // Determine the base path for this file based on its initRouter patterns - basePath := "" - switch entry.Name() { - case "index.go": - basePath = "" - case "spa.go": - basePath = "/panel" - case "api.go": - basePath = "/panel/api" - case "inbound.go": - basePath = "/panel/api/inbounds" - case "client.go": - basePath = "/panel/api/clients" - case "group.go": - basePath = "/panel/api/clients" - case "server.go": - basePath = "/panel/api/server" - case "node.go": - basePath = "/panel/api/nodes" - case "host.go": - basePath = "/panel/api/hosts" - case "setting.go": - basePath = "/panel/api/setting" - case "xray_setting.go": - basePath = "/panel/api/xray" - case "websocket.go": - basePath = "" - } - - // Find all route registrations - matches := routePattern.FindAllStringSubmatch(src, -1) - for _, m := range matches { - method := m[2] - path := strings.TrimSpace(m[3]) - if basePath == "" { - allRoutes = append(allRoutes, routeDef{Method: method, Path: path}) - } else { - fullPath := basePath + path - allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath}) - } - } - } - - // The WebSocket route /ws is registered in web/web.go (not a controller file) - allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"}) - - missingFromDocs := 0 - foundInDoc := 0 - sourceSet := make(map[string]bool) - - for _, r := range allRoutes { - key := r.Method + " " + r.Path - // Skip SPA page routes (these are UI pages, not API endpoints) - spaPages := map[string]bool{ - "/": true, "/panel/": true, "/panel/inbounds": true, - "/panel/clients": true, "/panel/groups": true, - "/panel/nodes": true, "/panel/settings": true, - "/panel/xray": true, "/panel/outbound": true, - "/panel/routing": true, "/panel/api-docs": true, - } - if spaPages[r.Path] { - continue - } - // Skip /panel/csrf-token (documented under auth as /csrf-token) - if r.Path == "/panel/csrf-token" { - continue - } - // Skip Chrome DevTools route - if strings.Contains(r.Path, ".well-known") { - continue - } - - sourceSet[key] = true - if docSet[key] { - foundInDoc++ - } else { - missingFromDocs++ - t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path) - } - } - - t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d", - len(sourceSet), len(docSet), foundInDoc, missingFromDocs) - - if missingFromDocs > 0 { - t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs) - } -} diff --git a/internal/web/routes_contract_test.go b/internal/web/routes_contract_test.go new file mode 100644 index 000000000..2c6ea464d --- /dev/null +++ b/internal/web/routes_contract_test.go @@ -0,0 +1,139 @@ +package web + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + "time" + + "github.com/robfig/cron/v3" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/web/global" +) + +/* +frontend/src/pages/api-docs/endpoints.ts is a hand-maintained registry: an +API route omitted there silently vanishes from the generated OpenAPI docs, +and an entry for a removed route documents an endpoint that 404s. This test +constructs the real router and diffs it against the registry both ways. + +Scope: everything under /panel/api/ plus the session-auth surface the +registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable, +/ws). SPA page routes are UI, not API, and stay out; registry paths that +start with "/{" describe the standalone subscription server, which this +engine does not serve. +*/ + +var contractExtraRoutes = map[string]bool{ + "POST /login": true, + "POST /logout": true, + "GET /csrf-token": true, + "POST /getTwoFactorEnable": true, + "GET /ws": true, +} + +func inContractScope(method, path string) bool { + return strings.HasPrefix(path, "/panel/api/") || contractExtraRoutes[method+" "+path] +} + +func registeredContractRoutes(t *testing.T) map[string]bool { + t.Helper() + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("init db: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + previous := global.GetWebServer() + s := NewServer() + s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds()) + global.SetWebServer(s) + t.Cleanup(func() { + s.cancel() + global.SetWebServer(previous) + }) + + engine, err := s.initRouter() + if err != nil { + t.Fatalf("init router: %v", err) + } + routes := make(map[string]bool) + for _, r := range engine.Routes() { + routes[r.Method+" "+r.Path] = true + } + if len(routes) == 0 { + t.Fatal("no routes registered; router construction is broken") + } + return routes +} + +func documentedContractRoutes(t *testing.T) map[string]bool { + t.Helper() + source, err := os.ReadFile(filepath.Join("..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")) + if err != nil { + t.Fatalf("read endpoints.ts: %v", err) + } + text := string(source) + methodRe := regexp.MustCompile(`method:\s*'(GET|POST|PUT|DELETE|PATCH|WS)'`) + pathRe := regexp.MustCompile(`path:\s*'([^']+)'`) + methods := methodRe.FindAllStringSubmatchIndex(text, -1) + if declared := strings.Count(text, "method: '"); len(methods) != declared { + t.Fatalf("parsed %d method fields but endpoints.ts declares %d — the parser regex no longer matches the file shape", len(methods), declared) + } + docs := make(map[string]bool) + for i, m := range methods { + segmentEnd := len(text) + if i+1 < len(methods) { + segmentEnd = methods[i+1][0] + } + pathMatch := pathRe.FindStringSubmatch(text[m[1]:segmentEnd]) + if pathMatch == nil { + t.Fatalf("entry %d in endpoints.ts has a method but no path before the next entry — the parser cannot pair it", i) + } + method := text[m[2]:m[3]] + if strings.HasPrefix(pathMatch[1], "/{") || !strings.HasPrefix(pathMatch[1], "/") { + continue + } + docs[method+" "+pathMatch[1]] = true + } + if len(docs) == 0 { + t.Fatal("no entries parsed from endpoints.ts; the parser regex is broken") + } + return docs +} + +func TestRouteRegistryContract(t *testing.T) { + registered := registeredContractRoutes(t) + documented := documentedContractRoutes(t) + + t.Run("every API route is documented", func(t *testing.T) { + var missing []string + for route := range registered { + fields := strings.Fields(route) + if inContractScope(fields[0], fields[1]) && !documented[route] { + missing = append(missing, route) + } + } + sort.Strings(missing) + for _, route := range missing { + t.Error(fmt.Errorf("route %s is registered but absent from endpoints.ts — add an entry or it vanishes from the API docs", route)) + } + }) + + t.Run("every documented route is registered", func(t *testing.T) { + var stale []string + for route := range documented { + if !registered[route] { + stale = append(stale, route) + } + } + sort.Strings(stale) + for _, route := range stale { + t.Error(fmt.Errorf("endpoints.ts documents %s but the server does not register it — remove or fix the entry", route)) + } + }) +} diff --git a/internal/web/web.go b/internal/web/web.go index a2cb0657e..51a638b56 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -247,7 +247,6 @@ func (s *Server) initRouter() (*gin.Engine, error) { s.index = controller.NewIndexController(g) s.panel = controller.NewXUIController(g) - g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec) s.api = controller.NewAPIController(g) // Initialize WebSocket hub