Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat openapiv3 oneof #1870

Merged
merged 3 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading