Преглед изворни кода

feat(swagger-api): 支持合并模式展示所有服务接口

- 新增合并模式 Handler,可将所有服务接口合并展示
- 支持通过 tags 分组展示不同服务的接口
- 添加 GetAllServicesOpenAPI 方法用于获取合并后的 OpenAPI 文档
- 更新 README 文档,详细说明两种展示模式的使用方法
-修复 generator.go 中 allServers 变量赋值错误的问题
- 对 Components 中的 SecuritySchemes、Responses 等组件进行去重和排序
- 优化服务列表处理逻辑,支持指定服务列表或获取全部服务
- 添加对 Server 列表的去重处理,避免重复 URL 出现
lihf пре 1 дан
родитељ
комит
2eba7cce38
4 измењених фајлова са 261 додато и 18 уклоњено
  1. 31 8
      README.md
  2. 95 8
      generator/generator.go
  3. 46 2
      handler.go
  4. 89 0
      service.go

+ 31 - 8
README.md

@@ -1,25 +1,48 @@
 # swagger-api
 ## Quick Start
 
-在项目中引入openapiv2
+在项目中引入openapiv2,支持两种模式:
+
+### 模式一:传统模式(服务下拉选择)
+
+每个服务单独显示,通过下拉框切换
 
 ```go
-import	"github.com/go-kratos/swagger-api/openapiv2"
+import	swagger_api "git.ikuban.com/server/swagger-api/v2"
+
+// 创建传统模式 Handler
+h := swagger_api.NewHandler(nil, false)
+// 或者指定服务列表
+h := swagger_api.NewHandler([]string{"service1", "service2"}, false)
 
-h := openapiv2.NewHandler()
 //将/q/路由放在最前匹配
 httpSrv.HandlePrefix("/q/", h)
 ```
 
-支持generator进行自定义配置
+启动应用后,在浏览器中输入 [http://\<ip>:\<port>/q/swagger-ui/](http://ip:port/q/swagger-ui/),在顶栏右侧选框选取希望查看的服务名。
+![select service](/img/swagger.png)
+
+### 模式二:合并模式(所有服务在一个页面)
+
+所有服务合并显示在同一个页面,通过 tags 分组
+
 ```go
-h := openapiv2.NewHandler(openapiv2.WithGeneratorOptions(generator.UseJSONNamesForFields(true), generator.EnumsAsInts(true)))
+import	swagger_api "git.ikuban.com/server/swagger-api/v2"
+
+// 创建合并模式 Handler
+h := swagger_api.NewMergedHandler(nil, false)
+// 或者只合并指定的服务
+h := swagger_api.NewMergedHandler([]string{"service1", "service2"}, false)
+
+//将/q/路由放在最前匹配
+httpSrv.HandlePrefix("/q/", h)
 ```
-更多配置参见 https://github.com/go-kratos/grpc-gateway/blob/master/protoc-gen-openapiv2/generator/option.go
 
+启动应用后,在浏览器中输入 [http://\<ip>:\<port>/q/swagger-ui/](http://ip:port/q/swagger-ui/),所有服务的接口会自动合并显示。
 
-启动应用后,在浏览器中输入 [http://\<ip>:\<port>/q/services](http://ip:port/q/services),在顶栏右侧选框选取希望查看的服务名,即可浏览接口文档。
-![select service](/img/swagger.png)
+**参数说明:**
+- `servicesList []string`: 指定要显示的服务列表,传 `nil` 则显示所有服务
+- `skipError bool`: 是否跳过加载错误的服务
 
 ## FAQ
 #### 1. 如果启动时顶栏选框未显示可选的服务名,或访问/q/services出现报错,`failed to decompress enc: bad gzipped descriptor: EOF`的报错说明部分依赖的proto文件生成的路径不对导致的,

+ 95 - 8
generator/generator.go

@@ -175,35 +175,35 @@ func (g *OpenAPIv3Generator) buildDocumentV3() *v3.Document {
 
 		if path.Value.Get != nil && len(path.Value.Get.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Get.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Get.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Get.Servers[0].Url)
 		}
 		if path.Value.Post != nil && len(path.Value.Post.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Post.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Post.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Post.Servers[0].Url)
 		}
 		if path.Value.Put != nil && len(path.Value.Put.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Put.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Put.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Put.Servers[0].Url)
 		}
 		if path.Value.Delete != nil && len(path.Value.Delete.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Delete.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Delete.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Delete.Servers[0].Url)
 		}
 		if path.Value.Patch != nil && len(path.Value.Patch.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Patch.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Patch.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Patch.Servers[0].Url)
 		}
 		if path.Value.Head != nil && len(path.Value.Head.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Head.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Head.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Head.Servers[0].Url)
 		}
 		if path.Value.Options != nil && len(path.Value.Options.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Options.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Options.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Options.Servers[0].Url)
 		}
 		if path.Value.Trace != nil && len(path.Value.Trace.Servers) == 1 {
 			servers = appendUnique(servers, path.Value.Trace.Servers[0].Url)
-			allServers = appendUnique(servers, path.Value.Trace.Servers[0].Url)
+			allServers = appendUnique(allServers, path.Value.Trace.Servers[0].Url)
 		}
 
 		if len(servers) == 1 {
@@ -275,6 +275,93 @@ func (g *OpenAPIv3Generator) buildDocumentV3() *v3.Document {
 		})
 		d.Components.Schemas.AdditionalProperties = pairs
 	}
+	// Deduplicate and sort security schemes.
+	if d.Components.SecuritySchemes != nil && d.Components.SecuritySchemes.AdditionalProperties != nil {
+		seen := make(map[string]bool)
+		unique := make([]*v3.NamedSecuritySchemeOrReference, 0)
+		for _, scheme := range d.Components.SecuritySchemes.AdditionalProperties {
+			if !seen[scheme.Name] {
+				seen[scheme.Name] = true
+				unique = append(unique, scheme)
+			}
+		}
+		sort.Slice(unique, func(i, j int) bool {
+			return unique[i].Name < unique[j].Name
+		})
+		d.Components.SecuritySchemes.AdditionalProperties = unique
+	}
+	// Deduplicate and sort responses.
+	if d.Components.Responses != nil && d.Components.Responses.AdditionalProperties != nil {
+		seen := make(map[string]bool)
+		unique := make([]*v3.NamedResponseOrReference, 0)
+		for _, resp := range d.Components.Responses.AdditionalProperties {
+			if !seen[resp.Name] {
+				seen[resp.Name] = true
+				unique = append(unique, resp)
+			}
+		}
+		sort.Slice(unique, func(i, j int) bool {
+			return unique[i].Name < unique[j].Name
+		})
+		d.Components.Responses.AdditionalProperties = unique
+	}
+	// Deduplicate and sort parameters.
+	if d.Components.Parameters != nil && d.Components.Parameters.AdditionalProperties != nil {
+		seen := make(map[string]bool)
+		unique := make([]*v3.NamedParameterOrReference, 0)
+		for _, param := range d.Components.Parameters.AdditionalProperties {
+			if !seen[param.Name] {
+				seen[param.Name] = true
+				unique = append(unique, param)
+			}
+		}
+		sort.Slice(unique, func(i, j int) bool {
+			return unique[i].Name < unique[j].Name
+		})
+		d.Components.Parameters.AdditionalProperties = unique
+	}
+	// Deduplicate and sort request bodies.
+	if d.Components.RequestBodies != nil && d.Components.RequestBodies.AdditionalProperties != nil {
+		seen := make(map[string]bool)
+		unique := make([]*v3.NamedRequestBodyOrReference, 0)
+		for _, body := range d.Components.RequestBodies.AdditionalProperties {
+			if !seen[body.Name] {
+				seen[body.Name] = true
+				unique = append(unique, body)
+			}
+		}
+		sort.Slice(unique, func(i, j int) bool {
+			return unique[i].Name < unique[j].Name
+		})
+		d.Components.RequestBodies.AdditionalProperties = unique
+	}
+	// Deduplicate and sort headers.
+	if d.Components.Headers != nil && d.Components.Headers.AdditionalProperties != nil {
+		seen := make(map[string]bool)
+		unique := make([]*v3.NamedHeaderOrReference, 0)
+		for _, header := range d.Components.Headers.AdditionalProperties {
+			if !seen[header.Name] {
+				seen[header.Name] = true
+				unique = append(unique, header)
+			}
+		}
+		sort.Slice(unique, func(i, j int) bool {
+			return unique[i].Name < unique[j].Name
+		})
+		d.Components.Headers.AdditionalProperties = unique
+	}
+	// Deduplicate servers by URL.
+	if d.Servers != nil && len(d.Servers) > 0 {
+		seen := make(map[string]bool)
+		unique := make([]*v3.Server, 0)
+		for _, server := range d.Servers {
+			if !seen[server.Url] {
+				seen[server.Url] = true
+				unique = append(unique, server)
+			}
+		}
+		d.Servers = unique
+	}
 	return d
 }
 

+ 46 - 2
handler.go

@@ -14,8 +14,8 @@ import (
 //go:embed q/swagger-ui/*
 var staticFS embed.FS
 
+// NewHandler creates a traditional handler with service selection dropdown
 func NewHandler(servicesList []string, skipError bool) http.Handler {
-
 	service := New(skipError)
 	r := mux.NewRouter()
 
@@ -66,7 +66,51 @@ func NewHandler(servicesList []string, skipError bool) http.Handler {
 
 	staticServer := http.FileServer(http.FS(staticFS))
 	sh := http.StripPrefix("", staticServer)
-	//r.Handle("/q/swagger-ui/", sh)
+	r.PathPrefix("/q/swagger-ui").Handler(sh)
+	return r
+}
+
+// NewMergedHandler creates a handler that shows all services merged in one page
+func NewMergedHandler(servicesList []string, skipError bool) http.Handler {
+	service := New(skipError)
+	r := mux.NewRouter()
+
+	// Return a single "merged" service for Swagger UI to load
+	r.HandleFunc("/q/services", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(200)
+		reply := map[string][]string{
+			"services": {"all"},
+		}
+		json.NewEncoder(w).Encode(reply)
+	}).Methods("GET")
+
+	// Handle the merged service request
+	r.HandleFunc("/q/service/{name}", func(w http.ResponseWriter, r *http.Request) {
+		raws := mux.Vars(r)
+		serviceName := raws["name"]
+
+		// Only handle "all" request
+		if serviceName != "all" {
+			w.WriteHeader(404)
+			w.Write([]byte("only 'all' is supported in merged mode"))
+			return
+		}
+
+		content, err := service.GetAllServicesOpenAPI(r.Context(), servicesList)
+		if err != nil {
+			w.WriteHeader(500)
+			w.Write([]byte(err.Error()))
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(200)
+		w.Write([]byte(content))
+	}).Methods("GET")
+
+	staticServer := http.FileServer(http.FS(staticFS))
+	sh := http.StripPrefix("", staticServer)
 	r.PathPrefix("/q/swagger-ui").Handler(sh)
 	return r
 }

+ 89 - 0
service.go

@@ -3,6 +3,7 @@ package swagger_api
 import (
 	"context"
 	"fmt"
+
 	"git.ikuban.com/server/swagger-api/v2/generator"
 
 	"github.com/go-kratos/kratos/v2/api/metadata"
@@ -85,3 +86,91 @@ func (s *Service) GetServiceOpenAPI(ctx context.Context, in *metadata.GetService
 	}
 	return string(content), nil
 }
+
+// GetAllServicesOpenAPI get all services merged into one openapi document
+// servicesList: specific services to include, pass nil or empty slice to include all services
+func (s *Service) GetAllServicesOpenAPI(ctx context.Context, servicesList []string) (string, error) {
+	// Determine which services to process
+	var servicesToProcess []string
+
+	if len(servicesList) > 0 {
+		// Use the specified services list
+		servicesToProcess = servicesList
+	} else {
+		// Get all services if no list specified
+		allServices, err := s.ser.ListServices(ctx, &metadata.ListServicesRequest{})
+		if err != nil {
+			return "", err
+		}
+		servicesToProcess = allServices.Services
+	}
+
+	if len(servicesToProcess) == 0 {
+		return "", fmt.Errorf("no services found")
+	}
+
+	// Collect all proto files from specified services
+	allFiles := make([]*descriptorpb.FileDescriptorProto, 0)
+	fileMap := make(map[string]byte)
+	filesToGenerate := make([]string, 0)
+
+	for _, serviceName := range servicesToProcess {
+		protoSet, err := s.ser.GetServiceDesc(ctx, &metadata.GetServiceDescRequest{Name: serviceName})
+		if err != nil {
+			// Skip services that fail to load
+			continue
+		}
+
+		for _, file := range protoSet.FileDescSet.File {
+			if _, ok := fileMap[file.GetName()]; !ok {
+				allFiles = append(allFiles, file)
+				fileMap[file.GetName()] = 1
+				// Add files that contain services to the generation list
+				if len(file.Service) > 0 {
+					filesToGenerate = append(filesToGenerate, file.GetName())
+				}
+			}
+		}
+	}
+
+	if len(allFiles) == 0 {
+		return "", fmt.Errorf("no proto files found")
+	}
+
+	if len(filesToGenerate) == 0 {
+		return "", fmt.Errorf("no service files found")
+	}
+
+	// Create code generator request
+	req := new(pluginpb.CodeGeneratorRequest)
+	req.FileToGenerate = filesToGenerate
+	var para = ""
+	req.Parameter = &para
+	req.ProtoFile = allFiles
+
+	opts := protogen.Options{}
+	plugin, err := opts.New(req)
+	if err != nil {
+		return "", err
+	}
+	plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL)
+
+	// Generate merged OpenAPI document
+	gen := generator.NewOpenAPIv3Generator(plugin, generator.Configuration{
+		Version:         utils.ToPointString("1.0"),
+		Title:           utils.ToPointString("All Services API"),
+		Description:     utils.ToPointString("Merged API documentation for all services"),
+		Naming:          utils.ToPointString("proto"),
+		FQSchemaNaming:  utils.ToPointBool(true),
+		EnumType:        utils.ToPointString("integer"),
+		CircularDepth:   utils.ToPointInt(2),
+		DefaultResponse: utils.ToPointBool(false),
+		OutputMode:      utils.ToPointString("merged"),
+	}, plugin.Files)
+	content, err := gen.RunV2()
+
+	if err != nil {
+		return "", err
+	}
+	return string(content), nil
+}