461f186337
Adds an in-process Swagger UI and OpenAPI JSON under /swagger, registered automatically when the Gin mode is not release. The admin-web Vite dev server and production nginx config now proxy /swagger to the tenant API so the docs are reachable from the existing admin host. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
591 lines
16 KiB
Go
591 lines
16 KiB
Go
package swagger
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type Config struct {
|
|
Title string
|
|
Version string
|
|
Description string
|
|
}
|
|
|
|
func EnabledForMode(mode string) bool {
|
|
return !strings.EqualFold(strings.TrimSpace(mode), gin.ReleaseMode)
|
|
}
|
|
|
|
func Register(engine *gin.Engine, cfg Config) {
|
|
cfg = withDefaults(cfg)
|
|
|
|
engine.GET("/swagger", func(c *gin.Context) {
|
|
c.Redirect(http.StatusMovedPermanently, "/swagger/index.html")
|
|
})
|
|
engine.GET("/swagger/", func(c *gin.Context) {
|
|
c.Redirect(http.StatusMovedPermanently, "/swagger/index.html")
|
|
})
|
|
engine.GET("/swagger/index.html", func(c *gin.Context) {
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(swaggerIndexHTML))
|
|
})
|
|
engine.GET("/swagger/openapi.json", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, BuildOpenAPI(engine.Routes(), cfg))
|
|
})
|
|
}
|
|
|
|
func BuildOpenAPI(routes gin.RoutesInfo, cfg Config) map[string]any {
|
|
cfg = withDefaults(cfg)
|
|
|
|
sort.Slice(routes, func(i, j int) bool {
|
|
if routes[i].Path == routes[j].Path {
|
|
return routes[i].Method < routes[j].Method
|
|
}
|
|
return routes[i].Path < routes[j].Path
|
|
})
|
|
|
|
paths := map[string]any{}
|
|
tagSet := map[string]struct{}{}
|
|
operationIDs := map[string]int{}
|
|
|
|
for _, route := range routes {
|
|
if shouldSkipRoute(route.Path) {
|
|
continue
|
|
}
|
|
|
|
path := toOpenAPIPath(route.Path)
|
|
method := strings.ToLower(route.Method)
|
|
if method == "" {
|
|
continue
|
|
}
|
|
|
|
pathItem, ok := paths[path].(map[string]any)
|
|
if !ok {
|
|
pathItem = map[string]any{}
|
|
paths[path] = pathItem
|
|
}
|
|
|
|
tag := tagForPath(route.Path)
|
|
tagSet[tag] = struct{}{}
|
|
|
|
operationID := uniqueOperationID(method+"_"+sanitizeIdentifier(path), operationIDs)
|
|
operation := map[string]any{
|
|
"tags": []string{tag},
|
|
"summary": route.Method + " " + path,
|
|
"operationId": operationID,
|
|
"responses": responsesForRoute(route),
|
|
}
|
|
|
|
if params := parametersForRoute(route); len(params) > 0 {
|
|
operation["parameters"] = params
|
|
}
|
|
|
|
if body := requestBodyForRoute(route); body != nil {
|
|
operation["requestBody"] = body
|
|
}
|
|
|
|
if security := securityForPath(route.Path); len(security) > 0 {
|
|
operation["security"] = security
|
|
}
|
|
|
|
pathItem[method] = operation
|
|
}
|
|
|
|
tags := make([]map[string]string, 0, len(tagSet))
|
|
for tag := range tagSet {
|
|
tags = append(tags, map[string]string{"name": tag})
|
|
}
|
|
sort.Slice(tags, func(i, j int) bool {
|
|
return tags[i]["name"] < tags[j]["name"]
|
|
})
|
|
|
|
return map[string]any{
|
|
"openapi": "3.0.3",
|
|
"info": map[string]any{
|
|
"title": cfg.Title,
|
|
"version": cfg.Version,
|
|
"description": cfg.Description,
|
|
},
|
|
"servers": []map[string]string{
|
|
{
|
|
"url": "/",
|
|
"description": "Current host. When opened from admin-web, /api is proxied to tenant-api.",
|
|
},
|
|
},
|
|
"tags": tags,
|
|
"paths": paths,
|
|
"components": components(),
|
|
}
|
|
}
|
|
|
|
func withDefaults(cfg Config) Config {
|
|
if strings.TrimSpace(cfg.Title) == "" {
|
|
cfg.Title = "Geo Rankly Tenant API"
|
|
}
|
|
if strings.TrimSpace(cfg.Version) == "" {
|
|
cfg.Version = "dev"
|
|
}
|
|
if strings.TrimSpace(cfg.Description) == "" {
|
|
cfg.Description = "Auto-generated from registered Gin routes for admin-web testing. Request and response bodies are intentionally generic where handlers do not expose typed OpenAPI metadata."
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func shouldSkipRoute(path string) bool {
|
|
return strings.HasPrefix(path, "/swagger") || strings.HasPrefix(path, "/api/internal/")
|
|
}
|
|
|
|
func toOpenAPIPath(path string) string {
|
|
segments := strings.Split(path, "/")
|
|
for i, segment := range segments {
|
|
if strings.HasPrefix(segment, ":") && len(segment) > 1 {
|
|
segments[i] = "{" + segment[1:] + "}"
|
|
continue
|
|
}
|
|
if strings.HasPrefix(segment, "*") && len(segment) > 1 {
|
|
segments[i] = "{" + segment[1:] + "}"
|
|
}
|
|
}
|
|
return strings.Join(segments, "/")
|
|
}
|
|
|
|
func tagForPath(path string) string {
|
|
segments := pathSegments(path)
|
|
if len(segments) < 2 || segments[0] != "api" {
|
|
return "General"
|
|
}
|
|
|
|
switch segments[1] {
|
|
case "health":
|
|
return "Health"
|
|
case "auth":
|
|
return "Auth"
|
|
case "public":
|
|
return "Public"
|
|
case "desktop":
|
|
if len(segments) >= 3 {
|
|
return "Desktop " + titleSegment(segments[2])
|
|
}
|
|
return "Desktop"
|
|
case "tenant":
|
|
if len(segments) >= 4 && segments[2] == "kol" {
|
|
return "Tenant KOL " + titleSegment(segments[3])
|
|
}
|
|
if len(segments) >= 3 {
|
|
return "Tenant " + titleSegment(segments[2])
|
|
}
|
|
return "Tenant"
|
|
default:
|
|
return titleSegment(segments[1])
|
|
}
|
|
}
|
|
|
|
func pathSegments(path string) []string {
|
|
trimmed := strings.Trim(path, "/")
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(trimmed, "/")
|
|
}
|
|
|
|
func titleSegment(segment string) string {
|
|
parts := strings.FieldsFunc(segment, func(r rune) bool {
|
|
return r == '-' || r == '_' || r == ':'
|
|
})
|
|
for i, part := range parts {
|
|
switch strings.ToLower(part) {
|
|
case "ai":
|
|
parts[i] = "AI"
|
|
case "kol":
|
|
parts[i] = "KOL"
|
|
default:
|
|
parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func uniqueOperationID(base string, seen map[string]int) string {
|
|
if base == "" {
|
|
base = "operation"
|
|
}
|
|
count := seen[base]
|
|
seen[base] = count + 1
|
|
if count == 0 {
|
|
return base
|
|
}
|
|
return base + "_" + strconv.Itoa(count+1)
|
|
}
|
|
|
|
func sanitizeIdentifier(value string) string {
|
|
var b strings.Builder
|
|
lastUnderscore := false
|
|
for _, r := range value {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
b.WriteRune(unicode.ToLower(r))
|
|
lastUnderscore = false
|
|
continue
|
|
}
|
|
if !lastUnderscore {
|
|
b.WriteByte('_')
|
|
lastUnderscore = true
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "_")
|
|
}
|
|
|
|
func parametersForRoute(route gin.RouteInfo) []map[string]any {
|
|
params := make([]map[string]any, 0)
|
|
params = append(params, pathParameters(route.Path)...)
|
|
|
|
for _, name := range queryParameterNames(route) {
|
|
params = append(params, queryParameter(name))
|
|
}
|
|
|
|
if requiresWorkspaceHeader(route.Path) {
|
|
params = append(params, map[string]any{
|
|
"name": "X-Workspace-ID",
|
|
"in": "header",
|
|
"required": false,
|
|
"description": "Optional workspace override. Defaults to the workspace in the access token.",
|
|
"schema": map[string]any{
|
|
"type": "integer",
|
|
"format": "int64",
|
|
},
|
|
})
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
func pathParameters(path string) []map[string]any {
|
|
segments := pathSegments(path)
|
|
params := make([]map[string]any, 0)
|
|
for _, segment := range segments {
|
|
if len(segment) < 2 || (segment[0] != ':' && segment[0] != '*') {
|
|
continue
|
|
}
|
|
name := segment[1:]
|
|
params = append(params, map[string]any{
|
|
"name": name,
|
|
"in": "path",
|
|
"required": true,
|
|
"description": "Route parameter `" + name + "`.",
|
|
"schema": map[string]any{
|
|
"type": "string",
|
|
},
|
|
})
|
|
}
|
|
return params
|
|
}
|
|
|
|
func queryParameterNames(route gin.RouteInfo) []string {
|
|
if route.Method != http.MethodGet && route.Method != http.MethodDelete && route.Method != http.MethodPost {
|
|
return nil
|
|
}
|
|
|
|
path := route.Path
|
|
names := make([]string, 0)
|
|
add := func(values ...string) {
|
|
for _, value := range values {
|
|
if value == "" || contains(names, value) {
|
|
continue
|
|
}
|
|
names = append(names, value)
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case strings.Contains(path, "/workspace/ai-point-usage"):
|
|
add("page", "page_size")
|
|
case strings.Contains(path, "/tenant/articles"):
|
|
add("page", "page_size", "generate_status", "publish_status", "source_type", "generation_mode", "template_id", "kol_prompt_id", "prompt_rule_id", "keyword", "created_from", "created_to")
|
|
case strings.Contains(path, "/tenant/prompt-rules"):
|
|
add("page", "page_size", "group_id", "ungrouped", "status", "keyword")
|
|
case strings.Contains(path, "/tenant/schedules"):
|
|
add("page", "page_size", "prompt_rule_id", "status", "keyword", "created_from", "created_to")
|
|
case strings.Contains(path, "/tenant/instant-tasks"):
|
|
add("page", "page_size", "prompt_rule_id", "status", "keyword", "created_from", "created_to")
|
|
case strings.Contains(path, "/tenant/images"):
|
|
add("page", "page_size", "folder_id", "q", "force")
|
|
case strings.Contains(path, "/tenant/knowledge/items"):
|
|
add("group_id")
|
|
case strings.Contains(path, "/tenant/brands") && strings.Contains(path, "/questions"):
|
|
add("keyword_id")
|
|
case strings.Contains(path, "/tenant/monitoring/dashboard/composite"):
|
|
add("brand_id", "keyword_id", "days", "business_date", "ai_platform_id")
|
|
case strings.Contains(path, "/tenant/monitoring/citation-summary"):
|
|
add("days")
|
|
case strings.Contains(path, "/tenant/monitoring/brands/"):
|
|
add("ai_platform_id", "date_from", "date_to", "question_hash")
|
|
case strings.Contains(path, "/tenant/kol/marketplace/packages"):
|
|
add("industry", "keyword", "offset", "limit")
|
|
case strings.Contains(path, "/tenant/kol/dashboard/trend"):
|
|
add("period_days")
|
|
case strings.Contains(path, "/tenant/accounts/"):
|
|
add("undo")
|
|
case strings.Contains(path, "/desktop/accounts"):
|
|
add("client", "if_sync_version")
|
|
case strings.Contains(path, "/desktop/publish-tasks"):
|
|
add("page", "page_size", "limit", "title")
|
|
case strings.Contains(path, "/templates/") && strings.Contains(path, "_task_result"):
|
|
add("task_id")
|
|
case strings.Contains(path, "/public/assets/"):
|
|
add("format")
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
func queryParameter(name string) map[string]any {
|
|
return map[string]any{
|
|
"name": name,
|
|
"in": "query",
|
|
"required": false,
|
|
"schema": schemaForQueryParameter(name),
|
|
}
|
|
}
|
|
|
|
func schemaForQueryParameter(name string) map[string]any {
|
|
switch name {
|
|
case "page", "page_size", "limit", "offset", "days", "period_days":
|
|
return map[string]any{"type": "integer"}
|
|
case "brand_id", "keyword_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
|
|
return map[string]any{"type": "integer", "format": "int64"}
|
|
case "force":
|
|
return map[string]any{"type": "string", "enum": []string{"1"}}
|
|
case "ungrouped", "undo":
|
|
return map[string]any{"type": "string", "enum": []string{"true", "false"}}
|
|
case "created_from", "created_to", "date_from", "date_to", "business_date":
|
|
return map[string]any{"type": "string", "format": "date"}
|
|
default:
|
|
return map[string]any{"type": "string"}
|
|
}
|
|
}
|
|
|
|
func requestBodyForRoute(route gin.RouteInfo) map[string]any {
|
|
switch route.Method {
|
|
case http.MethodPost, http.MethodPut, http.MethodPatch:
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
if isMultipartRoute(route) {
|
|
return multipartRequestBody(route.Path)
|
|
}
|
|
|
|
return map[string]any{
|
|
"required": false,
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": map[string]any{
|
|
"type": "object",
|
|
"additionalProperties": true,
|
|
},
|
|
"example": map[string]any{},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func isMultipartRoute(route gin.RouteInfo) bool {
|
|
if route.Method != http.MethodPost {
|
|
return false
|
|
}
|
|
path := route.Path
|
|
return path == "/api/tenant/images" ||
|
|
path == "/api/tenant/knowledge/items/file" ||
|
|
path == "/api/tenant/kol/manage/profile/avatar" ||
|
|
path == "/api/tenant/articles/:id/images"
|
|
}
|
|
|
|
func multipartRequestBody(path string) map[string]any {
|
|
properties := map[string]any{
|
|
"file": map[string]any{
|
|
"type": "string",
|
|
"format": "binary",
|
|
},
|
|
}
|
|
required := []string{"file"}
|
|
|
|
if path == "/api/tenant/images" {
|
|
properties["folder_id"] = map[string]any{"type": "string"}
|
|
}
|
|
if path == "/api/tenant/knowledge/items/file" {
|
|
properties["group_id"] = map[string]any{"type": "string"}
|
|
properties["name"] = map[string]any{"type": "string"}
|
|
required = append(required, "group_id")
|
|
}
|
|
|
|
return map[string]any{
|
|
"required": true,
|
|
"content": map[string]any{
|
|
"multipart/form-data": map[string]any{
|
|
"schema": map[string]any{
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func responsesForRoute(route gin.RouteInfo) map[string]any {
|
|
successContent := map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": map[string]any{
|
|
"$ref": "#/components/schemas/ApiEnvelope",
|
|
},
|
|
},
|
|
}
|
|
if strings.HasSuffix(route.Path, "/stream") {
|
|
successContent = map[string]any{
|
|
"text/event-stream": map[string]any{
|
|
"schema": map[string]any{"type": "string"},
|
|
},
|
|
}
|
|
}
|
|
|
|
responses := map[string]any{
|
|
"200": map[string]any{
|
|
"description": "OK",
|
|
"content": successContent,
|
|
},
|
|
"400": errorResponse("Bad Request"),
|
|
"401": errorResponse("Unauthorized"),
|
|
"403": errorResponse("Forbidden"),
|
|
"404": errorResponse("Not Found"),
|
|
"500": errorResponse("Internal Server Error"),
|
|
}
|
|
|
|
if route.Method == http.MethodPost {
|
|
responses["201"] = map[string]any{
|
|
"description": "Created",
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": map[string]any{
|
|
"$ref": "#/components/schemas/ApiEnvelope",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return responses
|
|
}
|
|
|
|
func errorResponse(description string) map[string]any {
|
|
return map[string]any{
|
|
"description": description,
|
|
"content": map[string]any{
|
|
"application/json": map[string]any{
|
|
"schema": map[string]any{
|
|
"$ref": "#/components/schemas/ApiError",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func securityForPath(path string) []map[string][]string {
|
|
if path == "/api/auth/login" || path == "/api/auth/refresh" ||
|
|
strings.HasPrefix(path, "/api/health/") ||
|
|
strings.HasPrefix(path, "/api/public/") {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(path, "/api/") {
|
|
return []map[string][]string{{"bearerAuth": []string{}}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requiresWorkspaceHeader(path string) bool {
|
|
return path == "/api/auth/me" ||
|
|
path == "/api/auth/logout" ||
|
|
path == "/api/desktop/clients/register" ||
|
|
strings.HasPrefix(path, "/api/tenant/")
|
|
}
|
|
|
|
func components() map[string]any {
|
|
return map[string]any{
|
|
"securitySchemes": map[string]any{
|
|
"bearerAuth": map[string]any{
|
|
"type": "http",
|
|
"scheme": "bearer",
|
|
"bearerFormat": "JWT",
|
|
"description": "Admin-web endpoints use the tenant access token. Desktop client endpoints also use Bearer, with the desktop client token.",
|
|
},
|
|
},
|
|
"schemas": map[string]any{
|
|
"ApiEnvelope": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"code": map[string]any{"type": "integer", "example": 0},
|
|
"message": map[string]any{"type": "string", "example": "ok"},
|
|
"data": map[string]any{"description": "Response payload."},
|
|
"request_id": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"ApiError": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"code": map[string]any{"type": "integer"},
|
|
"message": map[string]any{"type": "string"},
|
|
"detail": map[string]any{"type": "string"},
|
|
"request_id": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func contains(values []string, expected string) bool {
|
|
for _, value := range values {
|
|
if value == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
const swaggerIndexHTML = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Geo Rankly API Swagger</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
background: #f6f7f9;
|
|
}
|
|
.swagger-ui .topbar {
|
|
display: none;
|
|
}
|
|
.swagger-ui .info {
|
|
margin: 32px 0 24px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="swagger-ui"></div>
|
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
<script>
|
|
window.ui = SwaggerUIBundle({
|
|
url: "/swagger/openapi.json",
|
|
dom_id: "#swagger-ui",
|
|
deepLinking: true,
|
|
displayRequestDuration: true,
|
|
docExpansion: "none",
|
|
persistAuthorization: true,
|
|
tryItOutEnabled: true
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`
|