Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Differentiate between integer and number in OpenAPI Schema Validation #745

Open
xunleii opened this issue Mar 2, 2025 · 1 comment
Open
Labels
help wanted Extra attention is needed question Further information is requested

Comments

@xunleii
Copy link

xunleii commented Mar 2, 2025

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) 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"

Here is an example how to reproduce it:

package main

import (
	"context"
	"testing"

	"github.com/danielgtaylor/huma/v2"
	"github.com/danielgtaylor/huma/v2/humatest"
)

type Request struct {
	Value int `json:"value"`
}

func Test(t *testing.T) {
	_, api := humatest.New(t)

	huma.Post(api, "/", func(_ context.Context, input *struct {
		Body Request
	}) (*struct{}, error) {
		return nil, nil
	})

	resp := api.Post("/", map[string]any{"value": 1.3})
	_ = resp
}

Is this behavior intentional or an oversight?

@danielgtaylor danielgtaylor added help wanted Extra attention is needed question Further information is requested labels Mar 3, 2025
@danielgtaylor
Copy link
Owner

danielgtaylor commented Mar 3, 2025

@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:

https://go.dev/play/p/TwO1ozWqmWV

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants