diff --git a/apps/admin-web/nginx.conf b/apps/admin-web/nginx.conf index b623e5c..5451faa 100644 --- a/apps/admin-web/nginx.conf +++ b/apps/admin-web/nginx.conf @@ -22,6 +22,18 @@ server { proxy_send_timeout 300s; } + # Proxy Swagger UI and OpenAPI JSON to the backend for API testing + location ^~ /swagger { + proxy_pass http://tenant-api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + # Cache static assets aggressively location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 1y; diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts index e7b1db7..e6f73d9 100644 --- a/apps/admin-web/vite.config.ts +++ b/apps/admin-web/vite.config.ts @@ -58,6 +58,10 @@ export default defineConfig(({ mode }) => { target: env.VITE_API_PROXY_TARGET || "http://localhost:8080", changeOrigin: true, }, + "/swagger": { + target: env.VITE_API_PROXY_TARGET || "http://localhost:8080", + changeOrigin: true, + }, }, }, esbuild: isProd ? { drop: ["console", "debugger"] } : undefined, diff --git a/server/internal/shared/swagger/swagger.go b/server/internal/shared/swagger/swagger.go new file mode 100644 index 0000000..1405d6a --- /dev/null +++ b/server/internal/shared/swagger/swagger.go @@ -0,0 +1,590 @@ +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 = ` + +
+ + +