Skip to content

Commit

Permalink
Feat openapiv3 oneof (#1870)
Browse files Browse the repository at this point in the history
* fix: drive new schema from ref and field tags

* feat: support openapi-v3 oneOf tag

* feat: support openapi-v3 oneOf for response
  • Loading branch information
KKKIIO authored Oct 21, 2024
1 parent 2fa63cf commit 6700370
Show file tree
Hide file tree
Showing 8 changed files with 298 additions and 57 deletions.
67 changes: 67 additions & 0 deletions field_parser_v3_test.go

Large diffs are not rendered by default.

25 changes: 21 additions & 4 deletions field_parserv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,15 @@ func (sf *structFieldV3) setMax(valValue string) {

type tagBaseFieldParserV3 struct {
p *Parser
file *ast.File
field *ast.Field
tag reflect.StructTag
}

func newTagBaseFieldParserV3(p *Parser, field *ast.Field) FieldParserV3 {
func newTagBaseFieldParserV3(p *Parser, file *ast.File, field *ast.Field) FieldParserV3 {
fieldParser := tagBaseFieldParserV3{
p: p,
file: file,
field: field,
tag: "",
}
Expand Down Expand Up @@ -134,9 +136,10 @@ func (ps *tagBaseFieldParserV3) ComplementSchema(schema *spec.RefOrSpec[spec.Sch
if err != nil {
return err
}
// if !reflect.ValueOf(newSchema).IsZero() {
// *schema = *(newSchema.WithAllOf(*schema.Spec))
// }
if !reflect.ValueOf(newSchema).IsZero() {
newSchema.AllOf = []*spec.RefOrSpec[spec.Schema]{{Spec: schema.Spec}}
*schema = spec.RefOrSpec[spec.Schema]{Spec: &newSchema}
}
return nil
}

Expand Down Expand Up @@ -339,6 +342,19 @@ func (ps *tagBaseFieldParserV3) complementSchema(schema *spec.Schema, types []st
}
}

var oneOfSchemas []*spec.RefOrSpec[spec.Schema]
oneOfTagValue := ps.tag.Get(oneOfTag)
if oneOfTagValue != "" {
oneOfTypes := strings.Split((oneOfTagValue), ",")
for _, oneOfType := range oneOfTypes {
oneOfSchema, err := ps.p.getTypeSchemaV3(oneOfType, ps.file, true)
if err != nil {
return fmt.Errorf("can't find oneOf type %q: %v", oneOfType, err)
}
oneOfSchemas = append(oneOfSchemas, oneOfSchema)
}
}

elemSchema := schema

if field.schemaType == ARRAY {
Expand All @@ -362,6 +378,7 @@ func (ps *tagBaseFieldParserV3) complementSchema(schema *spec.Schema, types []st
elemSchema.MinLength = field.minLength
elemSchema.Enum = field.enums
elemSchema.Pattern = field.pattern
elemSchema.OneOf = oneOfSchemas

return nil
}
Expand Down
1 change: 1 addition & 0 deletions operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ const (
extensionsTag = "extensions"
collectionFormatTag = "collectionFormat"
patternTag = "pattern"
oneOfTag = "oneOf"
)

var regexAttributes = map[string]*regexp.Regexp{
Expand Down
131 changes: 82 additions & 49 deletions operationv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -926,22 +926,15 @@ func (o *OperationV3) ParseResponseComment(commentLine string, astFile *ast.File

for _, codeStr := range strings.Split(matches[1], ",") {
if strings.EqualFold(codeStr, defaultTag) {
response := o.DefaultResponse()
response.Description = description

mimeType := "application/json" // TODO: set correct mimeType
setResponseSchema(response, mimeType, schema)

continue
}

code, err := strconv.Atoi(codeStr)
if err != nil {
return fmt.Errorf("can not parse response comment \"%s\"", commentLine)
}

if description == "" {
description = http.StatusText(code)
codeStr = ""
} else {
code, err := strconv.Atoi(codeStr)
if err != nil {
return fmt.Errorf("can not parse response comment \"%s\"", commentLine)
}
if description == "" {
description = http.StatusText(code)
}
}

response := spec.NewResponseSpec()
Expand Down Expand Up @@ -979,15 +972,12 @@ func (o *OperationV3) ParseEmptyResponseComment(commentLine string) error {

for _, codeStr := range strings.Split(matches[1], ",") {
if strings.EqualFold(codeStr, defaultTag) {
response := o.DefaultResponse()
response.Description = description

continue
}

_, err := strconv.Atoi(codeStr)
if err != nil {
return fmt.Errorf("can not parse response comment \"%s\"", commentLine)
codeStr = ""
} else {
_, err := strconv.Atoi(codeStr)
if err != nil {
return fmt.Errorf("can not parse response comment \"%s\"", commentLine)
}
}

o.AddResponse(codeStr, newResponseWithDescription(description))
Expand All @@ -996,21 +986,10 @@ func (o *OperationV3) ParseEmptyResponseComment(commentLine string) error {
return nil
}

// DefaultResponse return the default response member pointer.
func (o *OperationV3) DefaultResponse() *spec.Response {
if o.Responses.Spec.Default == nil {
o.Responses.Spec.Default = spec.NewResponseSpec()
o.Responses.Spec.Default.Spec.Spec.Headers = make(map[string]*spec.RefOrSpec[spec.Extendable[spec.Header]])
}

if o.Responses.Spec.Default.Spec.Spec.Content == nil {
o.Responses.Spec.Default.Spec.Spec.Content = make(map[string]*spec.Extendable[spec.MediaType])
}

return o.Responses.Spec.Default.Spec.Spec
}

// AddResponse add a response for a code.
// If the code is already exist, it will merge with the old one:
// 1. The description will be replaced by the new one if the new one is not empty.
// 2. The content schema will be merged using `oneOf` if the new one is not empty.
func (o *OperationV3) AddResponse(code string, response *spec.RefOrSpec[spec.Extendable[spec.Response]]) {
if response.Spec.Spec.Headers == nil {
response.Spec.Spec.Headers = make(map[string]*spec.RefOrSpec[spec.Extendable[spec.Header]])
Expand All @@ -1020,24 +999,78 @@ func (o *OperationV3) AddResponse(code string, response *spec.RefOrSpec[spec.Ext
o.Responses.Spec.Response = make(map[string]*spec.RefOrSpec[spec.Extendable[spec.Response]])
}

o.Responses.Spec.Response[code] = response
res := response
var prev *spec.RefOrSpec[spec.Extendable[spec.Response]]
if code != "" {
prev = o.Responses.Spec.Response[code]
} else {
prev = o.Responses.Spec.Default
}
if prev != nil { // merge into prev
res = prev
if response.Spec.Spec.Description != "" {
prev.Spec.Spec.Description = response.Spec.Spec.Description
}
if len(response.Spec.Spec.Content) > 0 {
// responses should only have one content type
singleKey := ""
for k := range response.Spec.Spec.Content {
singleKey = k
break
}
if prevMediaType := prev.Spec.Spec.Content[singleKey]; prevMediaType == nil {
prev.Spec.Spec.Content = response.Spec.Spec.Content
} else {
newMediaType := response.Spec.Spec.Content[singleKey]
if len(newMediaType.Extensions) > 0 {
if prevMediaType.Extensions == nil {
prevMediaType.Extensions = make(map[string]interface{})
}
for k, v := range newMediaType.Extensions {
prevMediaType.Extensions[k] = v
}
}
if len(newMediaType.Spec.Examples) > 0 {
if prevMediaType.Spec.Examples == nil {
prevMediaType.Spec.Examples = make(map[string]*spec.RefOrSpec[spec.Extendable[spec.Example]])
}
for k, v := range newMediaType.Spec.Examples {
prevMediaType.Spec.Examples[k] = v
}
}
if prevSchema := prevMediaType.Spec.Schema; prevSchema.Ref != nil || prevSchema.Spec.OneOf == nil {
oneOfSchema := spec.NewSchemaSpec()
oneOfSchema.Spec.OneOf = []*spec.RefOrSpec[spec.Schema]{prevSchema, newMediaType.Spec.Schema}
prevMediaType.Spec.Schema = oneOfSchema
} else {
prevSchema.Spec.OneOf = append(prevSchema.Spec.OneOf, newMediaType.Spec.Schema)
}
}
}
}

if code != "" {
o.Responses.Spec.Response[code] = res
} else {
o.Responses.Spec.Default = res
}
}

// ParseEmptyResponseOnly parse only comment out status code ,eg: @Success 200.
func (o *OperationV3) ParseEmptyResponseOnly(commentLine string) error {
for _, codeStr := range strings.Split(commentLine, ",") {
var description string
if strings.EqualFold(codeStr, defaultTag) {
_ = o.DefaultResponse()

continue
}

code, err := strconv.Atoi(codeStr)
if err != nil {
return fmt.Errorf("can not parse response comment \"%s\"", commentLine)
codeStr = ""
} else {
code, err := strconv.Atoi(codeStr)
if err != nil {
return fmt.Errorf("can not parse response comment \"%s\"", commentLine)
}
description = http.StatusText(code)
}

o.AddResponse(codeStr, newResponseWithDescription(http.StatusText(code)))
o.AddResponse(codeStr, newResponseWithDescription(description))
}

return nil
Expand Down
6 changes: 3 additions & 3 deletions parserv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
"github.com/sv-tools/openapi/spec"
)

// FieldParserFactoryV3 func(ps *Parser, field *ast.Field) FieldParserV3 create FieldParser.
type FieldParserFactoryV3 func(ps *Parser, field *ast.Field) FieldParserV3
// FieldParserFactoryV3 create FieldParser.
type FieldParserFactoryV3 func(ps *Parser, file *ast.File, field *ast.Field) FieldParserV3

// FieldParserV3 parse struct field.
type FieldParserV3 interface {
Expand Down Expand Up @@ -920,7 +920,7 @@ func (p *Parser) parseStructFieldV3(file *ast.File, field *ast.Field) (map[strin
}
}

ps := p.fieldParserFactoryV3(p, field)
ps := p.fieldParserFactoryV3(p, file, field)

if ps.ShouldSkip() {
return nil, nil, nil
Expand Down
94 changes: 93 additions & 1 deletion parserv3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sv-tools/openapi/spec"
)

func TestOverridesGetTypeSchemaV3(t *testing.T) {
Expand Down Expand Up @@ -362,7 +363,6 @@ func TestParseSimpleApiV3(t *testing.T) {
assert.NoError(t, err)

paths := p.openAPI.Paths.Spec.Paths
assert.Equal(t, 15, len(paths))

path := paths["/testapi/get-string-by-int/{some_id}"].Spec.Spec.Get.Spec
assert.Equal(t, "get string by ID", path.Description)
Expand All @@ -376,6 +376,98 @@ func TestParseSimpleApiV3(t *testing.T) {
assert.NotNil(t, path)
assert.NotNil(t, path.RequestBody)
//TODO add asserts

t.Run("Test parse struct oneOf", func(t *testing.T) {
t.Parallel()

assert.Contains(t, p.openAPI.Components.Spec.Schemas, "web.OneOfTest")
schema := p.openAPI.Components.Spec.Schemas["web.OneOfTest"].Spec
expected := `{
"properties": {
"big_int": {
"oneOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"pet_detail": {
"oneOf": [
{
"$ref": "#/components/schemas/web.Cat"
},
{
"$ref": "#/components/schemas/web.Dog"
}
]
}
},
"type": "object"
}`
out, err := json.MarshalIndent(schema, "", " ")
assert.NoError(t, err)
assert.Equal(t, expected, string(out))

assert.Contains(t, p.openAPI.Components.Spec.Schemas, "web.Cat")
schema = p.openAPI.Components.Spec.Schemas["web.Cat"].Spec
expected = `{
"properties": {
"age": {
"type": "integer"
},
"hunts": {
"type": "boolean"
}
},
"type": "object"
}`
out, err = json.MarshalIndent(schema, "", " ")
assert.NoError(t, err)
assert.Equal(t, expected, string(out))

assert.Contains(t, p.openAPI.Components.Spec.Schemas, "web.Dog")
schema = p.openAPI.Components.Spec.Schemas["web.Dog"].Spec
expected = `{
"properties": {
"bark": {
"type": "boolean"
},
"breed": {
"enum": [
"Dingo",
"Husky",
"Retriever",
"Shepherd"
],
"type": "string"
}
},
"type": "object"
}`
out, err = json.MarshalIndent(schema, "", " ")
assert.NoError(t, err)
assert.Equal(t, expected, string(out))
})

t.Run("Test parse response oneOf", func(t *testing.T) {
t.Parallel()

assert.Contains(t, paths, "/pets/{id}")
path := paths["/pets/{id}"]
assert.Contains(t, path.Spec.Spec.Get.Spec.Responses.Spec.Response, "200")
response = path.Spec.Spec.Get.Spec.Responses.Spec.Response["200"]
assert.Equal(t, "Return Cat or Dog", response.Spec.Spec.Description)
mediaType := response.Spec.Spec.Content["application/json"]
rootSchema := mediaType.Spec.Schema.Spec
assert.Equal(t, []*spec.RefOrSpec[spec.Schema]{
{Ref: &spec.Ref{Ref: "#/components/schemas/web.Cat"}},
{Ref: &spec.Ref{Ref: "#/components/schemas/web.Dog"}},
}, rootSchema.OneOf)

})
}

func TestParserParseServers(t *testing.T) {
Expand Down
16 changes: 16 additions & 0 deletions testdata/v3/simple/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,19 @@ func GetPet6FunctionScopedResponse() {
func FormData() {

}

// @Success 200 {object} web.OneOfTest
// @Router /GetOneOfTypes [get]
func GetOneOfTypes() {

}

// @Summary Get pet by ID
// @Param id path string true "ID"
// @Success 200 {object} web.Cat
// @Success 200 {object} web.Dog
// @Success 200 "Return Cat or Dog"
// @Router /pets/{id} [get]
func GetPetByID() {

}
Loading

0 comments on commit 6700370

Please sign in to comment.