Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 42 additions & 7 deletions datamodel/low/model_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
package low

import (
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 68 additions & 0 deletions datamodel/low/model_builder_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package low

import (
"math"
"sync"
"testing"

Expand Down Expand Up @@ -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)
}
60 changes: 60 additions & 0 deletions issue665_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading