feat(swagger): add Chinese summaries and descriptions for routes
Replace generic "METHOD path" summaries with curated Chinese summaries and longer descriptions, surfaced in Swagger UI. Adds a parity test that fails when a new tenant-api route is wired up without a docs entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
package swagger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRouteDocs_CoversEveryRouterEntry parses the tenant-api router source and
|
||||
// asserts that every registered route has a corresponding entry in routeDocs.
|
||||
// This is a static-analysis safety net: when a new handler is wired up in
|
||||
// internal/tenant/transport/router.go, this test fails until a Chinese
|
||||
// summary is added.
|
||||
func TestRouteDocs_CoversEveryRouterEntry(t *testing.T) {
|
||||
routerPath, bootstrapPath := findRepoSources(t)
|
||||
|
||||
routes := append(parseRoutes(t, routerPath), parseEngineRoutes(t, bootstrapPath)...)
|
||||
if len(routes) == 0 {
|
||||
t.Fatalf("could not extract any routes from %s", routerPath)
|
||||
}
|
||||
|
||||
missing := make([]string, 0)
|
||||
for _, r := range routes {
|
||||
if shouldSkipRoute(r.path) {
|
||||
continue
|
||||
}
|
||||
if _, ok := routeDocs[r.method+" "+r.path]; !ok {
|
||||
missing = append(missing, r.method+" "+r.path)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("routeDocs is missing %d route(s):\n %s", len(missing), strings.Join(missing, "\n "))
|
||||
}
|
||||
|
||||
registered := map[string]struct{}{}
|
||||
for _, r := range routes {
|
||||
registered[r.method+" "+r.path] = struct{}{}
|
||||
}
|
||||
extras := make([]string, 0)
|
||||
for k := range routeDocs {
|
||||
if strings.HasPrefix(k, "GET /swagger") {
|
||||
continue
|
||||
}
|
||||
if _, ok := registered[k]; !ok {
|
||||
extras = append(extras, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extras)
|
||||
if len(extras) > 0 {
|
||||
t.Fatalf("routeDocs has %d entry/entries with no matching route (renamed or removed):\n %s",
|
||||
len(extras), strings.Join(extras, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
type parsedRoute struct {
|
||||
method string
|
||||
path string
|
||||
}
|
||||
|
||||
var (
|
||||
groupRe = regexp.MustCompile(`(\w+)\s*:?=\s*([\w.]+)\.Group\("([^"]*)"\)`)
|
||||
methodVarRe = regexp.MustCompile(`\b(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"`)
|
||||
methodEngRe = regexp.MustCompile(`\b(?:app\.Engine|engine)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"`)
|
||||
)
|
||||
|
||||
func parseRoutes(t *testing.T, path string) []parsedRoute {
|
||||
t.Helper()
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
text := string(src)
|
||||
|
||||
prefixes := map[string][2]string{} // var -> [parent, prefix]
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if m := groupRe.FindStringSubmatch(line); m != nil {
|
||||
prefixes[m[1]] = [2]string{m[2], m[3]}
|
||||
}
|
||||
}
|
||||
resolve := func(v string) string {
|
||||
var parts []string
|
||||
for {
|
||||
pp, ok := prefixes[v]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
parts = append([]string{pp[1]}, parts...)
|
||||
v = pp[0]
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
routes := make([]parsedRoute, 0)
|
||||
seen := map[string]struct{}{}
|
||||
add := func(method, fullPath string) {
|
||||
key := method + " " + fullPath
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
routes = append(routes, parsedRoute{method: method, path: fullPath})
|
||||
}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
for _, m := range methodVarRe.FindAllStringSubmatch(line, -1) {
|
||||
v, meth, p := m[1], m[2], m[3]
|
||||
if _, ok := prefixes[v]; !ok {
|
||||
continue
|
||||
}
|
||||
add(meth, resolve(v)+p)
|
||||
}
|
||||
for _, m := range methodEngRe.FindAllStringSubmatch(line, -1) {
|
||||
add(m[1], m[2])
|
||||
}
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func parseEngineRoutes(t *testing.T, path string) []parsedRoute {
|
||||
t.Helper()
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
routes := make([]parsedRoute, 0)
|
||||
for _, m := range methodEngRe.FindAllStringSubmatch(string(src), -1) {
|
||||
routes = append(routes, parsedRoute{method: m[1], path: m[2]})
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func findRepoSources(t *testing.T) (router, bootstrap string) {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatalf("runtime.Caller failed")
|
||||
}
|
||||
// .../server/internal/shared/swagger/descriptions_parity_test.go ->
|
||||
// .../server is three levels up from this file's parent dir.
|
||||
serverRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
|
||||
return filepath.Join(serverRoot, "internal", "tenant", "transport", "router.go"),
|
||||
filepath.Join(serverRoot, "internal", "bootstrap", "bootstrap.go")
|
||||
}
|
||||
Reference in New Issue
Block a user