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"`
FailReason string `json:"failReason,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 {
switch v := s.(type) {
case float64:
return int(v)
case int64:
return int(v)
case int:
return v
}
}
return -1
}
inferType := func(v interface{}) string {
switch v.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return "number"
case bool:
return "boolean"
case []interface{}, Slice:
return "array"
case map[string]interface{}, Map:
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、类型和示例值
// 成功用例(status=0)优先级最高,覆盖 in/typ/example,代表真实请求方式和数据类型
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 st == 0 {
p.in = "query"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if st == 0 && sv(v) != "" {
p.typ = inferType(v)
p.example = v
}
}
for k, v := range c.Form {
if _, exists := params[k]; !exists {
params[k] = ¶mInfo{in: "form"}
}
p := params[k]
if st == 0 {
p.in = "form"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if st == 0 && sv(v) != "" {
p.typ = inferType(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 st == 0 {
p.in = "json"
}
if p.typ == "" && sv(v) != "" {
p.typ = inferType(v)
}
if st == 0 && sv(v) != "" {
p.typ = inferType(v)
p.example = v
}
}
}
}
// 第二轮:标记必填 — 参数存在但值为空 且 status==3(不限制 in 类型,因为错误用例可能用不同传参方式)
for _, c := range cases {
if getStatus(c.Response) != 3 {
continue
}
for k, v := range c.Query {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
for k, v := range c.Form {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
if jmap := toStrMap(c.Json); jmap != nil {
for k, v := range jmap {
if p, ok := params[k]; ok && sv(v) == "" {
p.required = true
}
}
}
}
// 第三轮:标记必填 — 字段在 status==3 用例中缺失,但在下一个用例中出现
collectKeys := func(c swaggerTestCase) map[string]bool {
keys := map[string]bool{}
for k := range c.Query {
keys[k] = true
}
for k := range c.Form {
keys[k] = true
}
if jm := toStrMap(c.Json); jm != nil {
for k := range jm {
keys[k] = true
}
}
return keys
}
for i := 0; i < len(cases)-1; i++ {
if getStatus(cases[i].Response) != 3 {
continue
}
curKeys := collectKeys(cases[i])
nextKeys := collectKeys(cases[i+1])
for k := range nextKeys {
if !curKeys[k] {
if p, ok := params[k]; ok {
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 title == "" {
title = that.swaggerTitle
}
if version == "" {
version = that.swaggerVersion
}
if outputDir == "" {
outputDir = that.swaggerOutputDir()
}
if title == "" {
title = "API"
}
if version == "" {
version = "1.0.0"
}
that.swaggerEnabled = true
that.swaggerTitle = title
that.swaggerVersion = version
if that.swaggerDir == "" {
that.swaggerDir = outputDir
}
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)
visited := map[string]bool{}
for k, v := range that.collector.Visited {
visited[k] = v
}
visitedFlows := map[string]bool{}
for k, v := range that.collector.VisitedFlows {
visitedFlows[k] = v
}
that.collector.mu.Unlock()
pathRecords := map[string][]TestRecord{}
flowRecords := map[string][]TestRecord{}
for _, r := range records {
if r.FlowName != "" {
flowRecords[r.FlowName] = append(flowRecords[r.FlowName], r)
continue
}
pathRecords[r.Path] = append(pathRecords[r.Path], r)
}
fullComplete := that.isSwaggerFullComplete(visited, visitedFlows)
activeModules := map[string]bool{}
for projName, projDef := range that.projs {
activeModules[projName] = true
moduleDir := filepath.Join(swaggerRoot, projName)
if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil {
return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err)
}
specPath := filepath.Join(moduleDir, "api-spec.json")
existingEndpoints := loadExistingEndpoints(specPath)
existingFlows := loadExistingFlows(specPath)
endpoints := []map[string]interface{}{}
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
apiPath := "/" + projName + "/" + ctrName + "/" + methodName
recs := pathRecords[apiPath]
existingEp := existingEndpoints[apiPath]
// 无本轮用例记录:一律保留旧 swagger,禁止空 cases 覆盖已测内容
if len(recs) == 0 && existingEp != nil {
endpoints = append(endpoints, existingEp)
continue
}
ctrTest, hasCtr := projDef.Tests[ctrName]
hasTest := false
var apiTest ApiTestDef
if hasCtr {
apiTest, hasTest = ctrTest[methodName]
}
summary := methodName
if hasTest {
summary = apiTest.Desc
}
endpoints = append(endpoints, that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existingEp))
}
}
// 未全量时:保留旧 spec 中已不在当前 Proj 的 endpoint
if !fullComplete {
projPaths := map[string]bool{}
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
projPaths["/"+projName+"/"+ctrName+"/"+methodName] = true
}
}
have := map[string]bool{}
for _, ep := range endpoints {
if p, ok := ep["path"].(string); ok {
have[p] = true
}
}
for path, ep := range existingEndpoints {
if !projPaths[path] && !have[path] {
endpoints = append(endpoints, ep)
}
}
}
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
})
flows := that.mergeFlows(existingFlows, flowRecords, visitedFlows, fullComplete)
if err := that.writeModuleSpec(moduleDir, title, version, endpoints, flows); err != nil {
return err
}
fmt.Printf("Swagger 文档已生成: %s\n", moduleDir)
}
if fullComplete {
if err := that.pruneOrphanSwaggerModules(swaggerRoot, activeModules); err != nil {
return err
}
}
if err := generateSwaggerPortal(swaggerRoot); err != nil {
return fmt.Errorf("生成导航页失败: %w", err)
}
return nil
}
func (that *TestApp) isSwaggerFullComplete(visited, visitedFlows map[string]bool) bool {
totalPaths := 0
visitedPaths := 0
for projName, projDef := range that.projs {
for ctrName, ctr := range projDef.Proj {
for methodName := range ctr {
totalPaths++
if visited["/"+projName+"/"+ctrName+"/"+methodName] {
visitedPaths++
}
}
}
}
if totalPaths == 0 || visitedPaths < totalPaths {
return false
}
for name := range that.flows {
if !visitedFlows[name] {
return false
}
}
return true
}
func (that *TestApp) buildEndpointMap(projName, ctrName, apiPath, summary string, hasTest bool, recs []TestRecord, existing map[string]interface{}) map[string]interface{} {
httpMethod := "POST"
if len(recs) > 0 && recs[0].Method != "" {
httpMethod = strings.ToUpper(recs[0].Method)
} else if existing != nil {
if m, ok := existing["method"].(string); ok && m != "" {
httpMethod = m
}
}
cases := make([]swaggerTestCase, 0, len(recs))
for _, r := range recs {
cases = append(cases, recordToSwaggerCase(r))
}
return map[string]interface{}{
"path": apiPath,
"project": projName,
"ctr": ctrName,
"method": httpMethod,
"summary": summary,
"tested": hasTest,
"cases": cases,
"params": inferParamsFromCases(cases),
}
}
func recordToSwaggerCase(r TestRecord) swaggerTestCase {
tc := swaggerTestCase{
Name: r.CaseName, Note: r.Note, Method: r.Method, Passed: r.Passed,
Response: r.ResponseBody, Expect: r.ExpectResult, VerifyError: r.VerifyError,
FailReason: r.FailReason,
}
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
}
return tc
}
func (that *TestApp) mergeFlows(existing map[string]map[string]interface{}, flowRecords map[string][]TestRecord, visitedFlows map[string]bool, fullComplete bool) []map[string]interface{} {
out := []map[string]interface{}{}
seen := map[string]bool{}
for name, recs := range flowRecords {
out = append(out, that.buildFlowMap(name, recs))
seen[name] = true
}
for name, flowDef := range that.flows {
if seen[name] {
continue
}
if visitedFlows[name] {
continue
}
if existingFlow, ok := existing[name]; ok {
out = append(out, existingFlow)
seen[name] = true
continue
}
_ = flowDef
}
if !fullComplete {
for name, ep := range existing {
if !seen[name] {
// 未全量:保留旧 flow(含已从注册删除的)
out = append(out, ep)
seen[name] = true
}
}
} else {
// 全量:只保留仍注册或本轮跑过的
registered := map[string]bool{}
for name := range that.flows {
registered[name] = true
}
filtered := out[:0]
for _, f := range out {
name, _ := f["name"].(string)
if registered[name] || visitedFlows[name] {
filtered = append(filtered, f)
}
}
out = filtered
}
sort.Slice(out, func(i, j int) bool {
return out[i]["name"].(string) < out[j]["name"].(string)
})
return out
}
func (that *TestApp) buildFlowMap(name string, recs []TestRecord) map[string]interface{} {
desc := name
group := ""
prep := ""
passed := true
steps := []map[string]interface{}{}
sort.Slice(recs, func(i, j int) bool {
if recs[i].FlowStepIndex != recs[j].FlowStepIndex {
return recs[i].FlowStepIndex < recs[j].FlowStepIndex
}
return recs[i].CaseName < recs[j].CaseName
})
for _, r := range recs {
if r.FlowDesc != "" {
desc = r.FlowDesc
}
if r.FlowGroup != "" {
group = r.FlowGroup
}
if r.FlowPrep != "" {
prep = r.FlowPrep
}
if !r.Passed {
passed = false
}
stepName := r.FlowStep
if stepName == "" {
stepName = r.CaseName
}
tc := recordToSwaggerCase(r)
step := map[string]interface{}{
"index": r.FlowStepIndex,
"name": stepName,
"path": r.Path,
"method": tc.Method,
"passed": tc.Passed,
"note": tc.Note,
"query": tc.Query,
"json": tc.Json,
"form": tc.Form,
"response": tc.Response,
"expect": tc.Expect,
"failReason": tc.FailReason,
"verifyError": tc.VerifyError,
"caseName": tc.Name,
}
if r.BindCase != "" {
step["bindCase"] = r.BindCase
}
steps = append(steps, step)
}
if group == "" {
if def, ok := that.flows[name]; ok && def.Group != "" {
group = def.Group
}
}
if prep == "" {
if def, ok := that.flows[name]; ok && def.Prep != "" {
prep = def.Prep
}
}
if desc == name {
if def, ok := that.flows[name]; ok && def.Desc != "" {
desc = def.Desc
}
}
m := map[string]interface{}{
"name": name,
"desc": desc,
"passed": passed,
"steps": steps,
}
if group != "" {
m["group"] = group
}
if prep != "" {
m["prep"] = prep
}
return m
}
// caseTemplate 单接口验收用例的请求模板(供 FromCase 加载)
type caseTemplate struct {
Query map[string]interface{}
Json interface{}
Form map[string]interface{}
Note string
}
func (that *TestApp) findCaseTemplate(apiPath, caseName string) *caseTemplate {
that.collector.mu.Lock()
for _, r := range that.collector.Records {
if r.FlowName == "" && r.Path == apiPath && r.CaseName == caseName {
tmpl := &caseTemplate{Note: r.Note}
if r.Query != nil {
tmpl.Query = r.Query
}
if r.JsonBody != nil {
tmpl.Json = r.JsonBody
}
if r.FormBody != nil {
tmpl.Form = r.FormBody
}
that.collector.mu.Unlock()
return tmpl
}
}
that.collector.mu.Unlock()
dir := that.swaggerOutputDir()
parts := strings.Split(strings.Trim(apiPath, "/"), "/")
if len(parts) == 0 {
return nil
}
specPath := filepath.Join(dir, "swagger", parts[0], "api-spec.json")
existing := loadExistingEndpoints(specPath)
ep, ok := existing[apiPath]
if !ok {
return nil
}
cases, ok := ep["cases"].([]interface{})
if !ok {
// json 反序列化可能是 []swaggerTestCase 经 map 再读
raw, _ := json.Marshal(ep["cases"])
var parsed []swaggerTestCase
if json.Unmarshal(raw, &parsed) != nil {
return nil
}
for _, c := range parsed {
if c.Name == caseName {
return &caseTemplate{Query: c.Query, Json: c.Json, Form: c.Form, Note: c.Note}
}
}
return nil
}
for _, item := range cases {
cm, ok := item.(map[string]interface{})
if !ok {
continue
}
name, _ := cm["name"].(string)
if name != caseName {
continue
}
tmpl := &caseTemplate{}
if n, ok := cm["note"].(string); ok {
tmpl.Note = n
}
if q, ok := cm["query"].(map[string]interface{}); ok {
tmpl.Query = q
}
if j, ok := cm["json"]; ok {
tmpl.Json = j
}
if f, ok := cm["form"].(map[string]interface{}); ok {
tmpl.Form = f
}
return tmpl
}
return nil
}
// syncBoundCaseToEndpoint 流程步骤跑通后,把响应回写到绑定的单接口验收用例
func (that *TestApp) syncBoundCaseToEndpoint(record TestRecord) {
if record.BindCase == "" || record.Path == "" {
return
}
updated := false
that.collector.mu.Lock()
for i := range that.collector.Records {
r := &that.collector.Records[i]
if r.FlowName != "" || r.Path != record.Path || r.CaseName != record.BindCase {
continue
}
r.ResponseBody = record.ResponseBody
r.Passed = record.Passed
r.FailReason = record.FailReason
r.VerifyError = record.VerifyError
r.ExpectResult = record.ExpectResult
if record.Query != nil {
r.Query = record.Query
}
if record.JsonBody != nil {
r.JsonBody = record.JsonBody
}
if record.FormBody != nil {
r.FormBody = record.FormBody
}
updated = true
break
}
that.collector.mu.Unlock()
if !updated || !that.swaggerEnabled {
return
}
parts := strings.Split(strings.Trim(record.Path, "/"), "/")
if len(parts) == 0 {
return
}
that.flushEndpointSwagger(parts[0], record.Path)
}
func (that *TestApp) writeModuleSpec(moduleDir, title, version string, endpoints, flows []map[string]interface{}) error {
that.swaggerMu.Lock()
defer that.swaggerMu.Unlock()
if endpoints == nil {
endpoints = []map[string]interface{}{}
}
if flows == nil {
flows = []map[string]interface{}{}
}
spec := map[string]interface{}{
"title": title,
"version": version,
"endpoints": endpoints,
"flows": flows,
}
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("写入 api-spec.json 失败: %w", err)
}
if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil {
return fmt.Errorf("写入 index.html 失败: %w", err)
}
return nil
}
func (that *TestApp) pruneOrphanSwaggerModules(swaggerRoot string, active map[string]bool) error {
entries, err := os.ReadDir(swaggerRoot)
if err != nil {
return err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
if active[e.Name()] {
continue
}
_ = os.RemoveAll(filepath.Join(swaggerRoot, e.Name()))
}
return nil
}
// flushEndpointSwagger 单个接口跑完后按 path 替换写入;无用例记录则不写盘
func (that *TestApp) flushEndpointSwagger(projName, apiPath string) {
that.ensureInit()
that.collector.mu.Lock()
var recs []TestRecord
for _, r := range that.collector.Records {
if r.FlowName == "" && r.Path == apiPath {
recs = append(recs, r)
}
}
that.collector.mu.Unlock()
if len(recs) == 0 {
return
}
dir := that.swaggerOutputDir()
moduleDir := filepath.Join(dir, "swagger", projName)
_ = os.MkdirAll(moduleDir, os.ModePerm)
parts := strings.Split(strings.Trim(apiPath, "/"), "/")
ctrName := ""
if len(parts) >= 2 {
ctrName = parts[1]
}
summary := parts[len(parts)-1]
hasTest := false
if projDef, ok := that.projs[projName]; ok {
if ctrTest, ok := projDef.Tests[ctrName]; ok {
methodName := parts[len(parts)-1]
if apiTest, ok := ctrTest[methodName]; ok {
hasTest = true
summary = apiTest.Desc
}
}
}
specPath := filepath.Join(moduleDir, "api-spec.json")
existing := loadExistingEndpoints(specPath)
existingFlows := loadExistingFlows(specPath)
ep := that.buildEndpointMap(projName, ctrName, apiPath, summary, hasTest, recs, existing[apiPath])
existing[apiPath] = ep
endpoints := make([]map[string]interface{}, 0, len(existing))
for _, v := range existing {
endpoints = append(endpoints, v)
}
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
})
flows := make([]map[string]interface{}, 0, len(existingFlows))
for _, f := range existingFlows {
flows = append(flows, f)
}
sort.Slice(flows, func(i, j int) bool {
return flows[i]["name"].(string) < flows[j]["name"].(string)
})
title := that.swaggerTitle
version := that.swaggerVersion
if title == "" {
title = "API"
}
if version == "" {
version = "1.0.0"
}
_ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows)
_ = generateSwaggerPortal(filepath.Join(dir, "swagger"))
}
// flushFlowSwagger 单个流程跑完后替换写入;无步骤记录则不写盘
func (that *TestApp) flushFlowSwagger(flowName string) {
that.ensureInit()
that.collector.mu.Lock()
var recs []TestRecord
for _, r := range that.collector.Records {
if r.FlowName == flowName {
recs = append(recs, r)
}
}
that.collector.mu.Unlock()
if len(recs) == 0 {
return
}
dir := that.swaggerOutputDir()
flowMap := that.buildFlowMap(flowName, recs)
title := that.swaggerTitle
version := that.swaggerVersion
if title == "" {
title = "API"
}
if version == "" {
version = "1.0.0"
}
for projName := range that.projs {
moduleDir := filepath.Join(dir, "swagger", projName)
_ = os.MkdirAll(moduleDir, os.ModePerm)
specPath := filepath.Join(moduleDir, "api-spec.json")
existingEndpoints := loadExistingEndpoints(specPath)
existingFlows := loadExistingFlows(specPath)
existingFlows[flowName] = flowMap
endpoints := make([]map[string]interface{}, 0, len(existingEndpoints))
for _, v := range existingEndpoints {
endpoints = append(endpoints, v)
}
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
})
flows := make([]map[string]interface{}, 0, len(existingFlows))
for _, f := range existingFlows {
flows = append(flows, f)
}
sort.Slice(flows, func(i, j int) bool {
return flows[i]["name"].(string) < flows[j]["name"].(string)
})
_ = that.writeModuleSpec(moduleDir, title, version, endpoints, flows)
}
_ = generateSwaggerPortal(filepath.Join(dir, "swagger"))
}
// loadExistingEndpoints 读取已有的 api-spec.json,返回 path → endpoint 映射。
func loadExistingEndpoints(specPath string) map[string]map[string]interface{} {
result := map[string]map[string]interface{}{}
data, err := os.ReadFile(specPath)
if err != nil {
return result
}
var spec map[string]interface{}
if err := json.Unmarshal(data, &spec); err != nil {
return result
}
eps, ok := spec["endpoints"].([]interface{})
if !ok {
return result
}
for _, raw := range eps {
ep, ok := raw.(map[string]interface{})
if !ok {
continue
}
if path, ok := ep["path"].(string); ok {
result[path] = ep
}
}
return result
}
func loadExistingFlows(specPath string) map[string]map[string]interface{} {
result := map[string]map[string]interface{}{}
data, err := os.ReadFile(specPath)
if err != nil {
return result
}
var spec map[string]interface{}
if err := json.Unmarshal(data, &spec); err != nil {
return result
}
flows, ok := spec["flows"].([]interface{})
if !ok {
return result
}
for _, raw := range flows {
f, ok := raw.(map[string]interface{})
if !ok {
continue
}
if name, ok := f["name"].(string); ok {
result[name] = f
}
}
return result
}
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 += `