From b8071b6326a0d5300808e367e615224ab4b466fc Mon Sep 17 00:00:00 2001 From: kaassaly Date: Thu, 20 Mar 2025 13:44:56 +0000 Subject: [PATCH] feat(openapi/generator): add the x-omitempty extension to true on all fields that contains an omitempty in json tag --- openapi/generator.go | 25 +++++- openapi/generator_test.go | 8 +- openapi/spec.go | 93 +++++++++++++++++++++- openapi/validation_test.go | 4 +- testdata/schemas/X.json | 88 +++++++++++++-------- testdata/schemas/Y.json | 30 ++++--- testdata/schemas/path-item.json | 58 +++++++------- testdata/schemas/validation.json | 42 ++++++---- testdata/spec.json | 132 +++++++++++++------------------ testdata/spec.yaml | 44 +++++------ 10 files changed, 327 insertions(+), 197 deletions(-) diff --git a/openapi/generator.go b/openapi/generator.go index 99f6833..b456e8e 100644 --- a/openapi/generator.go +++ b/openapi/generator.go @@ -751,6 +751,9 @@ func (g *Generator) newSchemaFromStructField(sf reflect.StructField, required bo if sor == nil { return nil } + + sor = g.addExtensions(sor, sf) + // Get the underlying schema, it may be a reference // to a component, and update its fields using the // informations in the struct field tags. @@ -834,6 +837,26 @@ func (g *Generator) newSchemaFromStructField(sf reflect.StructField, required bo return sor } +func (g *Generator) addExtensions(sor *SchemaOrRef, sf reflect.StructField) *SchemaOrRef { + // Check if the json field has the omitempty tag. + jsonTag := sf.Tag.Get("json") + hasOmitEmpty := strings.Contains(jsonTag, "omitempty") + + if sor.Schema != nil { + if sor.Schema.Extensions == nil { + sor.Schema.Extensions = make(map[string]interface{}) + } + sor.Schema.Extensions["x-omitempty"] = hasOmitEmpty + } else if sor.Reference != nil { + if sor.Reference.Extensions == nil { + sor.Reference.Extensions = make(map[string]interface{}) + } + sor.Reference.Extensions["x-omitempty"] = hasOmitEmpty + } + + return sor +} + func (g *Generator) enumFromStructField(sf reflect.StructField, fname string, parent reflect.Type) []interface{} { var enum []interface{} @@ -1255,7 +1278,7 @@ func fieldNameFromTag(sf reflect.StructField, tagName string) string { return name } -/// parseExampleValue is used to transform the string representation of the example value to the correct type. +// / parseExampleValue is used to transform the string representation of the example value to the correct type. func parseExampleValue(t reflect.Type, value string) (interface{}, error) { // If the type implements Exampler use the ParseExample method to create the example i, ok := reflect.New(t).Interface().(Exampler) diff --git a/openapi/generator_test.go b/openapi/generator_test.go index e63ac74..b02d3cf 100644 --- a/openapi/generator_test.go +++ b/openapi/generator_test.go @@ -3,8 +3,8 @@ package openapi import ( "encoding/json" "fmt" - "io/ioutil" "math" + "os" "reflect" "strconv" "testing" @@ -213,7 +213,7 @@ func TestSchemaFromComplex(t *testing.T) { t.Error(err) } // see testdata/X.json. - expected, err := ioutil.ReadFile("../testdata/schemas/X.json") + expected, err := os.ReadFile("../testdata/schemas/X.json") if err != nil { t.Error(err) } @@ -234,7 +234,7 @@ func TestSchemaFromComplex(t *testing.T) { t.Error(err) } // see testdata/Y.json. - expected, err = ioutil.ReadFile("../testdata/schemas/Y.json") + expected, err = os.ReadFile("../testdata/schemas/Y.json") if err != nil { t.Error(err) } @@ -550,7 +550,7 @@ func TestAddOperation(t *testing.T) { t.Error(err) } // see testdata/schemas/path-item.json. - expected, err := ioutil.ReadFile("../testdata/schemas/path-item.json") + expected, err := os.ReadFile("../testdata/schemas/path-item.json") if err != nil { t.Error(err) } diff --git a/openapi/spec.go b/openapi/spec.go index 53f2075..a03ca52 100644 --- a/openapi/spec.go +++ b/openapi/spec.go @@ -93,7 +93,26 @@ type PathItem struct { // other components in the specification, internally and // externally. type Reference struct { - Ref string `json:"$ref" yaml:"$ref"` + Ref string `json:"$ref" yaml:"$ref"` + Extensions map[string]interface{} `json:"-" yaml:"-"` +} + +func (r *Reference) MarshalJSON() ([]byte, error) { + if r == nil { + return []byte("{}"), nil + } + + m := map[string]interface{}{ + "$ref": r.Ref, + } + + if r.Extensions != nil { + for k, v := range r.Extensions { + m[k] = v + } + } + + return json.Marshal(m) } // Parameter describes a single operation parameter. @@ -124,6 +143,16 @@ func (por *ParameterOrRef) MarshalYAML() (interface{}, error) { return por.Reference, nil } +func (p *ParameterOrRef) MarshalJSON() ([]byte, error) { + if p.Parameter != nil { + return json.Marshal(p.Parameter) + } + if p.Reference != nil { + return json.Marshal(p.Reference) + } + return []byte("{}"), nil +} + // RequestBody represents a request body. type RequestBody struct { Description string `json:"description,omitempty" yaml:"description,omitempty"` @@ -146,6 +175,22 @@ func (sor *SchemaOrRef) MarshalYAML() (interface{}, error) { return sor.Reference, nil } +func (sor *SchemaOrRef) MarshalJSON() ([]byte, error) { + if sor == nil { + return nil, nil + } + + if sor.Schema != nil { + return json.Marshal(sor.Schema) + } + + if sor.Reference != nil { + return json.Marshal(sor.Reference) + } + + return []byte("{}"), nil +} + // Schema represents the definition of input and output data // types of the API. type Schema struct { @@ -184,6 +229,35 @@ type Schema struct { Enum []interface{} `json:"enum,omitempty" yaml:"enum,omitempty"` Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + + Extensions map[string]interface{} `json:"-" yaml:"-"` +} + +func (s *Schema) MarshalJSON() ([]byte, error) { + if s == nil { + return nil, nil + } + + type Alias Schema + base, err := json.Marshal((*Alias)(s)) + if err != nil { + return nil, err + } + + if len(s.Extensions) == 0 { + return base, nil + } + + var baseMap map[string]interface{} + if err := json.Unmarshal(base, &baseMap); err != nil { + return nil, err + } + + for k, v := range s.Extensions { + baseMap[k] = v + } + + return json.Marshal(baseMap) } // Operation describes an API operation on a path. @@ -271,6 +345,16 @@ func (ror *ResponseOrRef) MarshalYAML() (interface{}, error) { return ror.Reference, nil } +func (r *ResponseOrRef) MarshalJSON() ([]byte, error) { + if r.Response != nil { + return json.Marshal(r.Response) + } + if r.Reference != nil { + return json.Marshal(r.Reference) + } + return []byte("{}"), nil +} + // Response describes a single response from an API. type Response struct { Description string `json:"description,omitempty" yaml:"description,omitempty"` @@ -317,6 +401,13 @@ func (mtor *MediaTypeOrRef) MarshalYAML() (interface{}, error) { return mtor.Reference, nil } +func (m *MediaTypeOrRef) MarshalJSON() ([]byte, error) { + if m.MediaType != nil { + return json.Marshal(m.MediaType) + } + return []byte("{}"), nil +} + // MediaType represents the type of a media. type MediaType struct { Schema *SchemaOrRef `json:"schema" yaml:"schema"` diff --git a/openapi/validation_test.go b/openapi/validation_test.go index 46b9b96..e2b8d91 100644 --- a/openapi/validation_test.go +++ b/openapi/validation_test.go @@ -2,7 +2,7 @@ package openapi import ( "encoding/json" - "io/ioutil" + "os" "testing" "github.com/stretchr/testify/assert" @@ -52,7 +52,7 @@ func TestSchemaValidation(t *testing.T) { t.Error(err) } // see testdata/validation/len.json. - expected, err := ioutil.ReadFile("../testdata/schemas/validation.json") + expected, err := os.ReadFile("../testdata/schemas/validation.json") if err != nil { t.Error(err) } diff --git a/testdata/schemas/X.json b/testdata/schemas/X.json index 44c157d..f5d9bbb 100644 --- a/testdata/schemas/X.json +++ b/testdata/schemas/X.json @@ -2,89 +2,109 @@ "type": "object", "properties": { "A": { - "type": "string" + "type": "string", + "x-omitempty": false }, "B": { - "type": "integer", "format": "int32", - "nullable": true + "nullable": true, + "type": "integer", + "x-omitempty": false }, "C": { + "deprecated": true, "type": "boolean", - "deprecated": true + "x-omitempty": false }, "D": { - "type": "array", "items": { "$ref": "#/components/schemas/Y" - } + }, + "type": "array", + "x-omitempty": false }, "E": { - "type": "array", "items": { "$ref": "#/components/schemas/XXX" }, "maxItems": 3, - "minItems": 3 + "minItems": 3, + "type": "array", + "x-omitempty": false }, "F": { - "$ref": "#/components/schemas/XXX" + "$ref": "#/components/schemas/XXX", + "x-omitempty": false }, "G": { - "$ref": "#/components/schemas/Y" + "$ref": "#/components/schemas/Y", + "x-omitempty": false }, "H": { + "format": "float", "type": "number", - "format": "float" + "x-omitempty": false }, "I": { + "format": "date", "type": "string", - "format": "date" + "x-omitempty": false }, "J": { - "type": "integer", "format": "int32", - "nullable": true + "nullable": true, + "type": "integer", + "x-omitempty": false }, "K": { - "type": "object", "additionalProperties": { "$ref": "#/components/schemas/Y" - } + }, + "type": "object", + "x-omitempty": false }, "N": { - "type": "object", "properties": { "Na": { - "type": "string" + "type": "string", + "x-omitempty": false }, "Nb": { - "type": "string" + "type": "string", + "x-omitempty": false }, "Nc": { + "format": "duration", "type": "string", - "format": "duration" + "x-omitempty": false } - } + }, + "type": "object", + "x-omitempty": false }, - "S": { + "NI": { + "format": "int32", + "nullable": true, "type": "integer", - "format": "int32" - }, - "nnNnnN":{ - "type":"string" - }, - "data": { - "$ref": "#/components/schemas/V" + "x-omitempty": false }, "NS": { "nullable": true, - "type": "string" + "type": "string", + "x-omitempty": false }, - "NI" : { - "nullable": true, + "S": { + "format": "int32", "type": "integer", - "format": "int32" + "x-omitempty": false + }, + "data": { + "$ref": "#/components/schemas/V", + "x-omitempty": false + }, + "nnNnnN": { + "type": "string", + "x-omitempty": false } }, "required": [ @@ -92,4 +112,4 @@ "H", "K" ] -} +} \ No newline at end of file diff --git a/testdata/schemas/Y.json b/testdata/schemas/Y.json index eb68889..72d6812 100644 --- a/testdata/schemas/Y.json +++ b/testdata/schemas/Y.json @@ -2,38 +2,46 @@ "type": "object", "properties": { "H": { + "format": "float", "type": "number", - "format": "float" + "x-omitempty": false }, "I": { + "format": "date", "type": "string", - "format": "date" + "x-omitempty": false }, "J": { - "type": "integer", "format": "int32", - "nullable": true + "nullable": true, + "type": "integer", + "x-omitempty": false }, "K": { - "type": "object", "additionalProperties": { "$ref": "#/components/schemas/Y" - } + }, + "type": "object", + "x-omitempty": false }, "N": { - "type": "object", "properties": { "Na": { - "type": "string" + "type": "string", + "x-omitempty": false }, "Nb": { - "type": "string" + "type": "string", + "x-omitempty": false }, "Nc": { + "format": "duration", "type": "string", - "format": "duration" + "x-omitempty": false } - } + }, + "type": "object", + "x-omitempty": false } }, "required": [ diff --git a/testdata/schemas/path-item.json b/testdata/schemas/path-item.json index fc4b69c..84158da 100644 --- a/testdata/schemas/path-item.json +++ b/testdata/schemas/path-item.json @@ -18,15 +18,17 @@ "summary": "ABC", "description": "XYZ", "operationId": "CreateTest", - "parameters": [{ + "parameters": [ + { "name": "a", "in": "path", "description": "This is A", "required": true, "schema": { - "type": "integer", "description": "This is A", - "format": "int32" + "format": "int32", + "type": "integer", + "x-omitempty": false } }, { @@ -35,9 +37,10 @@ "description": "This is B", "required": true, "schema": { - "type": "string", "description": "This is B", - "format": "date-time" + "format": "date-time", + "type": "string", + "x-omitempty": false } }, { @@ -45,45 +48,49 @@ "in": "query", "allowEmptyValue": true, "schema": { - "type": "boolean" + "type": "boolean", + "x-omitempty": false } }, { "name": "i", "in": "query", "schema": { - "type": "string" + "type": "string", + "x-omitempty": false } }, { "name": "k", "in": "query", "schema": { - "type": "array", "items": { - "type": "string", "enum": [ "aaa", "bbb", "ccc" - ] - } + ], + "type": "string" + }, + "type": "array", + "x-omitempty": false }, - "explode": true, - "style": "form" + "style": "form", + "explode": true }, { "name": "xd", "in": "query", "schema": { - "type": "integer", - "format": "int32", "default": 1, "enum": [ 1, 2, 3 - ] + ], + "format": "int32", + "type": "integer", + "x-omitempty": false } }, { @@ -91,9 +98,10 @@ "in": "header", "description": "This is C", "schema": { - "type": "string", + "default": "test", "description": "This is C", - "default": "test" + "type": "string", + "x-omitempty": false } } ], @@ -110,18 +118,8 @@ "201": { "description": "Created", "headers": { - "X-Test-Header": { - "description": "Test header", - "schema": { - "type": "string" - } - }, - "X-Test-Header-Alt": { - "description": "Test header alt", - "schema": { - "type": "string" - } - } + "X-Test-Header": {}, + "X-Test-Header-Alt": {} }, "content": { "application/json": { diff --git a/testdata/schemas/validation.json b/testdata/schemas/validation.json index 5f39fea..d6e7a91 100644 --- a/testdata/schemas/validation.json +++ b/testdata/schemas/validation.json @@ -2,58 +2,68 @@ "type": "object", "properties": { "A": { - "type": "string", "maxLength": 12, - "minLength": 12 + "minLength": 12, + "type": "string", + "x-omitempty": false }, "B": { - "type": "integer", "format": "int32", "maximum": 100, - "minimum": 5 + "minimum": 5, + "type": "integer", + "x-omitempty": false }, "C": { - "type": "array", "items": { "type": "boolean" }, "maxItems": 50, - "minItems": 50 + "minItems": 50, + "type": "array", + "x-omitempty": false }, "D": { - "type": "object", "additionalProperties": { "type": "string" }, "maxProperties": 5, - "minProperties": 5 + "minProperties": 5, + "type": "object", + "x-omitempty": false }, "E": { - "type": "string" + "type": "string", + "x-omitempty": false }, "F": { + "maxLength": 7, "type": "string", - "maxLength": 7 + "x-omitempty": false }, "G": { + "minLength": 7, "type": "string", - "minLength": 7 + "x-omitempty": false }, "H": { + "format": "int32", "type": "integer", - "format": "int32" + "x-omitempty": false }, "I": { - "type": "array", "items": { "type": "string" - } + }, + "type": "array", + "x-omitempty": false }, "J": { - "type": "object", "additionalProperties": { "type": "string" - } + }, + "type": "object", + "x-omitempty": false } } } \ No newline at end of file diff --git a/testdata/spec.json b/testdata/spec.json index ea89455..7a3328f 100644 --- a/testdata/spec.json +++ b/testdata/spec.json @@ -21,17 +21,6 @@ } } } - ], - "security": [ - { - "api_key": [] - }, - { - "oauth2": [ - "write:pets", - "read:pets" - ] - } ], "paths": { "/test/{a}": { @@ -45,7 +34,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "x-omitempty": false } } ], @@ -53,17 +43,12 @@ "200": { "description": "OK", "headers": { - "X-Request-Id": { - "description": "Unique request ID", - "schema": { - "type": "string" - } - } + "X-Request-Id": {} }, - "content":{ - "application/json":{ - "schema":{ - "$ref":"#/components/schemas/FizzT" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FizzT" } } } @@ -76,12 +61,8 @@ "type": "string" }, "examples": { - "one": { - "value": "message1" - }, - "two": { - "value": "message2" - } + "one": {}, + "two": {} } } } @@ -100,13 +81,7 @@ "429": { "description": "Too Many Requests", "headers": { - "X-Rate-Limit": { - "description": "Rate limit", - "schema": { - "type": "integer", - "format": "int32" - } - } + "X-Rate-Limit": {} }, "content": { "application/json": { @@ -118,6 +93,7 @@ } }, "deprecated": true, + "security": [], "x-codeSamples": [ { "lang": "Shell", @@ -125,7 +101,6 @@ "source": "curl http://0.0.0.0:8080" } ], - "security": [], "x-internal": true } }, @@ -138,7 +113,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "x-omitempty": false } }, { @@ -146,15 +122,17 @@ "in": "path", "required": true, "schema": { + "format": "int32", "type": "integer", - "format": "int32" + "x-omitempty": false } }, { "name": "q", "in": "query", "schema": { - "type": "string" + "type": "string", + "x-omitempty": false } } ], @@ -183,7 +161,8 @@ "in": "path", "required": true, "schema": { - "type": "string" + "type": "string", + "x-omitempty": false } } ], @@ -206,25 +185,28 @@ }, "components": { "schemas": { - "FizzCustomTime":{ - "type":"object", - "description":"This is Z", + "FizzCustomTime": { + "type": "object", + "description": "This is Z", "example": "2022-02-07T18:00:00" }, - "FizzT":{ - "type":"object", - "properties":{ - "x":{ - "type":"string", - "description":"This is X" + "FizzT": { + "type": "object", + "properties": { + "x": { + "description": "This is X", + "type": "string", + "x-omitempty": false }, - "y":{ - "type":"integer", - "description":"This is Y", - "format":"int32" + "y": { + "description": "This is Y", + "format": "int32", + "type": "integer", + "x-omitempty": false }, - "z":{ - "$ref":"#/components/schemas/FizzCustomTime" + "z": { + "$ref": "#/components/schemas/FizzCustomTime", + "x-omitempty": false } } }, @@ -232,34 +214,32 @@ "type": "object", "properties": { "message": { + "description": "A short message", "type": "string", - "description": "A short message" + "x-omitempty": false }, "value": { "description": "A nullable value of arbitrary type", - "nullable": true + "nullable": true, + "x-omitempty": false } } } }, "securitySchemes": { - "api_key": { - "type": "apiKey", - "name": "api_key", - "in": "header" - }, - "oauth2": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://example.com/api/oauth/dialog", - "scopes": { - "write:pets": "modify pets in your account", - "read:pets": "read your pets" - } - } - } - } + "api_key": {}, + "oauth2": {} + } + }, + "security": [ + { + "api_key": [] + }, + { + "oauth2": [ + "write:pets", + "read:pets" + ] } - } -} + ] +} \ No newline at end of file diff --git a/testdata/spec.yaml b/testdata/spec.yaml index 4d64d93..d3f26cb 100644 --- a/testdata/spec.yaml +++ b/testdata/spec.yaml @@ -9,16 +9,11 @@ servers: variables: basePath: enum: - - v1 - - v2 - - beta + - v1 + - v2 + - beta default: v2 description: version of the API -security: - - api_key: [] - - oauth2: - - write:pets - - read:pets paths: /test/{a}: get: @@ -32,7 +27,7 @@ paths: schema: type: string responses: - '200': + "200": description: OK headers: X-Request-Id: @@ -43,7 +38,7 @@ paths: application/json: schema: $ref: '#/components/schemas/FizzT' - '400': + "400": description: Bad Request content: application/json: @@ -54,14 +49,14 @@ paths: value: message1 two: value: message2 - '404': + "404": description: Not Found content: application/json: schema: type: string example: not-found-example - '429': + "429": description: Too Many Requests headers: X-Rate-Limit: @@ -74,11 +69,11 @@ paths: schema: type: string deprecated: true - x-codeSamples: - - lang: Shell - label: v4.4 - source: curl http://0.0.0.0:8080 security: [] + x-codeSamples: + - lang: Shell + label: v4.4 + source: curl http://0.0.0.0:8080 x-internal: true /test/{a}/{b}: get: @@ -100,7 +95,7 @@ paths: schema: type: string responses: - '200': + "200": description: OK security: - {} @@ -120,7 +115,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PostTestInput" + $ref: '#/components/schemas/PostTestInput' responses: "201": description: Created @@ -147,20 +142,25 @@ components: properties: message: type: string - description: "A short message" + description: A short message value: - description: "A nullable value of arbitrary type" + description: A nullable value of arbitrary type nullable: true securitySchemes: api_key: type: apiKey - name: api_key in: header + name: api_key oauth2: type: oauth2 flows: implicit: authorizationUrl: https://example.com/api/oauth/dialog scopes: - write:pets: modify pets in your account read:pets: read your pets + write:pets: modify pets in your account +security: +- api_key: [] +- oauth2: + - write:pets + - read:pets \ No newline at end of file