Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c8a4799
fix(generator): preserve untyped error response precedence
daveshanley Oct 31, 2026
303df32
fix(v2): resolve external path items in their source context
daveshanley Oct 31, 2026
ac4988e
fix(schema): follow external alias chains to their target
daveshanley Oct 31, 2026
7c70f25
fix(index): preserve required alias targets and circular journeys
daveshanley Oct 31, 2026
a3b787b
fix: compare the schema behind a changed $ref
josephaxisa Oct 20, 2026
6755721
fix: preserve exact decimal count constraints
daveshanley Oct 31, 2026
7690dbb
fix: bound retargeted schema comparisons by resolved pairs
daveshanley Oct 31, 2026
93ef837
fix: count allOf breaking changes once
daveshanley Oct 31, 2026
ea25a55
test: cover shared schema graphs with property changes
daveshanley Oct 31, 2026
f3d00d2
fix(index): resolve effective YAML component mappings
CaliLuke Oct 27, 2026
68e6f5d
fix(index): bound YAML alias traversal and report incomplete indexes
daveshanley Oct 31, 2026
dd8ee6a
fix(utils): expand each shared YAML merge source once
daveshanley Oct 31, 2026
737e665
fix(index): skip polymorphic fields inside example payloads
daveshanley Oct 31, 2026
9543c8f
fix(bundler): preserve encoded paths and shared alias targets
daveshanley Oct 31, 2026
d3746fb
test: preserve integer parsing compatibility and simplify path errors
daveshanley Oct 31, 2026
ee09a3a
fix(index): count inherited merge traversal against alias limit
daveshanley Oct 31, 2026
88d8218
fix: reuse candidate scores for nested schema unions
daveshanley Oct 31, 2026
b496dc7
test: cover recursive schema union candidate scoring
daveshanley Oct 31, 2026
3c26804
test: close reference repair coverage gaps
daveshanley Oct 31, 2026
9144f75
fix(index): distinguish named schemas from example payloads
daveshanley Oct 31, 2026
acacfa8
fix: stop recursive union scoring without caching partial results
daveshanley Oct 31, 2026
a435203
perf(index): track ancestry only for aliases and merges
daveshanley Oct 31, 2026
3d18ca3
feat: allow trusted documents to bypass alias expansion limits
daveshanley Oct 31, 2026
23bce34
fix: reject path reference cycles before model construction
daveshanley Oct 31, 2026
f5664c3
fix(index): preserve example payloads under schema keyword names
daveshanley Oct 31, 2026
eed0290
refactor(low): clarify the integer magnitude limit
daveshanley Oct 31, 2026
835d1d8
perf(what-changed): bound parallel comparisons and avoid absent-field…
daveshanley Oct 31, 2026
90e6e07
test: cover alias policy and external example boundaries
daveshanley Oct 31, 2026
3a1fdd5
fix(index): recognize named components before example keywords
daveshanley Oct 31, 2026
1fed1e0
perf(index): keep transient resolver paths on the stack
daveshanley Oct 31, 2026
990ddea
perf(index): combine node scans and keep traversal state on the stack
daveshanley Oct 31, 2026
c339b4b
perf(utils): validate JSONPath names with an ASCII lookup
daveshanley Oct 31, 2026
9d96a62
fix(index): isolate cached files across document owners
daveshanley Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions bundler/bundler_composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func handleIndex(c *handleIndexConfig) error {
continue
}
mappedReference := mappedReferences[sequenced.FullDefinition]
if mappedReference == nil {
mappedReference, _ = c.idx.SearchIndexForReference(sequenced.FullDefinition)
}

// Check for invalid sibling properties if strict validation is enabled
if c.compositionConfig.StrictValidation &&
Expand Down Expand Up @@ -188,6 +191,14 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
}
model.Components = components
}
// An alias contributes no component of its own. Inline its reference after
// the target has been composed, so both paths use the same component.
isAlias, _, _ := utils.IsNodeRefValue(pr.ref.Node)
isAlias = isAlias && len(pr.ref.Node.Content) == 2
if !pr.fromDiscriminator && isAlias {
cf.inlineRequired = append(cf.inlineRequired, pr)
return nil
}

var location []string
if strings.Contains(pr.ref.FullDefinition, "#/") {
Expand All @@ -198,7 +209,9 @@ func processReference(model *v3.Document, pr *processRef, cf *handleIndexConfig)
// Bare-file imports need the sequenced absolute definition so composition
// keys and later rewrites point at the same target.
pr.ref.FullDefinition = pr.seqRef.FullDefinition
if importType, ok := inferComponentTypeFromSourcePath(pr.seqRef.SourcePath); ok &&
if pr.fromDiscriminator && isAlias {
_, location = fileImportLocationForType(v3low.SchemasLabel, components, pr, cf)
} else if importType, ok := inferComponentTypeFromSourcePath(pr.seqRef.SourcePath); ok &&
canComposeContextualReference(importType, pr.ref.Node, true) {
_, location = fileImportLocationForType(importType, components, pr, cf)
} else if importType, ok := DetectOpenAPIComponentType(pr.ref.Node); ok {
Expand Down Expand Up @@ -383,7 +396,8 @@ func enqueueDiscriminatorMappingTargets(
mapKey := processRefMapKeyForComponent(ref, v3low.SchemasLabel)

// Skip targets already queued for composition.
if cf.refMap.GetOrZero(mapKey) != nil {
if existing := cf.refMap.GetOrZero(mapKey); existing != nil {
existing.fromDiscriminator = true
continue
}

Expand Down
10 changes: 9 additions & 1 deletion bundler/composer_functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ func processRefMapKey(target, source *index.Reference) string {
if fullDefinition == "" && source != nil {
fullDefinition = source.FullDefinition
}
return contextualProcessRefKey(fullDefinition, source)
key := contextualProcessRefKey(fullDefinition, source)
if key == fullDefinition && target != nil && !isExplicitComponentDefinition(fullDefinition) {
// A whole-file alias has no source path. Use the same detected bucket
// that composition uses, so it shares the target with contextual refs.
if componentType, ok := DetectOpenAPIComponentType(target.Node); ok {
return fullDefinition + contextualRefKeySeparator + componentType
}
}
return key
}

// processRefMapKeyForComponent scopes a target ref by an already-known component bucket.
Expand Down
152 changes: 152 additions & 0 deletions bundler/external_regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package bundler

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/pb33f/go-yaml"
"github.com/pb33f/libopenapi"
"github.com/pb33f/libopenapi/datamodel"
"github.com/pb33f/libopenapi/utils"
"github.com/pb33f/testify/require"
)

func externalRegressionConfig(t *testing.T, files map[string]string) *datamodel.DocumentConfiguration {
t.Helper()
dir := t.TempDir()
for name, content := range files {
path := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
require.NoError(t, os.WriteFile(path, []byte(content), 0600))
}
return &datamodel.DocumentConfiguration{BasePath: dir, AllowFileReferences: true}
}

func TestBundleBytesComposed_EncodedPathItem(t *testing.T) {
config := externalRegressionConfig(t, map[string]string{"paths.yaml": `openapi: 3.1.0
info: {title: Paths, version: "1"}
paths:
/items/{id}:
get:
parameters: [{name: id, in: path, required: true, schema: {type: string}}]
responses:
'200': {description: ok}
`})
root := []byte(`openapi: 3.1.0
info: {title: Encoded paths, version: "1"}
paths:
/items/{id}: {$ref: './paths.yaml#/paths/~1items~1%7Bid%7D'}
`)
out, err := BundleBytesComposed(root, config, nil)
require.NoError(t, err)
var bundled yaml.Node
require.NoError(t, yaml.Unmarshal(out, &bundled))
_, paths := utils.FindKeyNodeTop("paths", bundled.Content[0].Content)
require.NotNil(t, paths)
_, pathItem := utils.FindKeyNodeTop("/items/{id}", paths.Content)
require.NotNil(t, pathItem)
require.Nil(t, utils.GetRefValueNode(pathItem), "path item must contain the operation, not a self-reference")
doc, err := libopenapi.NewDocument(out)
require.NoError(t, err)
model, err := doc.BuildV3Model()
require.NoError(t, err)
item := model.Model.Paths.PathItems.GetOrZero("/items/{id}")
require.NotNil(t, item.Get)
require.Len(t, item.Get.Parameters, 1)
}

func TestBundleBytesComposed_SharedAliasTarget(t *testing.T) {
config := externalRegressionConfig(t, map[string]string{
"schemas/List.yaml": "type: object\nproperties:\n data:\n type: array\n items: {$ref: ./Item.yaml}\n",
"schemas/Alias.yaml": "$ref: ./Item.yaml\n",
"schemas/Item.yaml": "type: object\nproperties:\n id: {type: string}\n",
})
root := []byte(`openapi: 3.1.0
info: {title: Shared aliases, version: "1"}
paths:
/items:
get:
responses:
'200':
description: ok
content: {application/json: {schema: {$ref: ./schemas/List.yaml}}}
/item:
get:
responses:
'200':
description: ok
content: {application/json: {schema: {$ref: ./schemas/Alias.yaml}}}
`)
out, err := BundleBytesComposed(root, config, nil)
require.NoError(t, err)
doc, err := libopenapi.NewDocument(out)
require.NoError(t, err)
model, err := doc.BuildV3Model()
require.NoError(t, err)
list := model.Model.Components.Schemas.GetOrZero("List").Schema()
require.NotNil(t, list.Properties.GetOrZero("data"), string(out))
require.Equal(t, 2, model.Model.Components.Schemas.Len(), string(out))
item := list.Properties.GetOrZero("data").Schema().Items.A.Schema()
require.Equal(t, []string{"object"}, item.Type)
require.NotNil(t, item.Properties.GetOrZero("id"))
}

func TestBundleBytes_RecursiveSchemaAtDifferentDepths(t *testing.T) {
for depth := 0; depth <= 5; depth++ {
t.Run(fmt.Sprint(depth), func(t *testing.T) {
files := map[string]string{
"schemas/Step.yaml": "properties:\n steps:\n items: {$ref: ./Step.yaml}\n",
"schemas/V.yaml": "properties:\n steps:\n items: {$ref: ./Step.yaml}\n styles: {$ref: ./W.yaml}\n",
"schemas/W.yaml": "properties:\n c: {$ref: ./E.yaml}\n",
"schemas/E.yaml": "{}\n",
}
ref := "V.yaml"
for i := depth; i > 0; i-- {
name := fmt.Sprintf("A%d.yaml", i)
files["schemas/"+name] = "properties:\n p: {$ref: ./" + ref + "}\n"
ref = name
}
files["paths/item.yaml"] = "post:\n requestBody:\n content:\n application/json:\n schema: {$ref: ../schemas/" + ref + "}\n responses:\n '200': {description: ok}\n"
config := externalRegressionConfig(t, files)
root := []byte("openapi: 3.0.3\ninfo: {title: Nested recursion, version: '1'}\npaths:\n /items: {$ref: ./paths/item.yaml}\n")
out, err := BundleBytes(root, config)
require.NoError(t, err)
doc, err := libopenapi.NewDocument(out)
require.NoError(t, err)
model, err := doc.BuildV3Model()
require.NoError(t, err)
step := model.Model.Components.Schemas.GetOrZero("Step")
require.NotNil(t, step, string(out))
require.Equal(t, "#/components/schemas/Step", step.Schema().Properties.GetOrZero("steps").Schema().Items.A.GetReference())
})
}
}

func TestBundleBytesComposed_AliasDiscriminatorMapping(t *testing.T) {
cfg := externalRegressionConfig(t, map[string]string{"schemas/Alias.yaml": "$ref: ./Dog.yaml\n", "schemas/Dog.yaml": "type: object\nproperties:\n kind: {type: string, enum: [dog]}\n"})
out, err := BundleBytesComposed([]byte(`openapi: 3.1.0
info: {title: Aliases, version: '1'}
paths: {}
components:
schemas:
Animal:
oneOf:
- {$ref: ./schemas/Alias.yaml}
discriminator:
propertyName: kind
mapping:
dog: ./schemas/Alias.yaml
`), cfg, nil)
require.NoError(t, err)

doc, err := libopenapi.NewDocument(out)
require.NoError(t, err)
model, err := doc.BuildV3Model()
require.NoError(t, err)
mapping := model.Model.Components.Schemas.GetOrZero("Animal").Schema().Discriminator.Mapping.GetOrZero("dog")
require.True(t, strings.HasPrefix(mapping, "#/components/schemas/"), mapping)
require.NotNil(t, model.Model.Components.Schemas.GetOrZero(strings.TrimPrefix(mapping, "#/components/schemas/")), mapping)
}
5 changes: 5 additions & 0 deletions datamodel/document_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ type DocumentConfiguration struct {
// FileFilter should be used to limit the scope of the rolodex.
AllowFileReferences bool

// AllowExcessiveAliases disables the YAML alias expansion limit during indexing.
// Use it for trusted documents with large amounts of shared YAML content.
// The limit remains enabled by default; cycle detection is always enabled.
AllowExcessiveAliases bool

// SkipExternalRefResolution will skip resolving external $ref references (those not starting with #).
// When enabled, external references will be left as-is during model building. Schema proxies will
// report IsReference()=true and GetReference() will return the ref string, but Schema() will return nil.
Expand Down
1 change: 1 addition & 0 deletions datamodel/high/v2/path_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type PathItem struct {
func NewPathItem(pathItem *lowV2.PathItem) *PathItem {
p := new(PathItem)
p.low = pathItem
p.Ref = pathItem.Ref.Value
p.Extensions = high.ExtractExtensions(pathItem.Extensions)
if !pathItem.Parameters.IsEmpty() {
var params []*Parameter
Expand Down
2 changes: 1 addition & 1 deletion datamodel/low/base/schema_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (s *Schema) Build(ctx context.Context, root *yaml.Node, idx *index.SpecInde

if !isTransformed {
if h, _, _ := utils.IsNodeRefValue(root); h {
ref, foundIdx, err, fctx := low.LocateRefNodeWithContext(ctx, root, idx)
ref, foundIdx, err, fctx := low.LocateRefEnd(ctx, root, idx, 0)
if ref != nil {
root = utils.ResolveMergeNodes(ref)
if fctx != nil {
Expand Down
95 changes: 95 additions & 0 deletions datamodel/low/integer_decimal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package low

import (
"math"
"strconv"
"strings"
)

// parseDecimalInt64 converts a syntactically valid decimal without rounding.
// It scans the mantissa once and retains at most 19 digits, regardless of exponent.
func parseDecimalInt64(value string) (int64, bool) {
value = strings.ReplaceAll(value, "_", "")
negative := strings.HasPrefix(value, "-")
value = strings.TrimLeft(value, "+-")
mantissa := value
exponent := int64(0)
if i := strings.IndexAny(value, "eE"); i >= 0 {
mantissa = value[:i]
exponent, _ = strconv.ParseInt(value[i+1:], 10, 64)
}
var digits, leading, trailing, fractional int64
nonzero, afterPoint := false, false
for _, digit := range mantissa {
if digit == '.' {
afterPoint = true
continue
}
digits++
if afterPoint {
fractional++
}
if digit == '0' {
trailing++
if !nonzero {
leading++
}
} else {
nonzero = true
trailing = 0
}
}
if !nonzero {
return 0, true
}
limit := int64(math.MaxInt64)
if negative {
limit = math.MinInt64
}
// Bound the exponent before subtracting the mantissa's decimal places.
if exponent > digits+19 {
return limit, true
}
if exponent < -digits {
return 0, false
}
scale := exponent - fractional
if scale < -trailing {
return 0, false
}
integerDigits := digits - leading + scale
if integerDigits > 19 {
return limit, true
}
maxMagnitude := uint64(math.MaxInt64)
if negative {
maxMagnitude++
}
var result uint64
remaining := integerDigits
for _, digit := range mantissa {
if digit == '.' {
continue
}
if leading > 0 {
leading--
continue
}
if remaining == 0 {
break
}
result = result*10 + uint64(digit-'0')
remaining--
}
for remaining > 0 {
result *= 10
remaining--
}
if result > maxMagnitude {
return limit, true
}
if negative {
return -int64(result), true
}
return int64(result), true
}
Loading
Loading