1
0

api_docs_test.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. package controller
  2. import (
  3. "os"
  4. "path/filepath"
  5. "regexp"
  6. "strings"
  7. "testing"
  8. )
  9. type routeDef struct {
  10. Method string
  11. Path string
  12. }
  13. // routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
  14. var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
  15. // docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.js.
  16. var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
  17. // buildDocSet parses frontend/src/pages/api-docs/endpoints.js and returns the
  18. // set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
  19. // placeholders (paths starting with /{...}) are skipped because they aren't
  20. // registered on the main Gin engine.
  21. func buildDocSet(t *testing.T) map[string]bool {
  22. t.Helper()
  23. controllerDir, err := filepath.Abs(".")
  24. if err != nil {
  25. t.Fatalf("failed to get current dir: %v", err)
  26. }
  27. endpointsPath := filepath.Join(controllerDir, "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.js")
  28. data, err := os.ReadFile(endpointsPath)
  29. if err != nil {
  30. t.Fatalf("failed to read endpoints.js at %s: %v", endpointsPath, err)
  31. }
  32. docSet := make(map[string]bool)
  33. for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
  34. method, path := m[1], m[2]
  35. if method == "WS" {
  36. continue
  37. }
  38. if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
  39. continue
  40. }
  41. docSet[method+" "+path] = true
  42. }
  43. if len(docSet) == 0 {
  44. t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
  45. }
  46. return docSet
  47. }
  48. func TestAPIRoutesDocumented(t *testing.T) {
  49. docSet := buildDocSet(t)
  50. controllerDir, err := filepath.Abs(".")
  51. if err != nil {
  52. t.Fatalf("failed to get current dir: %v", err)
  53. }
  54. var allRoutes []routeDef
  55. entries, err := os.ReadDir(controllerDir)
  56. if err != nil {
  57. t.Fatalf("failed to read controller dir: %v", err)
  58. }
  59. for _, entry := range entries {
  60. if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
  61. continue
  62. }
  63. data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
  64. if err != nil {
  65. t.Fatalf("failed to read %s: %v", entry.Name(), err)
  66. }
  67. src := string(data)
  68. // Determine the base path for this file based on its initRouter patterns
  69. basePath := ""
  70. switch entry.Name() {
  71. case "index.go":
  72. basePath = ""
  73. case "xui.go":
  74. basePath = "/panel"
  75. case "api.go":
  76. basePath = "/panel/api"
  77. case "inbound.go":
  78. basePath = "/panel/api/inbounds"
  79. case "server.go":
  80. basePath = "/panel/api/server"
  81. case "node.go":
  82. basePath = "/panel/api/nodes"
  83. case "setting.go":
  84. basePath = "/panel/setting"
  85. case "xray_setting.go":
  86. basePath = "/panel/xray"
  87. case "custom_geo.go":
  88. basePath = "/panel/api/custom-geo"
  89. case "websocket.go":
  90. basePath = ""
  91. }
  92. // Find all route registrations
  93. matches := routePattern.FindAllStringSubmatch(src, -1)
  94. for _, m := range matches {
  95. method := m[2]
  96. path := strings.TrimSpace(m[3])
  97. if basePath == "" {
  98. allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
  99. } else {
  100. fullPath := basePath + path
  101. allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
  102. }
  103. }
  104. }
  105. // The WebSocket route /ws is registered in web/web.go (not a controller file)
  106. allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
  107. missingFromDocs := 0
  108. foundInDoc := 0
  109. sourceSet := make(map[string]bool)
  110. for _, r := range allRoutes {
  111. key := r.Method + " " + r.Path
  112. // Skip SPA page routes (these are UI pages, not API endpoints)
  113. spaPages := map[string]bool{
  114. "/": true, "/panel/": true, "/panel/inbounds": true,
  115. "/panel/nodes": true, "/panel/settings": true,
  116. "/panel/xray": true, "/panel/api-docs": true,
  117. }
  118. if spaPages[r.Path] {
  119. continue
  120. }
  121. // Skip /panel/csrf-token (documented under auth as /csrf-token)
  122. if r.Path == "/panel/csrf-token" {
  123. continue
  124. }
  125. // Skip Chrome DevTools route
  126. if strings.Contains(r.Path, ".well-known") {
  127. continue
  128. }
  129. sourceSet[key] = true
  130. if docSet[key] {
  131. foundInDoc++
  132. } else {
  133. missingFromDocs++
  134. t.Errorf("Route not documented in endpoints.js: %s %s", r.Method, r.Path)
  135. }
  136. }
  137. t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
  138. len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
  139. if missingFromDocs > 0 {
  140. t.Errorf("Found %d undocumented route(s). Update endpoints.js to match.", missingFromDocs)
  141. }
  142. }