4 Commits 523935a292 ... b466ebbffc

Author SHA1 Message Date
  lihf b466ebbffc fix(generator):修正 PreserveInfo 配置逻辑以保留文档信息 1 month ago
  lihf 74887d49f2 fix(generator):修复服务标签生成逻辑 1 month ago
  lihf e74ce2d0b8 feat(api): 支持自定义合并API文档的标题和描述 1 month ago
  lihf 2eba7cce38 feat(swagger-api): 支持合并模式展示所有服务接口 1 month ago
4 changed files with 277 additions and 21 deletions
  1. 31 8
      README.md
  2. 106 11
      generator/generator.go
  3. 48 2
      handler.go
  4. 92 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文件生成的路径不对导致的,

+ 106 - 11
generator/generator.go

@@ -49,6 +49,7 @@ type Configuration struct {
 	CircularDepth   *int
 	DefaultResponse *bool
 	OutputMode      *string
+	PreserveInfo    *bool
 }
 
 const (
@@ -166,6 +167,14 @@ func (g *OpenAPIv3Generator) buildDocumentV3() *v3.Document {
 		d.Tags[0].Description = ""
 	}
 
+	if g.conf.PreserveInfo != nil && *g.conf.PreserveInfo {
+		d.Info = &v3.Info{
+			Version:     *g.conf.Version,
+			Title:       *g.conf.Title,
+			Description: *g.conf.Description,
+		}
+	}
+
 	allServers := []string{}
 
 	// If paths methods has servers, but they're all the same, then move servers to path level
@@ -175,35 +184,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 +284,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
 }
 
@@ -807,7 +903,7 @@ func (g *OpenAPIv3Generator) addPathsToDocumentV3(d *v3.Document, services []*pr
 			defaultHost := proto.GetExtension(service.Desc.Options(), annotations.E_DefaultHost).(string)
 
 			op, path2 := g.buildOperationV3(
-				d, operationID, service.GoName, comment, defaultHost, path, bodyField, inputMessage, outputMessage)
+				d, operationID, d.Info.Title, comment, defaultHost, path, bodyField, inputMessage, outputMessage)
 
 			// Merge any `Operation` annotations with the current
 			proto.Merge(op, extOperation.(*v3.Operation))
@@ -816,8 +912,7 @@ func (g *OpenAPIv3Generator) addPathsToDocumentV3(d *v3.Document, services []*pr
 		}
 
 		if annotationsCount > 0 {
-			comment := g.filterCommentString(service.Comments.Leading)
-			d.Tags = append(d.Tags, &v3.Tag{Name: service.GoName, Description: comment})
+			d.Tags = append(d.Tags, &v3.Tag{Name: d.Info.Title, Description: d.Info.Description})
 		}
 	}
 }

+ 48 - 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,53 @@ 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
+// title: the title for the merged API document
+// description: the description for the merged API document
+func NewMergedHandler(servicesList []string, title, description 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, title, description)
+		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
 }

+ 92 - 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,94 @@ 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
+// title: the title for the merged API document
+// description: the description for the merged API document
+func (s *Service) GetAllServicesOpenAPI(ctx context.Context, servicesList []string, title, description 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(title),
+		Description:     utils.ToPointString(description),
+		Naming:          utils.ToPointString("proto"),
+		FQSchemaNaming:  utils.ToPointBool(true),
+		EnumType:        utils.ToPointString("integer"),
+		CircularDepth:   utils.ToPointInt(2),
+		DefaultResponse: utils.ToPointBool(false),
+		OutputMode:      utils.ToPointString("merged"),
+		PreserveInfo:    utils.ToPointBool(true),
+	}, plugin.Files)
+	content, err := gen.RunV2()
+
+	if err != nil {
+		return "", err
+	}
+	return string(content), nil
+}