You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
First of all, thank you very much for huma, it's so much better to write an API in Go with its own documentation now!
I’d like to report an issue with the OpenAPI schema validation: it currently does not distinguish between integer(defined as a JSON number without a fraction or exponent part) and number. This leads to a problem during object deserialization because a float cannot be converted to an int, and cannot use custom Resolver as they requires deserialization. As a result, an unsightly error is returned to the client when a float is provided where an integer is expected.
I made a “quick and dirty” fix using this patch, but it will ultimately create a break; anyone who has manually configured integer for a float field will have their API refuse requests. I haven't run the benchmark test, but this should only have a minor impact.
Patch
diff --git i/schema.go w/schema.go
index 19a44e4..355a345 100644
--- i/schema.go+++ w/schema.go@@ -232,19 +232,19 @@ func (s *Schema) PrecomputeMessages() {
return fmt.Sprintf("%v", v)
}), ", "))
if s.Minimum != nil {
- s.msgMinimum = ErrorFormatter(validation.MsgExpectedMinimumNumber, *s.Minimum)+ s.msgMinimum = ErrorFormatter(validation.MsgExpectedMinimumNumber, s.Type, *s.Minimum)
}
if s.ExclusiveMinimum != nil {
- s.msgExclusiveMinimum = ErrorFormatter(validation.MsgExpectedExclusiveMinimumNumber, *s.ExclusiveMinimum)+ s.msgExclusiveMinimum = ErrorFormatter(validation.MsgExpectedExclusiveMinimumNumber, s.Type, *s.ExclusiveMinimum)
}
if s.Maximum != nil {
- s.msgMaximum = ErrorFormatter(validation.MsgExpectedMaximumNumber, *s.Maximum)+ s.msgMaximum = ErrorFormatter(validation.MsgExpectedMaximumNumber, s.Type, *s.Maximum)
}
if s.ExclusiveMaximum != nil {
- s.msgExclusiveMaximum = ErrorFormatter(validation.MsgExpectedExclusiveMaximumNumber, *s.ExclusiveMaximum)+ s.msgExclusiveMaximum = ErrorFormatter(validation.MsgExpectedExclusiveMaximumNumber, s.Type, *s.ExclusiveMaximum)
}
if s.MultipleOf != nil {
- s.msgMultipleOf = ErrorFormatter(validation.MsgExpectedNumberBeMultipleOf, *s.MultipleOf)+ s.msgMultipleOf = ErrorFormatter(validation.MsgExpectedNumberBeMultipleOf, s.Type, *s.MultipleOf)
}
if s.MinLength != nil {
s.msgMinLength = ErrorFormatter(validation.MsgExpectedMinLength, *s.MinLength)
diff --git i/validate.go w/validate.go
index 50de8d0..703312c 100644
--- i/validate.go+++ w/validate.go@@ -402,6 +402,19 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any,
case TypeNumber, TypeInteger:
var num float64
+ if s.Type == TypeInteger {+ switch v := v.(type) {+ case float64:+ res.Addf(path, v, validation.MsgExpectedInteger)+ return+ case float32:+ res.Addf(path, v, validation.MsgExpectedInteger)+ return+ default:+ // Continue to check the number.+ }+ }+
switch v := v.(type) {
case float64:
num = v
@@ -428,7 +441,11 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any,
case uint64:
num = float64(v)
default:
- res.Add(path, v, validation.MsgExpectedNumber)+ if s.Type == TypeInteger {+ res.Add(path, v, validation.MsgExpectedInteger)+ } else {+ res.Add(path, v, validation.MsgExpectedNumber)+ }
return
}
@@ -539,9 +556,40 @@ func Validate(r Registry, s *Schema, path *PathBuffer, mode ValidateMode, v any,
}
if len(s.Enum) > 0 {
+ var val any++ // Numerical enums are stored as float64, so we need to convert the+ // value to the same type for comparison.+ switch v := v.(type) {+ case float32:+ val = float64(v)+ case int:+ val = float64(v)+ case int8:+ val = float64(v)+ case int16:+ val = float64(v)+ case int32:+ val = float64(v)+ case int64:+ val = float64(v)+ case uint:+ val = float64(v)+ case uint8:+ val = float64(v)+ case uint16:+ val = float64(v)+ case uint32:+ val = float64(v)+ case uint64:+ val = float64(v)+ default:+ val = v+ }+
found := false
for _, e := range s.Enum {
- if e == v {+ if e == val {
found = true
break
}
diff --git i/validate_test.go w/validate_test.go
index 767d0cc..d67cb67 100644
--- i/validate_test.go+++ w/validate_test.go@@ -55,9 +55,10 @@ var validateTests = []struct {
input: 0,
},
{
- name: "int from float64 success",+ name: "int from float64 failure",
typ: reflect.TypeOf(0),
input: float64(0),
+ errs: []string{"expected integer"},
},
{
name: "int from int8 success",
@@ -120,10 +121,10 @@ var validateTests = []struct {
input: int64(0),
},
{
- name: "expected number int",+ name: "expected integer int",
typ: reflect.TypeOf(0),
input: "",
- errs: []string{"expected number"},+ errs: []string{"expected integer"},
},
{
name: "expected number float64",
@@ -144,7 +145,7 @@ var validateTests = []struct {
Value int `json:"value" minimum:"1"`
}{}),
input: map[string]any{"value": 0},
- errs: []string{"expected number >= 1"},+ errs: []string{"expected integer >= 1"},
},
{
name: "exclusive minimum success",
@@ -159,7 +160,7 @@ var validateTests = []struct {
Value int `json:"value" exclusiveMinimum:"1"`
}{}),
input: map[string]any{"value": 1},
- errs: []string{"expected number > 1"},+ errs: []string{"expected integer > 1"},
},
{
name: "maximum success",
@@ -174,7 +175,7 @@ var validateTests = []struct {
Value int `json:"value" maximum:"1"`
}{}),
input: map[string]any{"value": 2},
- errs: []string{"expected number <= 1"},+ errs: []string{"expected integer <= 1"},
},
{
name: "exclusive maximum success",
@@ -189,7 +190,7 @@ var validateTests = []struct {
Value int `json:"value" exclusiveMaximum:"1"`
}{}),
input: map[string]any{"value": 1},
- errs: []string{"expected number < 1"},+ errs: []string{"expected integer < 1"},
},
{
name: "multiple of success",
@@ -204,7 +205,7 @@ var validateTests = []struct {
Value int `json:"value" multipleOf:"5"`
}{}),
input: map[string]any{"value": 2},
- errs: []string{"expected number to be a multiple of 5"},+ errs: []string{"expected integer to be a multiple of 5"},
},
{
name: "string success",
@@ -731,13 +732,13 @@ var validateTests = []struct {
name: "expected map item",
typ: reflect.TypeOf(map[any]int{}),
input: map[string]any{"one": 1, "two": true},
- errs: []string{"expected number"},+ errs: []string{"expected integer"},
},
{
name: "expected map any item",
typ: reflect.TypeOf(map[any]int{}),
input: map[any]any{"one": 1, "two": true},
- errs: []string{"expected number"},+ errs: []string{"expected integer"},
},
{
name: "map minProps success",
@@ -1026,20 +1027,34 @@ var validateTests = []struct {
typ: reflect.TypeOf(struct {
Value int `json:"value" enum:"1,5,9"`
}{}),
- input: map[string]any{"value": 1.0},+ input: map[string]any{"value": 1},+ },+ {+ name: "enum float64 success",+ typ: reflect.TypeOf(struct {+ Value float64 `json:"value" enum:"1.0,1.1,1.2"`+ }{}),+ input: map[string]any{"value": 1.1},
},
{
name: "enum uint16 success",
typ: reflect.TypeOf(struct {
Value uint16 `json:"value" enum:"1,5,9"`
}{}),
- input: map[string]any{"value": 1.0},+ input: map[string]any{"value": 1},
},
{
- name: "enum array success",+ name: "enum integer array success",
typ: reflect.TypeOf(struct {
Value []int `json:"value" enum:"1,5,9"`
}{}),
+ input: map[string]any{"value": []any{1}},+ },+ {+ name: "enum number array success",+ typ: reflect.TypeOf(struct {+ Value []float64 `json:"value" enum:"1.0,1.1,1.2"`+ }{}),
input: map[string]any{"value": []any{1.0}},
},
{
diff --git i/validation/messages.go w/validation/messages.go
index ea1160d..deaf855 100644
--- i/validation/messages.go+++ w/validation/messages.go@@ -22,6 +22,7 @@ var (
MsgExpectedNotMatchSchema = "expected value to not match schema"
MsgExpectedPropertyNameInObject = "expected propertyName value to be present in object"
MsgExpectedBoolean = "expected boolean"
+ MsgExpectedInteger = "expected integer"
MsgExpectedNumber = "expected number"
MsgExpectedString = "expected string"
MsgExpectedBase64String = "expected string to be base64 encoded"
@@ -29,11 +30,11 @@ var (
MsgExpectedObject = "expected object"
MsgExpectedArrayItemsUnique = "expected array items to be unique"
MsgExpectedOneOf = "expected value to be one of \"%s\""
- MsgExpectedMinimumNumber = "expected number >= %v"- MsgExpectedExclusiveMinimumNumber = "expected number > %v"- MsgExpectedMaximumNumber = "expected number <= %v"- MsgExpectedExclusiveMaximumNumber = "expected number < %v"- MsgExpectedNumberBeMultipleOf = "expected number to be a multiple of %v"+ MsgExpectedMinimumNumber = "expected %s >= %v"+ MsgExpectedExclusiveMinimumNumber = "expected %s > %v"+ MsgExpectedMaximumNumber = "expected %s <= %v"+ MsgExpectedExclusiveMaximumNumber = "expected %s < %v"+ MsgExpectedNumberBeMultipleOf = "expected %s to be a multiple of %v"
MsgExpectedMinLength = "expected length >= %d"
MsgExpectedMaxLength = "expected length <= %d"
MsgExpectedBePattern = "expected string to be %s"
@xunleii thanks for reporting! This is an interesting one. By default in Go, all JSON numbers (whether float or int) are decoded into float64 when unmarshaling into any, as you can see here:
That's what we use for validation, so I'm not sure if we can determine if a JSON integer or number was sent in. This isn't the case with other formats like CBOR as it has more number types.
One thing that would be easy to do is to determine if the input has a fractional component, i.e. if the JSON Schema type is integer then if num != math.Floor(num) we set a validation error. This will catch the vast majority of cases but has an edge case when the input is e.g. 1.0 as you can see in the playground link above, it would pass validation and then fail unmarshaling if the Go type is int. Thoughts? 🤔
Edit: this could be useful but likely requires a big code change to support it, and we'd have to continue to support int/float64 for other formats & scenarios... https://pkg.go.dev/encoding/json#Decoder.UseNumber
Hi @danielgtaylor,
First of all, thank you very much for
huma
, it's so much better to write an API in Go with its own documentation now!I’d like to report an issue with the OpenAPI schema validation: it currently does not distinguish between
integer
(defined as a JSON number without a fraction or exponent part) andnumber
. This leads to a problem during object deserialization because a float cannot be converted to an int, and cannot use customResolver
as they requires deserialization. As a result, an unsightly error is returned to the client when a float is provided where an integer is expected.I made a “quick and dirty” fix using this patch, but it will ultimately create a break; anyone who has manually configured
integer
for afloat
field will have their API refuse requests. I haven't run the benchmark test, but this should only have a minor impact.Patch
Here is an example how to reproduce it:
Is this behavior intentional or an oversight?
The text was updated successfully, but these errors were encountered: