feat: 升级swagger ui 注册
This commit is contained in:
@@ -1,95 +0,0 @@
|
||||
// Package api_doc ...
|
||||
//
|
||||
// Description : api_doc ...
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 2025-08-23 09:30
|
||||
package api_doc
|
||||
|
||||
import (
|
||||
"git.zhangdeman.cn/zhangdeman/api-doc/define"
|
||||
"git.zhangdeman.cn/zhangdeman/api-doc/enums"
|
||||
)
|
||||
|
||||
// SetGenerateOption 设置文档生成选项
|
||||
type SetGenerateOption func(opt *define.OpenapiDoc)
|
||||
|
||||
// generateOption 生成文档的一些配置选项
|
||||
type generateOption struct {
|
||||
license enums.License // 文档的license
|
||||
description string // 文档的描述
|
||||
title string // 文档的标题
|
||||
}
|
||||
|
||||
// WithDocLicense 设置文档协议名称 + 协议链接
|
||||
func WithDocLicense(l enums.License) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
if l == "" {
|
||||
return
|
||||
}
|
||||
opt.Info.License.Name = l
|
||||
opt.Info.License.Url = enums.LicenseUrlTable[l]
|
||||
}
|
||||
}
|
||||
|
||||
// WithDocDescription 设置文档描述
|
||||
func WithDocDescription(desc string) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
if desc == "" {
|
||||
return
|
||||
}
|
||||
opt.Info.Description = desc
|
||||
}
|
||||
}
|
||||
|
||||
// WithDocTitle 设置文档标题
|
||||
func WithDocTitle(title string) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
if len(title) == 0 {
|
||||
return
|
||||
}
|
||||
opt.Info.Title = title
|
||||
}
|
||||
}
|
||||
|
||||
// WithDocVersion 设置文档版本
|
||||
func WithDocVersion(version string) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
opt.Info.Version = version
|
||||
}
|
||||
}
|
||||
|
||||
// WithDocContactName 设置文档联系人名称
|
||||
func WithDocContactName(name string) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
opt.Info.Contact.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
// WithDocContactEmail 设置文档联系人邮箱
|
||||
func WithDocContactEmail(email string) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
opt.Info.Contact.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
// WithDocContactHomePage 设置文档联系人主页
|
||||
func WithDocContactHomePage(url string) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
opt.Info.Contact.Url = url
|
||||
}
|
||||
}
|
||||
|
||||
// WithDocServers 设置文档服务器列表
|
||||
func WithDocServers(serverList []*define.ServerItem) SetGenerateOption {
|
||||
return func(opt *define.OpenapiDoc) {
|
||||
if len(serverList) == 0 {
|
||||
return
|
||||
}
|
||||
opt.Servers = serverList
|
||||
}
|
||||
}
|
||||
831
generate.go
831
generate.go
@@ -1,831 +0,0 @@
|
||||
// Package api_doc ...
|
||||
//
|
||||
// Description : api_doc ...
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 2024-07-22 15:55
|
||||
package api_doc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.zhangdeman.cn/zhangdeman/api-doc/define"
|
||||
"git.zhangdeman.cn/zhangdeman/api-doc/enums"
|
||||
"git.zhangdeman.cn/zhangdeman/consts"
|
||||
"git.zhangdeman.cn/zhangdeman/wrapper/op_array"
|
||||
"git.zhangdeman.cn/zhangdeman/wrapper/op_string"
|
||||
)
|
||||
|
||||
// NewOpenapiDoc ...
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 15:56 2024/7/22
|
||||
func NewOpenapiDoc(ofs ...SetGenerateOption) *Generate {
|
||||
// 初始默认值
|
||||
docCfg := &define.OpenapiDoc{
|
||||
Openapi: consts.SwaggerDocVersion3,
|
||||
Info: &define.Info{
|
||||
Description: "openapi接口文档",
|
||||
Title: "openapi接口文档",
|
||||
TermsOfService: "",
|
||||
Contact: &define.Contact{
|
||||
Name: "研发人员(developer)",
|
||||
Url: "",
|
||||
Email: "",
|
||||
},
|
||||
License: &define.License{
|
||||
Name: enums.LicenseApache20,
|
||||
Url: enums.LicenseUrlTable[consts.LicenseApache20],
|
||||
},
|
||||
Version: "0.0.1",
|
||||
},
|
||||
Servers: []*define.ServerItem{},
|
||||
Components: &define.Components{Schemas: map[string]*define.Schema{}},
|
||||
Tags: make([]*define.TagItem, 0),
|
||||
Paths: make(map[string]*define.PathConfig),
|
||||
}
|
||||
|
||||
for _, option := range ofs {
|
||||
option(docCfg)
|
||||
}
|
||||
return &Generate{
|
||||
readMethodList: []string{
|
||||
http.MethodGet, http.MethodHead, http.MethodConnect, http.MethodOptions, http.MethodTrace,
|
||||
},
|
||||
docData: docCfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate 文档生成实例
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 15:57 2024/7/22
|
||||
type Generate struct {
|
||||
docData *define.OpenapiDoc
|
||||
readMethodList []string
|
||||
}
|
||||
|
||||
func (g *Generate) Doc() *define.OpenapiDoc {
|
||||
return g.docData
|
||||
}
|
||||
|
||||
// AddServer 添加server
|
||||
func (g *Generate) AddServer(serverDomain string, serverDesc string, serverVariable map[string]*define.ServerItemVariable) {
|
||||
if nil == serverVariable {
|
||||
serverVariable = make(map[string]*define.ServerItemVariable)
|
||||
}
|
||||
serverDomain = strings.TrimRight(serverDomain, "/")
|
||||
isHasServer := false
|
||||
for _, item := range g.docData.Servers {
|
||||
if item.Url != serverDomain {
|
||||
continue
|
||||
}
|
||||
isHasServer = true
|
||||
if len(serverDesc) > 0 {
|
||||
item.Description = serverDesc
|
||||
}
|
||||
for varName, varValue := range serverVariable {
|
||||
item.Variables[varName] = varValue
|
||||
}
|
||||
break
|
||||
}
|
||||
if !isHasServer {
|
||||
g.docData.Servers = append(g.docData.Servers, &define.ServerItem{
|
||||
Url: serverDomain,
|
||||
Description: serverDesc,
|
||||
Variables: serverVariable,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AddApiFromInAndOut 通过请求参数的
|
||||
func (g *Generate) AddApiFromInAndOut(uriPrefix string, paramType reflect.Type, resultType reflect.Type) error {
|
||||
if paramType.Kind() == reflect.Ptr {
|
||||
paramType = paramType.Elem()
|
||||
}
|
||||
if resultType.Kind() == reflect.Ptr {
|
||||
resultType = resultType.Elem()
|
||||
}
|
||||
baseCfg, err := g.parseBaseUriConfig(uriPrefix, paramType)
|
||||
if nil != err {
|
||||
return err
|
||||
}
|
||||
if _, exist := g.docData.Paths[baseCfg.Uri]; !exist {
|
||||
g.docData.Paths[baseCfg.Uri] = &define.PathConfig{}
|
||||
}
|
||||
// 接口文档初始化
|
||||
cfg := g.getApiDocBaseCfg(baseCfg, paramType)
|
||||
|
||||
if op_array.ArrayType[string](g.readMethodList).Has(baseCfg.Method) >= 0 {
|
||||
cfg.RequestBody = nil // get类请求没有request body
|
||||
// 参数解析
|
||||
g.ParseReadConfigParam(baseCfg, cfg, paramType)
|
||||
} else {
|
||||
// post类解析
|
||||
paramSchemaName := g.AddComponentsSchema("", paramType.PkgPath(), paramType)
|
||||
for _, itemType := range baseCfg.ContentType {
|
||||
cfg.RequestBody.Content[itemType] = &define.Media{
|
||||
Schema: &define.Schema{
|
||||
Ref: g.getSchemaRef(paramSchemaName),
|
||||
},
|
||||
Example: nil,
|
||||
Examples: nil,
|
||||
Encoding: nil,
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无论什么请求, 对于result解析逻辑一致
|
||||
resultSchemaName := g.AddComponentsSchema("", resultType.PkgPath(), resultType)
|
||||
for _, itemOutputType := range baseCfg.OutputContentType {
|
||||
cfg.Responses[fmt.Sprintf("%v", http.StatusOK)].Content[itemOutputType] = &define.Media{
|
||||
Schema: &define.Schema{
|
||||
Ref: g.getSchemaRef(resultSchemaName),
|
||||
},
|
||||
Example: nil,
|
||||
Examples: nil,
|
||||
Encoding: nil,
|
||||
}
|
||||
}
|
||||
g.setApiDoc(baseCfg, cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setApiDoc 设置文档配置
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 16:13 2025/2/14
|
||||
func (g *Generate) setApiDoc(baseCfg *define.UriBaseConfig, apiDocCfg *define.PathItemOperationConfig) {
|
||||
switch baseCfg.Method {
|
||||
case http.MethodGet:
|
||||
g.docData.Paths[baseCfg.Uri].Get = apiDocCfg
|
||||
case http.MethodHead:
|
||||
g.docData.Paths[baseCfg.Uri].Head = apiDocCfg
|
||||
case http.MethodPost:
|
||||
g.docData.Paths[baseCfg.Uri].Post = apiDocCfg
|
||||
case http.MethodPut:
|
||||
g.docData.Paths[baseCfg.Uri].Put = apiDocCfg
|
||||
case http.MethodPatch:
|
||||
g.docData.Paths[baseCfg.Uri].Patch = apiDocCfg
|
||||
case http.MethodDelete:
|
||||
g.docData.Paths[baseCfg.Uri].Delete = apiDocCfg
|
||||
case http.MethodConnect:
|
||||
g.docData.Paths[baseCfg.Uri].Connect = apiDocCfg
|
||||
case http.MethodOptions:
|
||||
g.docData.Paths[baseCfg.Uri].Options = apiDocCfg
|
||||
case http.MethodTrace:
|
||||
g.docData.Paths[baseCfg.Uri].Trace = apiDocCfg
|
||||
default:
|
||||
panic("unknown method: " + baseCfg.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// getApiDocBaseCfg 获取接口文档的基础配置
|
||||
func (g *Generate) getApiDocBaseCfg(baseCfg *define.UriBaseConfig, paramType reflect.Type) *define.PathItemOperationConfig {
|
||||
cfg := &define.PathItemOperationConfig{
|
||||
Tags: baseCfg.TagList,
|
||||
Summary: baseCfg.Summary,
|
||||
Description: baseCfg.Description,
|
||||
ExternalDocs: nil,
|
||||
OperationID: baseCfg.Summary + "(" + baseCfg.Method + "-" + strings.ReplaceAll(strings.TrimLeft(baseCfg.Uri, "/"), "/", "-") + ")",
|
||||
Parameters: make([]*define.PathConfigParameter, 0),
|
||||
RequestBody: &define.RequestBody{
|
||||
Required: true,
|
||||
Description: "",
|
||||
Content: map[string]*define.Media{},
|
||||
Ref: "",
|
||||
},
|
||||
Responses: map[string]*define.Response{
|
||||
fmt.Sprintf("%v", http.StatusOK): {
|
||||
Content: map[string]*define.Media{},
|
||||
},
|
||||
},
|
||||
Callbacks: nil,
|
||||
Deprecated: baseCfg.Deprecated,
|
||||
Security: nil,
|
||||
Servers: nil,
|
||||
}
|
||||
// 解析绑定在url中的参数
|
||||
if paramList := define.UriParamRegexp.FindAllString(baseCfg.Uri, -1); len(paramList) > 0 {
|
||||
for _, param := range paramList {
|
||||
param = strings.TrimPrefix(param, "{")
|
||||
param = strings.TrimSuffix(param, "}")
|
||||
cfg.Parameters = append(cfg.Parameters, &define.PathConfigParameter{
|
||||
Name: param,
|
||||
In: enums.DocParamLocationPath.String(),
|
||||
Description: param,
|
||||
Required: true,
|
||||
Deprecated: false,
|
||||
Schema: &define.Schema{
|
||||
Type: enums.SwaggerDataTypeString.String(),
|
||||
Format: consts.DataTypeString.String(),
|
||||
},
|
||||
AllowEmptyValue: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ParseReadConfigParam 解析get类请求参数
|
||||
func (g *Generate) ParseReadConfigParam(requestCfg *define.UriBaseConfig, baseReqCfg *define.PathItemOperationConfig, inputType reflect.Type) {
|
||||
if inputType.Kind() == reflect.Ptr {
|
||||
inputType = inputType.Elem()
|
||||
}
|
||||
if inputType.Kind() != reflect.Struct {
|
||||
panic(requestCfg.Uri + " : request param not struct")
|
||||
}
|
||||
if nil == baseReqCfg.Parameters {
|
||||
baseReqCfg.Parameters = make([]*define.PathConfigParameter, 0)
|
||||
}
|
||||
for i := 0; i < inputType.NumField(); i++ {
|
||||
if inputType.Field(i).Anonymous {
|
||||
// 匿名字段, 直接对齐到当前的父级
|
||||
g.ParseReadConfigParam(requestCfg, baseReqCfg, inputType.Field(i).Type)
|
||||
continue
|
||||
}
|
||||
propertyName := ParseStructFieldTag.GetParamName(inputType.Field(i))
|
||||
if propertyName == "-" {
|
||||
continue
|
||||
}
|
||||
if inputType.Field(i).Type.Kind() == reflect.Struct && (inputType.Field(i).Type.String() == "Meta" || strings.HasSuffix(inputType.Field(i).Type.String(), ".Meta")) && inputType.Field(i).Type.NumField() == 0 {
|
||||
// 空Meta字段认为是用来描述元信息的, 忽略
|
||||
continue
|
||||
}
|
||||
convertBaseType, isBaseType := g.realBaseType2SwaggerType(inputType.Field(i).Type.String())
|
||||
realInputTypeFormat := inputType.Field(i).Type.String()
|
||||
fieldType := inputType.Field(i).Type
|
||||
if isBaseType {
|
||||
// 当做默认基础类型, 默认不会出现 *map *[]
|
||||
itemParam := &define.PathConfigParameter{
|
||||
Name: propertyName,
|
||||
In: consts.SwaggerParameterInQuery,
|
||||
Description: ParseStructFieldTag.GetParamDesc(inputType.Field(i)),
|
||||
Required: ValidateRule.IsRequired(inputType.Field(i)),
|
||||
Deprecated: ParseStructFieldTag.Deprecated(inputType.Field(i)),
|
||||
Schema: &define.Schema{
|
||||
Type: convertBaseType,
|
||||
Format: realInputTypeFormat,
|
||||
},
|
||||
AllowEmptyValue: false,
|
||||
Style: "",
|
||||
Explode: false,
|
||||
AllowReserved: false,
|
||||
}
|
||||
g.setStructFieldProperty(itemParam.Schema, inputType.Field(i))
|
||||
baseReqCfg.Parameters = append(baseReqCfg.Parameters, itemParam)
|
||||
continue
|
||||
}
|
||||
if inputType.Field(i).Type.Kind() == reflect.Interface {
|
||||
itemParam := &define.PathConfigParameter{
|
||||
Name: ParseStructFieldTag.GetParamName(inputType.Field(i)),
|
||||
In: consts.SwaggerParameterInQuery,
|
||||
Description: ParseStructFieldTag.GetParamDesc(inputType.Field(i)),
|
||||
Required: ValidateRule.IsRequired(inputType.Field(i)),
|
||||
Deprecated: ParseStructFieldTag.Deprecated(inputType.Field(i)),
|
||||
Schema: &define.Schema{
|
||||
OneOf: g.anyTypeConfig(inputType.Field(i)).OneOf,
|
||||
Format: realInputTypeFormat,
|
||||
},
|
||||
}
|
||||
g.setStructFieldProperty(itemParam.Schema, inputType.Field(i))
|
||||
baseReqCfg.Parameters = append(baseReqCfg.Parameters, itemParam)
|
||||
continue
|
||||
}
|
||||
if inputType.Field(i).Type.Kind() == reflect.Ptr {
|
||||
// 处理指针
|
||||
if inputType.Field(i).Type.Elem().Kind() == reflect.Struct {
|
||||
// 结构体指针
|
||||
schemaNameNext := g.AddComponentsSchema("", propertyName, inputType.Field(i).Type.Elem())
|
||||
baseReqCfg.Parameters = append(baseReqCfg.Parameters, &define.PathConfigParameter{
|
||||
Name: propertyName,
|
||||
In: consts.SwaggerParameterInQuery,
|
||||
Description: ParseStructFieldTag.GetParamDesc(inputType.Field(i)),
|
||||
Required: ValidateRule.IsRequired(inputType.Field(i)),
|
||||
Deprecated: ParseStructFieldTag.Deprecated(inputType.Field(i)),
|
||||
Schema: &define.Schema{
|
||||
// Format: realInputTypeFormat,
|
||||
Ref: g.getSchemaRef(schemaNameNext),
|
||||
}, AllowEmptyValue: false,
|
||||
Style: "",
|
||||
Explode: false,
|
||||
AllowReserved: false,
|
||||
})
|
||||
} else {
|
||||
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fieldType.Kind() == reflect.Struct ||
|
||||
fieldType.Kind() == reflect.Map ||
|
||||
fieldType.Kind() == reflect.Array ||
|
||||
fieldType.Kind() == reflect.Slice {
|
||||
baseReqCfg.Parameters = append(baseReqCfg.Parameters, &define.PathConfigParameter{
|
||||
Name: ParseStructFieldTag.GetParamName(inputType.Field(i)),
|
||||
In: consts.SwaggerParameterInQuery,
|
||||
Description: ParseStructFieldTag.GetParamDesc(inputType.Field(i)),
|
||||
Required: ValidateRule.IsRequired(inputType.Field(i)),
|
||||
Deprecated: ParseStructFieldTag.Deprecated(inputType.Field(i)),
|
||||
Schema: &define.Schema{
|
||||
Type: convertBaseType,
|
||||
Items: nil,
|
||||
Ref: "",
|
||||
Format: realInputTypeFormat,
|
||||
Enum: ValidateRule.Enum(inputType.Field(i)),
|
||||
XEnumDescription: ParseStructFieldTag.EnumDescription(inputType.Field(i)),
|
||||
Example: ParseStructFieldTag.GetExampleValue(inputType.Field(i)),
|
||||
},
|
||||
AllowEmptyValue: false,
|
||||
Style: "",
|
||||
Explode: false,
|
||||
AllowReserved: false,
|
||||
Ref: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
switch requestCfg.Method {
|
||||
case http.MethodGet:
|
||||
g.docData.Paths[requestCfg.Uri].Get = baseReqCfg
|
||||
case http.MethodHead:
|
||||
g.docData.Paths[requestCfg.Uri].Head = baseReqCfg
|
||||
case http.MethodConnect:
|
||||
g.docData.Paths[requestCfg.Uri].Connect = baseReqCfg
|
||||
case http.MethodOptions:
|
||||
g.docData.Paths[requestCfg.Uri].Options = baseReqCfg
|
||||
case http.MethodTrace:
|
||||
g.docData.Paths[requestCfg.Uri].Trace = baseReqCfg
|
||||
}
|
||||
}
|
||||
|
||||
// AddComponentsSchema 添加schema
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 15:25 2025/2/8
|
||||
func (g *Generate) AddComponentsSchema(rootSchemaName string, pkgPath string, inputType reflect.Type) string {
|
||||
if inputType.Kind() == reflect.Struct && (inputType.String() == "Meta" || strings.HasSuffix(inputType.String(), ".Meta")) && inputType.NumField() == 0 {
|
||||
// 空Meta字段认为是用来描述元信息的, 忽略
|
||||
return "-"
|
||||
}
|
||||
inputNameArr := strings.Split(inputType.Name(), ".")
|
||||
inputName := inputNameArr[len(inputNameArr)-1]
|
||||
schemaName := strings.ReplaceAll(pkgPath+"."+inputName, "/", ".")
|
||||
if schemaName == "-" {
|
||||
// 忽略的属性
|
||||
return schemaName
|
||||
}
|
||||
if _, exist := g.docData.Components.Schemas[schemaName]; exist {
|
||||
// 已存在, 无需重复生成
|
||||
return schemaName
|
||||
}
|
||||
if _, exist := g.docData.Components.Schemas[schemaName]; !exist {
|
||||
s := &define.Schema{
|
||||
Nullable: false,
|
||||
Deprecated: false,
|
||||
Properties: make(map[string]*define.Property),
|
||||
Required: make([]string, 0),
|
||||
Enum: make([]any, 0),
|
||||
Type: consts.SwaggerDataTypeObject, // TODO : 区分数组
|
||||
Ref: g.getSchemaRef(schemaName),
|
||||
}
|
||||
if len(rootSchemaName) == 0 || inputType.Kind() == reflect.Struct {
|
||||
s.Ref = ""
|
||||
}
|
||||
g.docData.Components.Schemas[schemaName] = s
|
||||
}
|
||||
if inputType.Kind() == reflect.Map {
|
||||
// map, 直接添加公共
|
||||
g.docData.Components.Schemas[schemaName].Type = consts.SwaggerDataTypeObject
|
||||
return schemaName
|
||||
}
|
||||
// 数组
|
||||
if inputType.Kind() == reflect.Slice || inputType.Kind() == reflect.Array {
|
||||
sliceItemType, itemIsBaseType := g.parseSliceItem(schemaName, inputType)
|
||||
propertyXOf := &define.PropertyXOf{}
|
||||
if itemIsBaseType {
|
||||
propertyXOf.Type, _ = g.realBaseType2SwaggerType(sliceItemType)
|
||||
propertyXOf.Format = sliceItemType
|
||||
propertyXOf.Ref = ""
|
||||
} else {
|
||||
propertyXOf.Type = ""
|
||||
propertyXOf.Format = ""
|
||||
propertyXOf.Ref = g.getSchemaRef(sliceItemType)
|
||||
}
|
||||
if len(rootSchemaName) == 0 {
|
||||
g.docData.Components.Schemas[schemaName].Type = consts.SwaggerDataTypeArray
|
||||
g.docData.Components.Schemas[schemaName].Items = propertyXOf
|
||||
} else {
|
||||
g.docData.Components.Schemas[rootSchemaName].Properties[schemaName] = &define.Property{
|
||||
Type: consts.SwaggerDataTypeArray,
|
||||
Format: inputType.String(),
|
||||
Items: propertyXOf,
|
||||
}
|
||||
}
|
||||
return schemaName
|
||||
}
|
||||
// 结构体
|
||||
if inputType.Kind() == reflect.Struct {
|
||||
for i := 0; i < inputType.NumField(); i++ {
|
||||
propertyName := ParseStructFieldTag.GetParamName(inputType.Field(i))
|
||||
if propertyName == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
if inputType.Field(i).Anonymous {
|
||||
// 处理匿名字段
|
||||
g.handleAnonymousField(schemaName, inputType.Field(i))
|
||||
continue
|
||||
}
|
||||
if inputType.Kind() == reflect.Interface {
|
||||
// 处理interface{}类型参数
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName] = g.anyTypeConfig(inputType.Field(i))
|
||||
continue
|
||||
}
|
||||
if inputType.Field(i).Type.Kind() == reflect.Ptr {
|
||||
// 处理指针
|
||||
if inputType.Field(i).Type.Elem().Kind() == reflect.Struct {
|
||||
// 结构体指针
|
||||
schemaNameNext := g.AddComponentsSchema(schemaName, propertyName, inputType.Field(i).Type.Elem())
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName] = &define.Property{
|
||||
// Description: ParseStructFieldTag.GetParamDesc(inputType.Field(i)),
|
||||
Ref: g.getSchemaRef(schemaNameNext),
|
||||
}
|
||||
} else {
|
||||
// 当做默认基础类型, 默认不会出现 *map *[]
|
||||
convertBaseType, _ := g.realBaseType2SwaggerType(inputType.Field(i).Type.String())
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName] = &define.Property{
|
||||
Type: convertBaseType,
|
||||
Format: inputType.Field(i).Type.String(),
|
||||
}
|
||||
}
|
||||
// 设置参数各种属性
|
||||
g.setStructFieldProperty(g.docData.Components.Schemas[schemaName], inputType.Field(i))
|
||||
continue
|
||||
}
|
||||
if inputType.Field(i).Type.Kind() == reflect.Struct ||
|
||||
inputType.Field(i).Type.Kind() == reflect.Map ||
|
||||
inputType.Field(i).Type.Kind() == reflect.Array ||
|
||||
inputType.Field(i).Type.Kind() == reflect.Slice {
|
||||
if inputType.Field(i).Type.Kind() == reflect.Struct ||
|
||||
inputType.Field(i).Type.Kind() == reflect.Map {
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName] = &define.Property{
|
||||
Type: consts.SwaggerDataTypeObject,
|
||||
Format: inputType.Field(i).Type.String(),
|
||||
Properties: map[string]*define.Property{},
|
||||
}
|
||||
} else if inputType.Field(i).Type.Kind() == reflect.Array ||
|
||||
inputType.Field(i).Type.Kind() == reflect.Slice {
|
||||
sliceItemType, itemIsBaseType := g.parseSliceItem(schemaName, inputType.Field(i).Type)
|
||||
propertyXOf := &define.PropertyXOf{}
|
||||
if itemIsBaseType {
|
||||
propertyXOf.Type, _ = g.realBaseType2SwaggerType(sliceItemType)
|
||||
propertyXOf.Format = sliceItemType
|
||||
propertyXOf.Ref = ""
|
||||
} else {
|
||||
propertyXOf.Type = ""
|
||||
propertyXOf.Format = ""
|
||||
propertyXOf.Ref = g.getSchemaRef(sliceItemType)
|
||||
}
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName] = &define.Property{
|
||||
Type: consts.SwaggerDataTypeArray,
|
||||
Format: inputType.Field(i).Type.String(),
|
||||
Items: propertyXOf,
|
||||
}
|
||||
} else {
|
||||
g.AddComponentsSchema(schemaName, propertyName, inputType.Field(i).Type)
|
||||
}
|
||||
} else {
|
||||
if inputType.Field(i).Type.Kind() == reflect.Interface {
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName] = g.anyTypeConfig(inputType.Field(i))
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName].Example = ParseStructFieldTag.GetExampleValue(inputType.Field(i))
|
||||
} else {
|
||||
convertBaseType, _ := g.realBaseType2SwaggerType(inputType.Field(i).Type.String())
|
||||
g.docData.Components.Schemas[schemaName].Properties[propertyName] = &define.Property{
|
||||
Type: convertBaseType,
|
||||
Format: inputType.Field(i).Type.String(),
|
||||
}
|
||||
}
|
||||
}
|
||||
// 设置参数各种属性
|
||||
g.setStructFieldProperty(g.docData.Components.Schemas[schemaName], inputType.Field(i))
|
||||
}
|
||||
return schemaName
|
||||
}
|
||||
// 指针
|
||||
if inputType.Kind() == reflect.Ptr {
|
||||
if inputType.Elem().Kind() == reflect.Struct {
|
||||
// 非基础数据类型
|
||||
return g.AddComponentsSchema(schemaName, inputType.Elem().String(), inputType.Elem())
|
||||
} else {
|
||||
convertType, _ := g.realBaseType2SwaggerType(inputType.String())
|
||||
g.docData.Components.Schemas[schemaName].Properties[schemaName] = &define.Property{
|
||||
Type: convertType,
|
||||
Format: inputType.String(),
|
||||
Properties: map[string]*define.Property{},
|
||||
}
|
||||
}
|
||||
}
|
||||
return schemaName
|
||||
}
|
||||
|
||||
// handleAnonymousField 处理匿名字段
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 18:43 2025/2/17
|
||||
func (g *Generate) handleAnonymousField(schemaName string, field reflect.StructField) {
|
||||
if !field.Anonymous {
|
||||
// 不是匿名字段
|
||||
return
|
||||
}
|
||||
handleType := field.Type
|
||||
if handleType.Kind() == reflect.Ptr {
|
||||
handleType = handleType.Elem()
|
||||
}
|
||||
for i := 0; i < handleType.NumField(); i++ {
|
||||
itemField := handleType.Field(i)
|
||||
if itemField.Anonymous {
|
||||
// 递归处理多层嵌套匿名字段
|
||||
g.handleAnonymousField(schemaName, itemField)
|
||||
continue
|
||||
} else {
|
||||
baseConvertType, isBaseType := g.realBaseType2SwaggerType(itemField.Type.String())
|
||||
if !isBaseType {
|
||||
paramName := ParseStructFieldTag.GetParamName(itemField)
|
||||
if itemField.Type.Kind() == reflect.Interface {
|
||||
g.docData.Components.Schemas[schemaName].Properties[paramName] = g.anyTypeConfig(itemField)
|
||||
}
|
||||
g.AddComponentsSchema(schemaName, handleType.Field(i).Type.PkgPath(), handleType.Field(i).Type)
|
||||
continue
|
||||
} else {
|
||||
paramName := ParseStructFieldTag.GetParamName(itemField)
|
||||
g.docData.Components.Schemas[schemaName].Properties[paramName] = &define.Property{
|
||||
Type: baseConvertType,
|
||||
Format: itemField.Type.String(),
|
||||
Example: ParseStructFieldTag.GetExampleValue(itemField),
|
||||
Enum: ValidateRule.Enum(itemField),
|
||||
XEnumDescription: ParseStructFieldTag.EnumDescription(itemField),
|
||||
Default: ParseStructFieldTag.GetDefaultValue(handleType.Field(i)),
|
||||
Description: ParseStructFieldTag.GetParamDesc(handleType.Field(i)),
|
||||
}
|
||||
if ValidateRule.IsRequired(itemField) {
|
||||
g.docData.Components.Schemas[schemaName].Required = append(g.docData.Components.Schemas[schemaName].Required, paramName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseSliceItem 解析数组每一项
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 21:33 2025/2/8
|
||||
func (g *Generate) parseSliceItem(rootSchemaName string, inputType reflect.Type) (string, bool) {
|
||||
if inputType.Kind() != reflect.Slice && inputType.Kind() != reflect.Array {
|
||||
// 不是数组
|
||||
return "", false
|
||||
}
|
||||
sliceValue := reflect.MakeSlice(inputType, 1, 1)
|
||||
sliceItemType := sliceValue.Index(0).Type()
|
||||
realSliceItemType := sliceItemType.String()
|
||||
if sliceItemType.Kind() == reflect.Ptr {
|
||||
sliceItemType = sliceItemType.Elem()
|
||||
}
|
||||
_, isBaseType := g.realBaseType2SwaggerType(sliceItemType.String())
|
||||
if isBaseType {
|
||||
return realSliceItemType, true
|
||||
}
|
||||
g.AddComponentsSchema(rootSchemaName, sliceItemType.PkgPath(), sliceItemType)
|
||||
if len(sliceItemType.PkgPath()) == 0 {
|
||||
return realSliceItemType, false
|
||||
}
|
||||
return sliceItemType.PkgPath() + "." + sliceItemType.Name(), false
|
||||
}
|
||||
|
||||
// getSchemaRef 获取引用的类型
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 14:25 2025/2/9
|
||||
func (g *Generate) getSchemaRef(schemaName string) string {
|
||||
if "" == schemaName {
|
||||
return ""
|
||||
}
|
||||
schemaName = strings.ReplaceAll(schemaName, "*", "") // 去除指针类型 *
|
||||
convertType, isBaseType := g.realBaseType2SwaggerType(schemaName)
|
||||
if isBaseType {
|
||||
return convertType
|
||||
}
|
||||
return "#/components/schemas/" + strings.ReplaceAll(convertType, "/", ".")
|
||||
}
|
||||
|
||||
// realType2SwaggerType golang 真实数据类型转换为golang数据类型
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 20:25 2025/2/11
|
||||
func (g *Generate) realBaseType2SwaggerType(realType string) (string, bool) {
|
||||
switch realType {
|
||||
case "bool", "*bool":
|
||||
return consts.SwaggerDataTypeBoolean, true
|
||||
case "string", "*string":
|
||||
return consts.SwaggerDataTypeString, true
|
||||
case "byte", "*byte":
|
||||
return consts.SwaggerDataTypeByte, true
|
||||
case "float32", "*float32", "float64", "*float64":
|
||||
return consts.SwaggerDataTypeDouble, true
|
||||
case "int", "*int", "uint", "*uint", "int64", "*int64", "uint64", "*uint64":
|
||||
return consts.SwaggerDataTypeInteger, true
|
||||
case "int8", "*int8", "uint8", "*uint8", "int16", "*int16", "uint16", "*uint16", "int32", "*int32", "uint32", "*uint32":
|
||||
return consts.SwaggerDataTypeInteger, true
|
||||
default:
|
||||
if strings.HasPrefix(realType, "[]") {
|
||||
return consts.SwaggerDataTypeArray, true
|
||||
}
|
||||
return realType, false
|
||||
}
|
||||
}
|
||||
|
||||
// setStructFieldProperty 添加struct_field各种属性
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 16:13 2025/2/13
|
||||
func (g *Generate) setStructFieldProperty(schema *define.Schema, structField reflect.StructField) {
|
||||
paramName := ParseStructFieldTag.GetParamName(structField)
|
||||
if paramName == "" || paramName == "-" {
|
||||
return
|
||||
}
|
||||
isRequired := ValidateRule.IsRequired(structField)
|
||||
enum := ValidateRule.Enum(structField)
|
||||
xEnumDescription := ParseStructFieldTag.EnumDescription(structField)
|
||||
example := ParseStructFieldTag.GetExampleValue(structField)
|
||||
description := ParseStructFieldTag.GetParamDesc(structField)
|
||||
maxVal := ValidateRule.Maximum(structField)
|
||||
minVal := ValidateRule.Minimum(structField)
|
||||
if nil != schema {
|
||||
if isRequired {
|
||||
schema.Required = append(schema.Required, paramName)
|
||||
}
|
||||
if nil == schema.Properties[paramName] {
|
||||
if schema.Type == consts.SwaggerDataTypeString {
|
||||
schema.MinLength = minVal
|
||||
schema.MaxLength = maxVal
|
||||
} else {
|
||||
schema.Minimum = minVal
|
||||
schema.Maximum = maxVal
|
||||
}
|
||||
schema.Enum = enum
|
||||
schema.XEnumDescription = xEnumDescription
|
||||
schema.Example = example
|
||||
schema.Description = description
|
||||
return
|
||||
}
|
||||
if schema.Properties[paramName].Type == consts.SwaggerDataTypeString {
|
||||
schema.Properties[paramName].MinLength = minVal
|
||||
schema.Properties[paramName].MaxLength = maxVal
|
||||
} else {
|
||||
schema.Properties[paramName].Minimum = minVal
|
||||
schema.Properties[paramName].Maximum = maxVal
|
||||
}
|
||||
schema.Properties[paramName].Enum = enum
|
||||
schema.Properties[paramName].XEnumDescription = xEnumDescription
|
||||
schema.Properties[paramName].Example = example
|
||||
schema.Properties[paramName].Description = description
|
||||
}
|
||||
}
|
||||
|
||||
// parseBaseUriConfig 通过Meta字段解析Uri基础配置信息
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 15:13 2025/2/14
|
||||
func (g *Generate) parseBaseUriConfig(uriPrefix string, paramType reflect.Type) (*define.UriBaseConfig, error) {
|
||||
// 解析meta信息
|
||||
metaField, metaFieldExist := paramType.FieldByName("Meta")
|
||||
if !metaFieldExist {
|
||||
return nil, errors.New("Meta field not found")
|
||||
}
|
||||
res := &define.UriBaseConfig{
|
||||
Uri: "",
|
||||
Method: "",
|
||||
ContentType: nil,
|
||||
OutputContentType: nil,
|
||||
TagList: nil,
|
||||
Summary: "",
|
||||
Description: "",
|
||||
ParamList: nil,
|
||||
ResultList: nil,
|
||||
Deprecated: false,
|
||||
}
|
||||
res.Uri = metaField.Tag.Get(define.TagPath)
|
||||
if len(uriPrefix) > 0 {
|
||||
res.Uri = strings.TrimRight(uriPrefix, "/") + "/" + strings.TrimLeft(res.Uri, "/")
|
||||
}
|
||||
// 保证接口路由以 /开头
|
||||
res.Uri = "/" + strings.TrimLeft(res.Uri, "/")
|
||||
|
||||
res.Method = strings.ToUpper(metaField.Tag.Get(define.TagMethod))
|
||||
res.Description = metaField.Tag.Get(define.TagDesc)
|
||||
res.TagList = strings.Split(metaField.Tag.Get(define.TagUriTag), ",")
|
||||
// 解析第一个返回值, 要求必须是结构体或者是map
|
||||
outputStrictModel := metaField.Tag.Get(define.TagOutputStrict)
|
||||
res.OutputStrict = outputStrictModel == "1" || outputStrictModel == "true"
|
||||
deprecated := metaField.Tag.Get(define.TagDeprecated)
|
||||
res.Deprecated = deprecated == "1" || deprecated == "true"
|
||||
requestContentType := strings.TrimSpace(metaField.Tag.Get(define.TagContentType))
|
||||
if len(requestContentType) == 0 {
|
||||
if op_array.ArrayType[string](g.readMethodList).Has(res.Method) >= 0 {
|
||||
// get类请求
|
||||
requestContentType = consts.MimeTypeXWWWFormUrlencoded
|
||||
} else {
|
||||
requestContentType = consts.MimeTypeJson
|
||||
}
|
||||
}
|
||||
res.ContentType = strings.Split(requestContentType, ",")
|
||||
responseContentType := strings.TrimSpace(metaField.Tag.Get(define.TagOutputContentType))
|
||||
if len(responseContentType) == 0 {
|
||||
// 未设置响应类型默认JSON数据
|
||||
responseContentType = consts.MimeTypeJson
|
||||
}
|
||||
res.OutputContentType = strings.Split(responseContentType, ",")
|
||||
res.Summary = ParseStructFieldTag.Summary(metaField)
|
||||
if len(res.Summary) == 0 {
|
||||
res.Summary = op_string.SnakeCaseToCamel(strings.ReplaceAll(strings.TrimLeft(res.Uri, "/"), "/", "_"))
|
||||
}
|
||||
if res.Method == "" {
|
||||
return nil, errors.New("baseCfg.Method is empty")
|
||||
}
|
||||
if res.Uri == "" {
|
||||
return nil, errors.New("baseCfg.Uri is empty")
|
||||
}
|
||||
if nil == g.docData.Tags {
|
||||
g.docData.Tags = make([]*define.TagItem, 0)
|
||||
}
|
||||
// 增加tag
|
||||
for _, itemTag := range res.TagList {
|
||||
exist := false
|
||||
for _, t := range g.docData.Tags {
|
||||
if itemTag == t.Name {
|
||||
exist = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exist {
|
||||
g.docData.Tags = append(g.docData.Tags, &define.TagItem{
|
||||
Name: itemTag,
|
||||
Description: itemTag,
|
||||
})
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// anyTypeConfig 任意类型数据配置
|
||||
//
|
||||
// Author : go_developer@163.com<白茶清欢>
|
||||
//
|
||||
// Date : 14:33 2025/2/19
|
||||
func (g *Generate) anyTypeConfig(structField reflect.StructField) *define.Property {
|
||||
return &define.Property{
|
||||
Description: ParseStructFieldTag.GetParamDesc(structField),
|
||||
OneOf: []*define.PropertyXOf{
|
||||
{
|
||||
Type: consts.SwaggerDataTypeObject,
|
||||
Format: "map[string]any",
|
||||
},
|
||||
/* {
|
||||
Type: consts.SwaggerDataTypeArray,
|
||||
Format: "[]any",
|
||||
Items: &define.PropertyXOf{
|
||||
Items: nil,
|
||||
},
|
||||
},*/
|
||||
{
|
||||
Type: consts.SwaggerDataTypeInteger,
|
||||
Format: "int/uint",
|
||||
},
|
||||
{
|
||||
Type: consts.SwaggerDataTypeNumber,
|
||||
Format: "int/uint/float",
|
||||
},
|
||||
{
|
||||
Type: consts.SwaggerDataTypeString,
|
||||
Format: "string",
|
||||
},
|
||||
{
|
||||
Type: consts.SwaggerDataTypeBoolean,
|
||||
Format: "bool",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,12 +8,10 @@
|
||||
package api_doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.zhangdeman.cn/zhangdeman/api-doc/define"
|
||||
"git.zhangdeman.cn/zhangdeman/api-doc/enums"
|
||||
"git.zhangdeman.cn/zhangdeman/api-doc/theme"
|
||||
"git.zhangdeman.cn/zhangdeman/consts"
|
||||
@@ -25,96 +23,18 @@ import (
|
||||
)
|
||||
|
||||
// NewSwaggerUI ...
|
||||
func NewSwaggerUI(info *define.Info, servers []*define.ServerItem, uiTheme enums.SwaggerUITheme) *SwaggerUI {
|
||||
if nil == info {
|
||||
info = &define.Info{
|
||||
Description: "",
|
||||
Title: "",
|
||||
TermsOfService: "",
|
||||
Contact: &define.Contact{
|
||||
Name: "",
|
||||
Url: "",
|
||||
Email: "",
|
||||
},
|
||||
License: nil,
|
||||
Version: "",
|
||||
}
|
||||
}
|
||||
if nil == info.Contact {
|
||||
info.Contact = &define.Contact{
|
||||
Name: "",
|
||||
Url: "",
|
||||
Email: "",
|
||||
}
|
||||
}
|
||||
if nil == info.License {
|
||||
info.License = &define.License{
|
||||
Name: "",
|
||||
Url: "",
|
||||
}
|
||||
}
|
||||
func NewSwaggerUI(docTitle string, docBaseUri string, uiTheme enums.SwaggerUITheme) *SwaggerUI {
|
||||
return &SwaggerUI{
|
||||
docInstance: NewOpenapiDoc(
|
||||
WithDocDescription(info.Description),
|
||||
WithDocTitle(info.Title),
|
||||
WithDocContactEmail(info.Contact.Email),
|
||||
WithDocContactName(info.Contact.Name),
|
||||
WithDocLicense(info.License.Name),
|
||||
WithDocServers(servers),
|
||||
),
|
||||
uiTheme: uiTheme,
|
||||
baseUri: docBaseUri,
|
||||
docTitle: docTitle,
|
||||
uiTheme: uiTheme,
|
||||
}
|
||||
}
|
||||
|
||||
type SwaggerUI struct {
|
||||
docInstance *Generate // 文档实例
|
||||
uiTheme enums.SwaggerUITheme // 文档主题, swaggerUI / knife4go, 默认 knife4go
|
||||
router *gin.Engine
|
||||
baseUri string
|
||||
}
|
||||
|
||||
// DocInstance 文档实例
|
||||
func (su *SwaggerUI) DocInstance() *Generate {
|
||||
return su.docInstance
|
||||
}
|
||||
|
||||
// RegisterHandler ...
|
||||
func (su *SwaggerUI) RegisterHandler(router *gin.Engine, baseUri string) {
|
||||
su.router = router
|
||||
baseUri = strings.TrimRight(baseUri, "/")
|
||||
if len(baseUri) == 0 {
|
||||
baseUri = "/docs/swagger"
|
||||
}
|
||||
su.baseUri = baseUri
|
||||
router.GET(baseUri+"/*any", func(ctx *gin.Context) {
|
||||
if ctx.Request.RequestURI == baseUri+"/doc.json" {
|
||||
// 默认swagger, 通过此接口读取文档数据
|
||||
ctx.JSON(http.StatusOK, su.docInstance.Doc())
|
||||
ctx.Abort()
|
||||
}
|
||||
if ctx.Request.RequestURI == "/doc/swagger/openapi.json" {
|
||||
// knife4go 文档通过此接口读取文档列表
|
||||
ctx.JSON(http.StatusOK, []map[string]any{
|
||||
{
|
||||
"name": "服务文档",
|
||||
"url": "doc.json",
|
||||
"swaggerVersion": enums.SwaggerDocVersion3.String(),
|
||||
},
|
||||
})
|
||||
ctx.Abort()
|
||||
}
|
||||
}, su.Handler())
|
||||
router.GET("/swagger-resources", func(ctx *gin.Context) { // lucky UI获取分组信息
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*") // 允许访问所有域
|
||||
ctx.JSON(http.StatusOK, []map[string]any{
|
||||
{
|
||||
"name": "服务文档",
|
||||
"url": baseUri + "/doc.json",
|
||||
"swaggerVersion": enums.SwaggerDocVersion3.String(),
|
||||
},
|
||||
})
|
||||
// ctx.JSON(http.StatusOK, swaggerInstance.docInstance.Data())
|
||||
})
|
||||
baseUri string
|
||||
docTitle string
|
||||
uiTheme enums.SwaggerUITheme // 文档主题, swaggerUI / knife4go, 默认 knife4go
|
||||
}
|
||||
|
||||
// Handler 访问文档的接口处理
|
||||
@@ -178,7 +98,7 @@ func (su *SwaggerUI) HandleRedocFreeUI() func(ctx *gin.Context) {
|
||||
return func(ctx *gin.Context) {
|
||||
// TODO : 这部分数据支持外部传参替换
|
||||
replaceTable := map[string]string{
|
||||
"{{DOC_TITLE}}": fmt.Sprintf("【%v】%v", su.docInstance.Doc().Info.Version, su.docInstance.Doc().Info.Title),
|
||||
"{{DOC_TITLE}}": su.docTitle,
|
||||
"{{CSS_FAMILY}}": "https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700",
|
||||
"{{DOC_PATH}}": "doc.json",
|
||||
"{{REDOC_STANDALONE_JS}}": "https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js",
|
||||
|
||||
Reference in New Issue
Block a user