diff --git a/tools/goctl/api/cmd.go b/tools/goctl/api/cmd.go index c16fd1729..5eec842b9 100644 --- a/tools/goctl/api/cmd.go +++ b/tools/goctl/api/cmd.go @@ -10,10 +10,12 @@ import ( "github.com/zeromicro/go-zero/tools/goctl/api/javagen" "github.com/zeromicro/go-zero/tools/goctl/api/ktgen" "github.com/zeromicro/go-zero/tools/goctl/api/new" + "github.com/zeromicro/go-zero/tools/goctl/api/swagger" "github.com/zeromicro/go-zero/tools/goctl/api/tsgen" "github.com/zeromicro/go-zero/tools/goctl/api/validate" "github.com/zeromicro/go-zero/tools/goctl/config" "github.com/zeromicro/go-zero/tools/goctl/internal/cobrax" + "github.com/zeromicro/go-zero/tools/goctl/pkg/env" "github.com/zeromicro/go-zero/tools/goctl/plugin" ) @@ -31,6 +33,7 @@ var ( ktCmd = cobrax.NewCommand("kt", cobrax.WithRunE(ktgen.KtCommand)) pluginCmd = cobrax.NewCommand("plugin", cobrax.WithRunE(plugin.PluginCommand)) tsCmd = cobrax.NewCommand("ts", cobrax.WithRunE(tsgen.TsCommand)) + swaggerCmd = cobrax.NewCommand("swagger", cobrax.WithRunE(swagger.Command)) ) func init() { @@ -46,6 +49,7 @@ func init() { pluginCmdFlags = pluginCmd.Flags() tsCmdFlags = tsCmd.Flags() validateCmdFlags = validateCmd.Flags() + swaggerCmdFlags = swaggerCmd.Flags() ) apiCmdFlags.StringVar(&apigen.VarStringOutput, "o") @@ -97,8 +101,15 @@ func init() { tsCmdFlags.StringVar(&tsgen.VarStringCaller, "caller") tsCmdFlags.BoolVar(&tsgen.VarBoolUnWrap, "unwrap") + swaggerCmdFlags.StringVar(&swagger.VarStringAPI, "api") + swaggerCmdFlags.StringVar(&swagger.VarStringDir, "dir") + swaggerCmdFlags.BoolVar(&swagger.VarBoolYaml, "yaml") + validateCmdFlags.StringVar(&validate.VarStringAPI, "api") // Add sub-commands Cmd.AddCommand(dartCmd, docCmd, formatCmd, goCmd, javaCmd, ktCmd, newCmd, pluginCmd, tsCmd, validateCmd) + if env.UseExperimental() { + Cmd.AddCommand(swaggerCmd) + } } diff --git a/tools/goctl/api/spec/spec.go b/tools/goctl/api/spec/spec.go index e5ed12661..b42dad3ce 100644 --- a/tools/goctl/api/spec/spec.go +++ b/tools/goctl/api/spec/spec.go @@ -21,7 +21,7 @@ type ( // ApiSpec describes an api file ApiSpec struct { - Info Info // Deprecated: useless expression + Info Info Syntax ApiSyntax // Deprecated: useless expression Imports []Import // Deprecated: useless expression Types []Type diff --git a/tools/goctl/api/swagger/annotation.go b/tools/goctl/api/swagger/annotation.go new file mode 100644 index 000000000..6af5cd3be --- /dev/null +++ b/tools/goctl/api/swagger/annotation.go @@ -0,0 +1,62 @@ +package swagger + +import ( + "strconv" + + "github.com/zeromicro/go-zero/tools/goctl/util" + "google.golang.org/grpc/metadata" +) + +func getBoolFromKVOrDefault(properties map[string]string, key string, def bool) bool { + if len(properties) == 0 { + return def + } + md := metadata.New(properties) + val := md.Get(key) + if len(val) == 0 { + return def + } + str := util.Unquote(val[0]) + if len(str) == 0 { + return def + } + res, _ := strconv.ParseBool(str) + return res +} + +func getStringFromKVOrDefault(properties map[string]string, key string, def string) string { + if len(properties) == 0 { + return def + } + md := metadata.New(properties) + val := md.Get(key) + if len(val) == 0 { + return def + } + str := util.Unquote(val[0]) + if len(str) == 0 { + return def + } + return str +} + +func getListFromInfoOrDefault(properties map[string]string, key string, def []string) []string { + if len(properties) == 0 { + return def + } + md := metadata.New(properties) + val := md.Get(key) + if len(val) == 0 { + return def + } + + str := util.Unquote(val[0]) + if len(str) == 0 { + return def + } + resp := util.FieldsAndTrimSpace(str, commaRune) + if len(resp) == 0 { + return def + } + return resp +} diff --git a/tools/goctl/api/swagger/annotation_test.go b/tools/goctl/api/swagger/annotation_test.go new file mode 100644 index 000000000..9278bfdb0 --- /dev/null +++ b/tools/goctl/api/swagger/annotation_test.go @@ -0,0 +1,53 @@ +package swagger + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_getBoolFromKVOrDefault(t *testing.T) { + properties := map[string]string{ + "enabled": `"true"`, + "disabled": `"false"`, + "invalid": `"notabool"`, + "empty_value": `""`, + } + + assert.True(t, getBoolFromKVOrDefault(properties, "enabled", false)) + assert.False(t, getBoolFromKVOrDefault(properties, "disabled", true)) + assert.False(t, getBoolFromKVOrDefault(properties, "invalid", false)) + assert.True(t, getBoolFromKVOrDefault(properties, "missing", true)) + assert.False(t, getBoolFromKVOrDefault(properties, "empty_value", false)) + assert.False(t, getBoolFromKVOrDefault(nil, "nil", false)) + assert.False(t, getBoolFromKVOrDefault(map[string]string{}, "empty", false)) +} + +func Test_getStringFromKVOrDefault(t *testing.T) { + properties := map[string]string{ + "name": `"example"`, + "empty": `""`, + } + + assert.Equal(t, "example", getStringFromKVOrDefault(properties, "name", "default")) + assert.Equal(t, "default", getStringFromKVOrDefault(properties, "empty", "default")) + assert.Equal(t, "default", getStringFromKVOrDefault(properties, "missing", "default")) + assert.Equal(t, "default", getStringFromKVOrDefault(nil, "nil", "default")) + assert.Equal(t, "default", getStringFromKVOrDefault(map[string]string{}, "empty", "default")) +} + +func Test_getListFromInfoOrDefault(t *testing.T) { + properties := map[string]string{ + "list": `"a, b, c"`, + "empty": `""`, + } + + assert.Equal(t, []string{"a", "b", "c"}, getListFromInfoOrDefault(properties, "list", []string{"default"})) + assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(properties, "empty", []string{"default"})) + assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(properties, "missing", []string{"default"})) + assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(nil, "nil", []string{"default"})) + assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(map[string]string{}, "empty", []string{"default"})) + assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(map[string]string{ + "foo": ",,", + }, "foo", []string{"default"})) +} diff --git a/tools/goctl/api/swagger/api.go b/tools/goctl/api/swagger/api.go new file mode 100644 index 000000000..9f9baba1b --- /dev/null +++ b/tools/goctl/api/swagger/api.go @@ -0,0 +1,138 @@ +package swagger + +import "github.com/zeromicro/go-zero/tools/goctl/api/spec" + +func fillAllStructs(api *spec.ApiSpec) { + var ( + tps []spec.Type + structTypes = make(map[string]spec.DefineStruct) + groups []spec.Group + ) + for _, tp := range api.Types { + structTypes[tp.Name()] = tp.(spec.DefineStruct) + } + + for _, tp := range api.Types { + filledTP := fillStruct("", tp, structTypes) + tps = append(tps, filledTP) + structTypes[filledTP.Name()] = filledTP.(spec.DefineStruct) + } + + for _, group := range api.Service.Groups { + var routes []spec.Route + for _, route := range group.Routes { + route.RequestType = fillStruct("", route.RequestType, structTypes) + route.ResponseType = fillStruct("", route.ResponseType, structTypes) + routes = append(routes, route) + } + group.Routes = routes + groups = append(groups, group) + } + api.Service.Groups = groups + api.Types = tps +} + +func fillStruct(parent string, tp spec.Type, allTypes map[string]spec.DefineStruct) spec.Type { + switch val := tp.(type) { + case spec.DefineStruct: + var members []spec.Member + for _, member := range val.Members { + switch memberType := member.Type.(type) { + case spec.PointerType: + member.Type = spec.PointerType{ + RawName: memberType.RawName, + Type: fillStruct(val.Name(), memberType.Type, allTypes), + } + case spec.ArrayType: + member.Type = spec.ArrayType{ + RawName: memberType.RawName, + Value: fillStruct(val.Name(), memberType.Value, allTypes), + } + case spec.MapType: + member.Type = spec.MapType{ + RawName: memberType.RawName, + Key: memberType.Key, + Value: fillStruct(val.Name(), memberType.Value, allTypes), + } + case spec.DefineStruct: + if parent != memberType.Name() { // avoid recursive struct + if st, ok := allTypes[memberType.Name()]; ok { + member.Type = fillStruct("", st, allTypes) + } + } + case spec.NestedStruct: + member.Type = fillStruct("", member.Type, allTypes) + } + members = append(members, member) + } + if len(members) == 0 { + st, ok := allTypes[val.RawName] + if ok { + members = st.Members + } + } + val.Members = members + return val + case spec.NestedStruct: + var members []spec.Member + for _, member := range val.Members { + switch memberType := member.Type.(type) { + case spec.PointerType: + member.Type = spec.PointerType{ + RawName: memberType.RawName, + Type: fillStruct(val.Name(), memberType.Type, allTypes), + } + case spec.ArrayType: + member.Type = spec.ArrayType{ + RawName: memberType.RawName, + Value: fillStruct(val.Name(), memberType.Value, allTypes), + } + case spec.MapType: + member.Type = spec.MapType{ + RawName: memberType.RawName, + Key: memberType.Key, + Value: fillStruct(val.Name(), memberType.Value, allTypes), + } + case spec.DefineStruct: + if parent != memberType.Name() { // avoid recursive struct + if st, ok := allTypes[memberType.Name()]; ok { + member.Type = fillStruct("", st, allTypes) + } + } + case spec.NestedStruct: + if parent != memberType.Name() { + if st, ok := allTypes[memberType.Name()]; ok { + member.Type = fillStruct("", st, allTypes) + } + } + } + members = append(members, member) + } + if len(members) == 0 { + st, ok := allTypes[val.RawName] + if ok { + members = st.Members + } + } + val.Members = members + return val + case spec.PointerType: + return spec.PointerType{ + RawName: val.RawName, + Type: fillStruct(parent, val.Type, allTypes), + } + case spec.ArrayType: + return spec.ArrayType{ + RawName: val.RawName, + Value: fillStruct(parent, val.Value, allTypes), + } + case spec.MapType: + return spec.MapType{ + RawName: val.RawName, + Key: val.Key, + Value: fillStruct(parent, val.Value, allTypes), + } + default: + return tp + } +} diff --git a/tools/goctl/api/swagger/command.go b/tools/goctl/api/swagger/command.go new file mode 100644 index 000000000..75767aac4 --- /dev/null +++ b/tools/goctl/api/swagger/command.go @@ -0,0 +1,79 @@ +package swagger + +import ( + "encoding/json" + "errors" + "github.com/zeromicro/go-zero/tools/goctl/util/pathx" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/zeromicro/go-zero/tools/goctl/pkg/parser/api/parser" + "gopkg.in/yaml.v2" +) + +var ( + // VarStringAPI specifies the API filename. + VarStringAPI string + + // VarStringDir specifies the directory to generate swagger file. + VarStringDir string + + // VarBoolYaml specifies whether to generate a YAML file. + VarBoolYaml bool +) + +func Command(_ *cobra.Command, _ []string) error { + if len(VarStringAPI) == 0 { + return errors.New("missing -api") + } + + if len(VarStringDir) == 0 { + return errors.New("missing -dir") + } + + api, err := parser.Parse(VarStringAPI, "") + if err != nil { + return err + } + + fillAllStructs(api) + + if err := api.Validate(); err != nil { + return err + } + swagger, err := spec2Swagger(api) + if err != nil { + return err + } + data, err := json.MarshalIndent(swagger, "", " ") + if err != nil { + return err + } + + err = pathx.MkdirIfNotExist(VarStringDir) + if err != nil { + return err + } + + base := filepath.Base(VarStringAPI) + if VarBoolYaml { + filename := filepath.Join(VarStringDir, strings.TrimSuffix(base, filepath.Ext(base))+".yaml") + + var jsonObj interface{} + if err := yaml.Unmarshal(data, &jsonObj); err != nil { + return err + } + + data, err := yaml.Marshal(jsonObj) + if err != nil { + return err + } + return os.WriteFile(filename, data, 0644) + } + // generate json swagger file + filename := filepath.Join(VarStringDir, strings.TrimSuffix(base, filepath.Ext(base))+".json") + + return os.WriteFile(filename, data, 0644) +} diff --git a/tools/goctl/api/swagger/const.go b/tools/goctl/api/swagger/const.go new file mode 100644 index 000000000..1f2d94c7c --- /dev/null +++ b/tools/goctl/api/swagger/const.go @@ -0,0 +1,32 @@ +package swagger + +const ( + tagHeader = "header" + tagPath = "path" + tagForm = "form" + tagJson = "json" + defFlag = "default=" + enumFlag = "options=" + rangeFlag = "range=" + exampleFlag = "example=" + + paramsInHeader = "header" + paramsInPath = "path" + paramsInQuery = "query" + paramsInBody = "body" + paramsInForm = "formData" + + swaggerTypeInteger = "integer" + swaggerTypeNumber = "number" + swaggerTypeString = "string" + swaggerTypeBoolean = "boolean" + swaggerTypeArray = "array" + swaggerTypeObject = "object" + + swaggerVersion = "2.0" + applicationJson = "application/json" + applicationForm = "application/x-www-form-urlencoded" + schemeHttps = "https" + defaultHost = "127.0.0.1" + defaultBasePath = "/" +) diff --git a/tools/goctl/api/swagger/contenttype.go b/tools/goctl/api/swagger/contenttype.go new file mode 100644 index 000000000..ca0574108 --- /dev/null +++ b/tools/goctl/api/swagger/contenttype.go @@ -0,0 +1,25 @@ +package swagger + +import ( + "net/http" + "strings" + + "github.com/zeromicro/go-zero/tools/goctl/api/spec" +) + +func consumesFromTypeOrDef(method string, tp spec.Type) []string { + if strings.EqualFold(method, http.MethodGet) { + return []string{} + } + if tp == nil { + return []string{} + } + structType, ok := tp.(spec.DefineStruct) + if !ok { + return []string{} + } + if typeContainsTag(structType, tagJson) { + return []string{applicationJson} + } + return []string{applicationForm} +} diff --git a/tools/goctl/api/swagger/contenttype_test.go b/tools/goctl/api/swagger/contenttype_test.go new file mode 100644 index 000000000..3cc08342c --- /dev/null +++ b/tools/goctl/api/swagger/contenttype_test.go @@ -0,0 +1,68 @@ +package swagger + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/tools/goctl/api/spec" +) + +func TestConsumesFromTypeOrDef(t *testing.T) { + tests := []struct { + name string + method string + tp spec.Type + expected []string + }{ + { + name: "GET method with nil type", + method: http.MethodGet, + tp: nil, + expected: []string{}, + }, + { + name: "post nil", + method: http.MethodPost, + tp: nil, + expected: []string{}, + }, + { + name: "json tag", + method: http.MethodPost, + tp: spec.DefineStruct{ + Members: []spec.Member{ + { + Tag: `json:"example"`, + }, + }, + }, + expected: []string{applicationJson}, + }, + { + name: "form tag", + method: http.MethodPost, + tp: spec.DefineStruct{ + Members: []spec.Member{ + { + Tag: `form:"example"`, + }, + }, + }, + expected: []string{applicationForm}, + }, + { + name: "Non struct type", + method: http.MethodPost, + tp: spec.ArrayType{}, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := consumesFromTypeOrDef(tt.method, tt.tp) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/tools/goctl/api/swagger/example/.gitignore b/tools/goctl/api/swagger/example/.gitignore new file mode 100644 index 000000000..4997e4e42 --- /dev/null +++ b/tools/goctl/api/swagger/example/.gitignore @@ -0,0 +1,4 @@ +*.json +*.yaml +bin +output \ No newline at end of file diff --git a/tools/goctl/api/swagger/example/example.api b/tools/goctl/api/swagger/example/example.api new file mode 100644 index 000000000..40ab765a9 --- /dev/null +++ b/tools/goctl/api/swagger/example/example.api @@ -0,0 +1,234 @@ +syntax = "v1" + +info ( + title: "Demo API" // title corresponding to Swagger + description: "Generating Swagger files using the API demo." // description corresponding to Swagger + version: "v1" // version corresponding to Swagger + termsOfService: "https://github.com/zeromicro/go-zero" // termsOfService corresponding to Swagger + contactName: "keson.an" // contactName corresponding to Swagger + contactURL: "https://github.com/zeromicro/go-zero" // contactURL corresponding to Swagger + contactEmail: "example@gmail.com" // contactEmail corresponding to Swagger + licenseName: "MIT" // licenseName corresponding to Swagger + licenseURL: "https://github.com/zeromicro/go-zero" // licenseURL corresponding to Swagger + consumes: "application/json" // consumes corresponding to Swagger,default value is `application/json` + produces: "application/json" // produces corresponding to Swagger,default value is `application/json` + schemes: "https" // schemes corresponding to Swagger,default value is `https`` + host: "example.com" // host corresponding to Swagger,default value is `127.0.0.1` + basePath: "/v1" // basePath corresponding to Swagger,default value is `/` + wrapCodeMsg: "true" // to wrap in the universal code-msg structure, like {"code":0,"msg":"OK","data":$data} + bizCodeEnumDescription: "1001-User not login
1002-User permission denied" // enums of business error codes, in JSON format, with the key being the business error code and the value being the description of that error code. This only takes effect when wrapCodeMsg is set to true. +) + +type ( + QueryReq { + Id int `form:"id,range=[1:10000],example=10"` + Name string `form:"name,example=keson.an"` + Avatar string `form:"avatar,optional,example=https://example.com/avatar.png"` + } + QueryResp { + Id int `json:"id,example=10"` + Name string `json:"name,example=keson.an"` + } + PathQueryReq { + Id int `path:"id,range=[1:10000],example=10"` + Name string `form:"name,example=keson.an"` + } + PathQueryResp { + Id int `json:"id,example=10"` + Name string `json:"name,example=keson.an"` + } +) + +@server ( + tags: "query" // tags corresponding to Swagger + summary: "query API set" // summary corresponding to Swagger +) +service Swagger { + @doc ( + description: "query demo" + ) + @handler query + get /query (QueryReq) returns (QueryResp) + + @doc ( + description: "show path query demo" + ) + @handler queryPath + get /query/:id (PathQueryReq) returns (PathQueryResp) +} + +type ( + FormReq { + Id int `form:"id,range=[1:10000],example=10"` + Name string `form:"name,example=keson.an"` + } + FormResp { + Id int `json:"id,example=10"` + Name string `json:"name,example=keson.an"` + } +) + +@server ( + tags: "form" // tags corresponding to Swagger + summary: "form API set" // summary corresponding to Swagger +) +service Swagger { + @doc ( + description: "form demo" + ) + @handler form + post /form (FormReq) returns (FormResp) +} + +type ( + JsonReq { + Id int `json:"id,range=[1:10000],example=10"` + Name string `json:"name,example=keson.an"` + Avatar string `json:"avatar,optional"` + Language string `json:"language,options=golang|java|python|typescript|rust"` + Gender string `json:"gender,default=male,options=male|female,example=male"` + } + JsonResp { + Id int `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Language string `json:"language"` + Gender string `json:"gender"` + } + ComplexJsonLevel2 { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + } + ComplexJsonLevel1 { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + // Object + Object ComplexJsonLevel2 `json:"object"` + PointerObject *ComplexJsonLevel2 `json:"pointerObject"` + } + ComplexJsonReq { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + // basic array + ArrayInteger []int `json:"arrayInteger"` + ArrayNumber []float64 `json:"arrayNumber"` + ArrayBoolean []bool `json:"arrayBoolean"` + ArrayString []string `json:"arrayString"` + // basic array array + ArrayArrayInteger [][]int `json:"arrayArrayInteger"` + ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"` + ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"` + ArrayArrayString [][]string `json:"arrayArrayString"` + // basic map + MapInteger map[string]int `json:"mapInteger"` + MapNumber map[string]float64 `json:"mapNumber"` + MapBoolean map[string]bool `json:"mapBoolean"` + MapString map[string]string `json:"mapString"` + // basic map array + MapArrayInteger map[string][]int `json:"mapArrayInteger"` + MapArrayNumber map[string][]float64 `json:"mapArrayNumber"` + MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"` + MapArrayString map[string][]string `json:"mapArrayString"` + // basic map map + MapMapInteger map[string]map[string]int `json:"mapMapInteger"` + MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"` + MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"` + MapMapString map[string]map[string]string `json:"mapMapString"` + // Object + Object ComplexJsonLevel1 `json:"object"` + PointerObject *ComplexJsonLevel1 `json:"pointerObject"` + // Object array + ArrayObject []ComplexJsonLevel1 `json:"arrayObject"` + ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"` + // Object map + MapObject map[string]ComplexJsonLevel1 `json:"mapObject"` + MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"` + // Object array array + ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"` + ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"` + // Object array map + ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"` + ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"` + // Object map array + MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"` + MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"` + } + ComplexJsonResp { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + // basic array + ArrayInteger []int `json:"arrayInteger"` + ArrayNumber []float64 `json:"arrayNumber"` + ArrayBoolean []bool `json:"arrayBoolean"` + ArrayString []string `json:"arrayString"` + // basic array array + ArrayArrayInteger [][]int `json:"arrayArrayInteger"` + ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"` + ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"` + ArrayArrayString [][]string `json:"arrayArrayString"` + // basic map + MapInteger map[string]int `json:"mapInteger"` + MapNumber map[string]float64 `json:"mapNumber"` + MapBoolean map[string]bool `json:"mapBoolean"` + MapString map[string]string `json:"mapString"` + // basic map array + MapArrayInteger map[string][]int `json:"mapArrayInteger"` + MapArrayNumber map[string][]float64 `json:"mapArrayNumber"` + MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"` + MapArrayString map[string][]string `json:"mapArrayString"` + // basic map map + MapMapInteger map[string]map[string]int `json:"mapMapInteger"` + MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"` + MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"` + MapMapString map[string]map[string]string `json:"mapMapString"` + // Object + Object ComplexJsonLevel1 `json:"object"` + PointerObject *ComplexJsonLevel1 `json:"pointerObject"` + // Object array + ArrayObject []ComplexJsonLevel1 `json:"arrayObject"` + ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"` + // Object map + MapObject map[string]ComplexJsonLevel1 `json:"mapObject"` + MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"` + // Object array array + ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"` + ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"` + // Object array map + ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"` + ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"` + // Object map array + MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"` + MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"` + } +) + +@server ( + tags: "postJson" // tags corresponding to Swagger + summary: "json API set" // summary corresponding to Swagger +) +service Swagger { + @doc ( + description: "simple json request body API" + ) + @handler jsonSimple + post /json/simple (JsonReq) returns (JsonResp) + + @doc ( + description: "complex json request body API" + ) + @handler jsonComplex + post /json/complex (ComplexJsonReq) returns (ComplexJsonResp) +} + diff --git a/tools/goctl/api/swagger/example/example_cn.api b/tools/goctl/api/swagger/example/example_cn.api new file mode 100644 index 000000000..670a728e4 --- /dev/null +++ b/tools/goctl/api/swagger/example/example_cn.api @@ -0,0 +1,235 @@ +syntax = "v1" + +info ( + title: "演示 API" // 对应 swagger 的 title + description: "演示 api 生成 swagger 文件的 api 完整写法" // 对应 swagger 的 description + version: "v1" // 对应 swagger 的 version + termsOfService: "https://github.com/zeromicro/go-zero" // 对应 swagger 的 termsOfService + contactName: "keson.an" // 对应 swagger 的 contactName + contactURL: "https://github.com/zeromicro/go-zero" // 对应 swagger 的 contactURL + contactEmail: "example@gmail.com" // 对应 swagger 的 contactEmail + licenseName: "MIT" // 对应 swagger 的 licenseName + licenseURL: "https://github.com/zeromicro/go-zero" // 对应 swagger 的 licenseURL + consumes: "application/json" // 对应 swagger 的 consumes,不填默认为 application/json + produces: "application/json" // 对应 swagger 的 produces,不填默认为 application/json + schemes: "https" // 对应 swagger 的 schemes,不填默认为 https + host: "example.com" // 对应 swagger 的 host,不填默认为 127.0.0.1 + basePath: "/v1" // 对应 swagger 的 basePath,不填默认为 / + wrapCodeMsg: "true" // 是否用 code-msg 通用响应体,如果开启,则以格式 {"code":0,"msg":"OK","data":$data} 包括响应体 + bizCodeEnumDescription: "1001-未登录
1002-无权限操作" // 业务错误码枚举描述,json 格式,key 为业务错误码,value 为该错误码的描述,仅当 wrapCodeMsg 为 true 时生效 +) + +type ( + QueryReq { + Id int `form:"id,range=[1:10000],example=10"` + Name string `form:"name,example=keson.an"` + Avatar string `form:"avatar,optional,example=https://example.com/avatar.png"` + } + QueryResp { + Id int `json:"id,example=10"` + Name string `json:"name,example=keson.an"` + } + PathQueryReq { + Id int `path:"id,range=[1:10000],example=10"` + Name string `form:"name,example=keson.an"` + } + PathQueryResp { + Id int `json:"id,example=10"` + Name string `json:"name,example=keson.an"` + } +) + +@server ( + tags: "query 演示" // 对应 swagger 的 tags,可以对 swagger 中的 api 进行分组 + summary: "query 类型接口集合" // 对应 swagger 的 summary + prefix: v1 +) +service Swagger { + @doc ( + description: "query 接口" + ) + @handler query + get /query (QueryReq) returns (QueryResp) + + @doc ( + description: "query path 中包含 id 字段接口" + ) + @handler queryPath + get /query/:id (PathQueryReq) returns (PathQueryResp) +} + +type ( + FormReq { + Id int `form:"id,range=[1:10000],example=10"` + Name string `form:"name,example=keson.an"` + } + FormResp { + Id int `json:"id,example=10"` + Name string `json:"name,example=keson.an"` + } +) + +@server ( + tags: "form 表单 api 演示" // 对应 swagger 的 tags,可以对 swagger 中的 api 进行分组 + summary: "form 表单类型接口集合" // 对应 swagger 的 summary +) +service Swagger { + @doc ( + description: "form 接口" + ) + @handler form + post /form (FormReq) returns (FormResp) +} + +type ( + JsonReq { + Id int `json:"id,range=[1:10000],example=10"` + Name string `json:"name,example=keson.an"` + Avatar string `json:"avatar,optional"` + Language string `json:"language,options=golang|java|python|typescript|rust"` + Gender string `json:"gender,default=male,options=male|female,example=male"` + } + JsonResp { + Id int `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Language string `json:"language"` + Gender string `json:"gender"` + } + ComplexJsonLevel2 { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + } + ComplexJsonLevel1 { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + // Object + Object ComplexJsonLevel2 `json:"object"` + PointerObject *ComplexJsonLevel2 `json:"pointerObject"` + } + ComplexJsonReq { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + // basic array + ArrayInteger []int `json:"arrayInteger"` + ArrayNumber []float64 `json:"arrayNumber"` + ArrayBoolean []bool `json:"arrayBoolean"` + ArrayString []string `json:"arrayString"` + // basic array array + ArrayArrayInteger [][]int `json:"arrayArrayInteger"` + ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"` + ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"` + ArrayArrayString [][]string `json:"arrayArrayString"` + // basic map + MapInteger map[string]int `json:"mapInteger"` + MapNumber map[string]float64 `json:"mapNumber"` + MapBoolean map[string]bool `json:"mapBoolean"` + MapString map[string]string `json:"mapString"` + // basic map array + MapArrayInteger map[string][]int `json:"mapArrayInteger"` + MapArrayNumber map[string][]float64 `json:"mapArrayNumber"` + MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"` + MapArrayString map[string][]string `json:"mapArrayString"` + // basic map map + MapMapInteger map[string]map[string]int `json:"mapMapInteger"` + MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"` + MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"` + MapMapString map[string]map[string]string `json:"mapMapString"` + // Object + Object ComplexJsonLevel1 `json:"object"` + PointerObject *ComplexJsonLevel1 `json:"pointerObject"` + // Object array + ArrayObject []ComplexJsonLevel1 `json:"arrayObject"` + ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"` + // Object map + MapObject map[string]ComplexJsonLevel1 `json:"mapObject"` + MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"` + // Object array array + ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"` + ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"` + // Object array map + ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"` + ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"` + // Object map array + MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"` + MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"` + } + ComplexJsonResp { + // basic + Integer int `json:"integer,example=1"` + Number float64 `json:"number,example=1.1"` + Boolean bool `json:"boolean,options=true|false,example=true"` + String string `json:"string,example=some text"` + // basic array + ArrayInteger []int `json:"arrayInteger"` + ArrayNumber []float64 `json:"arrayNumber"` + ArrayBoolean []bool `json:"arrayBoolean"` + ArrayString []string `json:"arrayString"` + // basic array array + ArrayArrayInteger [][]int `json:"arrayArrayInteger"` + ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"` + ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"` + ArrayArrayString [][]string `json:"arrayArrayString"` + // basic map + MapInteger map[string]int `json:"mapInteger"` + MapNumber map[string]float64 `json:"mapNumber"` + MapBoolean map[string]bool `json:"mapBoolean"` + MapString map[string]string `json:"mapString"` + // basic map array + MapArrayInteger map[string][]int `json:"mapArrayInteger"` + MapArrayNumber map[string][]float64 `json:"mapArrayNumber"` + MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"` + MapArrayString map[string][]string `json:"mapArrayString"` + // basic map map + MapMapInteger map[string]map[string]int `json:"mapMapInteger"` + MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"` + MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"` + MapMapString map[string]map[string]string `json:"mapMapString"` + // Object + Object ComplexJsonLevel1 `json:"object"` + PointerObject *ComplexJsonLevel1 `json:"pointerObject"` + // Object array + ArrayObject []ComplexJsonLevel1 `json:"arrayObject"` + ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"` + // Object map + MapObject map[string]ComplexJsonLevel1 `json:"mapObject"` + MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"` + // Object array array + ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"` + ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"` + // Object array map + ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"` + ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"` + // Object map array + MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"` + MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"` + } +) + +@server ( + tags: "post json api 演示" // 对应 swagger 的 tags,可以对 swagger 中的 api 进行分组 + summary: "json 请求类型接口集合" // 对应 swagger 的 summary +) +service Swagger { + @doc ( + description: "简单的 json 请求体接口" + ) + @handler jsonSimple + post /json/simple (JsonReq) returns (JsonResp) + + @doc ( + description: "复杂的 json 请求体接口" + ) + @handler jsonComplex + post /json/complex (ComplexJsonReq) returns (ComplexJsonResp) +} + diff --git a/tools/goctl/api/swagger/example/go-swagger-cn.sh b/tools/goctl/api/swagger/example/go-swagger-cn.sh new file mode 100644 index 000000000..2a9ac64ea --- /dev/null +++ b/tools/goctl/api/swagger/example/go-swagger-cn.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# 1. 检查并安装 swagger +if ! command -v swagger &> /dev/null; then + echo "swagger 未安装,正在从 GitHub 安装..." + # 这里使用 go-swagger 的安装方式 + go install github.com/go-swagger/go-swagger/cmd/swagger@latest + if [ $? -ne 0 ]; then + echo "安装 swagger 失败" + exit 1 + fi + echo "swagger 安装成功" +else + echo "swagger 已安装" +fi + +mkdir bin output + +export GOBIN=$(pwd)/bin + +# 2. 安装最新版 goctl +go install ../../.. +if [ $? -ne 0 ]; then + echo "安装 goctl 失败" + exit 1 +fi +echo "goctl 安装成功" + +# 3. 生成 swagger 文件 +echo "正在生成 swagger 文件..." +./bin/goctl api swagger --api example_cn.api --dir output +if [ $? -ne 0 ]; then + echo "生成 swagger 文件失败" + exit 1 +fi + +# 4. 启动 swagger 服务 +echo "启动 swagger 服务..." +swagger serve ./output/example_cn.json \ No newline at end of file diff --git a/tools/goctl/api/swagger/example/go-swagger-ui-cn.png b/tools/goctl/api/swagger/example/go-swagger-ui-cn.png new file mode 100644 index 000000000..6e461c6de Binary files /dev/null and b/tools/goctl/api/swagger/example/go-swagger-ui-cn.png differ diff --git a/tools/goctl/api/swagger/example/go-swagger-ui.png b/tools/goctl/api/swagger/example/go-swagger-ui.png new file mode 100644 index 000000000..0d3766280 Binary files /dev/null and b/tools/goctl/api/swagger/example/go-swagger-ui.png differ diff --git a/tools/goctl/api/swagger/example/go-swagger.sh b/tools/goctl/api/swagger/example/go-swagger.sh new file mode 100644 index 000000000..d1f221bfe --- /dev/null +++ b/tools/goctl/api/swagger/example/go-swagger.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# 1. Check and install swagger if not exists +if ! command -v swagger &> /dev/null; then + echo "swagger not found, installing from GitHub..." + # Using go-swagger installation method + go install github.com/go-swagger/go-swagger/cmd/swagger@latest + if [ $? -ne 0 ]; then + echo "Failed to install swagger" + exit 1 + fi + echo "swagger installed successfully" +else + echo "swagger already installed" +fi + +mkdir bin output + +export GOBIN=$(pwd)/bin + +# 2. Install latest goctl version +go install ../../.. +if [ $? -ne 0 ]; then + echo "Failed to install goctl" + exit 1 +fi +echo "goctl installed successfully" + +# 3. Generate swagger files +echo "Generating swagger files..." +./bin/goctl api swagger --api example.api --dir output +if [ $? -ne 0 ]; then + echo "Failed to generate swagger files" + exit 1 +fi + +# 4. Start swagger server +echo "Starting swagger server..." +swagger serve ./output/example.json \ No newline at end of file diff --git a/tools/goctl/api/swagger/example/swagger-ui-cn-example.png b/tools/goctl/api/swagger/example/swagger-ui-cn-example.png new file mode 100644 index 000000000..b704cbcf9 Binary files /dev/null and b/tools/goctl/api/swagger/example/swagger-ui-cn-example.png differ diff --git a/tools/goctl/api/swagger/example/swagger-ui-cn-model.png b/tools/goctl/api/swagger/example/swagger-ui-cn-model.png new file mode 100644 index 000000000..bbeb4b9fe Binary files /dev/null and b/tools/goctl/api/swagger/example/swagger-ui-cn-model.png differ diff --git a/tools/goctl/api/swagger/example/swagger-ui-cn.sh b/tools/goctl/api/swagger/example/swagger-ui-cn.sh new file mode 100644 index 000000000..74ca27a31 --- /dev/null +++ b/tools/goctl/api/swagger/example/swagger-ui-cn.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# 检查Docker是否运行的函数 +is_docker_running() { + if ! docker info >/dev/null 2>&1; then + return 1 # Docker未运行 + else + return 0 # Docker正在运行 + fi +} + +mkdir bin output + +export GOBIN=$(pwd)/bin + +# 1. 检查并安装Docker(如果不存在) +if ! command -v docker &> /dev/null; then + echo "未检测到Docker,正在尝试安装..." + + # 使用官方脚本安装Docker + curl -fsSL https://get.docker.com -o get-docker.sh + sudo sh get-docker.sh + rm get-docker.sh + + # 验证安装 + if ! command -v docker &> /dev/null; then + echo "Docker安装失败" + exit 1 + fi + + # 将当前用户加入docker组(可能需要重新登录) + sudo usermod -aG docker $USER + echo "Docker安装成功。您可能需要注销并重新登录使更改生效。" +else + echo "Docker已安装" +fi + +# 2. 安装最新版goctl +go install ../../.. +if [ $? -ne 0 ]; then + echo "goctl安装失败" + exit 1 +fi +echo "goctl 安装成功" + +# 3. 生成swagger文件 +echo "正在生成swagger文件..." +./bin/goctl api swagger --api example_cn.api --dir output +if [ $? -ne 0 ]; then + echo "swagger文件生成失败" + exit 1 +fi + +# 检查Docker是否运行 +if ! is_docker_running; then + echo "Docker未运行,请先启动Docker服务" + exit 1 +fi + +# 4. 清理现有的swagger-ui容器 +echo "正在清理现有的swagger-ui容器..." +docker rm -f swagger-ui 2>/dev/null && echo "已移除现有的swagger-ui容器" + +# 5. 在Docker中运行swagger-ui +echo "正在启动swagger-ui容器..." +docker run -d --name swagger-ui -p 8080:8080 \ + -e SWAGGER_JSON=/tmp/example.json \ + -v $(pwd)/output/example_cn.json:/tmp/example.json \ + swaggerapi/swagger-ui + +if [ $? -ne 0 ]; then + echo "swagger-ui容器启动失败" + exit 1 +fi + +# 等待1秒确保服务就绪 +echo "等待swagger-ui初始化..." +sleep 1 + +# 显示访问信息并尝试打开浏览器 +SWAGGER_URL="http://localhost:8080" +echo -e "\nSwagger UI 已准备就绪,访问地址: \033[1;34m${SWAGGER_URL}\033[0m" +echo "正在尝试在默认浏览器中打开..." + +# 跨平台打开浏览器 +case "$(uname -s)" in + Linux*) xdg-open "$SWAGGER_URL";; + Darwin*) open "$SWAGGER_URL";; + CYGWIN*|MINGW*|MSYS*) start "$SWAGGER_URL";; + *) echo "无法在当前操作系统自动打开浏览器";; +esac diff --git a/tools/goctl/api/swagger/example/swagger-ui-example.png b/tools/goctl/api/swagger/example/swagger-ui-example.png new file mode 100644 index 000000000..129fe2dbb Binary files /dev/null and b/tools/goctl/api/swagger/example/swagger-ui-example.png differ diff --git a/tools/goctl/api/swagger/example/swagger-ui-model.png b/tools/goctl/api/swagger/example/swagger-ui-model.png new file mode 100644 index 000000000..cc7068391 Binary files /dev/null and b/tools/goctl/api/swagger/example/swagger-ui-model.png differ diff --git a/tools/goctl/api/swagger/example/swagger-ui.sh b/tools/goctl/api/swagger/example/swagger-ui.sh new file mode 100644 index 000000000..57c035883 --- /dev/null +++ b/tools/goctl/api/swagger/example/swagger-ui.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +is_docker_running() { + if ! docker info >/dev/null 2>&1; then + return 1 # Docker is not running + else + return 0 # Docker is running + fi +} + +mkdir bin output + +export GOBIN=$(pwd)/bin + +# 1. Check and install Docker if not exists +if ! command -v docker &> /dev/null; then + echo "Docker not found, attempting to install..." + + # Install Docker using official installation script + curl -fsSL https://get.docker.com -o get-docker.sh + sudo sh get-docker.sh + rm get-docker.sh + + # Verify installation + if ! command -v docker &> /dev/null; then + echo "Failed to install Docker" + exit 1 + fi + + # Add current user to docker group (may require logout/login) + sudo usermod -aG docker $USER + echo "Docker installed successfully. You may need to logout and login again for changes to take effect." +else + echo "Docker already installed" +fi + +# 2. Install latest goctl version +go install ../../.. +if [ $? -ne 0 ]; then + echo "Failed to install goctl" + exit 1 +fi +echo "goctl installed successfully" + +# 3. Generate swagger files +echo "Generating swagger files..." +./bin/goctl api swagger --api example.api --dir output +if [ $? -ne 0 ]; then + echo "Failed to generate swagger files" + exit 1 +fi + +if ! is_docker_running; then + echo "Docker is not running, Pls start Docker first" +fi + +# 4. Clean up any existing swagger-ui container +echo "Cleaning up existing swagger-ui containers..." +docker rm -f swagger-ui 2>/dev/null && echo "Removed existing swagger-ui container" + +# 5. Run swagger-ui in Docker +echo "Starting swagger-ui in Docker..." +docker run -d --name swagger-ui -p 8080:8080 -e SWAGGER_JSON=/tmp/example.json -v $(pwd)/output/example.json:/tmp/example.json swaggerapi/swagger-ui +if [ $? -ne 0 ]; then + echo "Failed to start swagger-ui container" + exit 1 +fi + +echo "Waiting for swagger-ui to initialize..." +sleep 1 +SWAGGER_URL="http://localhost:8080" +echo -e "\nSwagger UI is ready at: \033[1;34m${SWAGGER_URL}\033[0m" +echo "Opening in default browser..." + +case "$(uname -s)" in + Linux*) xdg-open "$SWAGGER_URL";; + Darwin*) open "$SWAGGER_URL";; + CYGWIN*|MINGW*|MSYS*) start "$SWAGGER_URL";; + *) echo "System not supported";; +esac \ No newline at end of file diff --git a/tools/goctl/api/swagger/options.go b/tools/goctl/api/swagger/options.go new file mode 100644 index 000000000..ead7069ab --- /dev/null +++ b/tools/goctl/api/swagger/options.go @@ -0,0 +1,123 @@ +package swagger + +import ( + "strconv" + "strings" + + "github.com/zeromicro/go-zero/tools/goctl/api/spec" + "github.com/zeromicro/go-zero/tools/goctl/util" +) + +func rangeValueFromOptions(options []string) (minimum *float64, maximum *float64, exclusiveMinimum bool, exclusiveMaximum bool) { + if len(options) == 0 { + return nil, nil, false, false + } + for _, option := range options { + if strings.HasPrefix(option, rangeFlag) { + val := option[6:] + start, end := val[0], val[len(val)-1] + if start != '[' && start != '(' { + return nil, nil, false, false + } + if end != ']' && end != ')' { + return nil, nil, false, false + } + exclusiveMinimum = start == '(' + exclusiveMaximum = end == ')' + + content := val[1 : len(val)-1] + idxColon := strings.Index(content, ":") + if idxColon < 0 { + return nil, nil, false, false + } + var ( + minStr, maxStr string + minVal, maxVal *float64 + ) + minStr = util.TrimWhiteSpace(content[:idxColon]) + if len(val) >= idxColon+1 { + maxStr = util.TrimWhiteSpace(content[idxColon+1:]) + } + + if len(minStr) > 0 { + min, err := strconv.ParseFloat(minStr, 64) + if err != nil { + return nil, nil, false, false + } + minVal = &min + } + + if len(maxStr) > 0 { + max, err := strconv.ParseFloat(maxStr, 64) + if err != nil { + return nil, nil, false, false + } + maxVal = &max + } + + return minVal, maxVal, exclusiveMinimum, exclusiveMaximum + } + } + return nil, nil, false, false +} + +func enumsValueFromOptions(options []string) []any { + if len(options) == 0 { + return []any{} + } + for _, option := range options { + if strings.HasPrefix(option, enumFlag) { + var resp = make([]any, 0) + val := option[8:] + fields := util.FieldsAndTrimSpace(val, func(r rune) bool { + return r == '|' + }) + for _, field := range fields { + resp = append(resp, field) + } + return resp + } + } + return []any{} +} + +func defValueFromOptions(options []string, apiType spec.Type) any { + tp := sampleTypeFromGoType(apiType) + return valueFromOptions(options, defFlag, tp) +} + +func exampleValueFromOptions(options []string, apiType spec.Type) any { + tp := sampleTypeFromGoType(apiType) + val := valueFromOptions(options, exampleFlag, tp) + if val != nil { + return val + } + return defValueFromOptions(options, apiType) +} + +func valueFromOptions(options []string, key string, tp string) any { + if len(options) == 0 { + return nil + } + for _, option := range options { + if strings.HasPrefix(option, key) { + s := option[len(key):] + switch tp { + case "integer": + val, _ := strconv.ParseInt(s, 10, 64) + return val + case "boolean": + val, _ := strconv.ParseBool(s) + return val + case "number": + val, _ := strconv.ParseFloat(s, 64) + return val + case "string": + return s + default: + return nil + } + } + } + return nil +} diff --git a/tools/goctl/api/swagger/options_test.go b/tools/goctl/api/swagger/options_test.go new file mode 100644 index 000000000..20abca009 --- /dev/null +++ b/tools/goctl/api/swagger/options_test.go @@ -0,0 +1,258 @@ +package swagger + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/zeromicro/go-zero/tools/goctl/api/spec" +) + +func TestRangeValueFromOptions(t *testing.T) { + tests := []struct { + name string + options []string + expectedMin *float64 + expectedMax *float64 + expectedExclMin bool + expectedExclMax bool + }{ + { + name: "Valid range with inclusive bounds", + options: []string{"range=[1.0:10.0]"}, + expectedMin: floatPtr(1.0), + expectedMax: floatPtr(10.0), + expectedExclMin: false, + expectedExclMax: false, + }, + { + name: "Valid range with exclusive bounds", + options: []string{"range=(1.0:10.0)"}, + expectedMin: floatPtr(1.0), + expectedMax: floatPtr(10.0), + expectedExclMin: true, + expectedExclMax: true, + }, + { + name: "Invalid range format", + options: []string{"range=1.0:10.0"}, + expectedMin: nil, + expectedMax: nil, + expectedExclMin: false, + expectedExclMax: false, + }, + { + name: "Invalid range start", + options: []string{"range=[a:1.0)"}, + expectedMin: nil, + expectedMax: nil, + expectedExclMin: false, + expectedExclMax: false, + }, + { + name: "Missing range end", + options: []string{"range=[1.0:)"}, + expectedMin: floatPtr(1.0), + expectedMax: nil, + expectedExclMin: false, + expectedExclMax: true, + }, + { + name: "Missing range start and end", + options: []string{"range=[:)"}, + expectedMin: nil, + expectedMax: nil, + expectedExclMin: false, + expectedExclMax: true, + }, + { + name: "Missing range start", + options: []string{"range=[:1.0)"}, + expectedMin: nil, + expectedMax: floatPtr(1.0), + expectedExclMin: false, + expectedExclMax: true, + }, + { + name: "Invalid range end", + options: []string{"range=[1.0:b)"}, + expectedMin: nil, + expectedMax: nil, + expectedExclMin: false, + expectedExclMax: false, + }, + { + name: "Empty options", + options: []string{}, + expectedMin: nil, + expectedMax: nil, + expectedExclMin: false, + expectedExclMax: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + min, max, exclMin, exclMax := rangeValueFromOptions(tt.options) + assert.Equal(t, tt.expectedMin, min) + assert.Equal(t, tt.expectedMax, max) + assert.Equal(t, tt.expectedExclMin, exclMin) + assert.Equal(t, tt.expectedExclMax, exclMax) + }) + } +} + +func TestEnumsValueFromOptions(t *testing.T) { + tests := []struct { + name string + options []string + expected []any + }{ + { + name: "Valid enums", + options: []string{"options=a|b|c"}, + expected: []any{"a", "b", "c"}, + }, + { + name: "Empty enums", + options: []string{"options="}, + expected: []any{}, + }, + { + name: "No enum option", + options: []string{}, + expected: []any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := enumsValueFromOptions(tt.options) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDefValueFromOptions(t *testing.T) { + tests := []struct { + name string + options []string + apiType spec.Type + expected any + }{ + { + name: "Default integer value", + options: []string{"default=42"}, + apiType: spec.PrimitiveType{RawName: "int"}, + expected: int64(42), + }, + { + name: "Default string value", + options: []string{"default=hello"}, + apiType: spec.PrimitiveType{RawName: "string"}, + expected: "hello", + }, + { + name: "No default value", + options: []string{}, + apiType: spec.PrimitiveType{RawName: "string"}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := defValueFromOptions(tt.options, tt.apiType) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExampleValueFromOptions(t *testing.T) { + tests := []struct { + name string + options []string + apiType spec.Type + expected any + }{ + { + name: "Example value present", + options: []string{"example=3.14"}, + apiType: spec.PrimitiveType{RawName: "float"}, + expected: 3.14, + }, + { + name: "Fallback to default value", + options: []string{"default=42"}, + apiType: spec.PrimitiveType{RawName: "int"}, + expected: int64(42), + }, + { + name: "Fallback to default value", + options: []string{"default="}, + apiType: spec.PrimitiveType{RawName: "int"}, + expected: int64(0), + }, + { + name: "No example or default value", + options: []string{}, + apiType: spec.PrimitiveType{RawName: "string"}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exampleValueFromOptions(tt.options, tt.apiType) + }) + } +} + +func TestValueFromOptions(t *testing.T) { + tests := []struct { + name string + options []string + key string + tp string + expected any + }{ + { + name: "Integer value", + options: []string{"default=42"}, + key: "default=", + tp: "integer", + expected: int64(42), + }, + { + name: "Boolean value", + options: []string{"default=true"}, + key: "default=", + tp: "boolean", + expected: true, + }, + { + name: "Number value", + options: []string{"default=1.1"}, + key: "default=", + tp: "number", + expected: 1.1, + }, + { + name: "No matching key", + options: []string{"example=42"}, + key: "default=", + tp: "integer", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := valueFromOptions(tt.options, tt.key, tt.tp) + assert.Equal(t, tt.expected, result) + }) + } +} + +func floatPtr(f float64) *float64 { + return &f +} diff --git a/tools/goctl/api/swagger/parameter.go b/tools/goctl/api/swagger/parameter.go new file mode 100644 index 000000000..0cf8e0a37 --- /dev/null +++ b/tools/goctl/api/swagger/parameter.go @@ -0,0 +1,178 @@ +package swagger + +import ( + "net/http" + "strings" + + "github.com/go-openapi/spec" + apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec" +) + +func parametersFromType(method string, tp apiSpec.Type) []spec.Parameter { + if tp == nil { + return []spec.Parameter{} + } + structType, ok := tp.(apiSpec.DefineStruct) + if !ok { + return []spec.Parameter{} + } + var ( + resp []spec.Parameter + properties = map[string]spec.Schema{} + requiredFields []string + ) + rangeMemberAndDo(structType, func(tag *apiSpec.Tags, required bool, member apiSpec.Member) { + headerTag, _ := tag.Get(tagHeader) + hasHeader := headerTag != nil + + pathParameterTag, _ := tag.Get(tagPath) + hasPathParameter := pathParameterTag != nil + + formTag, _ := tag.Get(tagForm) + hasForm := formTag != nil + + jsonTag, _ := tag.Get(tagJson) + hasJson := jsonTag != nil + if hasHeader { + minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(headerTag.Options) + resp = append(resp, spec.Parameter{ + CommonValidations: spec.CommonValidations{ + Maximum: maximum, + ExclusiveMaximum: exclusiveMaximum, + Minimum: minimum, + ExclusiveMinimum: exclusiveMinimum, + Enum: enumsValueFromOptions(headerTag.Options), + }, + SimpleSchema: spec.SimpleSchema{ + Type: sampleTypeFromGoType(member.Type), + Default: defValueFromOptions(headerTag.Options, member.Type), + Example: exampleValueFromOptions(headerTag.Options, member.Type), + Items: sampleItemsFromGoType(member.Type), + }, + ParamProps: spec.ParamProps{ + In: paramsInHeader, + Name: headerTag.Name, + Description: formatComment(member.Comment), + Required: required, + }, + }) + } + if hasPathParameter { + minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(pathParameterTag.Options) + resp = append(resp, spec.Parameter{ + CommonValidations: spec.CommonValidations{ + Maximum: maximum, + ExclusiveMaximum: exclusiveMaximum, + Minimum: minimum, + ExclusiveMinimum: exclusiveMinimum, + Enum: enumsValueFromOptions(pathParameterTag.Options), + }, + SimpleSchema: spec.SimpleSchema{ + Type: sampleTypeFromGoType(member.Type), + Default: defValueFromOptions(pathParameterTag.Options, member.Type), + Example: exampleValueFromOptions(pathParameterTag.Options, member.Type), + Items: sampleItemsFromGoType(member.Type), + }, + ParamProps: spec.ParamProps{ + In: paramsInPath, + Name: pathParameterTag.Name, + Description: formatComment(member.Comment), + Required: required, + }, + }) + } + if hasForm { + minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(formTag.Options) + if strings.EqualFold(method, http.MethodGet) { + resp = append(resp, spec.Parameter{ + CommonValidations: spec.CommonValidations{ + Maximum: maximum, + ExclusiveMaximum: exclusiveMaximum, + Minimum: minimum, + ExclusiveMinimum: exclusiveMinimum, + Enum: enumsValueFromOptions(formTag.Options), + }, + SimpleSchema: spec.SimpleSchema{ + Type: sampleTypeFromGoType(member.Type), + Default: defValueFromOptions(formTag.Options, member.Type), + Example: exampleValueFromOptions(formTag.Options, member.Type), + Items: sampleItemsFromGoType(member.Type), + }, + ParamProps: spec.ParamProps{ + In: paramsInQuery, + Name: formTag.Name, + Description: formatComment(member.Comment), + Required: required, + AllowEmptyValue: !required, + }, + }) + } else { + resp = append(resp, spec.Parameter{ + CommonValidations: spec.CommonValidations{ + Maximum: maximum, + ExclusiveMaximum: exclusiveMaximum, + Minimum: minimum, + ExclusiveMinimum: exclusiveMinimum, + Enum: enumsValueFromOptions(formTag.Options), + }, + SimpleSchema: spec.SimpleSchema{ + Type: sampleTypeFromGoType(member.Type), + Default: defValueFromOptions(formTag.Options, member.Type), + Example: exampleValueFromOptions(formTag.Options, member.Type), + Items: sampleItemsFromGoType(member.Type), + }, + ParamProps: spec.ParamProps{ + In: paramsInForm, + Name: formTag.Name, + Description: formatComment(member.Comment), + Required: required, + AllowEmptyValue: !required, + }, + }) + } + + } + if hasJson { + minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(jsonTag.Options) + if required { + requiredFields = append(requiredFields, jsonTag.Name) + } + p, r := propertiesFromType(member.Type) + properties[jsonTag.Name] = spec.Schema{ + SwaggerSchemaProps: spec.SwaggerSchemaProps{ + Example: exampleValueFromOptions(jsonTag.Options, member.Type), + }, + SchemaProps: spec.SchemaProps{ + Description: formatComment(member.Comment), + Type: typeFromGoType(member.Type), + Default: defValueFromOptions(jsonTag.Options, member.Type), + Maximum: maximum, + ExclusiveMaximum: exclusiveMaximum, + Minimum: minimum, + ExclusiveMinimum: exclusiveMinimum, + Enum: enumsValueFromOptions(jsonTag.Options), + Items: itemsFromGoType(member.Type), + Properties: p, + Required: r, + AdditionalProperties: mapFromGoType(member.Type), + }, + } + } + }) + if len(properties) > 0 { + resp = append(resp, spec.Parameter{ + ParamProps: spec.ParamProps{ + In: paramsInBody, + Required: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: typeFromGoType(structType), + Properties: properties, + Required: requiredFields, + }, + }, + }, + }) + } + return resp +} diff --git a/tools/goctl/api/swagger/path.go b/tools/goctl/api/swagger/path.go new file mode 100644 index 000000000..e15a9bb41 --- /dev/null +++ b/tools/goctl/api/swagger/path.go @@ -0,0 +1,105 @@ +package swagger + +import ( + "net/http" + "path" + "strings" + + "github.com/go-openapi/spec" + apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec" +) + +func spec2Paths(info apiSpec.Info, srv apiSpec.Service) *spec.Paths { + paths := &spec.Paths{ + Paths: make(map[string]spec.PathItem), + } + for _, group := range srv.Groups { + prefix := path.Clean(strings.TrimPrefix(group.GetAnnotation("prefix"), "/")) + for _, route := range group.Routes { + routPath := pathVariable2SwaggerVariable(route.Path) + if len(prefix) > 0 && prefix != "." { + routPath = "/" + path.Clean(prefix) + routPath + } + pathItem := spec2Path(info, group, route) + existPathItem, ok := paths.Paths[routPath] + if !ok { + paths.Paths[routPath] = pathItem + } else { + paths.Paths[routPath] = mergePathItem(existPathItem, pathItem) + } + } + } + return paths +} + +func mergePathItem(old, new spec.PathItem) spec.PathItem { + if new.Get != nil { + old.Get = new.Get + } + if new.Put != nil { + old.Put = new.Put + } + if new.Post != nil { + old.Post = new.Post + } + if new.Delete != nil { + old.Delete = new.Delete + } + if new.Options != nil { + old.Options = new.Options + } + if new.Head != nil { + old.Head = new.Head + } + if new.Patch != nil { + old.Patch = new.Patch + } + if new.Parameters != nil { + old.Parameters = new.Parameters + } + return old +} + +func spec2Path(info apiSpec.Info, group apiSpec.Group, route apiSpec.Route) spec.PathItem { + op := &spec.Operation{ + OperationProps: spec.OperationProps{ + Description: getStringFromKVOrDefault(route.AtDoc.Properties, "description", ""), + Consumes: consumesFromTypeOrDef(route.Method, route.RequestType), + Produces: getListFromInfoOrDefault(route.AtDoc.Properties, "produces", []string{applicationJson}), + Schemes: getListFromInfoOrDefault(route.AtDoc.Properties, "schemes", []string{schemeHttps}), + Tags: getListFromInfoOrDefault(group.Annotation.Properties, "tags", []string{""}), + Summary: getStringFromKVOrDefault(route.AtDoc.Properties, "summary", ""), + Deprecated: getBoolFromKVOrDefault(route.AtDoc.Properties, "deprecated", false), + Parameters: parametersFromType(route.Method, route.RequestType), + Responses: jsonResponseFromType(info, route.ResponseType), + }, + } + externalDocsDescription := getStringFromKVOrDefault(route.AtDoc.Properties, "externalDocsDescription", "") + externalDocsURL := getStringFromKVOrDefault(route.AtDoc.Properties, "externalDocsURL", "") + if len(externalDocsDescription) > 0 || len(externalDocsURL) > 0 { + op.ExternalDocs = &spec.ExternalDocumentation{ + Description: externalDocsDescription, + URL: externalDocsURL, + } + + } + item := spec.PathItem{} + switch strings.ToUpper(route.Method) { + case http.MethodGet: + item.Get = op + case http.MethodHead: + item.Head = op + case http.MethodPost: + item.Post = op + case http.MethodPut: + item.Put = op + case http.MethodPatch: + item.Patch = op + case http.MethodDelete: + item.Delete = op + case http.MethodOptions: + item.Options = op + default: // [http.MethodConnect,http.MethodTrace] not supported + } + return item +} diff --git a/tools/goctl/api/swagger/properties.go b/tools/goctl/api/swagger/properties.go new file mode 100644 index 000000000..5c6db4e3a --- /dev/null +++ b/tools/goctl/api/swagger/properties.go @@ -0,0 +1,63 @@ +package swagger + +import ( + "github.com/go-openapi/spec" + apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec" +) + +func propertiesFromType(tp apiSpec.Type) (spec.SchemaProperties, []string) { + var ( + properties = map[string]spec.Schema{} + requiredFields []string + ) + switch val := tp.(type) { + case apiSpec.PointerType: + return propertiesFromType(val.Type) + case apiSpec.ArrayType: + return propertiesFromType(val.Value) + case apiSpec.DefineStruct, apiSpec.NestedStruct: + rangeMemberAndDo(val, func(tag *apiSpec.Tags, required bool, member apiSpec.Member) { + var ( + jsonTagString = member.Name + minimum, maximum *float64 + exclusiveMinimum, exclusiveMaximum bool + example, defaultValue any + enum []any + ) + jsonTag, _ := tag.Get(tagJson) + if jsonTag != nil { + jsonTagString = jsonTag.Name + minimum, maximum, exclusiveMinimum, exclusiveMaximum = rangeValueFromOptions(jsonTag.Options) + example = exampleValueFromOptions(jsonTag.Options, member.Type) + defaultValue = defValueFromOptions(jsonTag.Options, member.Type) + enum = enumsValueFromOptions(jsonTag.Options) + } + + if required { + requiredFields = append(requiredFields, jsonTagString) + } + p, r := propertiesFromType(member.Type) + properties[jsonTagString] = spec.Schema{ + SwaggerSchemaProps: spec.SwaggerSchemaProps{ + Example: example, + }, + SchemaProps: spec.SchemaProps{ + Description: formatComment(member.Comment), + Type: typeFromGoType(member.Type), + Default: defaultValue, + Maximum: maximum, + ExclusiveMaximum: exclusiveMaximum, + Minimum: minimum, + ExclusiveMinimum: exclusiveMinimum, + Enum: enum, + Items: itemsFromGoType(member.Type), + Properties: p, + Required: r, + AdditionalProperties: mapFromGoType(member.Type), + }, + } + }) + } + + return properties, requiredFields +} diff --git a/tools/goctl/api/swagger/response.go b/tools/goctl/api/swagger/response.go new file mode 100644 index 000000000..affba0298 --- /dev/null +++ b/tools/goctl/api/swagger/response.go @@ -0,0 +1,28 @@ +package swagger + +import ( + "github.com/go-openapi/spec" + apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec" +) + +func jsonResponseFromType(info apiSpec.Info, tp apiSpec.Type) *spec.Responses { + p, _ := propertiesFromType(tp) + props := spec.SchemaProps{ + Type: typeFromGoType(tp), + Properties: p, + AdditionalProperties: mapFromGoType(tp), + Items: itemsFromGoType(tp), + } + + return &spec.Responses{ + ResponsesProps: spec.ResponsesProps{ + Default: &spec.Response{ + ResponseProps: spec.ResponseProps{ + Schema: &spec.Schema{ + SchemaProps: wrapCodeMsgProps(props, info), + }, + }, + }, + }, + } +} diff --git a/tools/goctl/api/swagger/swagger.go b/tools/goctl/api/swagger/swagger.go new file mode 100644 index 000000000..eb64d4a69 --- /dev/null +++ b/tools/goctl/api/swagger/swagger.go @@ -0,0 +1,326 @@ +package swagger + +import ( + "path/filepath" + "strings" + "time" + + "github.com/go-openapi/spec" + apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec" + "github.com/zeromicro/go-zero/tools/goctl/internal/version" + "github.com/zeromicro/go-zero/tools/goctl/util" +) + +func spec2Swagger(api *apiSpec.ApiSpec) (*spec.Swagger, error) { + extensions, info := specExtensions(api.Info) + swagger := &spec.Swagger{ + VendorExtensible: spec.VendorExtensible{ + Extensions: extensions, + }, + SwaggerProps: spec.SwaggerProps{ + Consumes: getListFromInfoOrDefault(api.Info.Properties, "consumes", []string{applicationJson}), + Produces: getListFromInfoOrDefault(api.Info.Properties, "produces", []string{applicationJson}), + Schemes: getListFromInfoOrDefault(api.Info.Properties, "schemes", []string{schemeHttps}), + Swagger: swaggerVersion, + Info: info, + Host: getStringFromKVOrDefault(api.Info.Properties, "host", defaultHost), + BasePath: getStringFromKVOrDefault(api.Info.Properties, "basePath", defaultBasePath), + Paths: spec2Paths(api.Info, api.Service), + }, + } + + return swagger, nil +} + +func formatComment(comment string) string { + s := strings.TrimPrefix(comment, "//") + return strings.TrimSpace(s) +} + +func sampleItemsFromGoType(tp apiSpec.Type) *spec.Items { + val, ok := tp.(apiSpec.ArrayType) + if !ok { + return nil + } + item := val.Value + switch item.(type) { + case apiSpec.PrimitiveType: + return &spec.Items{ + SimpleSchema: spec.SimpleSchema{ + Type: sampleTypeFromGoType(item), + }, + } + case apiSpec.ArrayType: + return &spec.Items{ + SimpleSchema: spec.SimpleSchema{ + Type: sampleTypeFromGoType(item), + Items: sampleItemsFromGoType(item), + }, + } + default: // unsupported type + } + return nil +} + +// itemsFromGoType returns the schema or array of the type, just for non json body parameters. +func itemsFromGoType(tp apiSpec.Type) *spec.SchemaOrArray { + array, ok := tp.(apiSpec.ArrayType) + if !ok { + return nil + } + return itemFromGoType(array) +} + +func mapFromGoType(tp apiSpec.Type) *spec.SchemaOrBool { + mapType, ok := tp.(apiSpec.MapType) + if !ok { + return nil + } + p, r := propertiesFromType(mapType.Value) + return &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: typeFromGoType(mapType.Value), + Items: itemsFromGoType(mapType.Value), + Properties: p, + Required: r, + AdditionalProperties: mapFromGoType(mapType.Value), + }, + }, + } +} + +// itemFromGoType returns the schema or array of the type, just for non json body parameters. +func itemFromGoType(tp apiSpec.Type) *spec.SchemaOrArray { + switch itemType := tp.(type) { + case apiSpec.PrimitiveType: + return &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: typeFromGoType(tp), + }, + }, + } + case apiSpec.DefineStruct: + var ( + properties = map[string]spec.Schema{} + requiredFields []string + ) + rangeMemberAndDo(itemType, func(tag *apiSpec.Tags, required bool, member apiSpec.Member) { + jsonTag, _ := tag.Get(tagJson) + if jsonTag == nil { + return + } + minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(jsonTag.Options) + if required { + requiredFields = append(requiredFields, jsonTag.Name) + } + p, r := propertiesFromType(member.Type) + properties[jsonTag.Name] = spec.Schema{ + SwaggerSchemaProps: spec.SwaggerSchemaProps{ + Example: exampleValueFromOptions(jsonTag.Options, member.Type), + }, + SchemaProps: spec.SchemaProps{ + Description: formatComment(member.Comment), + Type: typeFromGoType(member.Type), + Default: defValueFromOptions(jsonTag.Options, member.Type), + Maximum: maximum, + ExclusiveMaximum: exclusiveMaximum, + Minimum: minimum, + ExclusiveMinimum: exclusiveMinimum, + Enum: enumsValueFromOptions(jsonTag.Options), + Items: itemsFromGoType(member.Type), + Properties: p, + Required: r, + AdditionalProperties: mapFromGoType(member.Type), + }, + } + }) + return &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: typeFromGoType(itemType), + Items: itemsFromGoType(itemType), + Properties: properties, + Required: requiredFields, + AdditionalProperties: mapFromGoType(itemType), + }, + }, + } + case apiSpec.PointerType: + return itemsFromGoType(itemType.Type) + case apiSpec.ArrayType: + p, r := propertiesFromType(itemType.Value) + return &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: typeFromGoType(itemType.Value), + Items: itemsFromGoType(itemType.Value), + Properties: p, + Required: r, + }, + }, + } + } + return nil +} + +func typeFromGoType(tp apiSpec.Type) []string { + switch val := tp.(type) { + case apiSpec.PrimitiveType: + res, ok := tpMapper[val.RawName] + if ok { + return []string{res} + } + case apiSpec.ArrayType: + return []string{swaggerTypeArray} + case apiSpec.DefineStruct, apiSpec.MapType: + return []string{swaggerTypeObject} + case apiSpec.PointerType: + return typeFromGoType(val.Type) + } + return nil +} + +func sampleTypeFromGoType(tp apiSpec.Type) string { + switch val := tp.(type) { + case apiSpec.PrimitiveType: + return tpMapper[val.RawName] + case apiSpec.ArrayType: + return swaggerTypeArray + case apiSpec.DefineStruct, apiSpec.MapType, apiSpec.NestedStruct: + return swaggerTypeObject + case apiSpec.PointerType: + return sampleTypeFromGoType(val.Type) + default: + return "" + } +} + +func typeContainsTag(structType apiSpec.DefineStruct, tag string) bool { + for _, field := range structType.Members { + tags, _ := apiSpec.Parse(field.Tag) + for _, t := range tags.Tags() { + if t.Key == tag { + return true + } + } + } + return false +} + +func expandMembers(tp apiSpec.Type) []apiSpec.Member { + var members []apiSpec.Member + switch val := tp.(type) { + case apiSpec.DefineStruct: + for _, v := range val.Members { + if v.IsInline { + members = append(members, expandMembers(v.Type)...) + continue + } + members = append(members, v) + } + case apiSpec.NestedStruct: + for _, v := range val.Members { + if v.IsInline { + members = append(members, expandMembers(v.Type)...) + continue + } + members = append(members, v) + } + } + + return members +} + +func rangeMemberAndDo(structType apiSpec.Type, do func(tag *apiSpec.Tags, required bool, member apiSpec.Member)) { + var members = expandMembers(structType) + + for _, field := range members { + var required = false + for _, t := range field.Tags() { + required = len(t.Options) > 0 && t.Options[0] != "optional" + } + tags, _ := apiSpec.Parse(field.Tag) + do(tags, required, field) + + } +} + +func pathVariable2SwaggerVariable(path string) string { + pathItems := strings.FieldsFunc(path, slashRune) + var resp []string + for _, v := range pathItems { + if strings.HasPrefix(v, ":") { + resp = append(resp, "{"+v[1:]+"}") + } else { + resp = append(resp, v) + } + } + return "/" + filepath.Join(resp...) +} + +func wrapCodeMsgProps(properties spec.SchemaProps, api apiSpec.Info) spec.SchemaProps { + wrapCodeMsg := getBoolFromKVOrDefault(api.Properties, "wrapCodeMsg", false) + if !wrapCodeMsg { + return properties + } + return spec.SchemaProps{ + Type: []string{swaggerTypeObject}, + Properties: spec.SchemaProperties{ + "code": { + SwaggerSchemaProps: spec.SwaggerSchemaProps{ + Example: 0, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{swaggerTypeInteger}, + Description: getStringFromKVOrDefault(api.Properties, "bizCodeEnumDescription", "business code"), + }, + }, + "msg": { + SwaggerSchemaProps: spec.SwaggerSchemaProps{ + Example: "ok", + }, + SchemaProps: spec.SchemaProps{ + Type: []string{swaggerTypeString}, + Description: "business message", + }, + }, + "data": { + SchemaProps: properties, + }, + }, + } +} + +func specExtensions(api apiSpec.Info) (spec.Extensions, *spec.Info) { + ext := spec.Extensions{} + ext.Add("x-goctl-version", version.BuildVersion) + ext.Add("x-description", "This is a goctl generated swagger file.") + ext.Add("x-date", time.Now().Format("2006-01-02 15:04:05")) + ext.Add("x-github", "https://github.com/zeromicro/go-zero") + ext.Add("x-go-zero-doc", "https://go-zero.dev/") + + info := &spec.Info{} + info.Description = util.Unquote(api.Properties["description"]) + info.Title = util.Unquote(api.Properties["title"]) + info.TermsOfService = util.Unquote(api.Properties["termsOfService"]) + info.Version = util.Unquote(api.Properties["version"]) + + contactInfo := spec.ContactInfo{} + contactInfo.Name = util.Unquote(api.Properties["contactName"]) + contactInfo.URL = util.Unquote(api.Properties["contactURL"]) + contactInfo.Email = util.Unquote(api.Properties["contactEmail"]) + if len(contactInfo.Name) > 0 || len(contactInfo.URL) > 0 || len(contactInfo.Email) > 0 { + info.Contact = &contactInfo + } + + license := &spec.License{} + license.Name = util.Unquote(api.Properties["licenseName"]) + license.URL = util.Unquote(api.Properties["licenseURL"]) + if len(license.Name) > 0 || len(license.URL) > 0 { + info.License = license + } + return ext, info +} diff --git a/tools/goctl/api/swagger/swagger_test.go b/tools/goctl/api/swagger/swagger_test.go new file mode 100644 index 000000000..902337fbe --- /dev/null +++ b/tools/goctl/api/swagger/swagger_test.go @@ -0,0 +1,25 @@ +package swagger + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_pathVariable2SwaggerVariable(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {input: "/api/:id", expected: "/api/{id}"}, + {input: "/api/:id/details", expected: "/api/{id}/details"}, + {input: "/:version/api/:id", expected: "/{version}/api/{id}"}, + {input: "/api/v1", expected: "/api/v1"}, + {input: "/api/:id/:action", expected: "/api/{id}/{action}"}, + } + + for _, tc := range testCases { + result := pathVariable2SwaggerVariable(tc.input) + assert.Equal(t, tc.expected, result) + } +} diff --git a/tools/goctl/api/swagger/vars.go b/tools/goctl/api/swagger/vars.go new file mode 100644 index 000000000..8e7d62aa7 --- /dev/null +++ b/tools/goctl/api/swagger/vars.go @@ -0,0 +1,27 @@ +package swagger + +var ( + tpMapper = map[string]string{ + "uint8": swaggerTypeInteger, + "uint16": swaggerTypeInteger, + "uint32": swaggerTypeInteger, + "uint64": swaggerTypeInteger, + "int8": swaggerTypeInteger, + "int16": swaggerTypeInteger, + "int32": swaggerTypeInteger, + "int64": swaggerTypeInteger, + "int": swaggerTypeInteger, + "uint": swaggerTypeInteger, + "byte": swaggerTypeInteger, + "float32": swaggerTypeNumber, + "float64": swaggerTypeNumber, + "string": swaggerTypeString, + "bool": swaggerTypeBoolean, + } + commaRune = func(r rune) bool { + return r == ',' + } + slashRune = func(r rune) bool { + return r == '/' + } +) diff --git a/tools/goctl/go.mod b/tools/goctl/go.mod index 51ac7838d..85694262d 100644 --- a/tools/goctl/go.mod +++ b/tools/goctl/go.mod @@ -6,6 +6,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/emicklei/proto v1.14.0 github.com/fatih/structtag v1.2.0 + github.com/go-openapi/spec v0.21.1-0.20250328170532-a3928469592e github.com/go-sql-driver/mysql v1.9.0 github.com/gookit/color v1.5.4 github.com/iancoleman/strcase v0.3.0 @@ -38,9 +39,9 @@ require ( github.com/fatih/color v1.18.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -58,7 +59,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/tools/goctl/go.sum b/tools/goctl/go.sum index dde7332f6..a7017b16b 100644 --- a/tools/goctl/go.sum +++ b/tools/goctl/go.sum @@ -25,7 +25,6 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -45,13 +44,14 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/spec v0.21.1-0.20250328170532-a3928469592e h1:auobAirzhPsLHMso0NVMqK0QunuLDYCK83KnaVUM/RU= +github.com/go-openapi/spec v0.21.1-0.20250328170532-a3928469592e/go.mod h1:NAKTe9SplQBxIUlHlsuId1jk1I7bWTVV/2q/GtdRi6g= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= @@ -102,19 +102,16 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -151,8 +148,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -169,7 +166,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/tools/goctl/internal/flags/default_en.json b/tools/goctl/internal/flags/default_en.json index 4e3193645..ce0c784cf 100644 --- a/tools/goctl/internal/flags/default_en.json +++ b/tools/goctl/internal/flags/default_en.json @@ -71,6 +71,12 @@ "api": "{{.goctl.api.api}}", "caller": "The web api caller", "unwrap": "Unwrap the webapi caller for import" + }, + "swagger": { + "short": "Generate swagger file from api", + "dir": "{{.goctl.api.dir}}", + "api": "{{.goctl.api.api}}", + "yaml": "Generate swagger yaml file, default to json" } }, "bug": { diff --git a/tools/goctl/internal/version/version.go b/tools/goctl/internal/version/version.go index d62d398c0..09fab1581 100644 --- a/tools/goctl/internal/version/version.go +++ b/tools/goctl/internal/version/version.go @@ -6,7 +6,7 @@ import ( ) // BuildVersion is the version of goctl. -const BuildVersion = "1.8.2" +const BuildVersion = "1.8.3" var tag = map[string]int{"pre-alpha": 0, "alpha": 1, "pre-bata": 2, "beta": 3, "released": 4, "": 5} diff --git a/tools/goctl/pkg/parser/api/parser/parser.go b/tools/goctl/pkg/parser/api/parser/parser.go index c4d7a512c..6befab582 100644 --- a/tools/goctl/pkg/parser/api/parser/parser.go +++ b/tools/goctl/pkg/parser/api/parser/parser.go @@ -1342,7 +1342,7 @@ func (p *Parser) parseKVExpression() *ast.KVExpr { expr.Colon = p.curTokenNode() // token STRING - if !p.advanceIfPeekTokenIs(token.STRING) { + if !p.advanceIfPeekTokenIs(token.STRING, token.RAW_STRING) { return nil } diff --git a/tools/goctl/util/string.go b/tools/goctl/util/string.go index f55920658..7bcaf2ed0 100644 --- a/tools/goctl/util/string.go +++ b/tools/goctl/util/string.go @@ -121,3 +121,34 @@ func IsEmptyStringOrWhiteSpace(s string) bool { v := TrimWhiteSpace(s) return len(v) == 0 } + +func FieldsAndTrimSpace(s string, f func(r rune) bool) []string { + fields := strings.FieldsFunc(s, f) + var resp []string + for _, v := range fields { + val := TrimWhiteSpace(v) + if len(val) > 0 { + resp = append(resp, v) + } + } + return resp +} + +func Unquote(s string) string { + if len(s) == 0 { + return s + } + left := s[0] + + if left == '`' || left == '"' { + s = s[1:len(s)] + } + if len(s) == 0 { + return s + } + right := s[len(s)-1] + if right == '`' || right == '"' { + s = s[0 : len(s)-1] + } + return s +} diff --git a/tools/goctl/util/string_test.go b/tools/goctl/util/string_test.go index 46a0c47a7..548fee67e 100644 --- a/tools/goctl/util/string_test.go +++ b/tools/goctl/util/string_test.go @@ -3,6 +3,7 @@ package util import ( "strings" "testing" + "unicode" "github.com/stretchr/testify/assert" ) @@ -72,3 +73,67 @@ func TestEscapeGoKeyword(t *testing.T) { assert.False(t, isGolangKeyword(strings.Title(k))) } } + +func TestFieldsAndTrimSpace(t *testing.T) { + testCases := []struct { + name string + input string + delimiter func(r rune) bool + expected []string + }{ + { + name: "Comma-separated values", + input: "a, b, c", + delimiter: func(r rune) bool { return r == ',' }, + expected: []string{"a", " b", " c"}, + }, + { + name: "Space-separated values", + input: "a b c", + delimiter: unicode.IsSpace, + expected: []string{"a", "b", "c"}, + }, + { + name: "Mixed whitespace", + input: "a\tb\nc", + delimiter: unicode.IsSpace, + expected: []string{"a", "b", "c"}, + }, + { + name: "Empty input", + input: "", + delimiter: unicode.IsSpace, + expected: []string(nil), + }, + { + name: "Trailing and leading spaces", + input: " a , b , c ", + delimiter: func(r rune) bool { return r == ',' }, + expected: []string{" a ", " b ", " c "}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := FieldsAndTrimSpace(tc.input, tc.delimiter) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestUnquote(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {input: `"hello"`, expected: `hello`}, + {input: "`world`", expected: `world`}, + {input: `"foo'bar"`, expected: `foo'bar`}, + {input: "", expected: ""}, + } + + for _, tc := range testCases { + result := Unquote(tc.input) + assert.Equal(t, tc.expected, result) + } +}