From 555bb3fa6b465b9cb201d1428861e20225dc2681 Mon Sep 17 00:00:00 2001 From: UncleChair Date: Mon, 21 Oct 2024 21:16:45 +0800 Subject: [PATCH] feat(net/goai): enhance openapi doc with responses and examples (#3859) --- net/goai/goai_example.go | 50 ++++++++ net/goai/goai_path.go | 83 +++++++------- net/goai/goai_response.go | 9 ++ net/goai/goai_response_ref.go | 79 +++++++++++++ net/goai/goai_z_unit_issue_test.go | 114 +++++++++++++++++++ net/goai/testdata/Issue3747JsonFile/201.json | 12 ++ net/goai/testdata/Issue3747JsonFile/401.json | 12 ++ util/gtag/gtag.go | 75 ++++++------ 8 files changed, 354 insertions(+), 80 deletions(-) create mode 100644 net/goai/testdata/Issue3747JsonFile/201.json create mode 100644 net/goai/testdata/Issue3747JsonFile/401.json diff --git a/net/goai/goai_example.go b/net/goai/goai_example.go index 61b75d4fb6b..4f9ee6462ff 100644 --- a/net/goai/goai_example.go +++ b/net/goai/goai_example.go @@ -7,7 +7,13 @@ package goai import ( + "fmt" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/internal/empty" "github.com/gogf/gf/v2/internal/json" + "github.com/gogf/gf/v2/os/gfile" + "github.com/gogf/gf/v2/os/gres" ) // Example is specified by OpenAPI/Swagger 3.0 standard. @@ -25,6 +31,50 @@ type ExampleRef struct { Value *Example } +func (e *Examples) applyExamplesFile(path string) error { + if empty.IsNil(e) { + return nil + } + var json string + if resource := gres.Get(path); resource != nil { + json = string(resource.Content()) + } else { + absolutePath := gfile.RealPath(path) + if absolutePath != "" { + json = gfile.GetContents(absolutePath) + } + } + if json == "" { + return nil + } + var data interface{} + err := gjson.Unmarshal([]byte(json), &data) + if err != nil { + return err + } + + switch v := data.(type) { + case map[string]interface{}: + for key, value := range v { + (*e)[key] = &ExampleRef{ + Value: &Example{ + Value: value, + }, + } + } + case []interface{}: + for i, value := range v { + (*e)[fmt.Sprintf("example %d", i+1)] = &ExampleRef{ + Value: &Example{ + Value: value, + }, + } + } + default: + } + return nil +} + func (r ExampleRef) MarshalJSON() ([]byte, error) { if r.Ref != "" { return formatRefToBytes(r.Ref), nil diff --git a/net/goai/goai_path.go b/net/goai/goai_path.go index fe1013ebd1e..1727623e67f 100644 --- a/net/goai/goai_path.go +++ b/net/goai/goai_path.go @@ -85,14 +85,13 @@ func (oai *OpenApiV3) addPath(in addPathInput) error { } var ( - mime string - path = Path{XExtensions: make(XExtensions)} - inputMetaMap = gmeta.Data(inputObject.Interface()) - outputMetaMap = gmeta.Data(outputObject.Interface()) - isInputStructEmpty = oai.doesStructHasNoFields(inputObject.Interface()) - inputStructTypeName = oai.golangTypeToSchemaName(inputObject.Type()) - outputStructTypeName = oai.golangTypeToSchemaName(outputObject.Type()) - operation = Operation{ + mime string + path = Path{XExtensions: make(XExtensions)} + inputMetaMap = gmeta.Data(inputObject.Interface()) + outputMetaMap = gmeta.Data(outputObject.Interface()) + isInputStructEmpty = oai.doesStructHasNoFields(inputObject.Interface()) + inputStructTypeName = oai.golangTypeToSchemaName(inputObject.Type()) + operation = Operation{ Responses: map[string]ResponseRef{}, XExtensions: make(XExtensions), } @@ -129,7 +128,7 @@ func (oai *OpenApiV3) addPath(in addPathInput) error { ) } - if err := oai.addSchema(inputObject.Interface(), outputObject.Interface()); err != nil { + if err := oai.addSchema(inputObject.Interface()); err != nil { return err } @@ -235,48 +234,44 @@ func (oai *OpenApiV3) addPath(in addPathInput) error { } // ================================================================================================================= - // Response. + // Default Response. // ================================================================================================================= - if _, ok := operation.Responses[responseOkKey]; !ok { - var ( - response = Response{ - Content: map[string]MediaType{}, - XExtensions: make(XExtensions), - } - ) - if len(outputMetaMap) > 0 { - if err := oai.tagMapToResponse(outputMetaMap, &response); err != nil { - return err - } + status := responseOkKey + if statusValue, ok := outputMetaMap[gtag.Status]; ok { + statusCode := gconv.Int(statusValue) + if statusCode < 100 || statusCode >= 600 { + return gerror.Newf("Invalid HTTP status code: %s", statusValue) } - // Supported mime types of response. - var ( - contentTypes = oai.Config.ReadContentTypes - tagMimeValue = gmeta.Get(outputObject.Interface(), gtag.Mime).String() - refInput = getResponseSchemaRefInput{ - BusinessStructName: outputStructTypeName, - CommonResponseObject: oai.Config.CommonResponse, - CommonResponseDataField: oai.Config.CommonResponseDataField, - } - ) - if tagMimeValue != "" { - contentTypes = gstr.SplitAndTrim(tagMimeValue, ",") + status = statusValue + } + if _, ok := operation.Responses[status]; !ok { + response, err := oai.getResponseFromObject(outputObject.Interface(), true) + if err != nil { + return err } - for _, v := range contentTypes { - // If customized response mime type, it then ignores common response feature. - if tagMimeValue != "" { - refInput.CommonResponseObject = nil - refInput.CommonResponseDataField = "" + operation.Responses[status] = ResponseRef{Value: response} + } + + // ================================================================================================================= + // Other Responses. + // ================================================================================================================= + if enhancedResponse, ok := outputObject.Interface().(ResponseStatusDef); ok { + for statusCode, data := range enhancedResponse.ResponseStatusMap() { + if statusCode < 100 || statusCode >= 600 { + return gerror.Newf("Invalid HTTP status code: %d", statusCode) } - schemaRef, err := oai.getResponseSchemaRef(refInput) - if err != nil { - return err + if data == nil { + continue } - response.Content[v] = MediaType{ - Schema: schemaRef, + status := gconv.String(statusCode) + if _, ok := operation.Responses[status]; !ok { + response, err := oai.getResponseFromObject(data, false) + if err != nil { + return err + } + operation.Responses[status] = ResponseRef{Value: response} } } - operation.Responses[responseOkKey] = ResponseRef{Value: &response} } // Remove operation body duplicated properties. diff --git a/net/goai/goai_response.go b/net/goai/goai_response.go index ce702eda12b..5d68e019a13 100644 --- a/net/goai/goai_response.go +++ b/net/goai/goai_response.go @@ -12,6 +12,15 @@ import ( "github.com/gogf/gf/v2/util/gconv" ) +// StatusCode is http status for response. +type StatusCode = int + +// ResponseStatusDef is used to enhance the documentation of the response. +// Normal response structure could implement this interface to provide more information. +type ResponseStatusDef interface { + ResponseStatusMap() map[StatusCode]any +} + // Response is specified by OpenAPI/Swagger 3.0 standard. type Response struct { Description string `json:"description"` diff --git a/net/goai/goai_response_ref.go b/net/goai/goai_response_ref.go index 495aada717f..ac957ddf189 100644 --- a/net/goai/goai_response_ref.go +++ b/net/goai/goai_response_ref.go @@ -12,6 +12,8 @@ import ( "github.com/gogf/gf/v2/internal/json" "github.com/gogf/gf/v2/os/gstructs" "github.com/gogf/gf/v2/text/gstr" + "github.com/gogf/gf/v2/util/gmeta" + "github.com/gogf/gf/v2/util/gtag" ) type ResponseRef struct { @@ -22,6 +24,83 @@ type ResponseRef struct { // Responses is specified by OpenAPI/Swagger 3.0 standard. type Responses map[string]ResponseRef +// object could be someObject.Interface() +// There may be some difference between someObject.Type() and reflect.TypeOf(object). +func (oai *OpenApiV3) getResponseFromObject(object interface{}, isDefault bool) (*Response, error) { + // Add object schema to oai + if err := oai.addSchema(object); err != nil { + return nil, err + } + var ( + metaMap = gmeta.Data(object) + response = &Response{ + Content: map[string]MediaType{}, + XExtensions: make(XExtensions), + } + ) + if len(metaMap) > 0 { + if err := oai.tagMapToResponse(metaMap, response); err != nil { + return nil, err + } + } + // Supported mime types of response. + var ( + contentTypes = oai.Config.ReadContentTypes + tagMimeValue = gmeta.Get(object, gtag.Mime).String() + refInput = getResponseSchemaRefInput{ + BusinessStructName: oai.golangTypeToSchemaName(reflect.TypeOf(object)), + CommonResponseObject: oai.Config.CommonResponse, + CommonResponseDataField: oai.Config.CommonResponseDataField, + } + ) + + // If customized response mime type, it then ignores common response feature. + if tagMimeValue != "" { + contentTypes = gstr.SplitAndTrim(tagMimeValue, ",") + refInput.CommonResponseObject = nil + refInput.CommonResponseDataField = "" + } + + // If it is not default status, check if it has any fields. + // If so, it would override the common response. + if !isDefault { + fields, _ := gstructs.Fields(gstructs.FieldsInput{ + Pointer: object, + RecursiveOption: gstructs.RecursiveOptionEmbeddedNoTag, + }) + if len(fields) > 0 { + refInput.CommonResponseObject = nil + refInput.CommonResponseDataField = "" + } + } + + // Generate response example from meta data. + responseExamplePath := metaMap[gtag.ResponseExampleShort] + if responseExamplePath == "" { + responseExamplePath = metaMap[gtag.ResponseExample] + } + examples := make(Examples) + if responseExamplePath != "" { + if err := examples.applyExamplesFile(responseExamplePath); err != nil { + return nil, err + } + } + + // Generate response schema from input. + schemaRef, err := oai.getResponseSchemaRef(refInput) + if err != nil { + return nil, err + } + + for _, contentType := range contentTypes { + response.Content[contentType] = MediaType{ + Schema: schemaRef, + Examples: examples, + } + } + return response, nil +} + func (r ResponseRef) MarshalJSON() ([]byte, error) { if r.Ref != "" { return formatRefToBytes(r.Ref), nil diff --git a/net/goai/goai_z_unit_issue_test.go b/net/goai/goai_z_unit_issue_test.go index b9881dbd8d1..c3d28ee2c87 100644 --- a/net/goai/goai_z_unit_issue_test.go +++ b/net/goai/goai_z_unit_issue_test.go @@ -15,6 +15,7 @@ import ( "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/net/goai" "github.com/gogf/gf/v2/test/gtest" "github.com/gogf/gf/v2/util/guid" ) @@ -117,3 +118,116 @@ func Test_Issue3135(t *testing.T) { t.AssertIN("rgba", requiredArray) }) } + +type Issue3747CommonRes struct { + g.Meta `mime:"application/json"` + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` +} + +type Issue3747Req struct { + g.Meta `path:"/default" method:"post"` + Name string +} +type Issue3747Res struct { + g.Meta `status:"201" resEg:"testdata/Issue3747JsonFile/201.json"` + Info string `json:"info" eg:"Created!"` +} + +// Example case +type Issue3747Res401 struct { + g.Meta `resEg:"testdata/Issue3747JsonFile/401.json"` +} + +// Override case 1 +type Issue3747Res402 struct { + g.Meta `mime:"application/json"` +} + +// Override case 2 +type Issue3747Res403 struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Common response case +type Issue3747Res404 struct{} + +func (r Issue3747Res) ResponseStatusMap() map[goai.StatusCode]any { + return map[goai.StatusCode]any{ + 401: Issue3747Res401{}, + 402: Issue3747Res402{}, + 403: Issue3747Res403{}, + 404: Issue3747Res404{}, + 405: struct{}{}, + 407: interface{}(nil), + 406: nil, + } +} + +type Issue3747 struct{} + +func (Issue3747) Default(ctx context.Context, req *Issue3747Req) (res *Issue3747Res, err error) { + res = &Issue3747Res{} + return +} + +// https://github.com/gogf/gf/issues/3747 +func Test_Issue3747(t *testing.T) { + gtest.C(t, func(t *gtest.T) { + s := g.Server(guid.S()) + openapi := s.GetOpenApi() + openapi.Config.CommonResponse = Issue3747CommonRes{} + openapi.Config.CommonResponseDataField = `Data` + s.Use(ghttp.MiddlewareHandlerResponse) + s.Group("/", func(group *ghttp.RouterGroup) { + group.Bind( + new(Issue3747), + ) + }) + s.SetLogger(nil) + s.SetOpenApiPath("/api.json") + s.SetDumpRouterMap(false) + s.Start() + defer s.Shutdown() + time.Sleep(100 * time.Millisecond) + + c := g.Client() + c.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort())) + apiContent := c.GetBytes(ctx, "/api.json") + j, err := gjson.LoadJson(apiContent) + + t.AssertNil(err) + t.Assert(j.Get(`paths./default.post.responses.200`).String(), "") + t.AssertNE(j.Get(`paths./default.post.responses.201`).String(), "") + t.AssertNE(j.Get(`paths./default.post.responses.401`).String(), "") + t.AssertNE(j.Get(`paths./default.post.responses.402`).String(), "") + t.AssertNE(j.Get(`paths./default.post.responses.403`).String(), "") + t.AssertNE(j.Get(`paths./default.post.responses.404`).String(), "") + t.AssertNE(j.Get(`paths./default.post.responses.405`).String(), "") + t.Assert(j.Get(`paths./default.post.responses.406`).String(), "") + t.Assert(j.Get(`paths./default.post.responses.407`).String(), "") + + // Check content + commonResponseSchema := `{"properties":{"code":{"format":"int","type":"integer"},"data":{"properties":{},"type":"object"},"message":{"format":"string","type":"string"}},"type":"object"}` + Status201ExamplesContent := `{"code 1":{"value":{"code":1,"data":"Good","message":"Aha, 201 - 1"}},"code 2":{"value":{"code":2,"data":"Not Bad","message":"Aha, 201 - 2"}}}` + Status401ExamplesContent := `{"example 1":{"value":{"code":1,"data":null,"message":"Aha, 401 - 1"}},"example 2":{"value":{"code":2,"data":null,"message":"Aha, 401 - 2"}}}` + Status402SchemaContent := `{"$ref":"#/components/schemas/github.com.gogf.gf.v2.net.goai_test.Issue3747Res402"}` + Issue3747Res403Ref := `{"$ref":"#/components/schemas/github.com.gogf.gf.v2.net.goai_test.Issue3747Res403"}` + + t.Assert(j.Get(`paths./default.post.responses.201.content.application/json.examples`).String(), Status201ExamplesContent) + t.Assert(j.Get(`paths./default.post.responses.401.content.application/json.examples`).String(), Status401ExamplesContent) + t.Assert(j.Get(`paths./default.post.responses.402.content.application/json.schema`).String(), Status402SchemaContent) + t.Assert(j.Get(`paths./default.post.responses.403.content.application/json.schema`).String(), Issue3747Res403Ref) + t.Assert(j.Get(`paths./default.post.responses.404.content.application/json.schema`).String(), commonResponseSchema) + t.Assert(j.Get(`paths./default.post.responses.405.content.application/json.schema`).String(), commonResponseSchema) + + api := s.GetOpenApi() + reqPath := "github.com.gogf.gf.v2.net.goai_test.Issue3747Res403" + schema := api.Components.Schemas.Get(reqPath).Value + + Issue3747Res403Schema := `{"properties":{"code":{"format":"int","type":"integer"},"message":{"format":"string","type":"string"}},"type":"object"}` + t.Assert(schema, Issue3747Res403Schema) + }) +} diff --git a/net/goai/testdata/Issue3747JsonFile/201.json b/net/goai/testdata/Issue3747JsonFile/201.json new file mode 100644 index 00000000000..04938cc601e --- /dev/null +++ b/net/goai/testdata/Issue3747JsonFile/201.json @@ -0,0 +1,12 @@ +{ + "code 1": { + "code": 1, + "message": "Aha, 201 - 1", + "data": "Good" + }, + "code 2": { + "code": 2, + "message": "Aha, 201 - 2", + "data": "Not Bad" + } +} \ No newline at end of file diff --git a/net/goai/testdata/Issue3747JsonFile/401.json b/net/goai/testdata/Issue3747JsonFile/401.json new file mode 100644 index 00000000000..334599ef8de --- /dev/null +++ b/net/goai/testdata/Issue3747JsonFile/401.json @@ -0,0 +1,12 @@ +[ + { + "code": 1, + "message": "Aha, 401 - 1", + "data": null + }, + { + "code": 2, + "message": "Aha, 401 - 2", + "data": null + } +] \ No newline at end of file diff --git a/util/gtag/gtag.go b/util/gtag/gtag.go index 2dc46455277..d4d675523d2 100644 --- a/util/gtag/gtag.go +++ b/util/gtag/gtag.go @@ -11,42 +11,45 @@ package gtag const ( - Default = "default" // Default value tag of struct field for receiving parameters from HTTP request. - DefaultShort = "d" // Short name of Default. - Param = "param" // Parameter name for converting certain parameter to specified struct field. - ParamShort = "p" // Short name of Param. - Valid = "valid" // Validation rule tag for struct of field. - ValidShort = "v" // Short name of Valid. - NoValidation = "nv" // No validation for specified struct/field. - ORM = "orm" // ORM tag for ORM feature, which performs different features according scenarios. - Arg = "arg" // Arg tag for struct, usually for command argument option. - Brief = "brief" // Brief tag for struct, usually be considered as summary. - Root = "root" // Root tag for struct, usually for nested commands management. - Additional = "additional" // Additional tag for struct, usually for additional description of command. - AdditionalShort = "ad" // Short name of Additional. - Path = `path` // Route path for HTTP request. - Method = `method` // Route method for HTTP request. - Domain = `domain` // Route domain for HTTP request. - Mime = `mime` // MIME type for HTTP request/response. - Consumes = `consumes` // MIME type for HTTP request. - Summary = `summary` // Summary for struct, usually for OpenAPI in request struct. - SummaryShort = `sm` // Short name of Summary. - SummaryShort2 = `sum` // Short name of Summary. - Description = `description` // Description for struct, usually for OpenAPI in request struct. - DescriptionShort = `dc` // Short name of Description. - DescriptionShort2 = `des` // Short name of Description. - Example = `example` // Example for struct, usually for OpenAPI in request struct. - ExampleShort = `eg` // Short name of Example. - Examples = `examples` // Examples for struct, usually for OpenAPI in request struct. - ExamplesShort = `egs` // Short name of Examples. - ExternalDocs = `externalDocs` // External docs for struct, always for OpenAPI in request struct. - ExternalDocsShort = `ed` // Short name of ExternalDocs. - GConv = "gconv" // GConv defines the converting target name for specified struct field. - GConvShort = "c" // GConv defines the converting target name for specified struct field. - Json = "json" // Json tag is supported by stdlib. - Security = "security" // Security defines scheme for authentication. Detail to see https://swagger.io/docs/specification/authentication/ - In = "in" // Swagger distinguishes between the following parameter types based on the parameter location. Detail to see https://swagger.io/docs/specification/describing-parameters/ - Required = "required" // OpenAPIv3 required attribute name for request body. + Default = "default" // Default value tag of struct field for receiving parameters from HTTP request. + DefaultShort = "d" // Short name of Default. + Param = "param" // Parameter name for converting certain parameter to specified struct field. + ParamShort = "p" // Short name of Param. + Valid = "valid" // Validation rule tag for struct of field. + ValidShort = "v" // Short name of Valid. + NoValidation = "nv" // No validation for specified struct/field. + ORM = "orm" // ORM tag for ORM feature, which performs different features according scenarios. + Arg = "arg" // Arg tag for struct, usually for command argument option. + Brief = "brief" // Brief tag for struct, usually be considered as summary. + Root = "root" // Root tag for struct, usually for nested commands management. + Additional = "additional" // Additional tag for struct, usually for additional description of command. + AdditionalShort = "ad" // Short name of Additional. + Path = `path` // Route path for HTTP request. + Method = `method` // Route method for HTTP request. + Domain = `domain` // Route domain for HTTP request. + Mime = `mime` // MIME type for HTTP request/response. + Consumes = `consumes` // MIME type for HTTP request. + Summary = `summary` // Summary for struct, usually for OpenAPI in request struct. + SummaryShort = `sm` // Short name of Summary. + SummaryShort2 = `sum` // Short name of Summary. + Description = `description` // Description for struct, usually for OpenAPI in request struct. + DescriptionShort = `dc` // Short name of Description. + DescriptionShort2 = `des` // Short name of Description. + Example = `example` // Example for struct, usually for OpenAPI in request struct. + ExampleShort = `eg` // Short name of Example. + Examples = `examples` // Examples for struct, usually for OpenAPI in request struct. + ExamplesShort = `egs` // Short name of Examples. + ExternalDocs = `externalDocs` // External docs for struct, always for OpenAPI in request struct. + ExternalDocsShort = `ed` // Short name of ExternalDocs. + GConv = "gconv" // GConv defines the converting target name for specified struct field. + GConvShort = "c" // GConv defines the converting target name for specified struct field. + Json = "json" // Json tag is supported by stdlib. + Security = "security" // Security defines scheme for authentication. Detail to see https://swagger.io/docs/specification/authentication/ + In = "in" // Swagger distinguishes between the following parameter types based on the parameter location. Detail to see https://swagger.io/docs/specification/describing-parameters/ + Required = "required" // OpenAPIv3 required attribute name for request body. + Status = "status" // Response status code, usually for OpenAPI in response struct. + ResponseExample = "responseExample" // Response example resource path, usually for OpenAPI in response struct. + ResponseExampleShort = "resEg" // Short name of ResponseExample. ) // StructTagPriority defines the default priority tags for Map*/Struct* functions.