diff --git a/components/definition/definition.go b/components/definition/definition.go index 443737d..21a177b 100644 --- a/components/definition/definition.go +++ b/components/definition/definition.go @@ -14,6 +14,7 @@ import ( type Definition struct { Type string `json:"type"` Properties map[string]DefinitionProperties `json:"properties"` + Required []string `json:"required"` } // DefinitionProperties defines the details of a property within a Definition, @@ -25,6 +26,9 @@ type DefinitionProperties struct { Ref string `json:"$ref,omitempty"` Items *DefinitionPropertiesItems `json:"items,omitempty"` Example interface{} `json:"example,omitempty"` + + // keep this info to fill Required fields later + IsRequired bool `json:"-"` } // DefinitionPropertiesItems specifies the type or reference of array items when @@ -70,10 +74,38 @@ func (g DefinitionGenerator) CreateDefinition(t interface{}) { properties = g.createStructDefinitions(reflectReturn) } + // merge embedded struct fields with other fields + g.mergeEmbeddedStructFields(properties) + g.Definitions[definitionName] = Definition{ Type: "object", Properties: properties, + Required: g.findRequiredFields(properties), + } +} + +func (g DefinitionGenerator) mergeEmbeddedStructFields(properties map[string]DefinitionProperties) { + for k, v := range properties { + if k == "" && v.Ref != "" { // identify embedded structs + embeddedModelName, _ := strings.CutPrefix(v.Ref, "#/definitions/") + if def, ok := g.Definitions[embeddedModelName]; ok { + for propName, propValue := range def.Properties { + properties[propName] = propValue + } + delete(properties, "") + } + } + } +} + +func (g DefinitionGenerator) findRequiredFields(properties map[string]DefinitionProperties) []string { + requiredFields := []string{} + for k, v := range properties { + if v.IsRequired { + requiredFields = append(requiredFields, k) + } } + return requiredFields } func (g DefinitionGenerator) createStructDefinitions(structType reflect.Type) map[string]DefinitionProperties { @@ -96,37 +128,66 @@ func (g DefinitionGenerator) createStructDefinitions(structType reflect.Type) ma // if item type is array, create Definition for array element type switch fieldType { case "array": - if field.Type.Elem().Kind() == reflect.Struct { + if field.Type.Elem().Kind() == reflect.Pointer { // []*type + if field.Type.Elem().Elem().Kind() == reflect.Struct { // []*struct + properties[fieldJsonTag] = DefinitionProperties{ + Example: fields.ExampleTag(field), + Type: fieldType, + Items: &DefinitionPropertiesItems{ + Ref: fmt.Sprintf("#/definitions/%s", field.Type.Elem().Elem().String()), + }, + IsRequired: g.isRequired(field), + } + if structType == field.Type.Elem() { + continue // prevent recursion + } + g.CreateDefinition(reflect.New(field.Type.Elem().Elem()).Elem().Interface()) + } else { // []*other + itemType := fields.Type(field.Type.Elem().Elem().Kind().String()) + properties[fieldJsonTag] = DefinitionProperties{ + Example: fields.ExampleTag(field), + Type: fieldType, + Items: &DefinitionPropertiesItems{ + Type: itemType, + }, + IsRequired: g.isRequired(field), + } + } + } else if field.Type.Elem().Kind() == reflect.Struct { // []struct properties[fieldJsonTag] = DefinitionProperties{ Example: fields.ExampleTag(field), Type: fieldType, Items: &DefinitionPropertiesItems{ Ref: fmt.Sprintf("#/definitions/%s", field.Type.Elem().String()), }, + IsRequired: g.isRequired(field), } if structType == field.Type.Elem() { continue // prevent recursion } g.CreateDefinition(reflect.New(field.Type.Elem()).Elem().Interface()) - } else { + } else { // []other properties[fieldJsonTag] = DefinitionProperties{ Example: fields.ExampleTag(field), Type: fieldType, Items: &DefinitionPropertiesItems{ Type: fields.Type(field.Type.Elem().Kind().String()), }, + IsRequired: g.isRequired(field), } } case "struct": + isRequiredField := g.isRequired(field) if field.Type.String() == "time.Time" { - properties[fieldJsonTag] = g.timeProperty(field) + properties[fieldJsonTag] = g.timeProperty(field, isRequiredField) } else if field.Type.String() == "time.Duration" { - properties[fieldJsonTag] = g.durationProperty(field) + properties[fieldJsonTag] = g.durationProperty(field, isRequiredField) } else { properties[fieldJsonTag] = DefinitionProperties{ - Example: fields.ExampleTag(field), - Ref: fmt.Sprintf("#/definitions/%s", field.Type.String()), + Example: fields.ExampleTag(field), + Ref: fmt.Sprintf("#/definitions/%s", field.Type.String()), + IsRequired: isRequiredField, } g.CreateDefinition(reflect.New(field.Type).Elem().Interface()) } @@ -140,11 +201,11 @@ func (g DefinitionGenerator) createStructDefinitions(structType reflect.Type) ma } if field.Type.Elem().Kind() == reflect.Struct { if field.Type.Elem().String() == "time.Time" { - properties[fieldJsonTag] = g.timeProperty(field) + properties[fieldJsonTag] = g.timeProperty(field, false) } else if field.Type.String() == "time.Duration" { - properties[fieldJsonTag] = g.durationProperty(field) + properties[fieldJsonTag] = g.durationProperty(field, false) } else { - properties[fieldJsonTag] = g.refProperty(field) + properties[fieldJsonTag] = g.refProperty(field, false) g.CreateDefinition(reflect.New(field.Type.Elem()).Elem().Interface()) } } else if field.Type.Elem().Kind() == reflect.Array || field.Type.Elem().Kind() == reflect.Slice { @@ -210,43 +271,55 @@ func (g DefinitionGenerator) createStructDefinitions(structType reflect.Type) ma case "interface": // TODO: Find a way to get real model of interface{} properties[fieldJsonTag] = DefinitionProperties{ - Example: fields.ExampleTag(field), - Type: "Ambiguous Type: interface{}", + Example: fields.ExampleTag(field), + Type: "Ambiguous Type: interface{}", + IsRequired: g.isRequired(field), } - default: + default: properties[fieldJsonTag] = g.defaultProperty(field) + } } return properties } -func (g DefinitionGenerator) timeProperty(field reflect.StructField) DefinitionProperties { +func (g DefinitionGenerator) timeProperty(field reflect.StructField, required bool) DefinitionProperties { return DefinitionProperties{ - Example: fields.ExampleTag(field), - Type: "string", - Format: "date-time", + Example: fields.ExampleTag(field), + Type: "string", + Format: "date-time", + IsRequired: required, } } -func (g DefinitionGenerator) durationProperty(field reflect.StructField) DefinitionProperties { +func (g DefinitionGenerator) durationProperty(field reflect.StructField, required bool) DefinitionProperties { return DefinitionProperties{ - Example: fields.ExampleTag(field), - Type: "integer", + Example: fields.ExampleTag(field), + Type: "integer", + IsRequired: required, } } -func (g DefinitionGenerator) refProperty(field reflect.StructField) DefinitionProperties { +func (g DefinitionGenerator) refProperty(field reflect.StructField, required bool) DefinitionProperties { return DefinitionProperties{ - Example: fields.ExampleTag(field), - Ref: fmt.Sprintf("#/definitions/%s", field.Type.Elem().String()), + Example: fields.ExampleTag(field), + Ref: fmt.Sprintf("#/definitions/%s", field.Type.Elem().String()), + IsRequired: required, } } func (g DefinitionGenerator) defaultProperty(field reflect.StructField) DefinitionProperties { return DefinitionProperties{ - Example: fields.ExampleTag(field), - Type: fields.Type(field.Type.Kind().String()), + Example: fields.ExampleTag(field), + Type: fields.Type(field.Type.Kind().String()), + IsRequired: g.isRequired(field), } } + +func (g DefinitionGenerator) isRequired(field reflect.StructField) bool { + hasRequiredTag := fields.IsRequired(field) + hasOmitemptyTag := fields.IsOmitempty(field) + return hasRequiredTag || !hasOmitemptyTag +} diff --git a/components/fields/parsing.go b/components/fields/parsing.go index 29a4bc8..9f74177 100644 --- a/components/fields/parsing.go +++ b/components/fields/parsing.go @@ -32,6 +32,23 @@ func JsonTag(field reflect.StructField) string { return jsonTag } +// IsOmitempty extracts the 'json' struct tag's value of a struct field and returns if it has omitempty. +func IsOmitempty(field reflect.StructField) bool { + jsonTag := field.Tag.Get("json") + for _, part := range strings.Split(jsonTag, ",") { + if strings.TrimSpace(part) == "omitempty" { + return true + } + } + return false +} + +// IsRequired extracts the 'required' struct tag's value of a struct field and returns true if required is true. +func IsRequired(field reflect.StructField) bool { + tagValue := field.Tag.Get("required") + return tagValue == "true" +} + // Type maps a string to its corresponding Swagger type according to the // Swagger Specification version 2 data types (https://swagger.io/specification/v2/#data-types). func Type(t string) string { diff --git a/components/tag/tag.go b/components/tag/tag.go index f9fee4b..711eac3 100644 --- a/components/tag/tag.go +++ b/components/tag/tag.go @@ -2,14 +2,37 @@ package tag // https://swagger.io/specification/v2/#tagObject type Tag struct { + Name string `json:"name"` + Description string `json:"description"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` +} + +type ExternalDocs struct { Name string `json:"name"` Description string `json:"description"` } +type TagOpts func(*Tag) + +func WithExternalDocs(name string, description string) TagOpts { + return func(t *Tag) { + t.ExternalDocs = &ExternalDocs{ + Name: name, + Description: description, + } + } +} + // New returns a new Tag. -func New(name string, description string) Tag { - return Tag{ +func New(name string, description string, opts ...TagOpts) Tag { + t := Tag{ Name: name, Description: description, } + + for _, opt := range opts { + opt(&t) + } + + return t } diff --git a/example/fiber/server.go b/example/fiber/server.go index bc30fd6..8521ad3 100644 --- a/example/fiber/server.go +++ b/example/fiber/server.go @@ -28,6 +28,16 @@ func main() { endpoint.WithConsume([]mime.MIME{mime.JSON}), endpoint.WithSummary("this is a test summary"), ), + endpoint.New( + endpoint.POST, + "/product", + endpoint.WithTags("product"), + endpoint.WithBody(models.ProductPost{}), + endpoint.WithSuccessfulReturns([]response.Response{response.New(models.Product{}, "200", "OK")}), + endpoint.WithDescription(desc), + endpoint.WithProduce([]mime.MIME{mime.JSON, mime.XML}), + endpoint.WithConsume([]mime.MIME{mime.JSON}), + ), endpoint.New( endpoint.GET, "/product/{id}", diff --git a/example/models/products.go b/example/models/products.go index c5247a5..d4752fb 100644 --- a/example/models/products.go +++ b/example/models/products.go @@ -3,15 +3,27 @@ package models import "time" type Product struct { - Id uint64 `json:"id"` - Name string `json:"name"` - MerchantId uint64 `json:"merchant_id"` - CategoryId *uint64 `json:"category_id,omitempty"` - Tags []uint64 `json:"tags"` - Images []string `json:"image_ids"` - Sizes []Sizes `json:"sizes"` - SaleDate time.Time `json:"sale_date"` - EndDate *time.Time `json:"end_date"` + Id uint64 `json:"id"` + Name string `json:"name"` + MerchantId uint64 `json:"merchant_id"` + CategoryId *uint64 `json:"category_id,omitempty"` + Tags []uint64 `json:"tags"` + Images []*string `json:"image_ids"` + ImagesPtr *[]string `json:"image_ids_ptr"` + Sizes []Sizes `json:"sizes"` + SizePtrs []*Sizes `json:"size_ptrs"` + SaleDate time.Time `json:"sale_date"` + EndDate *time.Time `json:"end_date"` + Complex ComplexSuccessfulResponse `json:"complex"` + Interface interface{} `json:"interface"` + OmitEmpty string `json:"omitemptytest,omitempty"` + RequiredField interface{} `json:"required_field,omitempty" required:"true"` + EmbeddedStruct EmbeddedStruct `json:"embedded_struct"` +} + +type EmbeddedStruct struct { + Sizes + OtherField int `json:"other_field"` } type Sizes struct { diff --git a/generate_test.go b/generate_test.go index 288de62..91a7dd0 100644 --- a/generate_test.go +++ b/generate_test.go @@ -115,7 +115,7 @@ func TestSwaggerGeneration(t *testing.T) { got.AddEndpoints(tc.endpoints) got.generateSwaggerJson() - if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(Swagger{}, "endpoints"), cmpopts.IgnoreFields(definition.DefinitionProperties{}, "Example")); diff != "" { + if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(Swagger{}, "endpoints"), cmpopts.IgnoreFields(definition.DefinitionProperties{}, "Example", "IsRequired")); diff != "" { t.Errorf("JsonSwagger() mismatch (-expected +got):\n%s", diff) } }) diff --git a/swagger.go b/swagger.go index b9500dc..c5a89a4 100644 --- a/swagger.go +++ b/swagger.go @@ -29,6 +29,7 @@ type Swagger struct { // https://swagger.io/specification/v2/#info-object type Info struct { Title string `json:"title"` + Description string `json:"description"` Version string `json:"version"` TermsOfService string `json:"termsOfService,omitempty"` Contact *Contact `json:"contact,omitempty"` @@ -84,12 +85,14 @@ type License struct { // Config struct represents the configuration for Swagger documentation. type Config struct { - Title string // title of the Swagger documentation - Version string // version of the Swagger documentation - Host string // host URL for the API - Path string // path to the Swagger JSON file - License *License // license information for the Swagger documentation - Contact *Contact // contact information for the Swagger documentation + Title string // title of the Swagger documentation + Version string // version of the Swagger documentation + Description string // description of the Swagger documentation + Host string // host URL for the API + Path string // path to the Swagger JSON file + License *License // license information for the Swagger documentation + Contact *Contact // contact information for the Swagger documentation + TermsOfService string // term of service information for the Swagger documentation } // buildSwagger creates a new swagger instance with the given title, version, and optional arguments. @@ -110,10 +113,12 @@ func buildSwagger(c Config) (swagger *Swagger) { swagger = &Swagger{ Swagger: "2.0", Info: Info{ - Title: c.Title, - Version: c.Version, - License: c.License, - Contact: c.Contact, + Title: c.Title, + Description: c.Description, + Version: c.Version, + License: c.License, + Contact: c.Contact, + TermsOfService: c.TermsOfService, }, Paths: make(map[string]map[string]endpoint.JsonEndPoint), BasePath: c.Path, diff --git a/testdata/expected_output/bft.json b/testdata/expected_output/bft.json index 1c5141b..90e0470 100644 --- a/testdata/expected_output/bft.json +++ b/testdata/expected_output/bft.json @@ -8,16 +8,9 @@ "/product": { "get": { "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed id malesuada lorem, et fermentum sapien. Vivamus non pharetra risus, in efficitur leo. Suspendisse sed metus sit amet mi laoreet imperdiet. Donec aliquam eros eu blandit feugiat. Quisque scelerisque justo ac vehicula bibendum. Fusce suscipit arcu nisl, eu maximus odio consequat quis. Curabitur fermentum eleifend tellus, lobortis hendrerit velit varius vitae.", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json", - "application/xml" - ], - "tags": [ - "product" - ], + "consumes": ["application/json"], + "produces": ["application/json", "application/xml"], + "tags": ["product"], "summary": "this is a test summary", "operationId": "get-/product", "parameters": [], @@ -35,16 +28,9 @@ }, "post": { "description": "", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json", - "application/xml" - ], - "tags": [ - "product" - ], + "consumes": ["application/json"], + "produces": ["application/json", "application/xml"], + "tags": ["product"], "summary": "", "operationId": "post-/product", "parameters": [ @@ -78,16 +64,9 @@ "/product/{id}": { "get": { "description": "", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json", - "application/xml" - ], - "tags": [ - "product" - ], + "consumes": ["application/json"], + "produces": ["application/json", "application/xml"], + "tags": ["product"], "summary": "", "operationId": "get-/product/{id}", "parameters": [ @@ -118,16 +97,9 @@ "/product/{id}/detail": { "get": { "description": "", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json", - "application/xml" - ], - "tags": [ - "product" - ], + "consumes": ["application/json"], + "produces": ["application/json", "application/xml"], + "tags": ["product"], "summary": "", "operationId": "get-/product/{id}/detail", "parameters": [ @@ -161,10 +133,12 @@ "definitions": { "models.EmptySuccessfulResponse": { "type": "object", - "properties": {} + "properties": {}, + "required": [] }, "models.ProductPost": { "type": "object", + "required": ["name", "merchant_id"], "properties": { "category_id": { "type": "integer", @@ -182,6 +156,7 @@ }, "models.SuccessfulResponse": { "type": "object", + "required": ["ID"], "properties": { "ID": { "type": "string", @@ -191,6 +166,7 @@ }, "models.UnsuccessfulResponse": { "type": "object", + "required": ["error_msg1"], "properties": { "error_msg1": { "type": "string", @@ -199,8 +175,5 @@ } } }, - "schemes": [ - "http", - "https" - ] -} \ No newline at end of file + "schemes": ["http", "https"] +} diff --git a/testdata/expected_output/dnmt.json b/testdata/expected_output/dnmt.json index bdc317a..5896939 100644 --- a/testdata/expected_output/dnmt.json +++ b/testdata/expected_output/dnmt.json @@ -52,6 +52,7 @@ "definitions": { "models.ComplexSuccessfulResponse": { "type": "object", + "required": [], "properties": { "deeply": { "$ref": "#/definitions/models.Deeply" @@ -60,6 +61,7 @@ }, "models.Deeply": { "type": "object", + "required": ["nested"], "properties": { "nested": { "$ref": "#/definitions/models.Nested" @@ -68,6 +70,7 @@ }, "models.Nested": { "type": "object", + "required": [], "properties": { "objects": { "type": "array", @@ -85,6 +88,7 @@ }, "models.Object": { "type": "object", + "required": ["name"], "properties": { "name": { "type": "string",