Skip to content
10 changes: 4 additions & 6 deletions bundler/bundler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,9 @@ func TestBundleDocument_Circular(t *testing.T) {
bytes, e := BundleDocument(&v3Doc.Model)
assert.NoError(t, e)
if runtime.GOOS != "windows" {
assert.Len(t, *doc.GetSpecInfo().SpecBytes, 1563)
} else {
assert.Len(t, *doc.GetSpecInfo().SpecBytes, 1637)
assert.Len(t, *doc.GetSpecInfo().SpecBytes, 1692)
}
assert.Len(t, bytes, 2016)
assert.Len(t, bytes, 2068)

logEntries := strings.Split(byteBuf.String(), "\n")
if len(logEntries) == 1 && logEntries[0] == "" {
Expand Down Expand Up @@ -230,7 +228,7 @@ func TestBundleBytes(t *testing.T) {

bytes, e := BundleBytes(digi, config)
assert.Error(t, e)
assert.Len(t, bytes, 2016)
assert.Len(t, bytes, 2068)

logEntries := strings.Split(byteBuf.String(), "\n")
if len(logEntries) == 1 && logEntries[0] == "" {
Expand Down Expand Up @@ -358,7 +356,7 @@ components:
assert.Len(t, bytes, 458)

logEntries := strings.Split(byteBuf.String(), "\n")
assert.Len(t, logEntries, 13)
assert.Len(t, logEntries, 17)
}

func TestBundleBytes_Bad(t *testing.T) {
Expand Down
4 changes: 1 addition & 3 deletions bundler/composer_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ func calculateCollisionName(name, pointer, delimiter string, iteration int) stri
jsonPointer := strings.Split(pointer, "#/")
if len(jsonPointer) == 2 {

// TODO: make delimiter configurable.
// count the number of collisions by splitting the name by the __ delimiter.
nameSegments := strings.Split(name, delimiter)
if len(nameSegments) > 1 {
Expand All @@ -52,7 +51,6 @@ func calculateCollisionName(name, pointer, delimiter string, iteration int) stri
return fileName

}

}

// split a path into segments and then create a new name by appending the iteration count.
Expand All @@ -62,7 +60,7 @@ func calculateCollisionName(name, pointer, delimiter string, iteration int) stri

lastSegment := segments[len(segments)-(iteration)]

// split the name by __ and append the last segment of the path
// split the name by the delimiter and append the last segment of the path
nameSegments := strings.Split(name, delimiter)
if len(nameSegments) > 1 {
if len(nameSegments) <= iteration {
Expand Down
18 changes: 18 additions & 0 deletions datamodel/document_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ type DocumentConfiguration struct {
// The bundler will attempt to create a single document, with all references moved to the `components` section. Any names used
// will be kept, any collisions will be resolved by appending a number to the name
RecomposeRefs bool

// UseSchemaQuickHash will use a quick hash to determine if a schema is the same as another schema if its a reference.
// This is important when a root / entry document does not have a components/schemas node, and schemas are defined in
// external documents. Enabling this will allow the what-changed module to perform deeper schema reference checks.
///
// -- IMPORTANT --
///
// Enabling this (default is false) will stop changes from being detected if a schema is circular.
// As identified in https://github.com/pb33f/libopenapi/pull/441
//
// In the edge case where you have circular references in your root / entry components/schemas and you also
// want changes in them to be picked up, then you should not enable this.
//
// If your schemas are in external documents, and you want changes in them to be picked up, then you should enable this.
//
// By default schemas as references are ignored and only the root / entry document's components/schemas are
// used to determine changes.
UseSchemaQuickHash bool
}

func NewDocumentConfiguration() *DocumentConfiguration {
Expand Down
13 changes: 13 additions & 0 deletions datamodel/high/base/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ import (
// tag defined in the Operation Object instances.
// - v2: https://swagger.io/specification/v2/#tagObject
// - v3: https://swagger.io/specification/#tag-object
// - v3.2: https://spec.openapis.org/oas/v3.2.0#tag-object
type Tag struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Summary string `json:"summary,omitempty" yaml:"summary,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
ExternalDocs *ExternalDoc `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"`
Parent string `json:"parent,omitempty" yaml:"parent,omitempty"`
Kind string `json:"kind,omitempty" yaml:"kind,omitempty"`
Extensions *orderedmap.Map[string, *yaml.Node]
low *low.Tag
}
Expand All @@ -31,12 +35,21 @@ func NewTag(tag *low.Tag) *Tag {
if !tag.Name.IsEmpty() {
t.Name = tag.Name.Value
}
if !tag.Summary.IsEmpty() {
t.Summary = tag.Summary.Value
}
if !tag.Description.IsEmpty() {
t.Description = tag.Description.Value
}
if !tag.ExternalDocs.IsEmpty() {
t.ExternalDocs = NewExternalDoc(tag.ExternalDocs.Value)
}
if !tag.Parent.IsEmpty() {
t.Parent = tag.Parent.Value
}
if !tag.Kind.IsEmpty() {
t.Kind = tag.Kind.Value
}
t.Extensions = high.ExtractExtensions(tag.Extensions)
return t
}
Expand Down
41 changes: 41 additions & 0 deletions datamodel/high/base/tag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,47 @@ x-hack: code`
assert.Equal(t, strings.TrimSpace(string(highTagBytes)), yml)
}

func TestNewTag_OpenAPI32(t *testing.T) {
var cNode yaml.Node

yml := `name: account-updates
summary: Account Updates
description: Account update operations
parent: external
kind: nav
externalDocs:
url: https://pb33f.io
description: Find more info here
x-custom: value`

_ = yaml.Unmarshal([]byte(yml), &cNode)

var lowTag lowbase.Tag
_ = lowmodel.BuildModel(cNode.Content[0], &lowTag)
_ = lowTag.Build(context.Background(), nil, cNode.Content[0], nil)

highTag := NewTag(&lowTag)

var xCustom string
_ = highTag.Extensions.GetOrZero("x-custom").Decode(&xCustom)

assert.Equal(t, "account-updates", highTag.Name)
assert.Equal(t, "Account Updates", highTag.Summary)
assert.Equal(t, "Account update operations", highTag.Description)
assert.Equal(t, "external", highTag.Parent)
assert.Equal(t, "nav", highTag.Kind)
assert.Equal(t, "https://pb33f.io", highTag.ExternalDocs.URL)
assert.Equal(t, "Find more info here", highTag.ExternalDocs.Description)
assert.Equal(t, "value", xCustom)

wentLow := highTag.GoLow()
assert.Equal(t, "account-updates", wentLow.Name.Value)
assert.Equal(t, "Account Updates", wentLow.Summary.Value)
assert.Equal(t, "external", wentLow.Parent.Value)
assert.Equal(t, "nav", wentLow.Kind.Value)
assert.NotNil(t, highTag.GoLowUntyped())
}

func TestTag_RenderInline(t *testing.T) {
tag := &Tag{
Name: "cake",
Expand Down
3 changes: 3 additions & 0 deletions datamodel/low/base/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ const (
TitleLabel = "title"
EmailLabel = "email"
NameLabel = "name"
SummaryLabel = "summary"
URLLabel = "url"
ServersLabel = "servers"
ServerLabel = "server"
TagsLabel = "tags"
ParentLabel = "parent"
KindLabel = "kind"
ExternalDocsLabel = "externalDocs"
ExamplesLabel = "examples"
ExampleLabel = "example"
Expand Down
19 changes: 12 additions & 7 deletions datamodel/low/base/schema_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,12 @@ func (sp *SchemaProxy) Hash() [32]byte {
sp.rendered = sch
hashError := fmt.Errorf("circular reference detected: %s", sp.GetReference())
if sch != nil {
if !CheckSchemaProxyForCircularRefs(sp) {
return sch.Hash()
if sp.idx != nil && sp.idx.GetConfig() != nil && sp.idx.GetConfig().UseSchemaQuickHash {
if !CheckSchemaProxyForCircularRefs(sp) {
return sch.Hash()
}
}
return sch.Hash()
}
var logger *slog.Logger
if sp.idx != nil && sp.idx.GetLogger() != nil {
Expand All @@ -177,12 +180,14 @@ func (sp *SchemaProxy) Hash() [32]byte {
}

// let's check the rolodex for a potential circular reference, and if there isn't a match, go ahead and hash the reference value.
if sp.GetIndex() != nil && !CheckSchemaProxyForCircularRefs(sp) {
if sp.rendered == nil {
sp.rendered = sp.Schema()
if sp.idx != nil && sp.idx.GetConfig() != nil && sp.idx.GetConfig().UseSchemaQuickHash {
if sp.idx != nil && !CheckSchemaProxyForCircularRefs(sp) {
if sp.rendered == nil {
sp.rendered = sp.Schema()
}
qh := sp.rendered.QuickHash() // quick hash uses a cache to keep things fast.
return qh
}
qh := sp.rendered.QuickHash() // quick hash uses a cache to keep things fast.
return qh
}

// hash reference value only, do not resolve!
Expand Down
42 changes: 41 additions & 1 deletion datamodel/low/base/schema_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func TestSchemaProxy_QuickHash_Empty(t *testing.T) {
sp.Reference = r

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
cfg := &index.SpecIndexConfig{Logger: logger}
cfg := &index.SpecIndexConfig{Logger: logger, UseSchemaQuickHash: true}
idx := index.NewSpecIndexWithConfig(nil, cfg)
sp.idx = idx

Expand Down Expand Up @@ -238,3 +238,43 @@ func TestSchemaProxy_TestRolodexHasId(t *testing.T) {
assert.Equal(t, "6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8",
low.GenerateHashString(&sch))
}

func TestSchemaProxy_Hash_UseSchemaQuickHash_NonCircular(t *testing.T) {
yml := `type: object
properties:
name:
type: string
age:
type: integer`

var sch SchemaProxy
var idxNode yaml.Node
_ = yaml.Unmarshal([]byte(yml), &idxNode)

// Create index with UseSchemaQuickHash enabled
cfg := &index.SpecIndexConfig{UseSchemaQuickHash: true}
idx := index.NewSpecIndexWithConfig(idxNode.Content[0], cfg)
rolo := index.NewRolodex(cfg)
rolo.SetRootIndex(idx)
idx.SetRolodex(rolo)

err := sch.Build(context.Background(), nil, idxNode.Content[0], idx)
assert.NoError(t, err)

// Ensure this is not a reference schema (to trigger the !sp.IsReference() path)
assert.False(t, sch.IsReference())

// Pre-render the schema to ensure it's available
schema := sch.Schema()
assert.NotNil(t, schema)

// This should trigger lines 162-164: UseSchemaQuickHash is true,
// CheckSchemaProxyForCircularRefs returns false (no circular refs in simple object)
hash := sch.Hash()

// Verify we get a valid hash (not empty)
assert.NotEqual(t, [32]byte{}, hash)

// Verify the schema was rendered and available
assert.NotNil(t, sch.rendered)
}
15 changes: 14 additions & 1 deletion datamodel/low/base/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ import (
// tag defined in the Operation Object instances.
// - v2: https://swagger.io/specification/v2/#tagObject
// - v3: https://swagger.io/specification/#tag-object
// - v3.2: https://spec.openapis.org/oas/v3.2.0#tag-object
type Tag struct {
Name low.NodeReference[string]
Summary low.NodeReference[string]
Description low.NodeReference[string]
ExternalDocs low.NodeReference[*ExternalDoc]
Parent low.NodeReference[string]
Kind low.NodeReference[string]
Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]]
KeyNode *yaml.Node
RootNode *yaml.Node
Expand Down Expand Up @@ -84,18 +88,27 @@ func (t *Tag) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.Valu
return t.Extensions
}

// Hash will return a consistent SHA256 Hash of the Info object
// Hash will return a consistent SHA256 Hash of the Tag object
func (t *Tag) Hash() [32]byte {
var f []string
if !t.Name.IsEmpty() {
f = append(f, t.Name.Value)
}
if !t.Summary.IsEmpty() {
f = append(f, t.Summary.Value)
}
if !t.Description.IsEmpty() {
f = append(f, t.Description.Value)
}
if !t.ExternalDocs.IsEmpty() {
f = append(f, low.GenerateHashString(t.ExternalDocs.Value))
}
if !t.Parent.IsEmpty() {
f = append(f, t.Parent.Value)
}
if !t.Kind.IsEmpty() {
f = append(f, t.Kind.Value)
}
f = append(f, low.HashExtensions(t.Extensions)...)
return sha256.Sum256([]byte(strings.Join(f, "|")))
}
Loading