diff --git a/datamodel/high/v3/responses.go b/datamodel/high/v3/responses.go index 3888cab..237638a 100644 --- a/datamodel/high/v3/responses.go +++ b/datamodel/high/v3/responses.go @@ -5,6 +5,7 @@ package v3 import ( "fmt" + "github.com/pb33f/libopenapi/datamodel/high" low "github.com/pb33f/libopenapi/datamodel/low/v3" ) @@ -23,18 +24,20 @@ import ( // be the response for a successful operation call. // - https://spec.openapis.org/oas/v3.1.0#responses-object type Responses struct { - Codes map[string]*Response - Default *Response - low *low.Responses + Codes map[string]*Response + Default *Response + Extensions map[string]any + low *low.Responses } // NewResponses will create a new high-level Responses instance from a low-level one. It operates asynchronously // internally, as each response may be considerable in complexity. -func NewResponses(response *low.Responses) *Responses { +func NewResponses(responses *low.Responses) *Responses { r := new(Responses) - r.low = response - if !response.Default.IsEmpty() { - r.Default = NewResponse(response.Default.Value) + r.low = responses + r.Extensions = high.ExtractExtensions(responses.Extensions) + if !responses.Default.IsEmpty() { + r.Default = NewResponse(responses.Default.Value) } codes := make(map[string]*Response) @@ -49,10 +52,10 @@ func NewResponses(response *low.Responses) *Responses { var buildResponse = func(code string, resp *low.Response, c chan respRes) { c <- respRes{code: code, resp: NewResponse(resp)} } - for k, v := range response.Codes { + for k, v := range responses.Codes { go buildResponse(k.Value, v.Value, rChan) } - totalCodes := len(response.Codes) + totalCodes := len(responses.Codes) codesParsed := 0 for codesParsed < totalCodes { select { diff --git a/datamodel/low/v2/responses.go b/datamodel/low/v2/responses.go index 5b61a4f..57ec57a 100644 --- a/datamodel/low/v2/responses.go +++ b/datamodel/low/v2/responses.go @@ -4,11 +4,13 @@ package v2 import ( + "crypto/sha256" "fmt" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" "github.com/pb33f/libopenapi/utils" "gopkg.in/yaml.v3" + "strings" ) // Responses is a low-level representation of a Swagger / OpenAPI 2 Responses object. @@ -30,13 +32,13 @@ func (r *Responses) Build(root *yaml.Node, idx *index.SpecIndex) error { if codes != nil { r.Codes = codes } - - def, derr := low.ExtractObject[*Response](DefaultLabel, root, idx) - if derr != nil { - return derr - } - if def.Value != nil { - r.Default = def + if re := r.FindResponseByCode(DefaultLabel); re != nil { + r.Default = low.NodeReference[*Response]{ + Value: re.Value, + ValueNode: re.ValueNode, + KeyNode: re.ValueNode, + } + r.deleteCode(DefaultLabel) } } else { return fmt.Errorf("responses build failed: vn node is not a map! line %d, col %d", root.Line, root.Column) @@ -44,7 +46,37 @@ func (r *Responses) Build(root *yaml.Node, idx *index.SpecIndex) error { return nil } +func (r *Responses) deleteCode(code string) { + var key *low.KeyReference[string] + if r.Codes != nil { + for k := range r.Codes { + if k.Value == code { + key = &k + } + } + } + if key != nil { + delete(r.Codes, *key) + } +} + // FindResponseByCode will attempt to locate a Response instance using an HTTP response code string. func (r *Responses) FindResponseByCode(code string) *low.ValueReference[*Response] { return low.FindItemInMap[*Response](code, r.Codes) } + +// Hash will return a consistent SHA256 Hash of the Examples object +func (r *Responses) Hash() [32]byte { + var f []string + for k := range r.Codes { + f = append(f, low.GenerateHashString(r.Codes[k].Value)) + } + if !r.Default.IsEmpty() { + f = append(f, low.GenerateHashString(r.Default.Value)) + } + for k := range r.Extensions { + f = append(f, fmt.Sprintf("%s-%x", k.Value, + sha256.Sum256([]byte(fmt.Sprint(r.Extensions[k].Value))))) + } + return sha256.Sum256([]byte(strings.Join(f, "|"))) +} diff --git a/datamodel/low/v3/constants.go b/datamodel/low/v3/constants.go index 5260539..6904d9f 100644 --- a/datamodel/low/v3/constants.go +++ b/datamodel/low/v3/constants.go @@ -112,4 +112,5 @@ const ( ScopesLabel = "scopes" OperationRefLabel = "operationRef" OperationIdLabel = "operationId" + CodesLabel = "codes" ) diff --git a/datamodel/low/v3/response.go b/datamodel/low/v3/response.go index 893d592..21b2048 100644 --- a/datamodel/low/v3/response.go +++ b/datamodel/low/v3/response.go @@ -8,60 +8,10 @@ import ( "fmt" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" - "github.com/pb33f/libopenapi/utils" "gopkg.in/yaml.v3" "strings" ) -// Responses represents a low-level OpenAPI 3+ Responses object. -// -// It's a container for the expected responses of an operation. The container maps a HTTP response code to the -// expected response. -// -// The specification is not necessarily expected to cover all possible HTTP response codes because they may not be -// known in advance. However, documentation is expected to cover a successful operation response and any known errors. -// -// The default MAY be used as a default response object for all HTTP codes that are not covered individually by -// the Responses Object. -// -// The Responses Object MUST contain at least one response code, and if only one response code is provided it SHOULD -// be the response for a successful operation call. -// - https://spec.openapis.org/oas/v3.1.0#responses-object -type Responses struct { - Codes map[low.KeyReference[string]]low.ValueReference[*Response] - Default low.NodeReference[*Response] -} - -// Build will extract default response and all Response objects for each code -func (r *Responses) Build(root *yaml.Node, idx *index.SpecIndex) error { - if utils.IsNodeMap(root) { - codes, err := low.ExtractMapNoLookup[*Response](root, idx) - if err != nil { - return err - } - if codes != nil { - r.Codes = codes - } - - def, derr := low.ExtractObject[*Response](DefaultLabel, root, idx) - if derr != nil { - return derr - } - if def.Value != nil { - r.Default = def - } - } else { - return fmt.Errorf("responses build failed: vn node is not a map! line %d, col %d", - root.Line, root.Column) - } - return nil -} - -// FindResponseByCode will attempt to locate a Response using an HTTP response code. -func (r *Responses) FindResponseByCode(code string) *low.ValueReference[*Response] { - return low.FindItemInMap[*Response](code, r.Codes) -} - // Response represents a high-level OpenAPI 3+ Response object that is backed by a low-level one. // // Describes a single response from an API Operation, including design-time, static links to diff --git a/datamodel/low/v3/responses.go b/datamodel/low/v3/responses.go new file mode 100644 index 0000000..d8ac26b --- /dev/null +++ b/datamodel/low/v3/responses.go @@ -0,0 +1,86 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package v3 + +import ( + "crypto/sha256" + "fmt" + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/index" + "github.com/pb33f/libopenapi/utils" + "gopkg.in/yaml.v3" + "strings" +) + +// Responses represents a low-level OpenAPI 3+ Responses object. +// +// It's a container for the expected responses of an operation. The container maps a HTTP response code to the +// expected response. +// +// The specification is not necessarily expected to cover all possible HTTP response codes because they may not be +// known in advance. However, documentation is expected to cover a successful operation response and any known errors. +// +// The default MAY be used as a default response object for all HTTP codes that are not covered individually by +// the Responses Object. +// +// The Responses Object MUST contain at least one response code, and if only one response code is provided it SHOULD +// be the response for a successful operation call. +// - https://spec.openapis.org/oas/v3.1.0#responses-object +// +// This structure is identical to the v2 version, however they use different response types, hence +// the duplication. Perhaps in the future we could use generics here, but for now to keep things +// simple, they are broken out into individual versions. +type Responses struct { + Codes map[low.KeyReference[string]]low.ValueReference[*Response] + Default low.NodeReference[*Response] + Extensions map[low.KeyReference[string]]low.ValueReference[any] +} + +// Build will extract default response and all Response objects for each code +func (r *Responses) Build(root *yaml.Node, idx *index.SpecIndex) error { + r.Extensions = low.ExtractExtensions(root) + if utils.IsNodeMap(root) { + codes, err := low.ExtractMapNoLookup[*Response](root, idx) + + if err != nil { + return err + } + if codes != nil { + r.Codes = codes + } + + def, derr := low.ExtractObject[*Response](DefaultLabel, root, idx) + if derr != nil { + return derr + } + if def.Value != nil { + r.Default = def + } + } else { + return fmt.Errorf("responses build failed: vn node is not a map! line %d, col %d", + root.Line, root.Column) + } + return nil +} + +// FindResponseByCode will attempt to locate a Response using an HTTP response code. +func (r *Responses) FindResponseByCode(code string) *low.ValueReference[*Response] { + return low.FindItemInMap[*Response](code, r.Codes) +} + +// Hash will return a consistent SHA256 Hash of the Examples object +func (r *Responses) Hash() [32]byte { + var f []string + for k := range r.Codes { + f = append(f, low.GenerateHashString(r.Codes[k].Value)) + } + if !r.Default.IsEmpty() { + f = append(f, low.GenerateHashString(r.Default.Value)) + } + for k := range r.Extensions { + f = append(f, fmt.Sprintf("%s-%x", k.Value, + sha256.Sum256([]byte(fmt.Sprint(r.Extensions[k].Value))))) + } + return sha256.Sum256([]byte(strings.Join(f, "|"))) +} diff --git a/what-changed/model/response.go b/what-changed/model/response.go index 1a7dcff..380bc1e 100644 --- a/what-changed/model/response.go +++ b/what-changed/model/response.go @@ -65,6 +65,14 @@ func (r *ResponseChanges) TotalBreakingChanges() int { return c } +func CompareResponseV2(l, r *v2.Response) *ResponseChanges { + return CompareResponse(l, r) +} + +func CompareResponseV3(l, r *v3.Response) *ResponseChanges { + return CompareResponse(l, r) +} + func CompareResponse(l, r any) *ResponseChanges { var changes []*Change diff --git a/what-changed/model/responses.go b/what-changed/model/responses.go new file mode 100644 index 0000000..4b3c883 --- /dev/null +++ b/what-changed/model/responses.go @@ -0,0 +1,108 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package model + +import ( + "github.com/pb33f/libopenapi/datamodel/low" + "github.com/pb33f/libopenapi/datamodel/low/v2" + "github.com/pb33f/libopenapi/datamodel/low/v3" + "reflect" +) + +type ResponsesChanges struct { + PropertyChanges + ResponseChanges map[string]*ResponseChanges + DefaultChanges *ResponseChanges + ExtensionChanges *ExtensionChanges +} + +func (r *ResponsesChanges) TotalChanges() int { + c := r.PropertyChanges.TotalChanges() + for k := range r.ResponseChanges { + c += r.ResponseChanges[k].TotalChanges() + } + if r.ExtensionChanges != nil { + c += r.ExtensionChanges.TotalBreakingChanges() + } + return c +} + +func (r *ResponsesChanges) TotalBreakingChanges() int { + c := r.PropertyChanges.TotalBreakingChanges() + for k := range r.ResponseChanges { + c += r.ResponseChanges[k].TotalChanges() + } + return c +} + +func CompareResponses(l, r any) *ResponsesChanges { + + var changes []*Change + + rc := new(ResponsesChanges) + + if reflect.TypeOf(&v2.Responses{}) == reflect.TypeOf(l) && reflect.TypeOf(&v2.Responses{}) == reflect.TypeOf(r) { + + lResponses := l.(*v2.Responses) + rResponses := r.(*v2.Responses) + + // perform hash check to avoid further processing + if low.AreEqual(lResponses, rResponses) { + return nil + } + + if !lResponses.Default.IsEmpty() && !rResponses.Default.IsEmpty() { + rc.DefaultChanges = CompareResponse(lResponses.Default.Value, rResponses.Default.Value) + } + if !lResponses.Default.IsEmpty() && rResponses.Default.IsEmpty() { + CreateChange(&changes, ObjectRemoved, v3.DefaultLabel, + lResponses.Default.ValueNode, nil, true, + lResponses.Default.Value, nil) + } + if lResponses.Default.IsEmpty() && !rResponses.Default.IsEmpty() { + CreateChange(&changes, ObjectAdded, v3.DefaultLabel, + nil, rResponses.Default.ValueNode, false, + nil, lResponses.Default.Value) + } + + rc.ResponseChanges = CheckMapForChanges(lResponses.Codes, rResponses.Codes, + &changes, v3.CodesLabel, CompareResponseV2) + + rc.ExtensionChanges = CompareExtensions(lResponses.Extensions, rResponses.Extensions) + } + + if reflect.TypeOf(&v3.Responses{}) == reflect.TypeOf(l) && reflect.TypeOf(&v3.Responses{}) == reflect.TypeOf(r) { + + lResponses := l.(*v3.Responses) + rResponses := r.(*v3.Responses) + + // perform hash check to avoid further processing + if low.AreEqual(lResponses, rResponses) { + return nil + } + + if !lResponses.Default.IsEmpty() && !rResponses.Default.IsEmpty() { + rc.DefaultChanges = CompareResponse(lResponses.Default.Value, rResponses.Default.Value) + } + if !lResponses.Default.IsEmpty() && rResponses.Default.IsEmpty() { + CreateChange(&changes, ObjectRemoved, v3.DefaultLabel, + lResponses.Default.ValueNode, nil, true, + lResponses.Default.Value, nil) + } + if lResponses.Default.IsEmpty() && !rResponses.Default.IsEmpty() { + CreateChange(&changes, ObjectAdded, v3.DefaultLabel, + nil, rResponses.Default.ValueNode, false, + nil, lResponses.Default.Value) + } + + rc.ResponseChanges = CheckMapForChanges(lResponses.Codes, rResponses.Codes, + &changes, v3.CodesLabel, CompareResponseV3) + + rc.ExtensionChanges = CompareExtensions(lResponses.Extensions, rResponses.Extensions) + + } + + rc.Changes = changes + return rc +} diff --git a/what-changed/model/responses_test.go b/what-changed/model/responses_test.go new file mode 100644 index 0000000..b55eb0d --- /dev/null +++ b/what-changed/model/responses_test.go @@ -0,0 +1,221 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package model + +import ( + "github.com/pb33f/libopenapi/datamodel/low" + v2 "github.com/pb33f/libopenapi/datamodel/low/v2" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + "testing" +) + +func TestCompareResponses_V2(t *testing.T) { + + left := `default: + schema: + type: string +200: + description: OK response + schema: + type: string +404: + description: not found response + schema: + type: string` + + right := left + + var lNode, rNode yaml.Node + _ = yaml.Unmarshal([]byte(left), &lNode) + _ = yaml.Unmarshal([]byte(right), &rNode) + + // create low level objects + var lDoc v2.Responses + var rDoc v2.Responses + _ = low.BuildModel(&lNode, &lDoc) + _ = low.BuildModel(&rNode, &rDoc) + _ = lDoc.Build(lNode.Content[0], nil) + _ = rDoc.Build(rNode.Content[0], nil) + + extChanges := CompareResponses(&lDoc, &rDoc) + assert.Nil(t, extChanges) +} + +func TestCompareResponses_V2_ModifyCode(t *testing.T) { + + left := `200: + description: OK response + schema: + type: int +404: + description: not found response + schema: + type: int +x-ting: tang` + + right := `200: + description: OK response + schema: + type: string +404: + description: not found response + schema: + type: string +x-ting: tang` + + var lNode, rNode yaml.Node + _ = yaml.Unmarshal([]byte(left), &lNode) + _ = yaml.Unmarshal([]byte(right), &rNode) + + // create low level objects + var lDoc v2.Responses + var rDoc v2.Responses + _ = low.BuildModel(&lNode, &lDoc) + _ = low.BuildModel(&rNode, &rDoc) + _ = lDoc.Build(lNode.Content[0], nil) + _ = rDoc.Build(rNode.Content[0], nil) + + extChanges := CompareResponses(&lDoc, &rDoc) + assert.Equal(t, 2, extChanges.TotalChanges()) + assert.Equal(t, 2, extChanges.TotalBreakingChanges()) + assert.Equal(t, Modified, extChanges.ResponseChanges["404"].SchemaChanges.Changes[0].ChangeType) + assert.Equal(t, Modified, extChanges.ResponseChanges["200"].SchemaChanges.Changes[0].ChangeType) +} + +func TestCompareResponses_V2_AddSchema(t *testing.T) { + left := `200: + description: OK response + schema: + type: int +404: + description: not found response + schema: + type: int` + + right := `200: + description: OK response +404: + description: not found response + schema: + type: int` + + var lNode, rNode yaml.Node + _ = yaml.Unmarshal([]byte(left), &lNode) + _ = yaml.Unmarshal([]byte(right), &rNode) + + // create low level objects + var lDoc v2.Responses + var rDoc v2.Responses + _ = low.BuildModel(&lNode, &lDoc) + _ = low.BuildModel(&rNode, &rDoc) + _ = lDoc.Build(lNode.Content[0], nil) + _ = rDoc.Build(rNode.Content[0], nil) + + extChanges := CompareResponses(&rDoc, &lDoc) + assert.Equal(t, 1, extChanges.TotalChanges()) + assert.Equal(t, 1, extChanges.TotalBreakingChanges()) + assert.Equal(t, ObjectAdded, extChanges.ResponseChanges["200"].Changes[0].ChangeType) +} + +func TestCompareResponses_V2_RemoveSchema(t *testing.T) { + left := `200: + description: OK response + schema: + type: int +404: + description: not found response + schema: + type: int` + + right := `200: + description: OK response +404: + description: not found response + schema: + type: int` + + var lNode, rNode yaml.Node + _ = yaml.Unmarshal([]byte(left), &lNode) + _ = yaml.Unmarshal([]byte(right), &rNode) + + // create low level objects + var lDoc v2.Responses + var rDoc v2.Responses + _ = low.BuildModel(&lNode, &lDoc) + _ = low.BuildModel(&rNode, &rDoc) + _ = lDoc.Build(lNode.Content[0], nil) + _ = rDoc.Build(rNode.Content[0], nil) + + extChanges := CompareResponses(&lDoc, &rDoc) + assert.Equal(t, 1, extChanges.TotalChanges()) + assert.Equal(t, 1, extChanges.TotalBreakingChanges()) + assert.Equal(t, ObjectRemoved, extChanges.ResponseChanges["200"].Changes[0].ChangeType) +} + +func TestCompareResponses_V2_AddDefault(t *testing.T) { + left := `200: + description: OK response + schema: + type: int` + + right := `200: + description: OK response + schema: + type: int +default: + description: not found response + schema: + type: int` + + var lNode, rNode yaml.Node + _ = yaml.Unmarshal([]byte(left), &lNode) + _ = yaml.Unmarshal([]byte(right), &rNode) + + // create low level objects + var lDoc v2.Responses + var rDoc v2.Responses + _ = low.BuildModel(&lNode, &lDoc) + _ = low.BuildModel(&rNode, &rDoc) + _ = lDoc.Build(lNode.Content[0], nil) + _ = rDoc.Build(rNode.Content[0], nil) + + extChanges := CompareResponses(&lDoc, &rDoc) + assert.Equal(t, 1, extChanges.TotalChanges()) + assert.Equal(t, 0, extChanges.TotalBreakingChanges()) + assert.Equal(t, ObjectAdded, extChanges.Changes[0].ChangeType) +} + +func TestCompareResponses_V2_RemoveDefault(t *testing.T) { + left := `200: + description: OK response + schema: + type: int` + + right := `200: + description: OK response + schema: + type: int +default: + description: not found response + schema: + type: int` + + var lNode, rNode yaml.Node + _ = yaml.Unmarshal([]byte(left), &lNode) + _ = yaml.Unmarshal([]byte(right), &rNode) + + // create low level objects + var lDoc v2.Responses + var rDoc v2.Responses + _ = low.BuildModel(&lNode, &lDoc) + _ = low.BuildModel(&rNode, &rDoc) + _ = lDoc.Build(lNode.Content[0], nil) + _ = rDoc.Build(rNode.Content[0], nil) + + extChanges := CompareResponses(&rDoc, &lDoc) + assert.Equal(t, 1, extChanges.TotalChanges()) + assert.Equal(t, 1, extChanges.TotalBreakingChanges()) + assert.Equal(t, ObjectRemoved, extChanges.Changes[0].ChangeType) +}