diff --git a/datamodel/low/model_builder.go b/datamodel/low/model_builder.go index 0cb09622..00e4624e 100644 --- a/datamodel/low/model_builder.go +++ b/datamodel/low/model_builder.go @@ -4,7 +4,9 @@ package low import ( + "errors" "fmt" + "math" "reflect" "strconv" "strings" @@ -273,13 +275,14 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if utils.IsNodeIntValue(valueNode) || utils.IsNodeFloatValue(valueNode) { if field.CanSet() { - fv, _ := strconv.ParseInt(valueNode.Value, 10, 64) - nr := NodeReference[int64]{ - Value: fv, - ValueNode: valueNode, - KeyNode: keyNode, + if fv, ok := parseInt64Node(valueNode.Value); ok { + nr := NodeReference[int64]{ + Value: fv, + ValueNode: valueNode, + KeyNode: keyNode, + } + field.Set(reflect.ValueOf(nr)) } - field.Set(reflect.ValueOf(nr)) } } @@ -380,7 +383,7 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { if field.CanSet() { items := make([]NodeReference[int64], 0, len(content)) for _, sliceItem := range content { - iv, _ := strconv.ParseInt(sliceItem.Value, 10, 64) + iv, _ := parseInt64Node(sliceItem.Value) items = append(items, NodeReference[int64]{ Value: iv, ValueNode: sliceItem, @@ -521,6 +524,38 @@ func SetField(field *reflect.Value, valueNode *yaml.Node, keyNode *yaml.Node) { } } +// parseInt64Node parses the text of an int or float YAML node as an int64. +// +// Integers use strconv.ParseInt, so out-of-range values clamp to math.MaxInt64 / math.MinInt64. +// Floats with a whole-number value are converted (2.0 -> 2, 1e3 -> 1000), and floats at or beyond +// ±2^63 (including YAML .inf / -.inf) clamp the same way. Fractions and NaN return false. +func parseInt64Node(value string) (int64, bool) { + i, err := strconv.ParseInt(value, 10, 64) + if err == nil || errors.Is(err, strconv.ErrRange) { + return i, true + } + // YAML spells infinity with a leading dot (.inf, +.inf, -.inf), which strconv does not accept. + // .nan fails to parse and is rejected below. + switch strings.ToLower(strings.TrimPrefix(value, "+")) { + case ".inf": + return math.MaxInt64, true + case "-.inf": + return math.MinInt64, true + } + f, err := strconv.ParseFloat(value, 64) + switch { + case err != nil || math.IsNaN(f): + return 0, false + case f >= math.MaxInt64: + return math.MaxInt64, true + case f <= math.MinInt64: + return math.MinInt64, true + case f != math.Trunc(f): + return 0, false + } + return int64(f), true +} + // BuildModelAsync is a convenience function for calling BuildModel from a goroutine, requires a sync.WaitGroup func BuildModelAsync(n *yaml.Node, model interface{}, lwg *sync.WaitGroup, errors *[]error) { if n != nil { diff --git a/datamodel/low/model_builder_test.go b/datamodel/low/model_builder_test.go index 70699a64..ccf72fec 100644 --- a/datamodel/low/model_builder_test.go +++ b/datamodel/low/model_builder_test.go @@ -1,6 +1,7 @@ package low import ( + "math" "sync" "testing" @@ -437,3 +438,70 @@ func TestBuildModelAsync_HandlesError(t *testing.T) { BuildModelAsync(utils.CreateStringNode("cake"), "cake", &wg, &errs) assert.NotEmpty(t, errs) } + +func TestBuildModel_Int64FromFloatAndRange(t *testing.T) { + type holder struct { + MaxLength NodeReference[int64] + } + + tests := []struct { + name string + yml string + want int64 + wantSet bool + }{ + {name: "integer", yml: "maxLength: 5", want: 5, wantSet: true}, + {name: "whole float", yml: "maxLength: 2.0", want: 2, wantSet: true}, + {name: "scientific", yml: "maxLength: 1e3", want: 1000, wantSet: true}, + {name: "max float64", yml: "maxLength: 1.7976931348623157E+308", want: math.MaxInt64, wantSet: true}, + {name: "positive inf", yml: "maxLength: .inf", want: math.MaxInt64, wantSet: true}, + {name: "negative inf", yml: "maxLength: -.inf", want: math.MinInt64, wantSet: true}, + {name: "upper case inf", yml: "maxLength: +.INF", want: math.MaxInt64, wantSet: true}, + {name: "parseint range", yml: "maxLength: 9223372036854775808", want: math.MaxInt64, wantSet: true}, + {name: "negative parseint range", yml: "maxLength: -9223372036854775809", want: math.MinInt64, wantSet: true}, + {name: "negative whole float", yml: "maxLength: -2.0", want: -2, wantSet: true}, + {name: "negative huge float", yml: "maxLength: -1e20", want: math.MinInt64, wantSet: true}, + {name: "zero float", yml: "maxLength: 0.0", want: 0, wantSet: true}, + {name: "fractional", yml: "maxLength: 2.5", wantSet: false}, + {name: "nan", yml: "maxLength: .nan", wantSet: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var rootNode yaml.Node + mErr := yaml.Unmarshal([]byte(tc.yml), &rootNode) + assert.NoError(t, mErr) + + h := holder{} + cErr := BuildModel(rootNode.Content[0], &h) + assert.NoError(t, cErr) + if tc.wantSet { + assert.False(t, h.MaxLength.IsEmpty()) + assert.Equal(t, tc.want, h.MaxLength.Value) + } else { + assert.True(t, h.MaxLength.IsEmpty()) + } + }) + } +} + +func TestBuildModel_Int64SliceFromFloat(t *testing.T) { + type holder struct { + HighTemps []NodeReference[int64] + } + + yml := `highTemps: [2.0, 2.5, 1e3, .nan]` + + var rootNode yaml.Node + mErr := yaml.Unmarshal([]byte(yml), &rootNode) + assert.NoError(t, mErr) + + h := holder{} + cErr := BuildModel(rootNode.Content[0], &h) + assert.NoError(t, cErr) + assert.Len(t, h.HighTemps, 4) + assert.Equal(t, int64(2), h.HighTemps[0].Value) + assert.Equal(t, int64(0), h.HighTemps[1].Value) + assert.Equal(t, int64(1000), h.HighTemps[2].Value) + assert.Equal(t, int64(0), h.HighTemps[3].Value) +} diff --git a/issue665_test.go b/issue665_test.go new file mode 100644 index 00000000..decd5baf --- /dev/null +++ b/issue665_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 Princess Beef Heavy Industries / Dave Shanley +// https://pb33f.io +// SPDX-License-Identifier: MIT +package libopenapi + +import ( + "math" + "testing" + + "github.com/pb33f/testify/require" +) + +const issue665Spec = `openapi: 3.1.0 +info: + title: issue 665 + version: 1.0.0 +paths: {} +components: + schemas: + FloatWhole: + type: string + maxLength: 2.0 + HugeMaxItems: + type: array + maxItems: 1.7976931348623157E+308 + Integer: + type: string + maxLength: 5 + Fractional: + type: string + maxLength: 2.5 +` + +func TestIssue665FloatTaggedInt64Keywords(t *testing.T) { + doc, err := NewDocument([]byte(issue665Spec)) + require.NoError(t, err) + + model, err := doc.BuildV3Model() + require.NoError(t, err) + require.NotNil(t, model) + + floatWhole := model.Model.Components.Schemas.GetOrZero("FloatWhole").Schema() + require.NotNil(t, floatWhole) + require.NotNil(t, floatWhole.MaxLength) + require.Equal(t, int64(2), *floatWhole.MaxLength) + + hugeMaxItems := model.Model.Components.Schemas.GetOrZero("HugeMaxItems").Schema() + require.NotNil(t, hugeMaxItems) + require.NotNil(t, hugeMaxItems.MaxItems) + require.Equal(t, int64(math.MaxInt64), *hugeMaxItems.MaxItems) + + integer := model.Model.Components.Schemas.GetOrZero("Integer").Schema() + require.NotNil(t, integer) + require.NotNil(t, integer.MaxLength) + require.Equal(t, int64(5), *integer.MaxLength) + + fractional := model.Model.Components.Schemas.GetOrZero("Fractional").Schema() + require.NotNil(t, fractional) + require.Nil(t, fractional.MaxLength) +}