package hotime
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
. "code.hoteas.com/golang/hotime/common"
)
type swaggerTestCase struct {
Name string `json:"name"`
Note string `json:"note,omitempty"`
Method string `json:"method"`
Passed bool `json:"passed"`
Query map[string]interface{} `json:"query,omitempty"`
Json interface{} `json:"json,omitempty"`
Form map[string]interface{} `json:"form,omitempty"`
HasFile bool `json:"hasFile,omitempty"`
FileField string `json:"fileField,omitempty"`
Response map[string]interface{} `json:"response,omitempty"`
Expect interface{} `json:"expect,omitempty"`
VerifyError string `json:"verifyError,omitempty"`
}
type paramSpec struct {
Name string `json:"name"`
Required bool `json:"required"`
Type string `json:"type,omitempty"`
Example interface{} `json:"example,omitempty"`
In string `json:"in"`
}
// inferParamsFromCases 从测试用例自动推断参数必填性、类型和示例值。
// 必填判定:参数显式存在但值为空字符串,且该用例 response.status==3。
func inferParamsFromCases(cases []swaggerTestCase) []paramSpec {
type paramInfo struct {
in string
required bool
typ string
example interface{}
}
params := map[string]*paramInfo{}
getStatus := func(resp map[string]interface{}) int {
if resp == nil {
return -1
}
if s, ok := resp["status"]; ok {
if f, ok := s.(float64); ok {
return int(f)
}
}
return -1
}
inferType := func(v interface{}) string {
switch v.(type) {
case float64:
return "number"
case bool:
return "boolean"
case []interface{}:
return "array"
case map[string]interface{}:
return "object"
default:
return "string"
}
}
sv := func(v interface{}) string {
if v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}
// toStrMap 兼容 Map 类型别名和 map[string]interface{} 两种情况
toStrMap := func(v interface{}) map[string]interface{} {
if v == nil {
return nil
}
switch m := v.(type) {
case map[string]interface{}:
return m
case Map:
return map[string]interface{}(m)
}
return nil
}
// 第一轮:收集所有参数 key、类型和示例值
for _, c := range cases {
st := getStatus(c.Response)
for k, v := range c.Query {
if _, exists := params[k]; !exists {
params[k] = ¶mInfo{in: "query"}
}
p := params[k]
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if p.example == nil && st == 0 && sv(v) != "" {
p.example = v
}
}
for k, v := range c.Form {
if _, exists := params[k]; !exists {
params[k] = ¶mInfo{in: "form"}
}
p := params[k]
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if p.example == nil && st == 0 && sv(v) != "" {
p.example = v
}
}
if jmap := toStrMap(c.Json); jmap != nil {
for k, v := range jmap {
if _, exists := params[k]; !exists {
params[k] = ¶mInfo{in: "json"}
}
p := params[k]
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if p.example == nil && st == 0 && sv(v) != "" {
p.example = v
}
}
}
}
// 第二轮:标记必填 — 参数存在但值为空 且 status==3
for _, c := range cases {
if getStatus(c.Response) != 3 {
continue
}
for k, v := range c.Query {
if p, ok := params[k]; ok && p.in == "query" && sv(v) == "" {
p.required = true
}
}
for k, v := range c.Form {
if p, ok := params[k]; ok && p.in == "form" && sv(v) == "" {
p.required = true
}
}
if jmap := toStrMap(c.Json); jmap != nil {
for k, v := range jmap {
if p, ok := params[k]; ok && p.in == "json" && sv(v) == "" {
p.required = true
}
}
}
}
result := make([]paramSpec, 0, len(params))
for name, p := range params {
result = append(result, paramSpec{
Name: name,
Required: p.required,
Type: p.typ,
Example: p.example,
In: p.in,
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].Required != result[j].Required {
return result[i].Required
}
return result[i].Name < result[j].Name
})
return result
}
func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
if outputDir == "" {
outputDir = "tpt"
}
swaggerRoot := filepath.Join(outputDir, "swagger")
if err := os.MkdirAll(swaggerRoot, os.ModePerm); err != nil {
return fmt.Errorf("创建 swagger 目录失败: %w", err)
}
that.collector.mu.Lock()
records := make([]TestRecord, len(that.collector.Records))
copy(records, that.collector.Records)
that.collector.mu.Unlock()
pathRecords := map[string][]TestRecord{}
for _, r := range records {
pathRecords[r.Path] = append(pathRecords[r.Path], r)
}
for projName, projDef := range that.projs {
moduleDir := filepath.Join(swaggerRoot, projName)
if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil {
return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err)
}
endpoints := []map[string]interface{}{}
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
apiPath := "/" + projName + "/" + ctrName + "/" + methodName
ctrTest, hasCtr := projDef.Tests[ctrName]
hasTest := false
var apiTest ApiTestDef
if hasCtr {
apiTest, hasTest = ctrTest[methodName]
}
summary := methodName
if hasTest {
summary = apiTest.Desc
}
recs := pathRecords[apiPath]
httpMethod := "POST"
if len(recs) > 0 && recs[0].Method != "" {
httpMethod = strings.ToUpper(recs[0].Method)
}
var cases []swaggerTestCase
for _, r := range recs {
tc := swaggerTestCase{
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError,
}
if r.Query != nil {
tc.Query = r.Query
}
if r.JsonBody != nil {
tc.Json = r.JsonBody
}
if r.FormBody != nil {
tc.Form = r.FormBody
}
if r.HasFile {
tc.HasFile = true
tc.FileField = r.FileField
}
cases = append(cases, tc)
}
endpoints = append(endpoints, map[string]interface{}{
"path": apiPath,
"project": projName,
"ctr": ctrName,
"method": httpMethod,
"summary": summary,
"tested": hasTest,
"cases": cases,
"params": inferParamsFromCases(cases),
})
}
}
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
})
spec := map[string]interface{}{
"title": title,
"version": version,
"endpoints": endpoints,
}
specJSON, err := json.MarshalIndent(spec, "", " ")
if err != nil {
return fmt.Errorf("序列化 JSON 失败: %w", err)
}
if err := os.WriteFile(filepath.Join(moduleDir, "api-spec.json"), specJSON, os.ModePerm); err != nil {
return fmt.Errorf("写入 %s/api-spec.json 失败: %w", projName, err)
}
if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil {
return fmt.Errorf("写入 %s/index.html 失败: %w", projName, err)
}
fmt.Printf("Swagger 文档已生成: %s\n", moduleDir)
}
if err := generateSwaggerPortal(swaggerRoot); err != nil {
return fmt.Errorf("生成导航页失败: %w", err)
}
return nil
}
func generateSwaggerPortal(swaggerRoot string) error {
entries, err := os.ReadDir(swaggerRoot)
if err != nil {
return err
}
var modules []struct{ Name, Title string }
for _, e := range entries {
if !e.IsDir() {
continue
}
specPath := filepath.Join(swaggerRoot, e.Name(), "api-spec.json")
title := e.Name()
if data, err := os.ReadFile(specPath); err == nil {
var spec map[string]interface{}
if json.Unmarshal(data, &spec) == nil {
if t, ok := spec["title"].(string); ok && t != "" {
title = t
}
}
}
modules = append(modules, struct{ Name, Title string }{e.Name(), title})
}
sort.Slice(modules, func(i, j int) bool { return modules[i].Name < modules[j].Name })
html := swaggerPortalHTML(modules)
return os.WriteFile(filepath.Join(swaggerRoot, "index.html"), []byte(html), os.ModePerm)
}
func swaggerPortalHTML(modules []struct{ Name, Title string }) string {
var cards string
for _, m := range modules {
cards += `