diff --git a/bundler/bundler_test.go b/bundler/bundler_test.go index b58b4c05..a06a6b0b 100644 --- a/bundler/bundler_test.go +++ b/bundler/bundler_test.go @@ -20,8 +20,6 @@ import ( "sync" "testing" - "github.com/pb33f/libopenapi/datamodel/low" - "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi/datamodel" "github.com/pb33f/libopenapi/datamodel/high/base" @@ -575,8 +573,8 @@ func TestBundleDocument_BundleBytesComposed_NestedFiles(t *testing.T) { assert.Equal(t, len1, len2) // hash the two files and ensure they match - hash1 := low.HashToString(sha256.Sum256(preBundled)) - hash2 := low.HashToString(sha256.Sum256(bundledBytes)) + hash1 := sha256.Sum256(preBundled) + hash2 := sha256.Sum256(bundledBytes) assert.Equal(t, hash1, hash2) } } diff --git a/datamodel/high/overlay/action.go b/datamodel/high/overlay/action.go index 888ea561..c1ad4037 100644 --- a/datamodel/high/overlay/action.go +++ b/datamodel/high/overlay/action.go @@ -11,12 +11,13 @@ import ( ) // Action represents a high-level Overlay Action Object. -// https://spec.openapis.org/overlay/v1.0.0#action-object +// https://spec.openapis.org/overlay/v1.1.0#action-object type Action struct { Target string `json:"target,omitempty" yaml:"target,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Update *yaml.Node `json:"update,omitempty" yaml:"update,omitempty"` Remove bool `json:"remove,omitempty" yaml:"remove,omitempty"` + Copy string `json:"copy,omitempty" yaml:"copy,omitempty"` Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` low *low.Action } @@ -37,6 +38,9 @@ func NewAction(action *low.Action) *Action { if !action.Remove.IsEmpty() { a.Remove = action.Remove.Value } + if !action.Copy.IsEmpty() { + a.Copy = action.Copy.Value + } a.Extensions = high.ExtractExtensions(action.Extensions) return a } @@ -57,7 +61,7 @@ func (a *Action) Render() ([]byte, error) { } // MarshalYAML creates a ready to render YAML representation of the Action object. -func (a *Action) MarshalYAML() (interface{}, error) { +func (a *Action) MarshalYAML() (any, error) { m := orderedmap.New[string, any]() if a.Target != "" { m.Set("target", a.Target) @@ -65,6 +69,9 @@ func (a *Action) MarshalYAML() (interface{}, error) { if a.Description != "" { m.Set("description", a.Description) } + if a.Copy != "" { + m.Set("copy", a.Copy) + } if a.Update != nil { m.Set("update", a.Update) } diff --git a/datamodel/high/overlay/action_test.go b/datamodel/high/overlay/action_test.go index 4f020fcc..619037da 100644 --- a/datamodel/high/overlay/action_test.go +++ b/datamodel/high/overlay/action_test.go @@ -160,3 +160,127 @@ func TestAction_MarshalYAML_Empty(t *testing.T) { require.NoError(t, err) assert.NotNil(t, result) } + +func TestNewAction_WithCopy(t *testing.T) { + yml := `target: $.paths./users.post.responses.201 +copy: $.paths./users.get.responses.200` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowAction lowoverlay.Action + err = low.BuildModel(node.Content[0], &lowAction) + require.NoError(t, err) + err = lowAction.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highAction := NewAction(&lowAction) + + assert.Equal(t, "$.paths./users.post.responses.201", highAction.Target) + assert.Equal(t, "$.paths./users.get.responses.200", highAction.Copy) + assert.Nil(t, highAction.Update) + assert.False(t, highAction.Remove) +} + +func TestNewAction_WithCopyAndUpdate(t *testing.T) { + yml := `target: $.paths./users.post +copy: $.paths./users.get +update: + summary: Overridden` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowAction lowoverlay.Action + err = low.BuildModel(node.Content[0], &lowAction) + require.NoError(t, err) + err = lowAction.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highAction := NewAction(&lowAction) + + assert.Equal(t, "$.paths./users.post", highAction.Target) + assert.Equal(t, "$.paths./users.get", highAction.Copy) + assert.NotNil(t, highAction.Update) +} + +func TestNewAction_NoCopy(t *testing.T) { + yml := `target: $.info +update: + title: New` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowAction lowoverlay.Action + err = low.BuildModel(node.Content[0], &lowAction) + require.NoError(t, err) + err = lowAction.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highAction := NewAction(&lowAction) + + assert.Empty(t, highAction.Copy) +} + +func TestAction_MarshalYAML_WithCopy(t *testing.T) { + yml := `target: $.info +copy: $.source +update: + title: Test` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowAction lowoverlay.Action + _ = low.BuildModel(node.Content[0], &lowAction) + _ = lowAction.Build(context.Background(), nil, node.Content[0], nil) + + highAction := NewAction(&lowAction) + + result, err := highAction.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestAction_MarshalYAML_OmitsEmptyCopy(t *testing.T) { + yml := `target: $.info +update: + title: New` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowAction lowoverlay.Action + _ = low.BuildModel(node.Content[0], &lowAction) + _ = lowAction.Build(context.Background(), nil, node.Content[0], nil) + + highAction := NewAction(&lowAction) + + rendered, err := highAction.Render() + require.NoError(t, err) + // Should NOT contain copy key when empty + assert.NotContains(t, string(rendered), "copy:") +} + +func TestAction_Render_WithCopy(t *testing.T) { + yml := `target: $.paths./new +copy: $.paths./old` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowAction lowoverlay.Action + _ = low.BuildModel(node.Content[0], &lowAction) + _ = lowAction.Build(context.Background(), nil, node.Content[0], nil) + + highAction := NewAction(&lowAction) + + rendered, err := highAction.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "target: $.paths./new") + assert.Contains(t, string(rendered), "copy: $.paths./old") +} diff --git a/datamodel/high/overlay/info.go b/datamodel/high/overlay/info.go index 93fd6ab0..b4dfcd9b 100644 --- a/datamodel/high/overlay/info.go +++ b/datamodel/high/overlay/info.go @@ -11,12 +11,13 @@ import ( ) // Info represents a high-level Overlay Info Object. -// https://spec.openapis.org/overlay/v1.0.0#info-object +// https://spec.openapis.org/overlay/v1.1.0#info-object type Info struct { - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Version string `json:"version,omitempty" yaml:"version,omitempty"` - Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` - low *low.Info + Title string `json:"title,omitempty" yaml:"title,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Extensions *orderedmap.Map[string, *yaml.Node] `json:"-" yaml:"-"` + low *low.Info } // NewInfo creates a new high-level Info instance from a low-level one. @@ -29,6 +30,9 @@ func NewInfo(info *low.Info) *Info { if !info.Version.IsEmpty() { i.Version = info.Version.Value } + if !info.Description.IsEmpty() { + i.Description = info.Description.Value + } i.Extensions = high.ExtractExtensions(info.Extensions) return i } @@ -49,7 +53,7 @@ func (i *Info) Render() ([]byte, error) { } // MarshalYAML creates a ready to render YAML representation of the Info object. -func (i *Info) MarshalYAML() (interface{}, error) { +func (i *Info) MarshalYAML() (any, error) { m := orderedmap.New[string, any]() if i.Title != "" { m.Set("title", i.Title) @@ -57,6 +61,9 @@ func (i *Info) MarshalYAML() (interface{}, error) { if i.Version != "" { m.Set("version", i.Version) } + if i.Description != "" { + m.Set("description", i.Description) + } for pair := i.Extensions.First(); pair != nil; pair = pair.Next() { m.Set(pair.Key(), pair.Value()) } diff --git a/datamodel/high/overlay/info_test.go b/datamodel/high/overlay/info_test.go index 544323ce..1edcac30 100644 --- a/datamodel/high/overlay/info_test.go +++ b/datamodel/high/overlay/info_test.go @@ -118,3 +118,103 @@ func TestInfo_MarshalYAML_Empty(t *testing.T) { require.NoError(t, err) assert.NotNil(t, result) } + +func TestNewInfo_WithDescription(t *testing.T) { + yml := `title: My Overlay +version: 1.0.0 +description: This is a **markdown** description` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowInfo lowoverlay.Info + err = low.BuildModel(node.Content[0], &lowInfo) + require.NoError(t, err) + err = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highInfo := NewInfo(&lowInfo) + + assert.Equal(t, "My Overlay", highInfo.Title) + assert.Equal(t, "1.0.0", highInfo.Version) + assert.Equal(t, "This is a **markdown** description", highInfo.Description) +} + +func TestNewInfo_EmptyDescription(t *testing.T) { + yml := `title: Overlay` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var lowInfo lowoverlay.Info + err = low.BuildModel(node.Content[0], &lowInfo) + require.NoError(t, err) + err = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + highInfo := NewInfo(&lowInfo) + + assert.Equal(t, "Overlay", highInfo.Title) + assert.Empty(t, highInfo.Description) +} + +func TestInfo_MarshalYAML_WithDescription(t *testing.T) { + yml := `title: Test +version: 2.0.0 +description: A long description here` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowInfo lowoverlay.Info + _ = low.BuildModel(node.Content[0], &lowInfo) + _ = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + + highInfo := NewInfo(&lowInfo) + + result, err := highInfo.MarshalYAML() + require.NoError(t, err) + assert.NotNil(t, result) +} + +func TestInfo_MarshalYAML_OmitsEmptyDescription(t *testing.T) { + yml := `title: Test +version: 1.0.0` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowInfo lowoverlay.Info + _ = low.BuildModel(node.Content[0], &lowInfo) + _ = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + + highInfo := NewInfo(&lowInfo) + + rendered, err := highInfo.Render() + require.NoError(t, err) + // Should NOT contain description key when empty + assert.NotContains(t, string(rendered), "description:") +} + +func TestInfo_Render_WithDescription(t *testing.T) { + yml := `title: My Overlay +version: 1.0.0 +description: This overlay adds documentation` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var lowInfo lowoverlay.Info + _ = low.BuildModel(node.Content[0], &lowInfo) + _ = lowInfo.Build(context.Background(), nil, node.Content[0], nil) + + highInfo := NewInfo(&lowInfo) + + rendered, err := highInfo.Render() + require.NoError(t, err) + assert.Contains(t, string(rendered), "title: My Overlay") + assert.Contains(t, string(rendered), "version: 1.0.0") + assert.Contains(t, string(rendered), "description: This overlay adds documentation") +} diff --git a/datamodel/low/base/contact.go b/datamodel/low/base/contact.go index 53d2e3c9..cc1d43a2 100644 --- a/datamodel/low/base/contact.go +++ b/datamodel/low/base/contact.go @@ -5,7 +5,7 @@ package base import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -61,27 +61,24 @@ func (c *Contact) GetKeyNode() *yaml.Node { return c.KeyNode } -// Hash will return a consistent SHA256 Hash of the Contact object -func (c *Contact) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !c.Name.IsEmpty() { - sb.WriteString(c.Name.Value) - sb.WriteByte('|') - } - if !c.URL.IsEmpty() { - sb.WriteString(c.URL.Value) - sb.WriteByte('|') - } - if !c.Email.IsEmpty() { - sb.WriteString(c.Email.Value) - sb.WriteByte('|') - } - - // Note: Extensions are not included in the hash for Contact - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent hash of the Contact object +func (c *Contact) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !c.Name.IsEmpty() { + h.WriteString(c.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if !c.URL.IsEmpty() { + h.WriteString(c.URL.Value) + h.WriteByte(low.HASH_PIPE) + } + if !c.Email.IsEmpty() { + h.WriteString(c.Email.Value) + h.WriteByte(low.HASH_PIPE) + } + // Note: Extensions are not included in the hash for Contact + return h.Sum64() + }) } // GetExtensions returns all extensions for Contact diff --git a/datamodel/low/base/discriminator.go b/datamodel/low/base/discriminator.go index 45ec22ba..77157492 100644 --- a/datamodel/low/base/discriminator.go +++ b/datamodel/low/base/discriminator.go @@ -4,7 +4,7 @@ package base import ( - "crypto/sha256" + "hash/maphash" "go.yaml.in/yaml/v4" @@ -51,26 +51,21 @@ func (d *Discriminator) FindMappingValue(key string) *low.ValueReference[string] return nil } -// Hash will return a consistent SHA256 Hash of the Discriminator object -func (d *Discriminator) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if d.PropertyName.Value != "" { - sb.WriteString(d.PropertyName.Value) - sb.WriteByte('|') - } - - for v := range orderedmap.SortAlpha(d.Mapping.Value).ValuesFromOldest() { - sb.WriteString(v.Value) - sb.WriteByte('|') - } - - if d.DefaultMapping.Value != "" { - sb.WriteString(d.DefaultMapping.Value) - sb.WriteByte('|') - } - - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent hash of the Discriminator object +func (d *Discriminator) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if d.PropertyName.Value != "" { + h.WriteString(d.PropertyName.Value) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(d.Mapping.Value).ValuesFromOldest() { + h.WriteString(v.Value) + h.WriteByte(low.HASH_PIPE) + } + if d.DefaultMapping.Value != "" { + h.WriteString(d.DefaultMapping.Value) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/base/example.go b/datamodel/low/base/example.go index 520865ec..5b5efa14 100644 --- a/datamodel/low/base/example.go +++ b/datamodel/low/base/example.go @@ -5,8 +5,7 @@ package base import ( "context" - "crypto/sha256" - "fmt" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -49,45 +48,39 @@ func (ex *Example) GetKeyNode() *yaml.Node { return ex.KeyNode } -// Hash will return a consistent SHA256 Hash of the Example object -func (ex *Example) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if ex.Summary.Value != "" { - sb.WriteString(ex.Summary.Value) - sb.WriteByte('|') - } - if ex.Description.Value != "" { - sb.WriteString(ex.Description.Value) - sb.WriteByte('|') - } - if ex.Value.Value != nil && !ex.Value.Value.IsZero() { - // this could be anything! - b, _ := yaml.Marshal(ex.Value.Value) - sb.WriteString(fmt.Sprintf("%x", sha256.Sum256(b))) - sb.WriteByte('|') - } - if ex.ExternalValue.Value != "" { - sb.WriteString(ex.ExternalValue.Value) - sb.WriteByte('|') - } - if ex.DataValue.Value != nil && !ex.DataValue.Value.IsZero() { - // dataValue could be anything! - b, _ := yaml.Marshal(ex.DataValue.Value) - sb.WriteString(fmt.Sprintf("%x", sha256.Sum256(b))) - sb.WriteByte('|') - } - if ex.SerializedValue.Value != "" { - sb.WriteString(ex.SerializedValue.Value) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(ex.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent hash of the Example object +func (ex *Example) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if ex.Summary.Value != "" { + h.WriteString(ex.Summary.Value) + h.WriteByte(low.HASH_PIPE) + } + if ex.Description.Value != "" { + h.WriteString(ex.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if ex.Value.Value != nil && !ex.Value.Value.IsZero() { + h.WriteString(low.GenerateHashString(ex.Value.Value)) + h.WriteByte(low.HASH_PIPE) + } + if ex.ExternalValue.Value != "" { + h.WriteString(ex.ExternalValue.Value) + h.WriteByte(low.HASH_PIPE) + } + if ex.DataValue.Value != nil && !ex.DataValue.Value.IsZero() { + h.WriteString(low.GenerateHashString(ex.DataValue.Value)) + h.WriteByte(low.HASH_PIPE) + } + if ex.SerializedValue.Value != "" { + h.WriteString(ex.SerializedValue.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(ex.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // Build extracts extensions and example value diff --git a/datamodel/low/base/external_doc.go b/datamodel/low/base/external_doc.go index 22f6ae2d..534cc371 100644 --- a/datamodel/low/base/external_doc.go +++ b/datamodel/low/base/external_doc.go @@ -5,7 +5,7 @@ package base import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -66,25 +66,22 @@ func (ex *ExternalDoc) GetExtensions() *orderedmap.Map[low.KeyReference[string], return ex.Extensions } -func (ex *ExternalDoc) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if ex.Description.Value != "" { - sb.WriteString(ex.Description.Value) - sb.WriteByte('|') - } - if ex.URL.Value != "" { - sb.WriteString(ex.URL.Value) - sb.WriteByte('|') - } - - for _, ext := range low.HashExtensions(ex.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +func (ex *ExternalDoc) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if ex.Description.Value != "" { + h.WriteString(ex.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if ex.URL.Value != "" { + h.WriteString(ex.URL.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(ex.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // GetIndex returns the index.SpecIndex instance attached to the ExternalDoc object diff --git a/datamodel/low/base/info.go b/datamodel/low/base/info.go index 2b4ab8fc..4de8cd4e 100644 --- a/datamodel/low/base/info.go +++ b/datamodel/low/base/info.go @@ -5,7 +5,7 @@ package base import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" @@ -91,43 +91,41 @@ func (i *Info) GetContext() context.Context { return i.context } -// Hash will return a consistent SHA256 Hash of the Info object -func (i *Info) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !i.Title.IsEmpty() { - sb.WriteString(i.Title.Value) - sb.WriteByte('|') - } - if !i.Summary.IsEmpty() { - sb.WriteString(i.Summary.Value) - sb.WriteByte('|') - } - if !i.Description.IsEmpty() { - sb.WriteString(i.Description.Value) - sb.WriteByte('|') - } - if !i.TermsOfService.IsEmpty() { - sb.WriteString(i.TermsOfService.Value) - sb.WriteByte('|') - } - if !i.Contact.IsEmpty() { - sb.WriteString(low.GenerateHashString(i.Contact.Value)) - sb.WriteByte('|') - } - if !i.License.IsEmpty() { - sb.WriteString(low.GenerateHashString(i.License.Value)) - sb.WriteByte('|') - } - if !i.Version.IsEmpty() { - sb.WriteString(i.Version.Value) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(i.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent hash of the Info object +func (i *Info) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !i.Title.IsEmpty() { + h.WriteString(i.Title.Value) + h.WriteByte(low.HASH_PIPE) + } + if !i.Summary.IsEmpty() { + h.WriteString(i.Summary.Value) + h.WriteByte(low.HASH_PIPE) + } + if !i.Description.IsEmpty() { + h.WriteString(i.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !i.TermsOfService.IsEmpty() { + h.WriteString(i.TermsOfService.Value) + h.WriteByte(low.HASH_PIPE) + } + if !i.Contact.IsEmpty() { + h.WriteString(low.GenerateHashString(i.Contact.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !i.License.IsEmpty() { + h.WriteString(low.GenerateHashString(i.License.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !i.Version.IsEmpty() { + h.WriteString(i.Version.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(i.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/base/license.go b/datamodel/low/base/license.go index 826fbb9e..887dea59 100644 --- a/datamodel/low/base/license.go +++ b/datamodel/low/base/license.go @@ -5,7 +5,7 @@ package base import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -66,27 +66,24 @@ func (l *License) GetKeyNode() *yaml.Node { return l.KeyNode } -// Hash will return a consistent SHA256 Hash of the License object -func (l *License) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !l.Name.IsEmpty() { - sb.WriteString(l.Name.Value) - sb.WriteByte('|') - } - if !l.URL.IsEmpty() { - sb.WriteString(l.URL.Value) - sb.WriteByte('|') - } - if !l.Identifier.IsEmpty() { - sb.WriteString(l.Identifier.Value) - sb.WriteByte('|') - } - - // Note: Extensions are not included in the hash for License - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent hash of the License object +func (l *License) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !l.Name.IsEmpty() { + h.WriteString(l.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if !l.URL.IsEmpty() { + h.WriteString(l.URL.Value) + h.WriteByte(low.HASH_PIPE) + } + if !l.Identifier.IsEmpty() { + h.WriteString(l.Identifier.Value) + h.WriteByte(low.HASH_PIPE) + } + // Note: Extensions are not included in the hash for License + return h.Sum64() + }) } // GetExtensions returns all extensions for License diff --git a/datamodel/low/base/schema.go b/datamodel/low/base/schema.go index 9126a7f8..10cc08bf 100644 --- a/datamodel/low/base/schema.go +++ b/datamodel/low/base/schema.go @@ -2,8 +2,8 @@ package base import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "sort" "strconv" "sync" @@ -43,14 +43,15 @@ func (s *SchemaDynamicValue[A, B]) IsB() bool { } // Hash will generate a stable hash of the SchemaDynamicValue -func (s *SchemaDynamicValue[A, B]) Hash() [32]byte { - var hash string - if s.IsA() { - hash = low.GenerateHashString(s.A) - } else { - hash = low.GenerateHashString(s.B) - } - return sha256.Sum256([]byte(hash)) +func (s *SchemaDynamicValue[A, B]) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if s.IsA() { + h.WriteString(low.GenerateHashString(s.A)) + } else { + h.WriteString(low.GenerateHashString(s.B)) + } + return h.Sum64() + }) } // Schema represents a JSON Schema that support Swagger, OpenAPI 3 and OpenAPI 3.1 @@ -156,7 +157,7 @@ type Schema struct { RootNode *yaml.Node index *index.SpecIndex context context.Context - hashed [32]byte // quick hash of the schema, used for quick equality checking + hashed uint64 // quick hash of the schema, used for quick equality checking hashLock sync.Mutex // lock to prevent concurrent hashing of the same schema *low.Reference low.NodeMap @@ -172,16 +173,16 @@ func (s *Schema) GetContext() context.Context { return s.context } -// QuickHash will calculate a SHA256 hash from the values of the schema, however the hash is not very deep +// QuickHash will calculate a hash from the values of the schema, however the hash is not very deep // and is used for quick equality checking, This method exists because a full hash could end up churning through // thousands of polymorphic references. With a quick hash, polymorphic properties are not included. -func (s *Schema) QuickHash() [32]byte { +func (s *Schema) QuickHash() uint64 { return s.hash(true) } -// Hash will calculate a SHA256 hash from the values of the schema, This allows equality checking against +// Hash will calculate a hash from the values of the schema, This allows equality checking against // Schemas defined inside an OpenAPI document. The only way to know if a schema has changed, is to hash it. -func (s *Schema) Hash() [32]byte { +func (s *Schema) Hash() uint64 { return s.hash(false) } @@ -194,9 +195,9 @@ func (s *Schema) Hash() [32]byte { // The hash map means each schema is hashed once, and then the hash is reused for quick equality checking. var SchemaQuickHashMap sync.Map -func (s *Schema) hash(quick bool) [32]byte { +func (s *Schema) hash(quick bool) uint64 { if s == nil { - return [32]byte{} + return 0 } // create a key for the schema, this is used to quickly check if the schema has been hashed before, and prevent re-hashing. @@ -218,7 +219,7 @@ func (s *Schema) hash(quick bool) [32]byte { key := fmt.Sprintf("%s:%d:%d:%s", path, s.RootNode.Line, s.RootNode.Column, cfId) if quick { if v, ok := SchemaQuickHashMap.Load(key); ok { - if r, k := v.([32]byte); k { + if r, k := v.(uint64); k { return r } } @@ -620,7 +621,10 @@ func (s *Schema) hash(quick bool) [32]byte { } } - h := sha256.Sum256([]byte(sb.String())) + h := low.WithHasher(func(hasher *maphash.Hash) uint64 { + hasher.WriteString(sb.String()) + return hasher.Sum64() + }) SchemaQuickHashMap.Store(key, h) return h } diff --git a/datamodel/low/base/schema_proxy.go b/datamodel/low/base/schema_proxy.go index 87571d8c..4e7b80bb 100644 --- a/datamodel/low/base/schema_proxy.go +++ b/datamodel/low/base/schema_proxy.go @@ -5,9 +5,9 @@ package base import ( "context" - "crypto/sha256" "errors" "fmt" + "hash/maphash" "log/slog" "sync" @@ -58,7 +58,7 @@ type SchemaProxy struct { rendered *Schema buildError error ctx context.Context - cachedHash *[32]byte // Cache computed hash to avoid recalculation + cachedHash *uint64 // Cache computed hash to avoid recalculation TransformedRef *yaml.Node // Original node that contained the ref before transformation *low.NodeMap } @@ -183,20 +183,23 @@ func (sp *SchemaProxy) GetValueNode() *yaml.Node { return sp.vn } -// Hash will return a consistent SHA256 Hash of the SchemaProxy object (it will resolve it) -func (sp *SchemaProxy) Hash() [32]byte { +// Hash will return a consistent Hash of the SchemaProxy object (it will resolve it) +func (sp *SchemaProxy) Hash() uint64 { if sp.cachedHash != nil { return *sp.cachedHash } - var hash [32]byte + var hash uint64 if sp.rendered != nil { if !sp.IsReference() { hash = sp.rendered.Hash() } else { // For references, hash the reference value - hash = sha256.Sum256([]byte(sp.GetReference())) + hash = low.WithHasher(func(h *maphash.Hash) uint64 { + h.WriteString(sp.GetReference()) + return h.Sum64() + }) } } else { if !sp.IsReference() { @@ -223,7 +226,7 @@ func (sp *SchemaProxy) Hash() [32]byte { logger.Warn("SchemaProxy.Hash() unable to complete hash: ", "error", bErr.Error()) } } - hash = [32]byte{} + hash = 0 } } else { // Handle UseSchemaQuickHash case for references @@ -234,11 +237,17 @@ func (sp *SchemaProxy) Hash() [32]byte { } hash = sp.rendered.QuickHash() // quick hash uses a cache to keep things fast. } else { - hash = sha256.Sum256([]byte(sp.GetReference())) + hash = low.WithHasher(func(h *maphash.Hash) uint64 { + h.WriteString(sp.GetReference()) + return h.Sum64() + }) } } else { // Hash reference value only, do not resolve! - hash = sha256.Sum256([]byte(sp.GetReference())) + hash = low.WithHasher(func(h *maphash.Hash) uint64 { + h.WriteString(sp.GetReference()) + return h.Sum64() + }) } } } diff --git a/datamodel/low/base/schema_proxy_test.go b/datamodel/low/base/schema_proxy_test.go index fb640b45..6e7c35fd 100644 --- a/datamodel/low/base/schema_proxy_test.go +++ b/datamodel/low/base/schema_proxy_test.go @@ -5,7 +5,6 @@ package base import ( "context" - "fmt" "log/slog" "os" "testing" @@ -34,8 +33,9 @@ description: something` assert.NoError(t, err) assert.Equal(t, "value", sch.GetContext().Value("key")) - assert.Equal(t, "be79763a610e8016259d370c7f286eb747ee2ada7add3d21634ba96f8aa99838", - low.GenerateHashString(&sch)) + // maphash uses random seed per process, so test consistency, not specific values + hash1 := low.GenerateHashString(&sch) + assert.NotEmpty(t, hash1) assert.Equal(t, "something", sch.Schema().Description.GetValue()) assert.Empty(t, sch.GetReference()) @@ -45,9 +45,9 @@ description: something` sch.SetReference("coffee", nil) assert.Equal(t, "coffee", sch.GetReference()) - // already rendered, should spit out the same - assert.Equal(t, "be79763a610e8016259d370c7f286eb747ee2ada7add3d21634ba96f8aa99838", - low.GenerateHashString(&sch)) + // already rendered, should spit out the same hash + hash2 := low.GenerateHashString(&sch) + assert.Equal(t, hash1, hash2) assert.Equal(t, 1, orderedmap.Len(sch.Schema().GetExtensions())) assert.Nil(t, sch.GetIndex()) @@ -64,8 +64,8 @@ func TestSchemaProxy_Build_CheckRef(t *testing.T) { assert.NoError(t, err) assert.True(t, sch.IsReference()) assert.Equal(t, "wat", sch.GetReference()) - assert.Equal(t, "f00a787f7492a95e165b470702f4fe9373583fbdc025b2c8bdf0262cc48fcff4", - low.GenerateHashString(&sch)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&sch)) } func TestSchemaProxy_Build_HashInline(t *testing.T) { @@ -79,8 +79,8 @@ func TestSchemaProxy_Build_HashInline(t *testing.T) { assert.NoError(t, err) assert.False(t, sch.IsReference()) assert.NotNil(t, sch.Schema()) - assert.Equal(t, "5a5bb0d7677da2b3f5fa37fe78786e124568729675d0933b2a2982cd1410c14f", - low.GenerateHashString(&sch)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&sch)) } func TestSchemaProxy_Build_UsingMergeNodes(t *testing.T) { @@ -171,7 +171,8 @@ func TestSchemaProxy_Build_HashFail(t *testing.T) { idx := index.NewSpecIndexWithConfig(nil, &index.SpecIndexConfig{Logger: logger}) sp.idx = idx v := sp.Hash() - assert.Equal(t, [32]byte{}, v) + // Now returns uint64(0) instead of [32]byte{} for empty/error cases + assert.Equal(t, uint64(0), v) } func TestSchemaProxy_AddNodePassthrough(t *testing.T) { @@ -210,8 +211,8 @@ func TestSchemaProxy_HashRef(t *testing.T) { sp.rendered = &Schema{} v := sp.Hash() - y := fmt.Sprintf("%x", v) - assert.Equal(t, "811eb81b9d11d65a36c53c3ebdb738ee303403cb79d781ccf4b40764e0a9d12a", y) + // maphash uses random seed per process, so just test non-zero + assert.NotEqual(t, uint64(0), v) } func TestSchemaProxy_HashRef_NoRender(t *testing.T) { @@ -235,8 +236,8 @@ func TestSchemaProxy_HashRef_NoRender(t *testing.T) { sp.idx = idx v := sp.Hash() - y := fmt.Sprintf("%x", v) - assert.Equal(t, "7ebbb597617277b740e49886cf332de3de8c47baf1da4931cc59ff71944f81d9", y) + // maphash uses random seed per process, so just test non-zero + assert.NotEqual(t, uint64(0), v) } func TestSchemaProxy_QuickHash_Empty(t *testing.T) { @@ -256,7 +257,8 @@ func TestSchemaProxy_QuickHash_Empty(t *testing.T) { rolo.SetRootIndex(idx) v := sp.Hash() - assert.Equal(t, [32]byte{}, v) + // Now returns uint64(0) instead of [32]byte{} for empty/error cases + assert.Equal(t, uint64(0), v) } func TestSchemaProxy_TestRolodexHasId(t *testing.T) { @@ -276,8 +278,8 @@ func TestSchemaProxy_TestRolodexHasId(t *testing.T) { assert.NoError(t, err) assert.False(t, sch.IsReference()) assert.NotNil(t, sch.Schema()) - assert.Equal(t, "5a5bb0d7677da2b3f5fa37fe78786e124568729675d0933b2a2982cd1410c14f", - low.GenerateHashString(&sch)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&sch)) } func TestSchemaProxy_Hash_UseSchemaQuickHash_NonCircular(t *testing.T) { diff --git a/datamodel/low/base/schema_test.go b/datamodel/low/base/schema_test.go index b24d3767..6fcc4cb5 100644 --- a/datamodel/low/base/schema_test.go +++ b/datamodel/low/base/schema_test.go @@ -2,7 +2,6 @@ package base import ( "context" - "crypto/sha256" "sync" "testing" timeStd "time" @@ -1650,8 +1649,8 @@ func TestSchema_UnevaluatedPropertiesAsBool_DefinedAsTrue(t *testing.T) { assert.True(t, res.Value.Schema().UnevaluatedProperties.Value.IsB()) assert.True(t, res.Value.Schema().UnevaluatedProperties.Value.B) - assert.Equal(t, "571bd1853c22393131e2dcadce86894da714ec14968895c8b7ed18154b2be8cd", - low.GenerateHashString(res.Value.Schema().UnevaluatedProperties.Value)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(res.Value.Schema().UnevaluatedProperties.Value)) } func TestSchema_UnevaluatedPropertiesAsBool_DefinedAsFalse(t *testing.T) { @@ -2607,11 +2606,8 @@ func TestSchemaDynamicValue_Hash_IsA(t *testing.T) { hash := value.Hash() - // verify it uses the A value (string) - expectedHashString := low.GenerateHashString("test value") - expectedHash := sha256.Sum256([]byte(expectedHashString)) - - assert.Equal(t, expectedHash, hash) + // maphash uses random seed per process, just verify it's non-zero + assert.NotEqual(t, uint64(0), hash) assert.True(t, value.IsA()) assert.False(t, value.IsB()) } @@ -2667,11 +2663,8 @@ func TestSchemaDynamicValue_Hash_IsB(t *testing.T) { hash := value.Hash() - // verify it uses the B value (int) - expectedHashString := low.GenerateHashString(42) - expectedHash := sha256.Sum256([]byte(expectedHashString)) - - assert.Equal(t, expectedHash, hash) + // maphash uses random seed per process, just verify it's non-zero + assert.NotEqual(t, uint64(0), hash) assert.False(t, value.IsA()) assert.True(t, value.IsB()) } diff --git a/datamodel/low/base/security_requirement.go b/datamodel/low/base/security_requirement.go index acdd7e4f..c5413631 100644 --- a/datamodel/low/base/security_requirement.go +++ b/datamodel/low/base/security_requirement.go @@ -5,8 +5,7 @@ package base import ( "context" - "crypto/sha256" - "fmt" + "hash/maphash" "sort" "github.com/pb33f/libopenapi/datamodel/low" @@ -128,28 +127,27 @@ func (s *SecurityRequirement) GetKeys() []string { return keys } -// Hash will return a consistent SHA256 Hash of the SecurityRequirement object -func (s *SecurityRequirement) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - for k, v := range orderedmap.SortAlpha(s.Requirements.Value).FromOldest() { - // Pre-allocate vals slice - vals := make([]string, len(v.Value)) - for y := range v.Value { - vals[y] = v.Value[y].Value - } - sort.Strings(vals) - - sb.WriteString(fmt.Sprintf("%s-", k.Value)) - for i, val := range vals { - if i > 0 { - sb.WriteByte('|') +// Hash will return a consistent hash of the SecurityRequirement object +func (s *SecurityRequirement) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for k, v := range orderedmap.SortAlpha(s.Requirements.Value).FromOldest() { + // Pre-allocate vals slice + vals := make([]string, len(v.Value)) + for y := range v.Value { + vals[y] = v.Value[y].Value } - sb.WriteString(val) + sort.Strings(vals) + + h.WriteString(k.Value) + h.WriteByte('-') + for i, val := range vals { + if i > 0 { + h.WriteByte(low.HASH_PIPE) + } + h.WriteString(val) + } + h.WriteByte(low.HASH_PIPE) } - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) + return h.Sum64() + }) } diff --git a/datamodel/low/base/tag.go b/datamodel/low/base/tag.go index 1be056f2..3438476f 100644 --- a/datamodel/low/base/tag.go +++ b/datamodel/low/base/tag.go @@ -5,7 +5,7 @@ package base import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -87,60 +87,37 @@ func (t *Tag) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.Valu return t.Extensions } -// Hash will return a consistent SHA256 Hash of the Tag object -func (t *Tag) Hash() [32]byte { - // Pre-calculate field count for optimal allocation - fieldCount := 0 - if !t.Name.IsEmpty() { - fieldCount++ - } - if !t.Summary.IsEmpty() { - fieldCount++ - } - if !t.Description.IsEmpty() { - fieldCount++ - } - if !t.ExternalDocs.IsEmpty() { - fieldCount++ - } - if !t.Parent.IsEmpty() { - fieldCount++ - } - if !t.Kind.IsEmpty() { - fieldCount++ - } - - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !t.Name.IsEmpty() { - sb.WriteString(t.Name.Value) - sb.WriteByte('|') - } - if !t.Summary.IsEmpty() { - sb.WriteString(t.Summary.Value) - sb.WriteByte('|') - } - if !t.Description.IsEmpty() { - sb.WriteString(t.Description.Value) - sb.WriteByte('|') - } - if !t.ExternalDocs.IsEmpty() { - sb.WriteString(low.GenerateHashString(t.ExternalDocs.Value)) - sb.WriteByte('|') - } - if !t.Parent.IsEmpty() { - sb.WriteString(t.Parent.Value) - sb.WriteByte('|') - } - if !t.Kind.IsEmpty() { - sb.WriteString(t.Kind.Value) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(t.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent hash of the Tag object +func (t *Tag) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !t.Name.IsEmpty() { + h.WriteString(t.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if !t.Summary.IsEmpty() { + h.WriteString(t.Summary.Value) + h.WriteByte(low.HASH_PIPE) + } + if !t.Description.IsEmpty() { + h.WriteString(t.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !t.ExternalDocs.IsEmpty() { + h.WriteString(low.GenerateHashString(t.ExternalDocs.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !t.Parent.IsEmpty() { + h.WriteString(t.Parent.Value) + h.WriteByte(low.HASH_PIPE) + } + if !t.Kind.IsEmpty() { + h.WriteString(t.Kind.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(t.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/base/xml.go b/datamodel/low/base/xml.go index d3717360..e4688431 100644 --- a/datamodel/low/base/xml.go +++ b/datamodel/low/base/xml.go @@ -2,8 +2,7 @@ package base import ( "context" - "crypto/sha256" - "strconv" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -63,39 +62,37 @@ func (x *XML) GetIndex() *index.SpecIndex { return x.index } -// Hash generates a SHA256 hash of the XML object using properties -func (x *XML) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !x.Name.IsEmpty() { - sb.WriteString(x.Name.Value) - sb.WriteByte('|') - } - if !x.Namespace.IsEmpty() { - sb.WriteString(x.Namespace.Value) - sb.WriteByte('|') - } - if !x.Prefix.IsEmpty() { - sb.WriteString(x.Prefix.Value) - sb.WriteByte('|') - } - if !x.Attribute.IsEmpty() { - sb.WriteString(strconv.FormatBool(x.Attribute.Value)) - sb.WriteByte('|') - } - if !x.NodeType.IsEmpty() { - sb.WriteString(x.NodeType.Value) - sb.WriteByte('|') - } - if !x.Wrapped.IsEmpty() { - sb.WriteString(strconv.FormatBool(x.Wrapped.Value)) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(x.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash generates a hash of the XML object using properties +func (x *XML) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !x.Name.IsEmpty() { + h.WriteString(x.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if !x.Namespace.IsEmpty() { + h.WriteString(x.Namespace.Value) + h.WriteByte(low.HASH_PIPE) + } + if !x.Prefix.IsEmpty() { + h.WriteString(x.Prefix.Value) + h.WriteByte(low.HASH_PIPE) + } + if !x.Attribute.IsEmpty() { + low.HashBool(h, x.Attribute.Value) + h.WriteByte(low.HASH_PIPE) + } + if !x.NodeType.IsEmpty() { + h.WriteString(x.NodeType.Value) + h.WriteByte(low.HASH_PIPE) + } + if !x.Wrapped.IsEmpty() { + low.HashBool(h, x.Wrapped.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(x.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/extraction_functions.go b/datamodel/low/extraction_functions.go index 6e31af91..72302e52 100644 --- a/datamodel/low/extraction_functions.go +++ b/datamodel/low/extraction_functions.go @@ -87,8 +87,8 @@ func HashExtensions(ext *orderedmap.Map[KeyReference[string], ValueReference[*ya f := []string{} for e, node := range orderedmap.SortAlpha(ext).FromOldest() { - b, _ := yaml.Marshal(node.GetValue()) - f = append(f, fmt.Sprintf("%s-%x", e.Value, sha256.Sum256([]byte(b)))) + // Use content-only hash (not index.HashNode which includes line/column) + f = append(f, fmt.Sprintf("%s-%s", e.Value, hashYamlNodeFast(node.GetValue()))) } return f @@ -952,9 +952,9 @@ func GenerateHashString(v any) string { if h, ok := v.(Hashable); ok { if h != nil { - // Use hex.EncodeToString which is more efficient than fmt.Sprintf + // Format uint64 hash as hex string hash := h.Hash() - hashStr = hex.EncodeToString(hash[:]) + hashStr = strconv.FormatUint(hash, 16) } } else if n, ok := v.(*yaml.Node); ok { // Fast path for common YAML node types to avoid marshaling diff --git a/datamodel/low/extraction_functions_test.go b/datamodel/low/extraction_functions_test.go index a0d95617..f0959d23 100644 --- a/datamodel/low/extraction_functions_test.go +++ b/datamodel/low/extraction_functions_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha256" "fmt" + "hash/maphash" "net/url" "os" "path/filepath" @@ -1519,15 +1520,18 @@ type test_fresh struct { thang *bool } -func (f test_fresh) Hash() [32]byte { - var data []string - if f.val != "" { - data = append(data, f.val) - } - if f.thang != nil { - data = append(data, fmt.Sprintf("%v", *f.thang)) - } - return sha256.Sum256([]byte(strings.Join(data, "|"))) +func (f test_fresh) Hash() uint64 { + return WithHasher(func(h *maphash.Hash) uint64 { + if f.val != "" { + h.WriteString(f.val) + h.WriteByte(HASH_PIPE) + } + if f.thang != nil { + HashBool(h, *f.thang) + h.WriteByte(HASH_PIPE) + } + return h.Sum64() + }) } func TestAreEqual(t *testing.T) { @@ -1543,28 +1547,44 @@ func TestAreEqual(t *testing.T) { } func TestGenerateHashString(t *testing.T) { - assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - GenerateHashString(test_fresh{val: "hello"})) + // Note: maphash uses a random seed per process, so we can't test for specific values. + // Instead, we test for consistency and properties. - assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - GenerateHashString("hello")) + // Hashable produces consistent hash + hash1 := GenerateHashString(test_fresh{val: "hello"}) + hash2 := GenerateHashString(test_fresh{val: "hello"}) + assert.Equal(t, hash1, hash2) + assert.NotEmpty(t, hash1) - assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - GenerateHashString("")) + // String produces a hash (uses maphash for primitives) + strHash := GenerateHashString("hello") + assert.NotEmpty(t, strHash) - assert.Equal(t, "", - GenerateHashString(nil)) + // Empty string still produces a hash + emptyHash := GenerateHashString("") + assert.NotEmpty(t, emptyHash) - assert.Equal(t, "a8468424300fc9f9206c220da9683b8b8e70474586e28a9002e740cd687b74df", GenerateHashString(utils.CreateStringNode("test"))) + // Nil returns empty string + assert.Equal(t, "", GenerateHashString(nil)) + + // YAML node produces a hash + nodeHash := GenerateHashString(utils.CreateStringNode("test")) + assert.NotEmpty(t, nodeHash) } func TestGenerateHashString_Pointer(t *testing.T) { + // Note: maphash uses a random seed per process, so we can't test for specific values. val := true - assert.Equal(t, "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b", - GenerateHashString(test_fresh{thang: &val})) - assert.Equal(t, "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b", - GenerateHashString(&val)) + // Hashable with boolean produces consistent hash + hash1 := GenerateHashString(test_fresh{thang: &val}) + hash2 := GenerateHashString(test_fresh{thang: &val}) + assert.Equal(t, hash1, hash2) + assert.NotEmpty(t, hash1) + + // Boolean pointer produces a hash + boolHash := GenerateHashString(&val) + assert.NotEmpty(t, boolHash) } func TestSetReference(t *testing.T) { @@ -2119,45 +2139,40 @@ func TestArray_NotRefNotArray(t *testing.T) { } func TestHashExtensions(t *testing.T) { - type args struct { - ext *orderedmap.Map[KeyReference[string], ValueReference[*yaml.Node]] - } - tests := []struct { - name string - args args - want []string - }{ - { - name: "empty", - args: args{ - ext: orderedmap.New[KeyReference[string], ValueReference[*yaml.Node]](), - }, - want: []string{}, - }, - { - name: "hashes extensions", - args: args{ - ext: orderedmap.ToOrderedMap(map[KeyReference[string]]ValueReference[*yaml.Node]{ - {Value: "x-burger"}: { - Value: utils.CreateStringNode("yummy"), - }, - {Value: "x-car"}: { - Value: utils.CreateStringNode("ford"), - }, - }), + // Test empty extensions + t.Run("empty", func(t *testing.T) { + ext := orderedmap.New[KeyReference[string], ValueReference[*yaml.Node]]() + hash := HashExtensions(ext) + assert.Equal(t, []string{}, hash) + }) + + // Test hashing extensions - check structure, not specific values + // (maphash uses random seed per process) + t.Run("hashes extensions", func(t *testing.T) { + ext := orderedmap.ToOrderedMap(map[KeyReference[string]]ValueReference[*yaml.Node]{ + {Value: "x-burger"}: { + Value: utils.CreateStringNode("yummy"), }, - want: []string{ - "x-burger-2a296977a4572521773eb7e7773cc054fae3e8589511ce9bf90cec7dd93d016a", - "x-car-7d3aa6a5c79cdb0c2585daed714fa0936a18e6767b2dcc804992a90f6d0b8f5e", + {Value: "x-car"}: { + Value: utils.CreateStringNode("ford"), }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hash := HashExtensions(tt.args.ext) - assert.Equal(t, tt.want, hash) }) - } + hash := HashExtensions(ext) + + // Should have 2 entries + assert.Len(t, hash, 2) + + // Each should have format "x-name-hexhash" + for _, h := range hash { + assert.True(t, strings.HasPrefix(h, "x-"), "should have x- prefix") + parts := strings.Split(h, "-") + assert.GreaterOrEqual(t, len(parts), 2, "should have name-hash format") + } + + // Should be consistent (same input = same output) + hash2 := HashExtensions(ext) + assert.Equal(t, hash, hash2) + }) } func TestValueToString(t *testing.T) { @@ -3019,8 +3034,8 @@ func TestGenerateHashString_EmptyHashStr(t *testing.T) { // Hit the hashStr == "" condition in cache storage check (line ~1014) ClearHashCache() result := GenerateHashString(&testHashable{}) - // Empty hash should not be cached, but should return the empty hex string - assert.Equal(t, "0000000000000000000000000000000000000000000000000000000000000000", result) + // testHashable returns 0, which should convert to "0" + assert.Equal(t, "0", result) } func TestExtractMapExtensions_RefError(t *testing.T) { @@ -3184,8 +3199,8 @@ components: // Custom Hashable implementation for testing nil hash type testHashable struct{} -func (t testHashable) Hash() [32]byte { - return [32]byte{} // All zeros - empty hash +func (t testHashable) Hash() uint64 { + return 0 // Zero hash for testing } func TestGenerateHashString_EdgeCaseCoverage(t *testing.T) { diff --git a/datamodel/low/hash.go b/datamodel/low/hash.go new file mode 100644 index 00000000..37f2a517 --- /dev/null +++ b/datamodel/low/hash.go @@ -0,0 +1,64 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "encoding/binary" + "hash/maphash" + "sync" +) + +// globalHashSeed ensures consistent hashes across all pooled instances. +// Set once at init, deterministic within a process run. +var globalHashSeed maphash.Seed + +func init() { + globalHashSeed = maphash.MakeSeed() +} + +// hasherPool pools maphash.Hash instances for reuse +var hasherPool = sync.Pool{ + New: func() any { + h := &maphash.Hash{} + h.SetSeed(globalHashSeed) + return h + }, +} + +// WithHasher provides a pooled hasher for the duration of fn. +// The hasher is automatically returned to the pool after fn completes. +// This pattern eliminates forgotten PutHasher() bugs. +func WithHasher(fn func(h *maphash.Hash) uint64) uint64 { + hasher := hasherPool.Get().(*maphash.Hash) + hasher.Reset() + result := fn(hasher) + hasherPool.Put(hasher) + return result +} + +// HashBool writes a boolean as a single byte. +func HashBool(h *maphash.Hash, b bool) { + if b { + h.WriteByte(1) + } else { + h.WriteByte(0) + } +} + +// HashInt64 writes an int64 without allocation using binary encoding. +func HashInt64(h *maphash.Hash, n int64) { + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], uint64(n)) + h.Write(buf[:]) +} + +// HashUint64 writes another hash value (for composition of nested Hashable objects). +func HashUint64(h *maphash.Hash, v uint64) { + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], v) + h.Write(buf[:]) +} + +// HASH_PIPE is the separator byte used between hash fields. :) +const HASH_PIPE = '|' diff --git a/datamodel/low/hash_test.go b/datamodel/low/hash_test.go new file mode 100644 index 00000000..6c535b87 --- /dev/null +++ b/datamodel/low/hash_test.go @@ -0,0 +1,88 @@ +// Copyright 2022-2025 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package low + +import ( + "hash/maphash" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHashBool_True(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashBool(h, true) + return h.Sum64() + }) + assert.NotZero(t, result) +} + +func TestHashBool_False(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashBool(h, false) + return h.Sum64() + }) + assert.NotZero(t, result) +} + +func TestHashBool_DifferentValues(t *testing.T) { + trueHash := WithHasher(func(h *maphash.Hash) uint64 { + HashBool(h, true) + return h.Sum64() + }) + falseHash := WithHasher(func(h *maphash.Hash) uint64 { + HashBool(h, false) + return h.Sum64() + }) + // true and false should produce different hashes + assert.NotEqual(t, trueHash, falseHash) +} + +func TestHashInt64(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashInt64(h, 12345) + return h.Sum64() + }) + assert.NotZero(t, result) +} + +func TestHashInt64_Negative(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashInt64(h, -99999) + return h.Sum64() + }) + assert.NotZero(t, result) +} + +func TestHashInt64_Zero(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashInt64(h, 0) + return h.Sum64() + }) + assert.NotZero(t, result) +} + +func TestHashUint64(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashUint64(h, 987654321) + return h.Sum64() + }) + assert.NotZero(t, result) +} + +func TestHashUint64_Zero(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashUint64(h, 0) + return h.Sum64() + }) + assert.NotZero(t, result) +} + +func TestHashUint64_MaxValue(t *testing.T) { + result := WithHasher(func(h *maphash.Hash) uint64 { + HashUint64(h, ^uint64(0)) // max uint64 + return h.Sum64() + }) + assert.NotZero(t, result) +} diff --git a/datamodel/low/model_interfaces.go b/datamodel/low/model_interfaces.go index 51e266b1..e6248712 100644 --- a/datamodel/low/model_interfaces.go +++ b/datamodel/low/model_interfaces.go @@ -10,7 +10,7 @@ import ( type SharedParameters interface { HasDescription - Hash() [32]byte + Hash() uint64 GetName() *NodeReference[string] GetIn() *NodeReference[string] GetAllowEmptyValue() *NodeReference[bool] @@ -52,7 +52,7 @@ type SwaggerParameter interface { type SwaggerHeader interface { HasDescription - Hash() [32]byte + Hash() uint64 GetType() *NodeReference[string] GetFormat() *NodeReference[string] GetCollectionFormat() *NodeReference[string] @@ -74,7 +74,7 @@ type SwaggerHeader interface { type OpenAPIHeader interface { HasDescription - Hash() [32]byte + Hash() uint64 GetDeprecated() *NodeReference[bool] GetStyle() *NodeReference[string] GetAllowReserved() *NodeReference[bool] diff --git a/datamodel/low/overlay/action.go b/datamodel/low/overlay/action.go index 3526c4e6..7b9df036 100644 --- a/datamodel/low/overlay/action.go +++ b/datamodel/low/overlay/action.go @@ -5,7 +5,7 @@ package overlay import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -15,12 +15,13 @@ import ( ) // Action represents a low-level Overlay Action Object. -// https://spec.openapis.org/overlay/v1.0.0#action-object +// https://spec.openapis.org/overlay/v1.1.0#action-object type Action struct { Target low.NodeReference[string] Description low.NodeReference[string] Update low.NodeReference[*yaml.Node] Remove low.NodeReference[bool] + Copy low.NodeReference[string] Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] KeyNode *yaml.Node RootNode *yaml.Node @@ -87,34 +88,33 @@ func (a *Action) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.V return a.Extensions } -// Hash will return a consistent SHA256 Hash of the Action object -func (a *Action) Hash() [32]byte { - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !a.Target.IsEmpty() { - sb.WriteString(a.Target.Value) - sb.WriteByte('|') - } - if !a.Description.IsEmpty() { - sb.WriteString(a.Description.Value) - sb.WriteByte('|') - } - if !a.Update.IsEmpty() { - sb.WriteString(low.GenerateHashString(a.Update.Value)) - sb.WriteByte('|') - } - if !a.Remove.IsEmpty() { - if a.Remove.Value { - sb.WriteString("true") - } else { - sb.WriteString("false") +// Hash will return a consistent Hash of the Action object +func (a *Action) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !a.Target.IsEmpty() { + h.WriteString(a.Target.Value) + h.WriteByte(low.HASH_PIPE) } - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(a.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) + if !a.Description.IsEmpty() { + h.WriteString(a.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !a.Update.IsEmpty() { + h.WriteString(low.GenerateHashString(a.Update.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !a.Remove.IsEmpty() { + low.HashBool(h, a.Remove.Value) + h.WriteByte(low.HASH_PIPE) + } + if !a.Copy.IsEmpty() { + h.WriteString(a.Copy.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(a.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/overlay/action_test.go b/datamodel/low/overlay/action_test.go index e6ad8fee..82995c46 100644 --- a/datamodel/low/overlay/action_test.go +++ b/datamodel/low/overlay/action_test.go @@ -211,3 +211,133 @@ description: Just a description` assert.True(t, action.Update.IsEmpty()) } + +func TestAction_Build_WithCopy(t *testing.T) { + yml := `target: $.paths./users.post.responses.201 +copy: $.paths./users.get.responses.200` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "$.paths./users.post.responses.201", action.Target.Value) + assert.Equal(t, "$.paths./users.get.responses.200", action.Copy.Value) + assert.True(t, action.Update.IsEmpty()) + assert.True(t, action.Remove.IsEmpty()) +} + +func TestAction_Build_WithCopyAndUpdate(t *testing.T) { + yml := `target: $.paths./users.post +copy: $.paths./users.get +update: + summary: Overridden summary` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "$.paths./users.post", action.Target.Value) + assert.Equal(t, "$.paths./users.get", action.Copy.Value) + assert.False(t, action.Update.IsEmpty()) +} + +func TestAction_Build_NoCopy(t *testing.T) { + yml := `target: $.info +update: + title: New Title` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var action Action + err = low.BuildModel(node.Content[0], &action) + require.NoError(t, err) + + err = action.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.True(t, action.Copy.IsEmpty()) +} + +func TestAction_Hash_WithCopy(t *testing.T) { + yml1 := `target: $.info +copy: $.other` + + yml2 := `target: $.info +copy: $.other` + + yml3 := `target: $.info +copy: $.different` + + var node1, node2, node3 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + _ = yaml.Unmarshal([]byte(yml3), &node3) + + var action1, action2, action3 Action + _ = low.BuildModel(node1.Content[0], &action1) + _ = action1.Build(context.Background(), nil, node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &action2) + _ = action2.Build(context.Background(), nil, node2.Content[0], nil) + + _ = low.BuildModel(node3.Content[0], &action3) + _ = action3.Build(context.Background(), nil, node3.Content[0], nil) + + assert.Equal(t, action1.Hash(), action2.Hash()) + assert.NotEqual(t, action1.Hash(), action3.Hash()) +} + +func TestAction_Hash_CopyAffectsHash(t *testing.T) { + ymlWithCopy := `target: $.info +copy: $.source` + + ymlWithoutCopy := `target: $.info` + + var node1, node2 yaml.Node + _ = yaml.Unmarshal([]byte(ymlWithCopy), &node1) + _ = yaml.Unmarshal([]byte(ymlWithoutCopy), &node2) + + var action1, action2 Action + _ = low.BuildModel(node1.Content[0], &action1) + _ = action1.Build(context.Background(), nil, node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &action2) + _ = action2.Build(context.Background(), nil, node2.Content[0], nil) + + assert.NotEqual(t, action1.Hash(), action2.Hash()) +} + +func TestAction_Hash_AllFieldsIncludingCopy(t *testing.T) { + yml := `target: $.info +description: Copy and update info +copy: $.source +update: + title: Test +x-ext: value` + + var node yaml.Node + _ = yaml.Unmarshal([]byte(yml), &node) + + var action Action + _ = low.BuildModel(node.Content[0], &action) + _ = action.Build(context.Background(), nil, node.Content[0], nil) + + hash := action.Hash() + assert.NotEqual(t, uint64(0), hash) +} diff --git a/datamodel/low/overlay/constants.go b/datamodel/low/overlay/constants.go index 56696baa..0bbcaf01 100644 --- a/datamodel/low/overlay/constants.go +++ b/datamodel/low/overlay/constants.go @@ -4,7 +4,7 @@ package overlay // Constants for labels used to look up values within OpenAPI Overlay specifications. -// https://spec.openapis.org/overlay/v1.0.0 +// https://spec.openapis.org/overlay/v1.1.0 const ( OverlayLabel = "overlay" InfoLabel = "info" @@ -16,4 +16,5 @@ const ( DescriptionLabel = "description" UpdateLabel = "update" RemoveLabel = "remove" + CopyLabel = "copy" ) diff --git a/datamodel/low/overlay/info.go b/datamodel/low/overlay/info.go index 2306bd06..a2781029 100644 --- a/datamodel/low/overlay/info.go +++ b/datamodel/low/overlay/info.go @@ -5,7 +5,7 @@ package overlay import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -15,15 +15,16 @@ import ( ) // Info represents a low-level Overlay Info Object. -// https://spec.openapis.org/overlay/v1.0.0#info-object +// https://spec.openapis.org/overlay/v1.1.0#info-object type Info struct { - Title low.NodeReference[string] - Version low.NodeReference[string] - Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] - KeyNode *yaml.Node - RootNode *yaml.Node - index *index.SpecIndex - context context.Context + Title low.NodeReference[string] + Version low.NodeReference[string] + Description low.NodeReference[string] + Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] + KeyNode *yaml.Node + RootNode *yaml.Node + index *index.SpecIndex + context context.Context *low.Reference low.NodeMap } @@ -73,22 +74,25 @@ func (i *Info) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.Val return i.Extensions } -// Hash will return a consistent SHA256 Hash of the Info object -func (i *Info) Hash() [32]byte { - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !i.Title.IsEmpty() { - sb.WriteString(i.Title.Value) - sb.WriteByte('|') - } - if !i.Version.IsEmpty() { - sb.WriteString(i.Version.Value) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(i.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Info object +func (inf *Info) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !inf.Title.IsEmpty() { + h.WriteString(inf.Title.Value) + h.WriteByte(low.HASH_PIPE) + } + if !inf.Version.IsEmpty() { + h.WriteString(inf.Version.Value) + h.WriteByte(low.HASH_PIPE) + } + if !inf.Description.IsEmpty() { + h.WriteString(inf.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(inf.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/overlay/info_test.go b/datamodel/low/overlay/info_test.go index 95acb4ee..2104d44c 100644 --- a/datamodel/low/overlay/info_test.go +++ b/datamodel/low/overlay/info_test.go @@ -132,3 +132,116 @@ func TestInfo_FindExtension_NotFound(t *testing.T) { ext := info.FindExtension("x-nonexistent") assert.Nil(t, ext) } + +func TestInfo_Build_WithDescription(t *testing.T) { + yml := `title: My Overlay +version: 1.0.0 +description: This is a **markdown** description of the overlay` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var info Info + err = low.BuildModel(node.Content[0], &info) + require.NoError(t, err) + + err = info.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "My Overlay", info.Title.Value) + assert.Equal(t, "1.0.0", info.Version.Value) + assert.Equal(t, "This is a **markdown** description of the overlay", info.Description.Value) +} + +func TestInfo_Build_EmptyDescription(t *testing.T) { + yml := `title: Overlay +description: ""` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var info Info + err = low.BuildModel(node.Content[0], &info) + require.NoError(t, err) + + err = info.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.Equal(t, "Overlay", info.Title.Value) + assert.Equal(t, "", info.Description.Value) + assert.False(t, info.Description.IsEmpty()) +} + +func TestInfo_Build_NoDescription(t *testing.T) { + yml := `title: Overlay +version: 1.0.0` + + var node yaml.Node + err := yaml.Unmarshal([]byte(yml), &node) + require.NoError(t, err) + + var info Info + err = low.BuildModel(node.Content[0], &info) + require.NoError(t, err) + + err = info.Build(context.Background(), nil, node.Content[0], nil) + require.NoError(t, err) + + assert.True(t, info.Description.IsEmpty()) +} + +func TestInfo_Hash_WithDescription(t *testing.T) { + yml1 := `title: Overlay +version: 1.0.0 +description: Same description` + + yml2 := `title: Overlay +version: 1.0.0 +description: Same description` + + yml3 := `title: Overlay +version: 1.0.0 +description: Different description` + + var node1, node2, node3 yaml.Node + _ = yaml.Unmarshal([]byte(yml1), &node1) + _ = yaml.Unmarshal([]byte(yml2), &node2) + _ = yaml.Unmarshal([]byte(yml3), &node3) + + var info1, info2, info3 Info + _ = low.BuildModel(node1.Content[0], &info1) + _ = info1.Build(context.Background(), nil, node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &info2) + _ = info2.Build(context.Background(), nil, node2.Content[0], nil) + + _ = low.BuildModel(node3.Content[0], &info3) + _ = info3.Build(context.Background(), nil, node3.Content[0], nil) + + assert.Equal(t, info1.Hash(), info2.Hash()) + assert.NotEqual(t, info1.Hash(), info3.Hash()) +} + +func TestInfo_Hash_DescriptionAffectsHash(t *testing.T) { + ymlWithDesc := `title: Overlay +version: 1.0.0 +description: Has description` + + ymlWithoutDesc := `title: Overlay +version: 1.0.0` + + var node1, node2 yaml.Node + _ = yaml.Unmarshal([]byte(ymlWithDesc), &node1) + _ = yaml.Unmarshal([]byte(ymlWithoutDesc), &node2) + + var info1, info2 Info + _ = low.BuildModel(node1.Content[0], &info1) + _ = info1.Build(context.Background(), nil, node1.Content[0], nil) + + _ = low.BuildModel(node2.Content[0], &info2) + _ = info2.Build(context.Background(), nil, node2.Content[0], nil) + + assert.NotEqual(t, info1.Hash(), info2.Hash()) +} diff --git a/datamodel/low/overlay/overlay.go b/datamodel/low/overlay/overlay.go index 5a2ef606..d0e9e3f0 100644 --- a/datamodel/low/overlay/overlay.go +++ b/datamodel/low/overlay/overlay.go @@ -5,7 +5,7 @@ package overlay import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -121,32 +121,31 @@ func (o *Overlay) GetExtensions() *orderedmap.Map[low.KeyReference[string], low. return o.Extensions } -// Hash will return a consistent SHA256 Hash of the Overlay object -func (o *Overlay) Hash() [32]byte { - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !o.Overlay.IsEmpty() { - sb.WriteString(o.Overlay.Value) - sb.WriteByte('|') - } - if !o.Info.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.Info.Value)) - sb.WriteByte('|') - } - if !o.Extends.IsEmpty() { - sb.WriteString(o.Extends.Value) - sb.WriteByte('|') - } - if !o.Actions.IsEmpty() { - for _, action := range o.Actions.Value { - sb.WriteString(low.GenerateHashString(action.Value)) - sb.WriteByte('|') +// Hash will return a consistent Hash of the Overlay object +func (o *Overlay) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !o.Overlay.IsEmpty() { + h.WriteString(o.Overlay.Value) + h.WriteByte(low.HASH_PIPE) } - } - for _, ext := range low.HashExtensions(o.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) + if !o.Info.IsEmpty() { + h.WriteString(low.GenerateHashString(o.Info.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.Extends.IsEmpty() { + h.WriteString(o.Extends.Value) + h.WriteByte(low.HASH_PIPE) + } + if !o.Actions.IsEmpty() { + for _, action := range o.Actions.Value { + h.WriteString(low.GenerateHashString(action.Value)) + h.WriteByte(low.HASH_PIPE) + } + } + for _, ext := range low.HashExtensions(o.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/reference.go b/datamodel/low/reference.go index 1b38e7ef..c7d1cc19 100644 --- a/datamodel/low/reference.go +++ b/datamodel/low/reference.go @@ -71,10 +71,10 @@ type HasValueNodeUntyped interface { IsReferenced } -// Hashable defines any struct that implements a Hash function that returns a 256SHA hash of the state of the +// Hashable defines any struct that implements a Hash function that returns a 64-bit hash of the state of the // representative object. Great for equality checking! type Hashable interface { - Hash() [32]byte + Hash() uint64 } // HasExtensions is implemented by any object that exposes extensions @@ -344,6 +344,6 @@ func GetCircularReferenceResult(node *yaml.Node, idx *index.SpecIndex) *index.Ci return nil } -func HashToString(hash [32]byte) string { +func HashToString(hash uint64) string { return fmt.Sprintf("%x", hash) } diff --git a/datamodel/low/reference_test.go b/datamodel/low/reference_test.go index 098846ad..57f0ccbf 100644 --- a/datamodel/low/reference_test.go +++ b/datamodel/low/reference_test.go @@ -4,7 +4,6 @@ package low import ( - "crypto/sha256" "fmt" "strings" "testing" @@ -553,8 +552,10 @@ func TestGetCircularReferenceResult_NothingFound(t *testing.T) { } func TestHashToString(t *testing.T) { - assert.Equal(t, "5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5", - HashToString(sha256.Sum256([]byte("12345")))) + // Test with a known uint64 value + hash := uint64(0x123456789abcdef0) + result := HashToString(hash) + assert.Equal(t, "123456789abcdef0", result) } func TestReference_IsReference(t *testing.T) { diff --git a/datamodel/low/v2/definitions.go b/datamodel/low/v2/definitions.go index b85dbfe3..cbc6156f 100644 --- a/datamodel/low/v2/definitions.go +++ b/datamodel/low/v2/definitions.go @@ -5,8 +5,7 @@ package v2 import ( "context" - "crypto/sha256" - "strings" + "hash/maphash" "sync" "github.com/pb33f/libopenapi/datamodel" @@ -155,13 +154,15 @@ func (d *Definitions) Build(ctx context.Context, _, root *yaml.Node, idx *index. return nil } -// Hash will return a consistent SHA256 Hash of the Definitions object -func (d *Definitions) Hash() [32]byte { - var f []string - for k := range orderedmap.SortAlpha(d.Schemas).KeysFromOldest() { - f = append(f, low.GenerateHashString(d.FindSchema(k.Value).Value)) - } - return sha256.Sum256([]byte(strings.Join(f, "|"))) +// Hash will return a consistent Hash of the Definitions object +func (d *Definitions) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for k := range orderedmap.SortAlpha(d.Schemas).KeysFromOldest() { + h.WriteString(low.GenerateHashString(d.FindSchema(k.Value).Value)) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // Build will extract all ParameterDefinitions into Parameter instances. diff --git a/datamodel/low/v2/definitions_test.go b/datamodel/low/v2/definitions_test.go index ee00f497..8d549a03 100644 --- a/datamodel/low/v2/definitions_test.go +++ b/datamodel/low/v2/definitions_test.go @@ -61,8 +61,8 @@ func TestDefinitions_Hash(t *testing.T) { assert.NoError(t, err) _ = n.Build(context.Background(), nil, idxNode.Content[0], idx) - assert.Equal(t, "e32477b3f3c2dc0b95126b51a34564ad19d7d0b6b43cde4783fae4c4e04dfdf6", - low.GenerateHashString(&n)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&n)) } func TestDefinitions_Responses_Build_Error(t *testing.T) { diff --git a/datamodel/low/v2/examples.go b/datamodel/low/v2/examples.go index 9ac331ff..6e18c58a 100644 --- a/datamodel/low/v2/examples.go +++ b/datamodel/low/v2/examples.go @@ -5,8 +5,7 @@ package v2 import ( "context" - "crypto/sha256" - "strings" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -54,11 +53,13 @@ func (e *Examples) Build(_ context.Context, _, root *yaml.Node, _ *index.SpecInd return nil } -// Hash will return a consistent SHA256 Hash of the Examples object -func (e *Examples) Hash() [32]byte { - var f []string - for v := range orderedmap.SortAlpha(e.Values).ValuesFromOldest() { - f = append(f, low.GenerateHashString(v.Value)) - } - return sha256.Sum256([]byte(strings.Join(f, "|"))) +// Hash will return a consistent Hash of the Examples object +func (e *Examples) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for v := range orderedmap.SortAlpha(e.Values).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v2/header.go b/datamodel/low/v2/header.go index 607b736a..890d6dd6 100644 --- a/datamodel/low/v2/header.go +++ b/datamodel/low/v2/header.go @@ -5,10 +5,8 @@ package v2 import ( "context" - "crypto/sha256" - "fmt" + "hash/maphash" "sort" - "strings" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -77,52 +75,74 @@ func (h *Header) Build(ctx context.Context, _, root *yaml.Node, idx *index.SpecI return nil } -// Hash will return a consistent SHA256 Hash of the Header object -func (h *Header) Hash() [32]byte { - var f []string - if h.Description.Value != "" { - f = append(f, h.Description.Value) - } - if h.Type.Value != "" { - f = append(f, h.Type.Value) - } - if h.Format.Value != "" { - f = append(f, h.Format.Value) - } - if h.CollectionFormat.Value != "" { - f = append(f, h.CollectionFormat.Value) - } - if h.Default.Value != nil && !h.Default.Value.IsZero() { - f = append(f, low.GenerateHashString(h.Default.Value)) - } - f = append(f, fmt.Sprint(h.Maximum.Value)) - f = append(f, fmt.Sprint(h.Minimum.Value)) - f = append(f, fmt.Sprint(h.ExclusiveMinimum.Value)) - f = append(f, fmt.Sprint(h.ExclusiveMaximum.Value)) - f = append(f, fmt.Sprint(h.MinLength.Value)) - f = append(f, fmt.Sprint(h.MaxLength.Value)) - f = append(f, fmt.Sprint(h.MinItems.Value)) - f = append(f, fmt.Sprint(h.MaxItems.Value)) - f = append(f, fmt.Sprint(h.MultipleOf.Value)) - f = append(f, fmt.Sprint(h.UniqueItems.Value)) - if h.Pattern.Value != "" { - f = append(f, fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprint(h.Pattern.Value))))) - } - f = append(f, low.HashExtensions(h.Extensions)...) +// Hash will return a consistent Hash of the Header object +func (hdr *Header) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if hdr.Description.Value != "" { + h.WriteString(hdr.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if hdr.Type.Value != "" { + h.WriteString(hdr.Type.Value) + h.WriteByte(low.HASH_PIPE) + } + if hdr.Format.Value != "" { + h.WriteString(hdr.Format.Value) + h.WriteByte(low.HASH_PIPE) + } + if hdr.CollectionFormat.Value != "" { + h.WriteString(hdr.CollectionFormat.Value) + h.WriteByte(low.HASH_PIPE) + } + if hdr.Default.Value != nil && !hdr.Default.Value.IsZero() { + h.WriteString(low.GenerateHashString(hdr.Default.Value)) + h.WriteByte(low.HASH_PIPE) + } + low.HashInt64(h, int64(hdr.Maximum.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(hdr.Minimum.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, hdr.ExclusiveMinimum.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, hdr.ExclusiveMaximum.Value) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(hdr.MinLength.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(hdr.MaxLength.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(hdr.MinItems.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(hdr.MaxItems.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(hdr.MultipleOf.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, hdr.UniqueItems.Value) + h.WriteByte(low.HASH_PIPE) + if hdr.Pattern.Value != "" { + h.WriteString(hdr.Pattern.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(hdr.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } - keys := make([]string, len(h.Enum.Value)) - z := 0 - for k := range h.Enum.Value { - keys[z] = low.ValueToString(h.Enum.Value[k].Value) - z++ - } - sort.Strings(keys) - f = append(f, keys...) + keys := make([]string, len(hdr.Enum.Value)) + for k := range hdr.Enum.Value { + keys[k] = low.ValueToString(hdr.Enum.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - if h.Items.Value != nil { - f = append(f, low.GenerateHashString(h.Items.Value)) - } - return sha256.Sum256([]byte(strings.Join(f, "|"))) + if hdr.Items.Value != nil { + h.WriteString(low.GenerateHashString(hdr.Items.Value)) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // Getter methods to satisfy SwaggerHeader interface. diff --git a/datamodel/low/v2/items.go b/datamodel/low/v2/items.go index 0f871a9d..279fc856 100644 --- a/datamodel/low/v2/items.go +++ b/datamodel/low/v2/items.go @@ -5,10 +5,8 @@ package v2 import ( "context" - "crypto/sha256" - "fmt" + "hash/maphash" "sort" - "strings" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -53,48 +51,69 @@ func (i *Items) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.Va return i.Extensions } -// Hash will return a consistent SHA256 Hash of the Items object -func (i *Items) Hash() [32]byte { - var f []string - if i.Type.Value != "" { - f = append(f, i.Type.Value) - } - if i.Format.Value != "" { - f = append(f, i.Format.Value) - } - if i.CollectionFormat.Value != "" { - f = append(f, i.CollectionFormat.Value) - } - if i.Default.Value != nil && !i.Default.Value.IsZero() { - f = append(f, low.GenerateHashString(i.Default.Value)) - } - f = append(f, fmt.Sprint(i.Maximum.Value)) - f = append(f, fmt.Sprint(i.Minimum.Value)) - f = append(f, fmt.Sprint(i.ExclusiveMinimum.Value)) - f = append(f, fmt.Sprint(i.ExclusiveMaximum.Value)) - f = append(f, fmt.Sprint(i.MinLength.Value)) - f = append(f, fmt.Sprint(i.MaxLength.Value)) - f = append(f, fmt.Sprint(i.MinItems.Value)) - f = append(f, fmt.Sprint(i.MaxItems.Value)) - f = append(f, fmt.Sprint(i.MultipleOf.Value)) - f = append(f, fmt.Sprint(i.UniqueItems.Value)) - if i.Pattern.Value != "" { - f = append(f, fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprint(i.Pattern.Value))))) - } - keys := make([]string, len(i.Enum.Value)) - z := 0 - for k := range i.Enum.Value { - keys[z] = low.ValueToString(i.Enum.Value[k].Value) - z++ - } - sort.Strings(keys) - f = append(f, keys...) +// Hash will return a consistent Hash of the Items object +func (itm *Items) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if itm.Type.Value != "" { + h.WriteString(itm.Type.Value) + h.WriteByte(low.HASH_PIPE) + } + if itm.Format.Value != "" { + h.WriteString(itm.Format.Value) + h.WriteByte(low.HASH_PIPE) + } + if itm.CollectionFormat.Value != "" { + h.WriteString(itm.CollectionFormat.Value) + h.WriteByte(low.HASH_PIPE) + } + if itm.Default.Value != nil && !itm.Default.Value.IsZero() { + h.WriteString(low.GenerateHashString(itm.Default.Value)) + h.WriteByte(low.HASH_PIPE) + } + low.HashInt64(h, int64(itm.Maximum.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(itm.Minimum.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, itm.ExclusiveMinimum.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, itm.ExclusiveMaximum.Value) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(itm.MinLength.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(itm.MaxLength.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(itm.MinItems.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(itm.MaxItems.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(itm.MultipleOf.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, itm.UniqueItems.Value) + h.WriteByte(low.HASH_PIPE) + if itm.Pattern.Value != "" { + h.WriteString(itm.Pattern.Value) + h.WriteByte(low.HASH_PIPE) + } + keys := make([]string, len(itm.Enum.Value)) + for k := range itm.Enum.Value { + keys[k] = low.ValueToString(itm.Enum.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - if i.Items.Value != nil { - f = append(f, low.GenerateHashString(i.Items.Value)) - } - f = append(f, low.HashExtensions(i.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) + if itm.Items.Value != nil { + h.WriteString(low.GenerateHashString(itm.Items.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(itm.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // Build will build out items and default value. diff --git a/datamodel/low/v2/operation.go b/datamodel/low/v2/operation.go index b95e062a..1dcfa481 100644 --- a/datamodel/low/v2/operation.go +++ b/datamodel/low/v2/operation.go @@ -5,10 +5,8 @@ package v2 import ( "context" - "crypto/sha256" - "fmt" + "hash/maphash" "sort" - "strings" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -86,74 +84,99 @@ func (o *Operation) Build(ctx context.Context, _, root *yaml.Node, idx *index.Sp return nil } -// Hash will return a consistent SHA256 Hash of the Operation object -func (o *Operation) Hash() [32]byte { - var f []string - if !o.Summary.IsEmpty() { - f = append(f, o.Summary.Value) - } - if !o.Description.IsEmpty() { - f = append(f, o.Description.Value) - } - if !o.OperationId.IsEmpty() { - f = append(f, o.OperationId.Value) - } - if !o.Summary.IsEmpty() { - f = append(f, o.Summary.Value) - } - if !o.ExternalDocs.IsEmpty() { - f = append(f, low.GenerateHashString(o.ExternalDocs.Value)) - } - if !o.Responses.IsEmpty() { - f = append(f, low.GenerateHashString(o.Responses.Value)) - } - if !o.Deprecated.IsEmpty() { - f = append(f, fmt.Sprint(o.Deprecated.Value)) - } - var keys []string - keys = make([]string, len(o.Tags.Value)) - for k := range o.Tags.Value { - keys[k] = o.Tags.Value[k].Value - } - sort.Strings(keys) - f = append(f, keys...) +// Hash will return a consistent Hash of the Operation object +func (o *Operation) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !o.Summary.IsEmpty() { + h.WriteString(o.Summary.Value) + h.WriteByte(low.HASH_PIPE) + } + if !o.Description.IsEmpty() { + h.WriteString(o.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !o.OperationId.IsEmpty() { + h.WriteString(o.OperationId.Value) + h.WriteByte(low.HASH_PIPE) + } + if !o.ExternalDocs.IsEmpty() { + h.WriteString(low.GenerateHashString(o.ExternalDocs.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.Responses.IsEmpty() { + h.WriteString(low.GenerateHashString(o.Responses.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.Deprecated.IsEmpty() { + low.HashBool(h, o.Deprecated.Value) + h.WriteByte(low.HASH_PIPE) + } + var keys []string + keys = make([]string, len(o.Tags.Value)) + for k := range o.Tags.Value { + keys[k] = o.Tags.Value[k].Value + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - keys = make([]string, len(o.Consumes.Value)) - for k := range o.Consumes.Value { - keys[k] = o.Consumes.Value[k].Value - } - sort.Strings(keys) - f = append(f, keys...) + keys = make([]string, len(o.Consumes.Value)) + for k := range o.Consumes.Value { + keys[k] = o.Consumes.Value[k].Value + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - keys = make([]string, len(o.Produces.Value)) - for k := range o.Produces.Value { - keys[k] = o.Produces.Value[k].Value - } - sort.Strings(keys) - f = append(f, keys...) + keys = make([]string, len(o.Produces.Value)) + for k := range o.Produces.Value { + keys[k] = o.Produces.Value[k].Value + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - keys = make([]string, len(o.Schemes.Value)) - for k := range o.Schemes.Value { - keys[k] = o.Schemes.Value[k].Value - } - sort.Strings(keys) - f = append(f, keys...) + keys = make([]string, len(o.Schemes.Value)) + for k := range o.Schemes.Value { + keys[k] = o.Schemes.Value[k].Value + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - keys = make([]string, len(o.Parameters.Value)) - for k := range o.Parameters.Value { - keys[k] = low.GenerateHashString(o.Parameters.Value[k].Value) - } - sort.Strings(keys) - f = append(f, keys...) + keys = make([]string, len(o.Parameters.Value)) + for k := range o.Parameters.Value { + keys[k] = low.GenerateHashString(o.Parameters.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - keys = make([]string, len(o.Security.Value)) - for k := range o.Security.Value { - keys[k] = low.GenerateHashString(o.Security.Value[k].Value) - } - sort.Strings(keys) - f = append(f, keys...) - f = append(f, low.HashExtensions(o.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) + keys = make([]string, len(o.Security.Value)) + for k := range o.Security.Value { + keys[k] = low.GenerateHashString(o.Security.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(o.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // methods to satisfy swagger operations interface diff --git a/datamodel/low/v2/parameter.go b/datamodel/low/v2/parameter.go index e3f942a3..e38aaf6c 100644 --- a/datamodel/low/v2/parameter.go +++ b/datamodel/low/v2/parameter.go @@ -5,10 +5,8 @@ package v2 import ( "context" - "crypto/sha256" - "fmt" + "hash/maphash" "sort" - "strings" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -126,63 +124,90 @@ func (p *Parameter) Build(ctx context.Context, _, root *yaml.Node, idx *index.Sp return nil } -// Hash will return a consistent SHA256 Hash of the Parameter object -func (p *Parameter) Hash() [32]byte { - var f []string - if p.Name.Value != "" { - f = append(f, p.Name.Value) - } - if p.In.Value != "" { - f = append(f, p.In.Value) - } - if p.Type.Value != "" { - f = append(f, p.Type.Value) - } - if p.Format.Value != "" { - f = append(f, p.Format.Value) - } - if p.Description.Value != "" { - f = append(f, p.Description.Value) - } - f = append(f, fmt.Sprint(p.Required.Value)) - f = append(f, fmt.Sprint(p.AllowEmptyValue.Value)) - if p.Schema.Value != nil { - f = append(f, low.GenerateHashString(p.Schema.Value.Schema())) - } - if p.CollectionFormat.Value != "" { - f = append(f, p.CollectionFormat.Value) - } - if p.Default.Value != nil && !p.Default.Value.IsZero() { - f = append(f, low.GenerateHashString(p.Default.Value)) - } - f = append(f, fmt.Sprint(p.Maximum.Value)) - f = append(f, fmt.Sprint(p.Minimum.Value)) - f = append(f, fmt.Sprint(p.ExclusiveMinimum.Value)) - f = append(f, fmt.Sprint(p.ExclusiveMaximum.Value)) - f = append(f, fmt.Sprint(p.MinLength.Value)) - f = append(f, fmt.Sprint(p.MaxLength.Value)) - f = append(f, fmt.Sprint(p.MinItems.Value)) - f = append(f, fmt.Sprint(p.MaxItems.Value)) - f = append(f, fmt.Sprint(p.MultipleOf.Value)) - f = append(f, fmt.Sprint(p.UniqueItems.Value)) - if p.Pattern.Value != "" { - f = append(f, fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprint(p.Pattern.Value))))) - } +// Hash will return a consistent Hash of the Parameter object +func (p *Parameter) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if p.Name.Value != "" { + h.WriteString(p.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if p.In.Value != "" { + h.WriteString(p.In.Value) + h.WriteByte(low.HASH_PIPE) + } + if p.Type.Value != "" { + h.WriteString(p.Type.Value) + h.WriteByte(low.HASH_PIPE) + } + if p.Format.Value != "" { + h.WriteString(p.Format.Value) + h.WriteByte(low.HASH_PIPE) + } + if p.Description.Value != "" { + h.WriteString(p.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + low.HashBool(h, p.Required.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, p.AllowEmptyValue.Value) + h.WriteByte(low.HASH_PIPE) + if p.Schema.Value != nil { + h.WriteString(low.GenerateHashString(p.Schema.Value.Schema())) + h.WriteByte(low.HASH_PIPE) + } + if p.CollectionFormat.Value != "" { + h.WriteString(p.CollectionFormat.Value) + h.WriteByte(low.HASH_PIPE) + } + if p.Default.Value != nil && !p.Default.Value.IsZero() { + h.WriteString(low.GenerateHashString(p.Default.Value)) + h.WriteByte(low.HASH_PIPE) + } + low.HashInt64(h, int64(p.Maximum.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(p.Minimum.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, p.ExclusiveMinimum.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, p.ExclusiveMaximum.Value) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(p.MinLength.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(p.MaxLength.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(p.MinItems.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(p.MaxItems.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashInt64(h, int64(p.MultipleOf.Value)) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, p.UniqueItems.Value) + h.WriteByte(low.HASH_PIPE) + if p.Pattern.Value != "" { + h.WriteString(p.Pattern.Value) + h.WriteByte(low.HASH_PIPE) + } - keys := make([]string, len(p.Enum.Value)) - z := 0 - for k := range p.Enum.Value { - keys[z] = low.ValueToString(p.Enum.Value[k].Value) - z++ - } - sort.Strings(keys) - f = append(f, keys...) + keys := make([]string, len(p.Enum.Value)) + for k := range p.Enum.Value { + keys[k] = low.ValueToString(p.Enum.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } - f = append(f, low.HashExtensions(p.Extensions)...) - if p.Items.Value != nil { - f = append(f, fmt.Sprintf("%x", p.Items.Value.Hash())) - } - return sha256.Sum256([]byte(strings.Join(f, "|"))) + for _, ext := range low.HashExtensions(p.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + if p.Items.Value != nil { + low.HashUint64(h, p.Items.Value.Hash()) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // Getters used by what-changed feature to satisfy the SwaggerParameter interface. diff --git a/datamodel/low/v2/path_item.go b/datamodel/low/v2/path_item.go index 2b1ef457..85a24a4c 100644 --- a/datamodel/low/v2/path_item.go +++ b/datamodel/low/v2/path_item.go @@ -5,8 +5,7 @@ package v2 import ( "context" - "crypto/sha256" - "fmt" + "hash/maphash" "sort" "strings" "sync" @@ -187,36 +186,64 @@ func (p *PathItem) Build(ctx context.Context, _, root *yaml.Node, idx *index.Spe return nil } -// Hash will return a consistent SHA256 Hash of the PathItem object -func (p *PathItem) Hash() [32]byte { - var f []string - if !p.Get.IsEmpty() { - f = append(f, fmt.Sprintf("%s-%s", GetLabel, low.GenerateHashString(p.Get.Value))) - } - if !p.Put.IsEmpty() { - f = append(f, fmt.Sprintf("%s-%s", PutLabel, low.GenerateHashString(p.Put.Value))) - } - if !p.Post.IsEmpty() { - f = append(f, fmt.Sprintf("%s-%s", PostLabel, low.GenerateHashString(p.Post.Value))) - } - if !p.Delete.IsEmpty() { - f = append(f, fmt.Sprintf("%s-%s", DeleteLabel, low.GenerateHashString(p.Delete.Value))) - } - if !p.Options.IsEmpty() { - f = append(f, fmt.Sprintf("%s-%s", OptionsLabel, low.GenerateHashString(p.Options.Value))) - } - if !p.Head.IsEmpty() { - f = append(f, fmt.Sprintf("%s-%s", HeadLabel, low.GenerateHashString(p.Head.Value))) - } - if !p.Patch.IsEmpty() { - f = append(f, fmt.Sprintf("%s-%s", PatchLabel, low.GenerateHashString(p.Patch.Value))) - } - keys := make([]string, len(p.Parameters.Value)) - for k := range p.Parameters.Value { - keys[k] = low.GenerateHashString(p.Parameters.Value[k].Value) - } - sort.Strings(keys) - f = append(f, keys...) - f = append(f, low.HashExtensions(p.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) +// Hash will return a consistent Hash of the PathItem object +func (p *PathItem) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !p.Get.IsEmpty() { + h.WriteString(GetLabel) + h.WriteByte('-') + h.WriteString(low.GenerateHashString(p.Get.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !p.Put.IsEmpty() { + h.WriteString(PutLabel) + h.WriteByte('-') + h.WriteString(low.GenerateHashString(p.Put.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !p.Post.IsEmpty() { + h.WriteString(PostLabel) + h.WriteByte('-') + h.WriteString(low.GenerateHashString(p.Post.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !p.Delete.IsEmpty() { + h.WriteString(DeleteLabel) + h.WriteByte('-') + h.WriteString(low.GenerateHashString(p.Delete.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !p.Options.IsEmpty() { + h.WriteString(OptionsLabel) + h.WriteByte('-') + h.WriteString(low.GenerateHashString(p.Options.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !p.Head.IsEmpty() { + h.WriteString(HeadLabel) + h.WriteByte('-') + h.WriteString(low.GenerateHashString(p.Head.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !p.Patch.IsEmpty() { + h.WriteString(PatchLabel) + h.WriteByte('-') + h.WriteString(low.GenerateHashString(p.Patch.Value)) + h.WriteByte(low.HASH_PIPE) + } + keys := make([]string, len(p.Parameters.Value)) + for k := range p.Parameters.Value { + keys[k] = low.GenerateHashString(p.Parameters.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(p.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v2/paths.go b/datamodel/low/v2/paths.go index 6b2831ae..dc4b7fb9 100644 --- a/datamodel/low/v2/paths.go +++ b/datamodel/low/v2/paths.go @@ -5,7 +5,7 @@ package v2 import ( "context" - "crypto/sha256" + "hash/maphash" "strings" "sync" @@ -154,12 +154,17 @@ func (p *Paths) Build(ctx context.Context, _, root *yaml.Node, idx *index.SpecIn return nil } -// Hash will return a consistent SHA256 Hash of the PathItem object -func (p *Paths) Hash() [32]byte { - var f []string - for v := range orderedmap.SortAlpha(p.PathItems).ValuesFromOldest() { - f = append(f, low.GenerateHashString(v.Value)) - } - f = append(f, low.HashExtensions(p.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) +// Hash will return a consistent Hash of the Paths object +func (p *Paths) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for v := range orderedmap.SortAlpha(p.PathItems).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(p.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v2/response.go b/datamodel/low/v2/response.go index 5cc852f3..a5bf4630 100644 --- a/datamodel/low/v2/response.go +++ b/datamodel/low/v2/response.go @@ -5,8 +5,7 @@ package v2 import ( "context" - "crypto/sha256" - "strings" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -78,20 +77,27 @@ func (r *Response) Build(ctx context.Context, _, root *yaml.Node, idx *index.Spe return nil } -// Hash will return a consistent SHA256 Hash of the Response object -func (r *Response) Hash() [32]byte { - var f []string - if r.Description.Value != "" { - f = append(f, r.Description.Value) - } - if !r.Schema.IsEmpty() { - f = append(f, low.GenerateHashString(r.Schema.Value)) - } - if !r.Examples.IsEmpty() { - for v := range orderedmap.SortAlpha(r.Examples.Value.Values).ValuesFromOldest() { - f = append(f, low.GenerateHashString(v.Value)) +// Hash will return a consistent Hash of the Response object +func (r *Response) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if r.Description.Value != "" { + h.WriteString(r.Description.Value) + h.WriteByte(low.HASH_PIPE) } - } - f = append(f, low.HashExtensions(r.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) + if !r.Schema.IsEmpty() { + h.WriteString(low.GenerateHashString(r.Schema.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !r.Examples.IsEmpty() { + for v := range orderedmap.SortAlpha(r.Examples.Value.Values).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + } + for _, ext := range low.HashExtensions(r.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v2/responses.go b/datamodel/low/v2/responses.go index 3adfaaad..f389e284 100644 --- a/datamodel/low/v2/responses.go +++ b/datamodel/low/v2/responses.go @@ -5,8 +5,8 @@ package v2 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "strings" "github.com/pb33f/libopenapi/datamodel/low" @@ -91,13 +91,21 @@ func (r *Responses) FindResponseByCode(code string) *low.ValueReference[*Respons return low.FindItemInOrderedMap[*Response](code, r.Codes) } -// Hash will return a consistent SHA256 Hash of the Examples object -func (r *Responses) Hash() [32]byte { - var f []string - f = low.AppendMapHashes(f, orderedmap.SortAlpha(r.Codes)) - if !r.Default.IsEmpty() { - f = append(f, low.GenerateHashString(r.Default.Value)) - } - f = append(f, low.HashExtensions(r.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) +// Hash will return a consistent Hash of the Responses object +func (r *Responses) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for _, hash := range low.AppendMapHashes(nil, orderedmap.SortAlpha(r.Codes)) { + h.WriteString(hash) + h.WriteByte(low.HASH_PIPE) + } + if !r.Default.IsEmpty() { + h.WriteString(low.GenerateHashString(r.Default.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(r.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v2/scopes.go b/datamodel/low/v2/scopes.go index d76e7813..0b22ddea 100644 --- a/datamodel/low/v2/scopes.go +++ b/datamodel/low/v2/scopes.go @@ -5,8 +5,8 @@ package v2 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "strings" "github.com/pb33f/libopenapi/datamodel/low" @@ -64,12 +64,17 @@ func (s *Scopes) Build(_ context.Context, _, root *yaml.Node, _ *index.SpecIndex return nil } -// Hash will return a consistent SHA256 Hash of the Scopes object -func (s *Scopes) Hash() [32]byte { - var f []string - for k, v := range orderedmap.SortAlpha(s.Values).FromOldest() { - f = append(f, fmt.Sprintf("%s-%s", k.Value, v.Value)) - } - f = append(f, low.HashExtensions(s.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) +// Hash will return a consistent Hash of the Scopes object +func (s *Scopes) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for k, v := range orderedmap.SortAlpha(s.Values).FromOldest() { + h.WriteString(fmt.Sprintf("%s-%s", k.Value, v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(s.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v2/security_scheme.go b/datamodel/low/v2/security_scheme.go index ccce9c2b..6e93e7d4 100644 --- a/datamodel/low/v2/security_scheme.go +++ b/datamodel/low/v2/security_scheme.go @@ -5,8 +5,7 @@ package v2 import ( "context" - "crypto/sha256" - "strings" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -52,33 +51,45 @@ func (ss *SecurityScheme) Build(ctx context.Context, _, root *yaml.Node, idx *in return nil } -// Hash will return a consistent SHA256 Hash of the SecurityScheme object -func (ss *SecurityScheme) Hash() [32]byte { - var f []string - if !ss.Type.IsEmpty() { - f = append(f, ss.Type.Value) - } - if !ss.Description.IsEmpty() { - f = append(f, ss.Description.Value) - } - if !ss.Name.IsEmpty() { - f = append(f, ss.Name.Value) - } - if !ss.In.IsEmpty() { - f = append(f, ss.In.Value) - } - if !ss.Flow.IsEmpty() { - f = append(f, ss.Flow.Value) - } - if !ss.AuthorizationUrl.IsEmpty() { - f = append(f, ss.AuthorizationUrl.Value) - } - if !ss.TokenUrl.IsEmpty() { - f = append(f, ss.TokenUrl.Value) - } - if !ss.Scopes.IsEmpty() { - f = append(f, low.GenerateHashString(ss.Scopes.Value)) - } - f = append(f, low.HashExtensions(ss.Extensions)...) - return sha256.Sum256([]byte(strings.Join(f, "|"))) +// Hash will return a consistent Hash of the SecurityScheme object +func (ss *SecurityScheme) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !ss.Type.IsEmpty() { + h.WriteString(ss.Type.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Description.IsEmpty() { + h.WriteString(ss.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Name.IsEmpty() { + h.WriteString(ss.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.In.IsEmpty() { + h.WriteString(ss.In.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Flow.IsEmpty() { + h.WriteString(ss.Flow.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.AuthorizationUrl.IsEmpty() { + h.WriteString(ss.AuthorizationUrl.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.TokenUrl.IsEmpty() { + h.WriteString(ss.TokenUrl.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Scopes.IsEmpty() { + h.WriteString(low.GenerateHashString(ss.Scopes.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(ss.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/callback.go b/datamodel/low/v3/callback.go index 2d2e9a89..b1014745 100644 --- a/datamodel/low/v3/callback.go +++ b/datamodel/low/v3/callback.go @@ -5,7 +5,7 @@ package v3 import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/orderedmap" "github.com/pb33f/libopenapi/utils" @@ -91,20 +91,18 @@ func (cb *Callback) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in return nil } -// Hash will return a consistent SHA256 Hash of the Callback object -func (cb *Callback) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - for v := range orderedmap.SortAlpha(cb.Expression).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } - - for _, ext := range low.HashExtensions(cb.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Callback object +func (cb *Callback) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for v := range orderedmap.SortAlpha(cb.Expression).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + + for _, ext := range low.HashExtensions(cb.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/components.go b/datamodel/low/v3/components.go index 3772902a..7edde619 100644 --- a/datamodel/low/v3/components.go +++ b/datamodel/low/v3/components.go @@ -5,10 +5,9 @@ package v3 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "reflect" - "strings" "sync" "github.com/pb33f/libopenapi/datamodel" @@ -81,34 +80,32 @@ func (co *Components) GetKeyNode() *yaml.Node { return co.KeyNode } -// Hash will return a consistent SHA256 Hash of the Components object -func (co *Components) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - generateHashForObjectMapBuilder(co.Schemas.Value, sb) - generateHashForObjectMapBuilder(co.Responses.Value, sb) - generateHashForObjectMapBuilder(co.Parameters.Value, sb) - generateHashForObjectMapBuilder(co.Examples.Value, sb) - generateHashForObjectMapBuilder(co.RequestBodies.Value, sb) - generateHashForObjectMapBuilder(co.Headers.Value, sb) - generateHashForObjectMapBuilder(co.SecuritySchemes.Value, sb) - generateHashForObjectMapBuilder(co.Links.Value, sb) - generateHashForObjectMapBuilder(co.Callbacks.Value, sb) - generateHashForObjectMapBuilder(co.PathItems.Value, sb) - generateHashForObjectMapBuilder(co.MediaTypes.Value, sb) - for _, ext := range low.HashExtensions(co.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Components object +func (co *Components) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + generateHashForObjectMap(co.Schemas.Value, h) + generateHashForObjectMap(co.Responses.Value, h) + generateHashForObjectMap(co.Parameters.Value, h) + generateHashForObjectMap(co.Examples.Value, h) + generateHashForObjectMap(co.RequestBodies.Value, h) + generateHashForObjectMap(co.Headers.Value, h) + generateHashForObjectMap(co.SecuritySchemes.Value, h) + generateHashForObjectMap(co.Links.Value, h) + generateHashForObjectMap(co.Callbacks.Value, h) + generateHashForObjectMap(co.PathItems.Value, h) + generateHashForObjectMap(co.MediaTypes.Value, h) + for _, ext := range low.HashExtensions(co.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } -func generateHashForObjectMapBuilder[T any](collection *orderedmap.Map[low.KeyReference[string], low.ValueReference[T]], sb *strings.Builder) { +func generateHashForObjectMap[T any](collection *orderedmap.Map[low.KeyReference[string], low.ValueReference[T]], h *maphash.Hash) { for v := range orderedmap.SortAlpha(collection).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) } } diff --git a/datamodel/low/v3/components_test.go b/datamodel/low/v3/components_test.go index 55c9c824..f3e8390e 100644 --- a/datamodel/low/v3/components_test.go +++ b/datamodel/low/v3/components_test.go @@ -112,8 +112,8 @@ func TestComponents_Build_Success(t *testing.T) { assert.Equal(t, "nineteen of many", n.FindPathItem("/nineteen").Value.Get.Value.Description.Value) assert.Equal(t, "twenty of many", n.FindMediaType("jsonMediaType").Value.Schema.Value.Schema().Description.Value) - assert.Equal(t, "f006293389659445d3f7a9c20737719cc0f6137392f8905c8ff18831aa5f0372", - low.GenerateHashString(&n)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&n)) assert.NotNil(t, n.GetContext()) assert.NotNil(t, n.GetIndex()) @@ -272,8 +272,8 @@ func TestComponents_Build_HashEmpty(t *testing.T) { assert.Equal(t, "seagull", xCurry) assert.Equal(t, 1, orderedmap.Len(n.GetExtensions())) - assert.Equal(t, "678c1fd2ce9c85a24a88275b240ddd91db7fde985d8312d8e9721b176444f584", - low.GenerateHashString(&n)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&n)) } func TestComponents_IsReference(t *testing.T) { diff --git a/datamodel/low/v3/create_document_test.go b/datamodel/low/v3/create_document_test.go index af626d71..616de6e5 100644 --- a/datamodel/low/v3/create_document_test.go +++ b/datamodel/low/v3/create_document_test.go @@ -61,7 +61,8 @@ paths: {}` require.NoError(t, err) assert.NotNil(t, doc) assert.Equal(t, "http://pb33f.io/path/to/spec.yaml", doc.Self.Value) - assert.Equal(t, "8f7b690b245347286036a21ad93340f56accee2149c0d385ff5bf88cdd3254f0", fmt.Sprintf("%x", doc.Hash())) + // maphash uses random seed per process, just verify non-zero + assert.NotEqual(t, uint64(0), doc.Hash()) } func TestCreateDocument_SelfWithNonHttpURL(t *testing.T) { diff --git a/datamodel/low/v3/document.go b/datamodel/low/v3/document.go index 1eb45ad4..1b7ddd49 100644 --- a/datamodel/low/v3/document.go +++ b/datamodel/low/v3/document.go @@ -9,8 +9,7 @@ package v3 import ( - "crypto/sha256" - "fmt" + "hash/maphash" "sort" "github.com/pb33f/libopenapi/datamodel/low" @@ -132,117 +131,103 @@ func (d *Document) GetIndex() *index.SpecIndex { return d.Index } -// Hash will return a consistent SHA256 Hash of the Document object -func (d *Document) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) +// Hash will return a consistent Hash of the Document object +func (d *Document) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if d.Version.Value != "" { + h.WriteString(d.Version.Value) + h.WriteByte(low.HASH_PIPE) + } + if d.Info.Value != nil { + h.WriteString(low.GenerateHashString(d.Info.Value)) + h.WriteByte(low.HASH_PIPE) + } + if d.JsonSchemaDialect.Value != "" { + h.WriteString(d.JsonSchemaDialect.Value) + h.WriteByte(low.HASH_PIPE) + } + if d.Self.Value != "" { + h.WriteString(d.Self.Value) + h.WriteByte(low.HASH_PIPE) + } - if d.Version.Value != "" { - sb.WriteString(d.Version.Value) - sb.WriteByte('|') - } - if d.Info.Value != nil { - sb.WriteString(low.GenerateHashString(d.Info.Value)) - sb.WriteByte('|') - } - if d.JsonSchemaDialect.Value != "" { - sb.WriteString(d.JsonSchemaDialect.Value) - sb.WriteByte('|') - } - if d.Self.Value != "" { - sb.WriteString(d.Self.Value) - sb.WriteByte('|') - } + // Webhooks - pre-allocate slice + if d.Webhooks.GetValue() != nil { + webhookLen := d.Webhooks.GetValue().Len() + if webhookLen > 0 { + keys := make([]string, 0, webhookLen) + for k, v := range d.Webhooks.GetValue().FromOldest() { + keys = append(keys, k.Value+"-"+low.GenerateHashString(v.Value)) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } + } + } - // Webhooks - pre-allocate slice - if d.Webhooks.GetValue() != nil { - webhookLen := d.Webhooks.GetValue().Len() - if webhookLen > 0 { - keys := make([]string, 0, webhookLen) - for k, v := range d.Webhooks.GetValue().FromOldest() { - keys = append(keys, k.Value+"-"+low.GenerateHashString(v.Value)) + // Servers - pre-allocate slice + serverLen := len(d.Servers.Value) + if serverLen > 0 { + keys := make([]string, 0, serverLen) + for i := range d.Servers.Value { + keys = append(keys, low.GenerateHashString(d.Servers.Value[i].Value)) } sort.Strings(keys) for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) } } - } - // Servers - pre-allocate slice - serverLen := len(d.Servers.Value) - if serverLen > 0 { - keys := make([]string, 0, serverLen) - for i := range d.Servers.Value { - keys = append(keys, low.GenerateHashString(d.Servers.Value[i].Value)) + if d.Paths.Value != nil { + h.WriteString(low.GenerateHashString(d.Paths.Value)) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + if d.Components.Value != nil { + h.WriteString(low.GenerateHashString(d.Components.Value)) + h.WriteByte(low.HASH_PIPE) } - } - if d.Paths.Value != nil { - sb.WriteString(low.GenerateHashString(d.Paths.Value)) - sb.WriteByte('|') - } - if d.Components.Value != nil { - sb.WriteString(low.GenerateHashString(d.Components.Value)) - sb.WriteByte('|') - } - - // Security - pre-allocate slice - securityLen := len(d.Security.Value) - if securityLen > 0 { - keys := make([]string, 0, securityLen) - for i := range d.Security.Value { - keys = append(keys, low.GenerateHashString(d.Security.Value[i].Value)) - } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + // Security - pre-allocate slice + securityLen := len(d.Security.Value) + if securityLen > 0 { + keys := make([]string, 0, securityLen) + for i := range d.Security.Value { + keys = append(keys, low.GenerateHashString(d.Security.Value[i].Value)) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } } - } - // Tags - pre-allocate slice - tagLen := len(d.Tags.Value) - if tagLen > 0 { - keys := make([]string, 0, tagLen) - for i := range d.Tags.Value { - keys = append(keys, low.GenerateHashString(d.Tags.Value[i].Value)) - } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + // Tags - pre-allocate slice + tagLen := len(d.Tags.Value) + if tagLen > 0 { + keys := make([]string, 0, tagLen) + for i := range d.Tags.Value { + keys = append(keys, low.GenerateHashString(d.Tags.Value[i].Value)) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } } - } - if d.ExternalDocs.Value != nil { - sb.WriteString(low.GenerateHashString(d.ExternalDocs.Value)) - sb.WriteByte('|') - } - - // Extensions - pre-allocate slice - extLen := d.Extensions.Len() - if extLen > 0 { - keys := make([]string, 0, extLen) - for k, v := range d.Extensions.FromOldest() { - // Optimize extension hash generation - var nodeHash [32]byte - nodeHashStr := fmt.Sprint(v.Value) - nodeHash = sha256.Sum256([]byte(nodeHashStr)) - keys = append(keys, k.Value+"-"+fmt.Sprintf("%x", nodeHash)) + if d.ExternalDocs.Value != nil { + h.WriteString(low.GenerateHashString(d.ExternalDocs.Value)) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + + // Extensions + for _, ext := range low.HashExtensions(d.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) } - } - return sha256.Sum256([]byte(sb.String())) + return h.Sum64() + }) } diff --git a/datamodel/low/v3/encoding.go b/datamodel/low/v3/encoding.go index 07d60f2a..5c3b46a2 100644 --- a/datamodel/low/v3/encoding.go +++ b/datamodel/low/v3/encoding.go @@ -5,9 +5,8 @@ package v3 import ( "context" - "crypto/sha256" "fmt" - "strconv" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -57,34 +56,27 @@ func (en *Encoding) GetKeyNode() *yaml.Node { return en.KeyNode } -// Hash will return a consistent SHA256 Hash of the Encoding object -func (en *Encoding) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if en.ContentType.Value != "" { - sb.WriteString(en.ContentType.Value) - sb.WriteByte('|') - } - for k, v := range orderedmap.SortAlpha(en.Headers.Value).FromOldest() { - sb.WriteString(fmt.Sprintf("%s-%x", k.Value, v.Value.Hash())) - sb.WriteByte('|') - } - if en.Style.Value != "" { - sb.WriteString(en.Style.Value) - sb.WriteByte('|') - } - // Optimize boolean handling - explodeBytes := []byte(strconv.FormatBool(en.Explode.Value)) - sb.WriteString(fmt.Sprint(sha256.Sum256(explodeBytes))) - sb.WriteByte('|') - - allowReservedBytes := []byte(strconv.FormatBool(en.AllowReserved.Value)) - sb.WriteString(fmt.Sprint(sha256.Sum256(allowReservedBytes))) - sb.WriteByte('|') - - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Encoding object +func (en *Encoding) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if en.ContentType.Value != "" { + h.WriteString(en.ContentType.Value) + h.WriteByte(low.HASH_PIPE) + } + for k, v := range orderedmap.SortAlpha(en.Headers.Value).FromOldest() { + h.WriteString(fmt.Sprintf("%s-%x", k.Value, v.Value.Hash())) + h.WriteByte(low.HASH_PIPE) + } + if en.Style.Value != "" { + h.WriteString(en.Style.Value) + h.WriteByte(low.HASH_PIPE) + } + low.HashBool(h, en.Explode.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, en.AllowReserved.Value) + h.WriteByte(low.HASH_PIPE) + return h.Sum64() + }) } // Build will extract all Header objects from supplied node. diff --git a/datamodel/low/v3/header.go b/datamodel/low/v3/header.go index 622a6429..2acbc1b3 100644 --- a/datamodel/low/v3/header.go +++ b/datamodel/low/v3/header.go @@ -5,9 +5,8 @@ package v3 import ( "context" - "crypto/sha256" "fmt" - "strconv" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -80,51 +79,49 @@ func (h *Header) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.V return h.Extensions } -// Hash will return a consistent SHA256 Hash of the Header object -func (h *Header) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if h.Description.Value != "" { - sb.WriteString(h.Description.Value) - sb.WriteByte('|') - } - sb.WriteString(strconv.FormatBool(h.Required.Value)) - sb.WriteByte('|') - sb.WriteString(strconv.FormatBool(h.Deprecated.Value)) - sb.WriteByte('|') - sb.WriteString(strconv.FormatBool(h.AllowEmptyValue.Value)) - sb.WriteByte('|') - if h.Style.Value != "" { - sb.WriteString(h.Style.Value) - sb.WriteByte('|') - } - sb.WriteString(strconv.FormatBool(h.Explode.Value)) - sb.WriteByte('|') - sb.WriteString(strconv.FormatBool(h.AllowReserved.Value)) - sb.WriteByte('|') - if h.Schema.Value != nil { - sb.WriteString(low.GenerateHashString(h.Schema.Value)) - sb.WriteByte('|') - } - if h.Example.Value != nil && !h.Example.Value.IsZero() { - sb.WriteString(low.GenerateHashString(h.Example.Value)) - sb.WriteByte('|') - } - for k, v := range orderedmap.SortAlpha(h.Examples.Value).FromOldest() { - sb.WriteString(fmt.Sprintf("%s-%x", k.Value, v.Value.Hash())) - sb.WriteByte('|') - } - for k, v := range orderedmap.SortAlpha(h.Content.Value).FromOldest() { - sb.WriteString(fmt.Sprintf("%s-%x", k.Value, v.Value.Hash())) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(h.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Header object +func (h *Header) Hash() uint64 { + return low.WithHasher(func(hsh *maphash.Hash) uint64 { + if h.Description.Value != "" { + hsh.WriteString(h.Description.Value) + hsh.WriteByte(low.HASH_PIPE) + } + low.HashBool(hsh, h.Required.Value) + hsh.WriteByte(low.HASH_PIPE) + low.HashBool(hsh, h.Deprecated.Value) + hsh.WriteByte(low.HASH_PIPE) + low.HashBool(hsh, h.AllowEmptyValue.Value) + hsh.WriteByte(low.HASH_PIPE) + if h.Style.Value != "" { + hsh.WriteString(h.Style.Value) + hsh.WriteByte(low.HASH_PIPE) + } + low.HashBool(hsh, h.Explode.Value) + hsh.WriteByte(low.HASH_PIPE) + low.HashBool(hsh, h.AllowReserved.Value) + hsh.WriteByte(low.HASH_PIPE) + if h.Schema.Value != nil { + hsh.WriteString(low.GenerateHashString(h.Schema.Value)) + hsh.WriteByte(low.HASH_PIPE) + } + if h.Example.Value != nil && !h.Example.Value.IsZero() { + hsh.WriteString(low.GenerateHashString(h.Example.Value)) + hsh.WriteByte(low.HASH_PIPE) + } + for k, v := range orderedmap.SortAlpha(h.Examples.Value).FromOldest() { + hsh.WriteString(fmt.Sprintf("%s-%x", k.Value, v.Value.Hash())) + hsh.WriteByte(low.HASH_PIPE) + } + for k, v := range orderedmap.SortAlpha(h.Content.Value).FromOldest() { + hsh.WriteString(fmt.Sprintf("%s-%x", k.Value, v.Value.Hash())) + hsh.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(h.Extensions) { + hsh.WriteString(ext) + hsh.WriteByte(low.HASH_PIPE) + } + return hsh.Sum64() + }) } // Build will extract extensions, examples, schema and content/media types from node. diff --git a/datamodel/low/v3/link.go b/datamodel/low/v3/link.go index 3f1b77bd..f7e3cd45 100644 --- a/datamodel/low/v3/link.go +++ b/datamodel/low/v3/link.go @@ -5,7 +5,7 @@ package v3 import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -109,39 +109,37 @@ func (l *Link) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index.S return nil } -// Hash will return a consistent SHA256 Hash of the Link object -func (l *Link) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if l.Description.Value != "" { - sb.WriteString(l.Description.Value) - sb.WriteByte('|') - } - if l.OperationRef.Value != "" { - sb.WriteString(l.OperationRef.Value) - sb.WriteByte('|') - } - if l.OperationId.Value != "" { - sb.WriteString(l.OperationId.Value) - sb.WriteByte('|') - } - if l.RequestBody.Value != "" { - sb.WriteString(l.RequestBody.Value) - sb.WriteByte('|') - } - if l.Server.Value != nil { - sb.WriteString(low.GenerateHashString(l.Server.Value)) - sb.WriteByte('|') - } - for v := range orderedmap.SortAlpha(l.Parameters.Value).ValuesFromOldest() { - sb.WriteString(v.Value) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(l.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Link object +func (l *Link) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if l.Description.Value != "" { + h.WriteString(l.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if l.OperationRef.Value != "" { + h.WriteString(l.OperationRef.Value) + h.WriteByte(low.HASH_PIPE) + } + if l.OperationId.Value != "" { + h.WriteString(l.OperationId.Value) + h.WriteByte(low.HASH_PIPE) + } + if l.RequestBody.Value != "" { + h.WriteString(l.RequestBody.Value) + h.WriteByte(low.HASH_PIPE) + } + if l.Server.Value != nil { + h.WriteString(low.GenerateHashString(l.Server.Value)) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(l.Parameters.Value).ValuesFromOldest() { + h.WriteString(v.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(l.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/media_type.go b/datamodel/low/v3/media_type.go index 01aae4a9..65dd4ef0 100644 --- a/datamodel/low/v3/media_type.go +++ b/datamodel/low/v3/media_type.go @@ -5,7 +5,7 @@ package v3 import ( "context" - "crypto/sha256" + "hash/maphash" "slices" "github.com/pb33f/libopenapi/datamodel/low" @@ -184,39 +184,37 @@ func (mt *MediaType) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i return nil } -// Hash will return a consistent SHA256 Hash of the MediaType object -func (mt *MediaType) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if mt.Schema.Value != nil { - sb.WriteString(low.GenerateHashString(mt.Schema.Value)) - sb.WriteByte('|') - } - if mt.ItemSchema.Value != nil { - sb.WriteString(low.GenerateHashString(mt.ItemSchema.Value)) - sb.WriteByte('|') - } - if mt.Example.Value != nil && !mt.Example.Value.IsZero() { - sb.WriteString(low.GenerateHashString(mt.Example.Value)) - sb.WriteByte('|') - } - for v := range orderedmap.SortAlpha(mt.Examples.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } - for v := range orderedmap.SortAlpha(mt.Encoding.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } - for v := range orderedmap.SortAlpha(mt.ItemEncoding.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(mt.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the MediaType object +func (mt *MediaType) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if mt.Schema.Value != nil { + h.WriteString(low.GenerateHashString(mt.Schema.Value)) + h.WriteByte(low.HASH_PIPE) + } + if mt.ItemSchema.Value != nil { + h.WriteString(low.GenerateHashString(mt.ItemSchema.Value)) + h.WriteByte(low.HASH_PIPE) + } + if mt.Example.Value != nil && !mt.Example.Value.IsZero() { + h.WriteString(low.GenerateHashString(mt.Example.Value)) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(mt.Examples.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(mt.Encoding.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(mt.ItemEncoding.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(mt.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/oauth_flows.go b/datamodel/low/v3/oauth_flows.go index 70a86ca4..67b46e3a 100644 --- a/datamodel/low/v3/oauth_flows.go +++ b/datamodel/low/v3/oauth_flows.go @@ -5,8 +5,8 @@ package v3 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -107,37 +107,35 @@ func (o *OAuthFlows) Build(ctx context.Context, keyNode, root *yaml.Node, idx *i return nil } -// Hash will return a consistent SHA256 Hash of the OAuthFlow object -func (o *OAuthFlows) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !o.Implicit.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.Implicit.Value)) - sb.WriteByte('|') - } - if !o.Password.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.Password.Value)) - sb.WriteByte('|') - } - if !o.ClientCredentials.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.ClientCredentials.Value)) - sb.WriteByte('|') - } - if !o.AuthorizationCode.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.AuthorizationCode.Value)) - sb.WriteByte('|') - } - if !o.Device.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.Device.Value)) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(o.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the OAuthFlows object +func (o *OAuthFlows) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !o.Implicit.IsEmpty() { + h.WriteString(low.GenerateHashString(o.Implicit.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.Password.IsEmpty() { + h.WriteString(low.GenerateHashString(o.Password.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.ClientCredentials.IsEmpty() { + h.WriteString(low.GenerateHashString(o.ClientCredentials.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.AuthorizationCode.IsEmpty() { + h.WriteString(low.GenerateHashString(o.AuthorizationCode.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.Device.IsEmpty() { + h.WriteString(low.GenerateHashString(o.Device.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(o.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // OAuthFlow represents a low-level OpenAPI 3+ OAuthFlow object. @@ -204,31 +202,29 @@ func (o *OAuthFlow) Build(ctx context.Context, _, root *yaml.Node, idx *index.Sp return nil } -// Hash will return a consistent SHA256 Hash of the OAuthFlow object -func (o *OAuthFlow) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !o.AuthorizationUrl.IsEmpty() { - sb.WriteString(o.AuthorizationUrl.Value) - sb.WriteByte('|') - } - if !o.TokenUrl.IsEmpty() { - sb.WriteString(o.TokenUrl.Value) - sb.WriteByte('|') - } - if !o.RefreshUrl.IsEmpty() { - sb.WriteString(o.RefreshUrl.Value) - sb.WriteByte('|') - } - for k, v := range orderedmap.SortAlpha(o.Scopes.Value).FromOldest() { - sb.WriteString(fmt.Sprintf("%s-%s", k.Value, sha256.Sum256([]byte(v.Value)))) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(o.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the OAuthFlow object +func (o *OAuthFlow) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !o.AuthorizationUrl.IsEmpty() { + h.WriteString(o.AuthorizationUrl.Value) + h.WriteByte(low.HASH_PIPE) + } + if !o.TokenUrl.IsEmpty() { + h.WriteString(o.TokenUrl.Value) + h.WriteByte(low.HASH_PIPE) + } + if !o.RefreshUrl.IsEmpty() { + h.WriteString(o.RefreshUrl.Value) + h.WriteByte(low.HASH_PIPE) + } + for k, v := range orderedmap.SortAlpha(o.Scopes.Value).FromOldest() { + h.WriteString(fmt.Sprintf("%s-%s", k.Value, v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(o.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/operation.go b/datamodel/low/v3/operation.go index be6bb4c8..ba5dbf5d 100644 --- a/datamodel/low/v3/operation.go +++ b/datamodel/low/v3/operation.go @@ -5,9 +5,8 @@ package v3 import ( "context" - "crypto/sha256" + "hash/maphash" "sort" - "strconv" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -201,105 +200,103 @@ func (o *Operation) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in return nil } -// Hash will return a consistent SHA256 Hash of the Operation object -func (o *Operation) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !o.Summary.IsEmpty() { - sb.WriteString(o.Summary.Value) - sb.WriteByte('|') - } - if !o.Description.IsEmpty() { - sb.WriteString(o.Description.Value) - sb.WriteByte('|') - } - if !o.OperationId.IsEmpty() { - sb.WriteString(o.OperationId.Value) - sb.WriteByte('|') - } - if !o.RequestBody.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.RequestBody.Value)) - sb.WriteByte('|') - } - if !o.ExternalDocs.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.ExternalDocs.Value)) - sb.WriteByte('|') - } - if !o.Responses.IsEmpty() { - sb.WriteString(low.GenerateHashString(o.Responses.Value)) - sb.WriteByte('|') - } - if !o.Security.IsEmpty() { - // Pre-allocate keys for sorting - secKeys := make([]string, len(o.Security.Value)) - for k := range o.Security.Value { - secKeys[k] = low.GenerateHashString(o.Security.Value[k].Value) +// Hash will return a consistent Hash of the Operation object +func (o *Operation) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !o.Summary.IsEmpty() { + h.WriteString(o.Summary.Value) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(secKeys) - for _, key := range secKeys { - sb.WriteString(key) - sb.WriteByte('|') + if !o.Description.IsEmpty() { + h.WriteString(o.Description.Value) + h.WriteByte(low.HASH_PIPE) } - } - if !o.Deprecated.IsEmpty() { - sb.WriteString(strconv.FormatBool(o.Deprecated.Value)) - sb.WriteByte('|') - } - - // Tags array - pre-allocate and sort - if len(o.Tags.Value) > 0 { - tags := make([]string, len(o.Tags.Value)) - for k := range o.Tags.Value { - tags[k] = o.Tags.Value[k].Value + if !o.OperationId.IsEmpty() { + h.WriteString(o.OperationId.Value) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(tags) - for _, tag := range tags { - sb.WriteString(tag) - sb.WriteByte('|') + if !o.RequestBody.IsEmpty() { + h.WriteString(low.GenerateHashString(o.RequestBody.Value)) + h.WriteByte(low.HASH_PIPE) } - } - - // Servers array - pre-allocate and sort - if len(o.Servers.Value) > 0 { - servers := make([]string, len(o.Servers.Value)) - for k := range o.Servers.Value { - servers[k] = low.GenerateHashString(o.Servers.Value[k].Value) + if !o.ExternalDocs.IsEmpty() { + h.WriteString(low.GenerateHashString(o.ExternalDocs.Value)) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(servers) - for _, server := range servers { - sb.WriteString(server) - sb.WriteByte('|') + if !o.Responses.IsEmpty() { + h.WriteString(low.GenerateHashString(o.Responses.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !o.Security.IsEmpty() { + // Pre-allocate keys for sorting + secKeys := make([]string, len(o.Security.Value)) + for k := range o.Security.Value { + secKeys[k] = low.GenerateHashString(o.Security.Value[k].Value) + } + sort.Strings(secKeys) + for _, key := range secKeys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } + } + if !o.Deprecated.IsEmpty() { + low.HashBool(h, o.Deprecated.Value) + h.WriteByte(low.HASH_PIPE) } - } - // Parameters array - pre-allocate and sort - if len(o.Parameters.Value) > 0 { - params := make([]string, len(o.Parameters.Value)) - for k := range o.Parameters.Value { - params[k] = low.GenerateHashString(o.Parameters.Value[k].Value) + // Tags array - pre-allocate and sort + if len(o.Tags.Value) > 0 { + tags := make([]string, len(o.Tags.Value)) + for k := range o.Tags.Value { + tags[k] = o.Tags.Value[k].Value + } + sort.Strings(tags) + for _, tag := range tags { + h.WriteString(tag) + h.WriteByte(low.HASH_PIPE) + } } - sort.Strings(params) - for _, param := range params { - sb.WriteString(param) - sb.WriteByte('|') + + // Servers array - pre-allocate and sort + if len(o.Servers.Value) > 0 { + servers := make([]string, len(o.Servers.Value)) + for k := range o.Servers.Value { + servers[k] = low.GenerateHashString(o.Servers.Value[k].Value) + } + sort.Strings(servers) + for _, server := range servers { + h.WriteString(server) + h.WriteByte(low.HASH_PIPE) + } } - } - // Callbacks - for v := range orderedmap.SortAlpha(o.Callbacks.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } + // Parameters array - pre-allocate and sort + if len(o.Parameters.Value) > 0 { + params := make([]string, len(o.Parameters.Value)) + for k := range o.Parameters.Value { + params[k] = low.GenerateHashString(o.Parameters.Value[k].Value) + } + sort.Strings(params) + for _, param := range params { + h.WriteString(param) + h.WriteByte(low.HASH_PIPE) + } + } - // Extensions - for _, ext := range low.HashExtensions(o.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } + // Callbacks + for v := range orderedmap.SortAlpha(o.Callbacks.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + + // Extensions + for _, ext := range low.HashExtensions(o.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } - return sha256.Sum256([]byte(sb.String())) + return h.Sum64() + }) } // methods to satisfy swagger operations interface diff --git a/datamodel/low/v3/parameter.go b/datamodel/low/v3/parameter.go index e01f3299..05a925b2 100644 --- a/datamodel/low/v3/parameter.go +++ b/datamodel/low/v3/parameter.go @@ -5,10 +5,9 @@ package v3 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "slices" - "strconv" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/datamodel/low/base" @@ -155,59 +154,57 @@ func (p *Parameter) Build(ctx context.Context, keyNode, root *yaml.Node, idx *in return nil } -// Hash will return a consistent SHA256 Hash of the Parameter object -func (p *Parameter) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if p.Name.Value != "" { - sb.WriteString(p.Name.Value) - sb.WriteByte('|') - } - if p.In.Value != "" { - sb.WriteString(p.In.Value) - sb.WriteByte('|') - } - if p.Description.Value != "" { - sb.WriteString(p.Description.Value) - sb.WriteByte('|') - } - sb.WriteString(strconv.FormatBool(p.Required.Value)) - sb.WriteByte('|') - sb.WriteString(strconv.FormatBool(p.Deprecated.Value)) - sb.WriteByte('|') - sb.WriteString(strconv.FormatBool(p.AllowEmptyValue.Value)) - sb.WriteByte('|') - if p.Style.Value != "" { - sb.WriteString(p.Style.Value) - sb.WriteByte('|') - } - sb.WriteString(strconv.FormatBool(p.Explode.Value)) - sb.WriteByte('|') - sb.WriteString(strconv.FormatBool(p.AllowReserved.Value)) - sb.WriteByte('|') - if p.Schema.Value != nil && p.Schema.Value.Schema() != nil { - sb.WriteString(fmt.Sprintf("%x", p.Schema.Value.Schema().Hash())) - sb.WriteByte('|') - } - if p.Example.Value != nil && !p.Example.Value.IsZero() { - sb.WriteString(low.GenerateHashString(p.Example.Value)) - sb.WriteByte('|') - } - for v := range orderedmap.SortAlpha(p.Examples.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } - for v := range orderedmap.SortAlpha(p.Content.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(p.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Parameter object +func (p *Parameter) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if p.Name.Value != "" { + h.WriteString(p.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if p.In.Value != "" { + h.WriteString(p.In.Value) + h.WriteByte(low.HASH_PIPE) + } + if p.Description.Value != "" { + h.WriteString(p.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + low.HashBool(h, p.Required.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, p.Deprecated.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, p.AllowEmptyValue.Value) + h.WriteByte(low.HASH_PIPE) + if p.Style.Value != "" { + h.WriteString(p.Style.Value) + h.WriteByte(low.HASH_PIPE) + } + low.HashBool(h, p.Explode.Value) + h.WriteByte(low.HASH_PIPE) + low.HashBool(h, p.AllowReserved.Value) + h.WriteByte(low.HASH_PIPE) + if p.Schema.Value != nil && p.Schema.Value.Schema() != nil { + h.WriteString(fmt.Sprintf("%x", p.Schema.Value.Schema().Hash())) + h.WriteByte(low.HASH_PIPE) + } + if p.Example.Value != nil && !p.Example.Value.IsZero() { + h.WriteString(low.GenerateHashString(p.Example.Value)) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(p.Examples.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(p.Content.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(p.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // IsParameter compliance methods. diff --git a/datamodel/low/v3/path_item.go b/datamodel/low/v3/path_item.go index 7299b2d3..51660b5e 100644 --- a/datamodel/low/v3/path_item.go +++ b/datamodel/low/v3/path_item.go @@ -5,8 +5,8 @@ package v3 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "sort" "strings" "sync" @@ -59,101 +59,99 @@ func (p *PathItem) GetContext() context.Context { return p.context } -// Hash will return a consistent SHA256 Hash of the PathItem object -func (p *PathItem) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !p.Description.IsEmpty() { - sb.WriteString(p.Description.Value) - sb.WriteByte('|') - } - if !p.Summary.IsEmpty() { - sb.WriteString(p.Summary.Value) - sb.WriteByte('|') - } - if !p.Get.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", GetLabel, low.GenerateHashString(p.Get.Value))) - sb.WriteByte('|') - } - if !p.Put.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", PutLabel, low.GenerateHashString(p.Put.Value))) - sb.WriteByte('|') - } - if !p.Post.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", PostLabel, low.GenerateHashString(p.Post.Value))) - sb.WriteByte('|') - } - if !p.Delete.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", DeleteLabel, low.GenerateHashString(p.Delete.Value))) - sb.WriteByte('|') - } - if !p.Options.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", OptionsLabel, low.GenerateHashString(p.Options.Value))) - sb.WriteByte('|') - } - if !p.Head.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", HeadLabel, low.GenerateHashString(p.Head.Value))) - sb.WriteByte('|') - } - if !p.Patch.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", PatchLabel, low.GenerateHashString(p.Patch.Value))) - sb.WriteByte('|') - } - if !p.Trace.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", TraceLabel, low.GenerateHashString(p.Trace.Value))) - sb.WriteByte('|') - } - if !p.Query.IsEmpty() { - sb.WriteString(fmt.Sprintf("%s-%s", QueryLabel, low.GenerateHashString(p.Query.Value))) - sb.WriteByte('|') - } - - // Process AdditionalOperations with pre-allocation and sorting - if p.AdditionalOperations.Value != nil && p.AdditionalOperations.Value.Len() > 0 { - keys := make([]string, 0, p.AdditionalOperations.Value.Len()) - for k, v := range p.AdditionalOperations.Value.FromOldest() { - keys = append(keys, fmt.Sprintf("%s-%s", k.Value, low.GenerateHashString(v.Value))) +// Hash will return a consistent Hash of the PathItem object +func (p *PathItem) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !p.Description.IsEmpty() { + h.WriteString(p.Description.Value) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + if !p.Summary.IsEmpty() { + h.WriteString(p.Summary.Value) + h.WriteByte(low.HASH_PIPE) } - } - - // Process Parameters with pre-allocation and sorting - if len(p.Parameters.Value) > 0 { - keys := make([]string, len(p.Parameters.Value)) - for k := range p.Parameters.Value { - keys[k] = low.GenerateHashString(p.Parameters.Value[k].Value) + if !p.Get.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", GetLabel, low.GenerateHashString(p.Get.Value))) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + if !p.Put.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", PutLabel, low.GenerateHashString(p.Put.Value))) + h.WriteByte(low.HASH_PIPE) + } + if !p.Post.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", PostLabel, low.GenerateHashString(p.Post.Value))) + h.WriteByte(low.HASH_PIPE) + } + if !p.Delete.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", DeleteLabel, low.GenerateHashString(p.Delete.Value))) + h.WriteByte(low.HASH_PIPE) + } + if !p.Options.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", OptionsLabel, low.GenerateHashString(p.Options.Value))) + h.WriteByte(low.HASH_PIPE) + } + if !p.Head.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", HeadLabel, low.GenerateHashString(p.Head.Value))) + h.WriteByte(low.HASH_PIPE) + } + if !p.Patch.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", PatchLabel, low.GenerateHashString(p.Patch.Value))) + h.WriteByte(low.HASH_PIPE) + } + if !p.Trace.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", TraceLabel, low.GenerateHashString(p.Trace.Value))) + h.WriteByte(low.HASH_PIPE) + } + if !p.Query.IsEmpty() { + h.WriteString(fmt.Sprintf("%s-%s", QueryLabel, low.GenerateHashString(p.Query.Value))) + h.WriteByte(low.HASH_PIPE) } - } - // Process Servers with pre-allocation and sorting - if len(p.Servers.Value) > 0 { - keys := make([]string, len(p.Servers.Value)) - for k := range p.Servers.Value { - keys[k] = low.GenerateHashString(p.Servers.Value[k].Value) + // Process AdditionalOperations with pre-allocation and sorting + if p.AdditionalOperations.Value != nil && p.AdditionalOperations.Value.Len() > 0 { + keys := make([]string, 0, p.AdditionalOperations.Value.Len()) + for k, v := range p.AdditionalOperations.Value.FromOldest() { + keys = append(keys, fmt.Sprintf("%s-%s", k.Value, low.GenerateHashString(v.Value))) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + + // Process Parameters with pre-allocation and sorting + if len(p.Parameters.Value) > 0 { + keys := make([]string, len(p.Parameters.Value)) + for k := range p.Parameters.Value { + keys[k] = low.GenerateHashString(p.Parameters.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } } - } - for _, ext := range low.HashExtensions(p.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) + // Process Servers with pre-allocation and sorting + if len(p.Servers.Value) > 0 { + keys := make([]string, len(p.Servers.Value)) + for k := range p.Servers.Value { + keys[k] = low.GenerateHashString(p.Servers.Value[k].Value) + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } + } + + for _, ext := range low.HashExtensions(p.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } // GetRootNode returns the root yaml node of the PathItem object diff --git a/datamodel/low/v3/paths.go b/datamodel/low/v3/paths.go index eeaaf43e..1306a5e1 100644 --- a/datamodel/low/v3/paths.go +++ b/datamodel/low/v3/paths.go @@ -5,8 +5,8 @@ package v3 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "strings" "sync" @@ -117,21 +117,19 @@ func (p *Paths) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index. return nil } -// Hash will return a consistent SHA256 Hash of the PathItem object -func (p *Paths) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - for _, hash := range low.AppendMapHashes(nil, p.PathItems) { - sb.WriteString(hash) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(p.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Paths object +func (p *Paths) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for _, hash := range low.AppendMapHashes(nil, p.PathItems) { + h.WriteString(hash) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(p.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } func extractPathItemsMap(ctx context.Context, root *yaml.Node, idx *index.SpecIndex) (*orderedmap.Map[low.KeyReference[string], low.ValueReference[*PathItem]], error) { diff --git a/datamodel/low/v3/request_body.go b/datamodel/low/v3/request_body.go index 74372858..06b05460 100644 --- a/datamodel/low/v3/request_body.go +++ b/datamodel/low/v3/request_body.go @@ -5,8 +5,7 @@ package v3 import ( "context" - "crypto/sha256" - "strconv" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -101,27 +100,25 @@ func (rb *RequestBody) Build(ctx context.Context, keyNode, root *yaml.Node, idx return nil } -// Hash will return a consistent SHA256 Hash of the RequestBody object -func (rb *RequestBody) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if rb.Description.Value != "" { - sb.WriteString(rb.Description.Value) - sb.WriteByte('|') - } - if !rb.Required.IsEmpty() { - sb.WriteString(strconv.FormatBool(rb.Required.Value)) - sb.WriteByte('|') - } - for v := range orderedmap.SortAlpha(rb.Content.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(rb.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the RequestBody object +func (rb *RequestBody) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if rb.Description.Value != "" { + h.WriteString(rb.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !rb.Required.IsEmpty() { + low.HashBool(h, rb.Required.Value) + h.WriteByte(low.HASH_PIPE) + } + for v := range orderedmap.SortAlpha(rb.Content.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(rb.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/response.go b/datamodel/low/v3/response.go index 102e2352..72eca09b 100644 --- a/datamodel/low/v3/response.go +++ b/datamodel/low/v3/response.go @@ -5,7 +5,7 @@ package v3 import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -148,36 +148,34 @@ func (r *Response) Build(ctx context.Context, keyNode, root *yaml.Node, idx *ind return nil } -// Hash will return a consistent SHA256 Hash of the Response object -func (r *Response) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if r.Summary.Value != "" { - sb.WriteString(r.Summary.Value) - sb.WriteByte('|') - } - if r.Description.Value != "" { - sb.WriteString(r.Description.Value) - sb.WriteByte('|') - } +// Hash will return a consistent Hash of the Response object +func (r *Response) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if r.Summary.Value != "" { + h.WriteString(r.Summary.Value) + h.WriteByte(low.HASH_PIPE) + } + if r.Description.Value != "" { + h.WriteString(r.Description.Value) + h.WriteByte(low.HASH_PIPE) + } - for _, hash := range low.AppendMapHashes(nil, r.Headers.Value) { - sb.WriteString(hash) - sb.WriteByte('|') - } - for _, hash := range low.AppendMapHashes(nil, r.Content.Value) { - sb.WriteString(hash) - sb.WriteByte('|') - } - for _, hash := range low.AppendMapHashes(nil, r.Links.Value) { - sb.WriteString(hash) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(r.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) + for _, hash := range low.AppendMapHashes(nil, r.Headers.Value) { + h.WriteString(hash) + h.WriteByte(low.HASH_PIPE) + } + for _, hash := range low.AppendMapHashes(nil, r.Content.Value) { + h.WriteString(hash) + h.WriteByte(low.HASH_PIPE) + } + for _, hash := range low.AppendMapHashes(nil, r.Links.Value) { + h.WriteString(hash) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(r.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/response_test.go b/datamodel/low/v3/response_test.go index 936b1a57..c902101b 100644 --- a/datamodel/low/v3/response_test.go +++ b/datamodel/low/v3/response_test.go @@ -164,9 +164,8 @@ x-shoes: old` err = n.Build(context.Background(), nil, idxNode.Content[0], idx) assert.NoError(t, err) - // check hash - assert.Equal(t, "9fc8294b7dcfc242fffe2586e10c9272fa2b9c828702a6b268ca68e8aa35cbbe", - low.GenerateHashString(&n)) + // check hash - maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&n)) assert.Equal(t, 1, orderedmap.Len(n.FindResponseByCode("200").Value.GetExtensions())) assert.Equal(t, 1, orderedmap.Len(n.GetExtensions())) diff --git a/datamodel/low/v3/responses.go b/datamodel/low/v3/responses.go index 0cbffc86..5a43db46 100644 --- a/datamodel/low/v3/responses.go +++ b/datamodel/low/v3/responses.go @@ -5,8 +5,8 @@ package v3 import ( "context" - "crypto/sha256" "fmt" + "hash/maphash" "strings" "github.com/pb33f/libopenapi/datamodel/low" @@ -144,23 +144,21 @@ func (r *Responses) FindResponseByCode(code string) *low.ValueReference[*Respons return low.FindItemInOrderedMap[*Response](code, r.Codes) } -// Hash will return a consistent SHA256 Hash of the Examples object -func (r *Responses) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - for _, hash := range low.AppendMapHashes(nil, r.Codes) { - sb.WriteString(hash) - sb.WriteByte('|') - } - if !r.Default.IsEmpty() { - sb.WriteString(low.GenerateHashString(r.Default.Value)) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(r.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the Responses object +func (r *Responses) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + for _, hash := range low.AppendMapHashes(nil, r.Codes) { + h.WriteString(hash) + h.WriteByte(low.HASH_PIPE) + } + if !r.Default.IsEmpty() { + h.WriteString(low.GenerateHashString(r.Default.Value)) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(r.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/security_scheme.go b/datamodel/low/v3/security_scheme.go index ffb1b022..fb760281 100644 --- a/datamodel/low/v3/security_scheme.go +++ b/datamodel/low/v3/security_scheme.go @@ -5,8 +5,7 @@ package v3 import ( "context" - "crypto/sha256" - "strconv" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -102,55 +101,53 @@ func (ss *SecurityScheme) Build(ctx context.Context, keyNode, root *yaml.Node, i return nil } -// Hash will return a consistent SHA256 Hash of the SecurityScheme object -func (ss *SecurityScheme) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !ss.Type.IsEmpty() { - sb.WriteString(ss.Type.Value) - sb.WriteByte('|') - } - if !ss.Description.IsEmpty() { - sb.WriteString(ss.Description.Value) - sb.WriteByte('|') - } - if !ss.Name.IsEmpty() { - sb.WriteString(ss.Name.Value) - sb.WriteByte('|') - } - if !ss.In.IsEmpty() { - sb.WriteString(ss.In.Value) - sb.WriteByte('|') - } - if !ss.Scheme.IsEmpty() { - sb.WriteString(ss.Scheme.Value) - sb.WriteByte('|') - } - if !ss.BearerFormat.IsEmpty() { - sb.WriteString(ss.BearerFormat.Value) - sb.WriteByte('|') - } - if !ss.Flows.IsEmpty() { - sb.WriteString(low.GenerateHashString(ss.Flows.Value)) - sb.WriteByte('|') - } - if !ss.OpenIdConnectUrl.IsEmpty() { - sb.WriteString(ss.OpenIdConnectUrl.Value) - sb.WriteByte('|') - } - if !ss.OAuth2MetadataUrl.IsEmpty() { - sb.WriteString(ss.OAuth2MetadataUrl.Value) - sb.WriteByte('|') - } - if !ss.Deprecated.IsEmpty() { - sb.WriteString(strconv.FormatBool(ss.Deprecated.Value)) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(ss.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) +// Hash will return a consistent Hash of the SecurityScheme object +func (ss *SecurityScheme) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !ss.Type.IsEmpty() { + h.WriteString(ss.Type.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Description.IsEmpty() { + h.WriteString(ss.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Name.IsEmpty() { + h.WriteString(ss.Name.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.In.IsEmpty() { + h.WriteString(ss.In.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Scheme.IsEmpty() { + h.WriteString(ss.Scheme.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.BearerFormat.IsEmpty() { + h.WriteString(ss.BearerFormat.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Flows.IsEmpty() { + h.WriteString(low.GenerateHashString(ss.Flows.Value)) + h.WriteByte(low.HASH_PIPE) + } + if !ss.OpenIdConnectUrl.IsEmpty() { + h.WriteString(ss.OpenIdConnectUrl.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.OAuth2MetadataUrl.IsEmpty() { + h.WriteString(ss.OAuth2MetadataUrl.Value) + h.WriteByte(low.HASH_PIPE) + } + if !ss.Deprecated.IsEmpty() { + low.HashBool(h, ss.Deprecated.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(ss.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/security_scheme_test.go b/datamodel/low/v3/security_scheme_test.go index 6a08289d..1cebf781 100644 --- a/datamodel/low/v3/security_scheme_test.go +++ b/datamodel/low/v3/security_scheme_test.go @@ -65,8 +65,8 @@ x-milk: please` assert.NotNil(t, n.GetRootNode()) assert.Nil(t, n.GetKeyNode()) - assert.Equal(t, "45cf8d044a079a416a22ef0b1ff6947d0eca31ae39170a2493bae4d845df663b", - low.GenerateHashString(&n)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&n)) assert.Equal(t, "tea", n.Type.Value) assert.Equal(t, "cake", n.Description.Value) diff --git a/datamodel/low/v3/server.go b/datamodel/low/v3/server.go index 1e9b3b0a..5b45b176 100644 --- a/datamodel/low/v3/server.go +++ b/datamodel/low/v3/server.go @@ -5,7 +5,7 @@ package v3 import ( "context" - "crypto/sha256" + "hash/maphash" "github.com/pb33f/libopenapi/datamodel/low" "github.com/pb33f/libopenapi/index" @@ -113,33 +113,31 @@ func (s *Server) Build(ctx context.Context, keyNode, root *yaml.Node, idx *index return nil } -// Hash will return a consistent SHA256 Hash of the Server object -func (s *Server) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) - - if !s.Name.IsEmpty() { - sb.WriteString(s.Name.Value) - sb.WriteByte('|') - } - if s.Variables.Value != nil { - for v := range orderedmap.SortAlpha(s.Variables.Value).ValuesFromOldest() { - sb.WriteString(low.GenerateHashString(v.Value)) - sb.WriteByte('|') +// Hash will return a consistent Hash of the Server object +func (s *Server) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + if !s.Name.IsEmpty() { + h.WriteString(s.Name.Value) + h.WriteByte(low.HASH_PIPE) } - } - if !s.URL.IsEmpty() { - sb.WriteString(s.URL.Value) - sb.WriteByte('|') - } - if !s.Description.IsEmpty() { - sb.WriteString(s.Description.Value) - sb.WriteByte('|') - } - for _, ext := range low.HashExtensions(s.Extensions) { - sb.WriteString(ext) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) + if s.Variables.Value != nil { + for v := range orderedmap.SortAlpha(s.Variables.Value).ValuesFromOldest() { + h.WriteString(low.GenerateHashString(v.Value)) + h.WriteByte(low.HASH_PIPE) + } + } + if !s.URL.IsEmpty() { + h.WriteString(s.URL.Value) + h.WriteByte(low.HASH_PIPE) + } + if !s.Description.IsEmpty() { + h.WriteString(s.Description.Value) + h.WriteByte(low.HASH_PIPE) + } + for _, ext := range low.HashExtensions(s.Extensions) { + h.WriteString(ext) + h.WriteByte(low.HASH_PIPE) + } + return h.Sum64() + }) } diff --git a/datamodel/low/v3/server_test.go b/datamodel/low/v3/server_test.go index cf9d0c29..f90f6078 100644 --- a/datamodel/low/v3/server_test.go +++ b/datamodel/low/v3/server_test.go @@ -36,18 +36,17 @@ variables: err = n.Build(context.Background(), nil, idxNode.Content[0], idx) assert.NoError(t, err) assert.NotNil(t, n.GetRootNode()) - assert.Equal(t, "0c2c833ff3934ac3a0351f56e0ed42e5ffee1d5a7856fb0278857701ef52d6ae", - low.GenerateHashString(&n)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&n)) assert.Equal(t, "https://pb33f.io", n.URL.Value) assert.Equal(t, "high quality software for developers.", n.Description.Value) assert.Equal(t, "hello", n.FindVariable("var1").Value.Default.Value) assert.Equal(t, "a var", n.FindVariable("var1").Value.Description.Value) - // test var hash + // test var hash - maphash uses random seed per process, so just test non-empty s := n.FindVariable("var1") - assert.Equal(t, "c58f2e9eb6548e9ea9c3bd6ca3ff1f6c5ba850cdb20eb6f362eba5520fc3a011", - low.GenerateHashString(s.Value)) + assert.NotEmpty(t, low.GenerateHashString(s.Value)) assert.Equal(t, 1, orderedmap.Len(n.GetExtensions())) assert.NotNil(t, n.GetContext()) @@ -84,8 +83,8 @@ variables: err = n.Build(context.Background(), nil, idxNode.Content[0], idx) assert.NoError(t, err) assert.NotNil(t, n.GetRootNode()) - assert.Equal(t, "841e49335ea4ae63b677544ae815f9605988f625d24fbaa0a1992ec97f71b00b", - low.GenerateHashString(&n)) + // maphash uses random seed per process, so just test non-empty + assert.NotEmpty(t, low.GenerateHashString(&n)) assert.Equal(t, "https://pb33f.io", n.URL.Value) assert.Equal(t, "high quality software for developers.", n.Description.Value) @@ -100,10 +99,9 @@ variables: assert.Equal(t, "allowMissing", variable_pair.Value.Value.Content[0].Value) assert.Equal(t, "true", variable_pair.Value.Value.Content[1].Value) - // test var hash + // test var hash - maphash uses random seed per process, so just test non-empty s := n.FindVariable("var1") - assert.Equal(t, "c58f2e9eb6548e9ea9c3bd6ca3ff1f6c5ba850cdb20eb6f362eba5520fc3a011", - low.GenerateHashString(s.Value)) + assert.NotEmpty(t, low.GenerateHashString(s.Value)) assert.Equal(t, 0, orderedmap.Len(n.GetExtensions())) diff --git a/datamodel/low/v3/server_variable.go b/datamodel/low/v3/server_variable.go index 4eba480b..b9e6636f 100644 --- a/datamodel/low/v3/server_variable.go +++ b/datamodel/low/v3/server_variable.go @@ -1,7 +1,7 @@ package v3 import ( - "crypto/sha256" + "hash/maphash" "sort" "github.com/pb33f/libopenapi/datamodel/low" @@ -42,32 +42,30 @@ func (s *ServerVariable) GetExtensions() *orderedmap.Map[low.KeyReference[string return s.Extensions } -// Hash will return a consistent SHA256 Hash of the ServerVariable object -func (s *ServerVariable) Hash() [32]byte { - // Use string builder pool - sb := low.GetStringBuilder() - defer low.PutStringBuilder(sb) +// Hash will return a consistent Hash of the ServerVariable object +func (s *ServerVariable) Hash() uint64 { + return low.WithHasher(func(h *maphash.Hash) uint64 { + // Pre-allocate and sort enum values + if len(s.Enum) > 0 { + keys := make([]string, len(s.Enum)) + for i := range s.Enum { + keys[i] = s.Enum[i].Value + } + sort.Strings(keys) + for _, key := range keys { + h.WriteString(key) + h.WriteByte(low.HASH_PIPE) + } + } - // Pre-allocate and sort enum values - if len(s.Enum) > 0 { - keys := make([]string, len(s.Enum)) - for i := range s.Enum { - keys[i] = s.Enum[i].Value + if !s.Default.IsEmpty() { + h.WriteString(s.Default.Value) + h.WriteByte(low.HASH_PIPE) } - sort.Strings(keys) - for _, key := range keys { - sb.WriteString(key) - sb.WriteByte('|') + if !s.Description.IsEmpty() { + h.WriteString(s.Description.Value) + h.WriteByte(low.HASH_PIPE) } - } - - if !s.Default.IsEmpty() { - sb.WriteString(s.Default.Value) - sb.WriteByte('|') - } - if !s.Description.IsEmpty() { - sb.WriteString(s.Description.Value) - sb.WriteByte('|') - } - return sha256.Sum256([]byte(sb.String())) + return h.Sum64() + }) } diff --git a/index/utility_methods.go b/index/utility_methods.go index e725368e..76a38edf 100644 --- a/index/utility_methods.go +++ b/index/utility_methods.go @@ -700,17 +700,13 @@ func init() { emptyNodeHash = strconv.FormatUint(h.Sum64(), 16) } -// writeIntToHash writes an integer to the hash without heap allocations. -// uses a stack-allocated buffer to avoid strconv.Itoa's string allocation. +// writeIntToHash writes a non-negative integer to the hash without heap allocations. +// Uses a stack-allocated buffer. Line/Column values are always non-negative. func writeIntToHash(h *maphash.Hash, n int) { if n == 0 { h.WriteByte('0') return } - if n < 0 { - h.WriteByte('-') - n = -n - } // max int64 is 19 digits, 20 is safe var buf [20]byte i := len(buf) @@ -742,18 +738,10 @@ func HashNode(n *yaml.Node) string { // get stack from pool, reset length but keep capacity stackPtr := stackPool.Get().(*[]*yaml.Node) - origStack := *stackPtr // save original before any growth - stack := origStack[:0] + stack := (*stackPtr)[:0] stack = append(stack, n) defer func() { - if cap(stack) > 256 { - // stack grew too large - return original small slice to pool - // let the grown slice be GC'd - *stackPtr = origStack[:0] - } else { - // acceptable size - return current (possibly grown) slice - *stackPtr = stack[:0] - } + *stackPtr = stack[:0] stackPool.Put(stackPtr) }() diff --git a/index/utility_methods_test.go b/index/utility_methods_test.go index 0325586c..57d65ac9 100644 --- a/index/utility_methods_test.go +++ b/index/utility_methods_test.go @@ -320,6 +320,7 @@ func Test_WriteIntToHash(t *testing.T) { assert.NotZero(t, h.Sum64()) } + // verify consistency - hash should be same on repeated calls func Test_Empty_HashNode(t *testing.T) { assert.Equal(t, emptyNodeHash, HashNode(nil)) } diff --git a/overlay/engine.go b/overlay/engine.go index dc5b231d..4a18b55e 100644 --- a/overlay/engine.go +++ b/overlay/engine.go @@ -26,8 +26,8 @@ func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) { return nil, err } - // Parent index is built lazily and rebuilt after updates to ensure - // remove actions can target nodes created by earlier update actions. + // Parent index is built lazily and rebuilt after updates/copies to ensure + // remove actions can target nodes created by earlier update/copy actions. var parentIdx parentIndex parentIdxStale := true @@ -44,7 +44,9 @@ func Apply(targetBytes []byte, overlay *highoverlay.Overlay) (*Result, error) { } warnings = append(warnings, actionWarnings...) - if action.Update != nil { + // Mark parent index as stale after update or copy operations + // (both can add new nodes that subsequent remove actions may target) + if action.Update != nil || action.Copy != "" { parentIdxStale = true } } @@ -83,20 +85,69 @@ func applyAction(root *yaml.Node, action *highoverlay.Action, parentIdx parentIn return warnings, nil } + // Operation order per spec: copy → update → remove + // This allows: + // - Copy to populate the target first + // - Update to override copied values + // - Remove to clean up afterwards (move pattern) + + // 1. Copy (if present) + if action.Copy != "" { + copyWarnings, err := applyCopyAction(root, nodes, action.Copy) + if err != nil { + return nil, err + } + warnings = append(warnings, copyWarnings...) + } + + // 2. Update (if present) // Validate targets for UPDATE actions (must be objects or arrays, not primitives). + // Validation happens AFTER copy because copy may change the target node type. // REMOVE actions can target any node type. - if !action.Remove && action.Update != nil { + if action.Update != nil { for _, node := range nodes { if err := validateTarget(node); err != nil { return nil, err } } + applyUpdateAction(nodes, action.Update) } + // 3. Remove (if present) if action.Remove { applyRemoveAction(parentIdx, nodes) - } else if action.Update != nil { - applyUpdateAction(nodes, action.Update) + } + + return warnings, nil +} + +func applyCopyAction(root *yaml.Node, targetNodes []*yaml.Node, copyPath string) ([]*Warning, error) { + var warnings []*Warning + + path, err := jsonpath.NewPath(copyPath, config.WithPropertyNameExtension()) + if err != nil { + return nil, ErrInvalidJSONPath + } + + sourceNodes := path.Query(root) + + // Single-node constraint per spec: copy source must select exactly one node + if len(sourceNodes) == 0 { + return nil, ErrCopySourceNotFound + } + if len(sourceNodes) > 1 { + return nil, ErrCopySourceMultiple + } + + sourceNode := sourceNodes[0] + + // Type compatibility check per spec: "If the target expression and + // copy expression do not return the same type, an error MUST be reported" + for _, targetNode := range targetNodes { + if sourceNode.Kind != targetNode.Kind { + return nil, ErrCopyTypeMismatch + } + mergeNode(targetNode, sourceNode) } return warnings, nil @@ -191,10 +242,16 @@ NextKey: } func mergeSequenceNode(node *yaml.Node, merge *yaml.Node) { - node.Content = append(node.Content, cloneNode(merge).Content...) + // clone each child individually to avoid wasteful intermediate allocation + for _, child := range merge.Content { + node.Content = append(node.Content, cloneNode(child)) + } } func cloneNode(node *yaml.Node) *yaml.Node { + if node == nil { + return nil + } newNode := &yaml.Node{ Kind: node.Kind, Style: node.Style, diff --git a/overlay/engine_test.go b/overlay/engine_test.go index 71f89c72..ffaef2d2 100644 --- a/overlay/engine_test.go +++ b/overlay/engine_test.go @@ -571,6 +571,12 @@ func TestCloneNode_WithAlias(t *testing.T) { assert.Equal(t, "aliased", cloned.Alias.Value) } +func TestCloneNode_Nil(t *testing.T) { + // cloneNode should handle nil input gracefully + cloned := cloneNode(nil) + assert.Nil(t, cloned) +} + func TestApply_InvalidJSONPath(t *testing.T) { targetYAML := `openapi: 3.0.0 info: @@ -671,3 +677,545 @@ actions: assert.NotContains(t, string(result.Bytes), "This will be added then removed") assert.NotContains(t, string(result.Bytes), "description") } + +// Copy action tests + +func TestApply_CopySingleNodeSuccess(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /users: + get: + summary: Get users + responses: + '200': + description: Success response + content: + application/json: + schema: + type: array + post: + summary: Create user + responses: + '201': + description: Created` + + overlayYAML := `overlay: 1.1.0 +info: + title: Copy Test + version: 1.0.0 +actions: + - target: $.paths['/users'].post.responses['201'] + copy: $.paths['/users'].get.responses['200']` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + // Should have copied the content from GET 200 to POST 201 + assert.Contains(t, string(result.Bytes), "Success response") +} + +func TestApply_CopyToMultipleTargets(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /source: + get: + summary: Source + /target1: + get: + summary: Target1 + /target2: + get: + summary: Target2` + + // Copy single source to multiple targets + overlay := &highoverlay.Overlay{ + Overlay: "1.1.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.paths['/target1'].get", Copy: "$.paths['/source'].get"}, + {Target: "$.paths['/target2'].get", Copy: "$.paths['/source'].get"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + // Both targets should have the source summary merged in + assert.Contains(t, string(result.Bytes), "Source") +} + +func TestApply_CopySourceNotFound(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlay := &highoverlay.Overlay{ + Overlay: "1.1.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info", Copy: "$.nonexistent"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrCopySourceNotFound) + assert.Nil(t, result) +} + +func TestApply_CopySourceMultipleNodes(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /a: + get: + summary: A + /b: + get: + summary: B + /c: + get: + summary: C` + + // Try to copy from a path that matches multiple nodes + overlay := &highoverlay.Overlay{ + Overlay: "1.1.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info", Copy: "$.paths.*.get"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrCopySourceMultiple) + assert.Nil(t, result) +} + +func TestApply_CopyTypeMismatch_ObjectToArray(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +tags: + - name: tag1 +paths: + /users: + get: + summary: Get` + + overlay := &highoverlay.Overlay{ + Overlay: "1.1.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + // Try to copy an object (paths./users.get) to an array (tags) + {Target: "$.tags", Copy: "$.paths['/users'].get"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrCopyTypeMismatch) + assert.Nil(t, result) +} + +func TestApply_CopyTypeMismatch_ArrayToObject(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +tags: + - name: tag1 +paths: + /users: + get: + summary: Get` + + overlay := &highoverlay.Overlay{ + Overlay: "1.1.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + // Try to copy an array (tags) to an object (paths./users.get) + {Target: "$.paths['/users'].get", Copy: "$.tags"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrCopyTypeMismatch) + assert.Nil(t, result) +} + +func TestApply_CopyObjectsMergeCorrectly(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /source: + get: + summary: Source Summary + description: Source Description + /target: + get: + summary: Target Summary + operationId: targetOp` + + overlayYAML := `overlay: 1.1.0 +info: + title: Copy Test + version: 1.0.0 +actions: + - target: $.paths['/target'].get + copy: $.paths['/source'].get` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + // Should have merged: source overwrites summary, adds description, target keeps operationId + resultStr := string(result.Bytes) + assert.Contains(t, resultStr, "Source Summary") + assert.Contains(t, resultStr, "Source Description") + assert.Contains(t, resultStr, "targetOp") +} + +func TestApply_CopyWithUpdateOverride(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /source: + get: + summary: Source Summary + description: Source Description + /target: + get: + summary: Target Summary` + + overlayYAML := `overlay: 1.1.0 +info: + title: Copy Override Test + version: 1.0.0 +actions: + - target: $.paths['/target'].get + copy: $.paths['/source'].get + update: + summary: Overridden Summary` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + resultStr := string(result.Bytes) + // Copy happens first, then update overrides + assert.Contains(t, resultStr, "Overridden Summary") + assert.Contains(t, resultStr, "Source Description") + // The target path should have Overridden Summary, not Target Summary + assert.NotContains(t, resultStr, "Target Summary") +} + +func TestApply_CopyWithRemove_MovePattern(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /old-endpoint: + get: + summary: Old Endpoint + /new-endpoint: + get: + summary: New Endpoint Placeholder` + + // Move pattern: copy then remove source in separate action + overlayYAML := `overlay: 1.1.0 +info: + title: Move Test + version: 1.0.0 +actions: + - target: $.paths['/new-endpoint'].get + copy: $.paths['/old-endpoint'].get + - target: $.paths['/old-endpoint'] + remove: true` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + resultStr := string(result.Bytes) + // Old endpoint should be removed, new endpoint should have old content + assert.NotContains(t, resultStr, "/old-endpoint") + assert.Contains(t, resultStr, "/new-endpoint") + assert.Contains(t, resultStr, "Old Endpoint") +} + +func TestApply_CopyAlone_NoUpdateNoRemove(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /source: + get: + summary: Source + /target: + get: + summary: Target` + + overlayYAML := `overlay: 1.1.0 +info: + title: Copy Only Test + version: 1.0.0 +actions: + - target: $.paths['/target'].get + copy: $.paths['/source'].get` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + // Copy works independently + assert.Contains(t, string(result.Bytes), "Source") +} + +func TestApply_CopyInvalidJSONPath(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlay := &highoverlay.Overlay{ + Overlay: "1.1.0", + Info: &highoverlay.Info{ + Title: "Test", + Version: "1.0.0", + }, + Actions: []*highoverlay.Action{ + {Target: "$.info", Copy: "$..[[[invalid"}, + }, + } + + result, err := Apply([]byte(targetYAML), overlay) + assert.ErrorIs(t, err, ErrInvalidJSONPath) + assert.Nil(t, result) +} + +func TestApply_CopyParentIndexStaleness(t *testing.T) { + // Test that copy operations mark parent index as stale + // so subsequent remove actions can find nodes added by copy + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /source: + get: + summary: Source + newField: source-only-value + /target: + get: + summary: Target` + + overlayYAML := `overlay: 1.1.0 +info: + title: Staleness Test + version: 1.0.0 +actions: + - target: $.paths['/target'].get + copy: $.paths['/source'].get + - target: $.paths['/target'].get.newField + remove: true + - target: $.paths['/source'].get.newField + remove: true` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + resultStr := string(result.Bytes) + // Copy added newField to target, then remove deleted it from both + assert.NotContains(t, resultStr, "source-only-value") + assert.NotContains(t, resultStr, "newField") + assert.Contains(t, resultStr, "Source") // summary should still be there +} + +func TestApply_CopySequentialDependency(t *testing.T) { + // Later action can copy from state modified by earlier action + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /a: + get: + summary: Original A + /b: + get: + summary: Original B + /c: + get: + summary: Original C` + + overlayYAML := `overlay: 1.1.0 +info: + title: Sequential Test + version: 1.0.0 +actions: + - target: $.paths['/a'].get + update: + summary: Modified A + - target: $.paths['/b'].get + copy: $.paths['/a'].get + - target: $.paths['/c'].get + copy: $.paths['/b'].get` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + resultStr := string(result.Bytes) + // Chain: A modified -> B copies from A -> C copies from B + // All should have "Modified A" + // This is a bit tricky to verify, but we can check that the modification propagated + assert.Contains(t, resultStr, "Modified A") +} + +func TestApply_CopyArraysConcatenate(t *testing.T) { + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0 +paths: + /users: + get: + tags: + - source-tag + /items: + get: + tags: + - target-tag` + + overlayYAML := `overlay: 1.1.0 +info: + title: Array Copy Test + version: 1.0.0 +actions: + - target: $.paths['/items'].get.tags + copy: $.paths['/users'].get.tags` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + resultStr := string(result.Bytes) + // Arrays should be concatenated (merge behavior) + assert.Contains(t, resultStr, "target-tag") + assert.Contains(t, resultStr, "source-tag") +} + +func TestApply_CopyPrimitiveWithUpdateFails(t *testing.T) { + // When copy source is a primitive and update is also present, + // the update validation should fail because you can't merge into a primitive + targetYAML := `openapi: 3.0.0 +info: + title: Target Title + version: 1.0.0 + description: Target Description` + + overlayYAML := `overlay: 1.1.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info.title + copy: $.info.description + update: + should: fail` + + overlay := parseOverlay(t, overlayYAML) + + _, err := Apply([]byte(targetYAML), overlay) + require.Error(t, err) + assert.ErrorIs(t, err, ErrPrimitiveTarget) +} + +func TestApply_CopyObjectWithUpdateSucceeds(t *testing.T) { + // When copy source and target are both objects (same type), + // the copy merges content and then update can modify the result + targetYAML := `openapi: 3.0.0 +info: + title: Target Title + version: 1.0.0 + contact: + name: Original Contact + email: original@example.com + license: + name: MIT` + + overlayYAML := `overlay: 1.1.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info.license + copy: $.info.contact + update: + url: https://example.com` + + overlay := parseOverlay(t, overlayYAML) + + result, err := Apply([]byte(targetYAML), overlay) + require.NoError(t, err) + resultStr := string(result.Bytes) + // The license object was merged with contact (contact's name overwrites license's name), + // then updated with url + assert.Contains(t, resultStr, "Original Contact") + assert.Contains(t, resultStr, "original@example.com") + assert.Contains(t, resultStr, "https://example.com") +} + +func TestApply_UpdateOnPrimitiveStillFailsWithoutCopy(t *testing.T) { + // Verify that update on primitive target still fails when no copy is present + // (regression test for the validation logic) + targetYAML := `openapi: 3.0.0 +info: + title: Test + version: 1.0.0` + + overlayYAML := `overlay: 1.1.0 +info: + title: Test Overlay + version: 1.0.0 +actions: + - target: $.info.title + update: + should: fail` + + overlay := parseOverlay(t, overlayYAML) + + _, err := Apply([]byte(targetYAML), overlay) + require.Error(t, err) + assert.ErrorIs(t, err, ErrPrimitiveTarget) +} diff --git a/overlay/errors.go b/overlay/errors.go index 31ce90b4..68f38509 100644 --- a/overlay/errors.go +++ b/overlay/errors.go @@ -53,4 +53,9 @@ var ( // Application errors ErrNoTargetDocument = errors.New("no target document provided") + + // Copy action errors + ErrCopySourceNotFound = errors.New("copy source JSONPath matched zero nodes") + ErrCopySourceMultiple = errors.New("copy source JSONPath must match exactly one node") + ErrCopyTypeMismatch = errors.New("copy source and target must be the same type") ) diff --git a/what-changed/model/schema_test.go b/what-changed/model/schema_test.go index 4c1db42a..53622a74 100644 --- a/what-changed/model/schema_test.go +++ b/what-changed/model/schema_test.go @@ -2147,8 +2147,8 @@ components: assert.Equal(t, 1, changes.TotalBreakingChanges()) assert.Equal(t, v3.DiscriminatorLabel, changes.Changes[0].Property) assert.Equal(t, ObjectAdded, changes.Changes[0].ChangeType) - assert.Equal(t, "d998db65844824d9fe1c4b3fe13d9d969697a3f5353611dc7f2a6a158da77de1", - low.HashToString(changes.Changes[0].NewObject.(*base.Discriminator).Hash())) + // maphash uses random seed per process, just verify non-zero + assert.NotEqual(t, uint64(0), changes.Changes[0].NewObject.(*base.Discriminator).Hash()) } func TestCompareSchemas_DiscriminatorRemove(t *testing.T) { @@ -2181,8 +2181,8 @@ components: assert.Equal(t, 1, changes.TotalBreakingChanges()) assert.Equal(t, v3.DiscriminatorLabel, changes.Changes[0].Property) assert.Equal(t, ObjectRemoved, changes.Changes[0].ChangeType) - assert.Equal(t, "d998db65844824d9fe1c4b3fe13d9d969697a3f5353611dc7f2a6a158da77de1", - low.HashToString(changes.Changes[0].OriginalObject.(*base.Discriminator).Hash())) + // maphash uses random seed per process, just verify non-zero + assert.NotEqual(t, uint64(0), changes.Changes[0].OriginalObject.(*base.Discriminator).Hash()) } func TestCompareSchemas_ExternalDocsChange(t *testing.T) { @@ -2251,8 +2251,8 @@ components: assert.Equal(t, 0, changes.TotalBreakingChanges()) assert.Equal(t, v3.ExternalDocsLabel, changes.Changes[0].Property) assert.Equal(t, ObjectAdded, changes.Changes[0].ChangeType) - assert.Equal(t, "cc072505e1639fd745ccaf6d2d4188db0c0475d4e9a48a6b4d1b33a77183a882", - low.HashToString(changes.Changes[0].NewObject.(*base.ExternalDoc).Hash())) + // maphash uses random seed per process, just verify non-zero + assert.NotEqual(t, uint64(0), changes.Changes[0].NewObject.(*base.ExternalDoc).Hash()) } func TestCompareSchemas_ExternalDocsRemove(t *testing.T) { @@ -2285,8 +2285,8 @@ components: assert.Equal(t, 0, changes.TotalBreakingChanges()) assert.Equal(t, v3.ExternalDocsLabel, changes.Changes[0].Property) assert.Equal(t, ObjectRemoved, changes.Changes[0].ChangeType) - assert.Equal(t, "cc072505e1639fd745ccaf6d2d4188db0c0475d4e9a48a6b4d1b33a77183a882", - low.HashToString(changes.Changes[0].OriginalObject.(*base.ExternalDoc).Hash())) + // maphash uses random seed per process, just verify non-zero + assert.NotEqual(t, uint64(0), changes.Changes[0].OriginalObject.(*base.ExternalDoc).Hash()) } func TestCompareSchemas_AddExtension(t *testing.T) {