mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
gin was in go.mod for two reflection helpers and zero routing: pkg/http/binding/query.go ginbinding.MapFormWithTag pkg/valuer/valuer.go ginbinding.BindUnmarshaler BindUnmarshaler was one method; the Valuer contract now states it directly. MapFormWithTag is reimplemented in pkg/http/binding/values.go, which is where it belonged: a binding package should not import a web framework to reflect over form values. bindValues is the whole mapper. It carries exactly the semantics 20 bound structs across this repo actually depend on, enumerated field by field: string · bool · int/8/16/32/64 · uint/8/16/32/64 · float32/64 slices · pointers (nil when the key is absent, allocated when it is not) anonymous embedded structs, flattened into the parent namespace `,default=` (4 live sites: format, limit, order, orderBy) `-` to skip, empty tag falls back to the field name Unmarshaler — the one extension point, which is how every valuer enum binds Dropped as dead weight, none of it reachable from any bound struct: map targets, multipart, time_format/time_utc/time_location, collection_format, the `parser` tag, arrays, and the JSON fallback for unrecognised structs. An unsupported kind is now a named error instead of a silent JSON decode. `required:"..."` is openapi metadata — MapFormWithTag never read it and neither does this; a test pins that so nobody re-derives it. Verified against the code being deleted, not asserted: a differential test ran gin's MapFormWithTag and bindValues over all 20 real query structs plus an every-kind fixture, 3520 input cases, comparing decoded structs and error-ness — identical throughout. Two deliberate mutations (the empty-value default rule, the pointer-allocation rule) turned it red, so it bites. That harness is removed here with gin; 82 table cases stand in its place. go mod tidy also drops goccy/go-yaml, ugorji/go/codec, pelletier/go-toml and mongo-driver, which were gin codec plugins and nothing else. Route table unchanged at 367. Build and full suite green. Co-authored-by: Hanzo Dev <[email protected]>
245 lines
12 KiB
Go
245 lines
12 KiB
Go
package binding
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/o11y/pkg/errors"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestQueryBinding_BindQuery(t *testing.T) {
|
|
one := 1
|
|
zero := 0
|
|
testCases := []struct {
|
|
name string
|
|
query map[string][]string
|
|
obj any
|
|
expected any
|
|
}{
|
|
{name: "SingleIntField_NonEmptyValue", query: map[string][]string{"a": {"1"}}, obj: &struct {
|
|
A int `query:"a"`
|
|
}{}, expected: &struct{ A int }{A: 1}},
|
|
{name: "SingleIntField_EmptyValue", query: map[string][]string{"a": {""}}, obj: &struct {
|
|
A int `query:"a"`
|
|
}{}, expected: &struct{ A int }{A: 0}},
|
|
{name: "SingleIntField_MissingField", query: map[string][]string{}, obj: &struct {
|
|
A int `query:"a"`
|
|
}{}, expected: &struct{ A int }{A: 0}},
|
|
{name: "SinglePointerIntField_NonEmptyValue", query: map[string][]string{"a": {"1"}}, obj: &struct {
|
|
A *int `query:"a"`
|
|
}{}, expected: &struct{ A *int }{A: &one}},
|
|
{name: "SinglePointerIntField_EmptyValue", query: map[string][]string{"a": {""}}, obj: &struct {
|
|
A *int `query:"a"`
|
|
}{}, expected: &struct{ A *int }{A: &zero}},
|
|
{name: "SinglePointerIntField_MissingField", query: map[string][]string{}, obj: &struct {
|
|
A *int `query:"a"`
|
|
}{}, expected: &struct{ A *int }{A: nil}},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
err := Query.BindQuery(testCase.query, testCase.obj)
|
|
assert.NoError(t, err)
|
|
assert.EqualValues(t, testCase.expected, testCase.obj)
|
|
})
|
|
}
|
|
}
|
|
|
|
// param stands in for a valuer: a named type that decodes itself from one raw
|
|
// query value. Every enum in the codebase binds through this path.
|
|
type param struct{ decoded string }
|
|
|
|
func (p *param) UnmarshalParam(raw string) error {
|
|
if raw == "boom" {
|
|
return fmt.Errorf("param: refusing %q", raw)
|
|
}
|
|
|
|
p.decoded = "<" + raw + ">"
|
|
return nil
|
|
}
|
|
|
|
type embedded struct {
|
|
Inner string `query:"inner"`
|
|
InnerPtr *int64 `query:"innerPtr"`
|
|
}
|
|
|
|
// kinds carries one field of every kind bound anywhere in this repo, plus the
|
|
// widths a future handler may reach for.
|
|
type kinds struct {
|
|
String string `query:"string"`
|
|
Bool bool `query:"bool"`
|
|
Int int `query:"int"`
|
|
Int8 int8 `query:"int8"`
|
|
Int16 int16 `query:"int16"`
|
|
Int32 int32 `query:"int32"`
|
|
Int64 int64 `query:"int64"`
|
|
Uint uint `query:"uint"`
|
|
Uint8 uint8 `query:"uint8"`
|
|
Uint16 uint16 `query:"uint16"`
|
|
Uint32 uint32 `query:"uint32"`
|
|
Uint64 uint64 `query:"uint64"`
|
|
Float32 float32 `query:"float32"`
|
|
Float64 float64 `query:"float64"`
|
|
Strings []string `query:"strings"`
|
|
Ints []int `query:"ints"`
|
|
PtrInt *int `query:"ptrInt"`
|
|
PtrBool *bool `query:"ptrBool"`
|
|
PtrString *string `query:"ptrString"`
|
|
Custom param `query:"custom"`
|
|
PtrCustom *param `query:"ptrCustom"`
|
|
CustomSlice []param `query:"customSlice"`
|
|
Required string `query:"required" required:"true"`
|
|
Skipped string `query:"-"`
|
|
Untagged string
|
|
hidden string `query:"hidden"`
|
|
embedded
|
|
}
|
|
|
|
func TestQueryBinding_Kinds(t *testing.T) {
|
|
ptr := func(i int) *int { return &i }
|
|
yes := true
|
|
empty := ""
|
|
seven := int64(7)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
query map[string][]string
|
|
expected kinds
|
|
}{
|
|
{name: "String", query: map[string][]string{"string": {" keeps spaces "}}, expected: kinds{String: " keeps spaces "}},
|
|
{name: "Bool", query: map[string][]string{"bool": {"true"}}, expected: kinds{Bool: true}},
|
|
{name: "BoolOne", query: map[string][]string{"bool": {"1"}}, expected: kinds{Bool: true}},
|
|
{name: "Int", query: map[string][]string{"int": {"42"}}, expected: kinds{Int: 42}},
|
|
{name: "IntNegative", query: map[string][]string{"int": {"-42"}}, expected: kinds{Int: -42}},
|
|
{name: "IntTrimsSpace", query: map[string][]string{"int": {" 42 "}}, expected: kinds{Int: 42}},
|
|
{name: "Int8", query: map[string][]string{"int8": {"127"}}, expected: kinds{Int8: 127}},
|
|
{name: "Int16", query: map[string][]string{"int16": {"32767"}}, expected: kinds{Int16: 32767}},
|
|
{name: "Int32", query: map[string][]string{"int32": {"2147483647"}}, expected: kinds{Int32: 2147483647}},
|
|
{name: "Int64", query: map[string][]string{"int64": {"9223372036854775807"}}, expected: kinds{Int64: 9223372036854775807}},
|
|
{name: "Uint", query: map[string][]string{"uint": {"42"}}, expected: kinds{Uint: 42}},
|
|
{name: "Uint8", query: map[string][]string{"uint8": {"255"}}, expected: kinds{Uint8: 255}},
|
|
{name: "Uint16", query: map[string][]string{"uint16": {"65535"}}, expected: kinds{Uint16: 65535}},
|
|
{name: "Uint32", query: map[string][]string{"uint32": {"4294967295"}}, expected: kinds{Uint32: 4294967295}},
|
|
{name: "Uint64", query: map[string][]string{"uint64": {"18446744073709551615"}}, expected: kinds{Uint64: 18446744073709551615}},
|
|
{name: "Float32", query: map[string][]string{"float32": {"1.5"}}, expected: kinds{Float32: 1.5}},
|
|
{name: "Float64", query: map[string][]string{"float64": {"-0.25"}}, expected: kinds{Float64: -0.25}},
|
|
{name: "EmptyValueIsZero", query: map[string][]string{"int": {""}, "bool": {""}, "float64": {""}, "uint": {""}}, expected: kinds{}},
|
|
{name: "EmptyValueListIsZero", query: map[string][]string{"int": {}}, expected: kinds{}},
|
|
{name: "ScalarTakesFirstValue", query: map[string][]string{"int": {"1", "2"}}, expected: kinds{Int: 1}},
|
|
{name: "SliceOfOne", query: map[string][]string{"strings": {"a"}}, expected: kinds{Strings: []string{"a"}}},
|
|
{name: "SliceOfMany", query: map[string][]string{"strings": {"a", "b"}}, expected: kinds{Strings: []string{"a", "b"}}},
|
|
{name: "SliceIsNotSplitOnComma", query: map[string][]string{"strings": {"a,b"}}, expected: kinds{Strings: []string{"a,b"}}},
|
|
{name: "SliceOfInts", query: map[string][]string{"ints": {"1", "2"}}, expected: kinds{Ints: []int{1, 2}}},
|
|
{name: "SliceEmptyValueList", query: map[string][]string{"strings": {}}, expected: kinds{}},
|
|
{name: "SliceOfEmptyValue", query: map[string][]string{"strings": {""}}, expected: kinds{Strings: []string{""}}},
|
|
{name: "PointerSet", query: map[string][]string{"ptrInt": {"9"}, "ptrBool": {"true"}}, expected: kinds{PtrInt: ptr(9), PtrBool: &yes}},
|
|
{name: "PointerEmptyValueAllocatesZero", query: map[string][]string{"ptrInt": {""}, "ptrString": {""}}, expected: kinds{PtrInt: ptr(0), PtrString: &empty}},
|
|
{name: "PointerMissingStaysNil", query: map[string][]string{}, expected: kinds{}},
|
|
{name: "Custom", query: map[string][]string{"custom": {"abc"}}, expected: kinds{Custom: param{decoded: "<abc>"}}},
|
|
{name: "CustomEmptyValue", query: map[string][]string{"custom": {""}}, expected: kinds{Custom: param{decoded: "<>"}}},
|
|
{name: "CustomMissingIsUntouched", query: map[string][]string{}, expected: kinds{}},
|
|
{name: "CustomPointer", query: map[string][]string{"ptrCustom": {"abc"}}, expected: kinds{PtrCustom: ¶m{decoded: "<abc>"}}},
|
|
{name: "CustomSlice", query: map[string][]string{"customSlice": {"a", "b"}}, expected: kinds{CustomSlice: []param{{decoded: "<a>"}, {decoded: "<b>"}}}},
|
|
{name: "Embedded", query: map[string][]string{"inner": {"deep"}, "innerPtr": {"7"}}, expected: kinds{embedded: embedded{Inner: "deep", InnerPtr: &seven}}},
|
|
{name: "DashTagIsSkipped", query: map[string][]string{"-": {"x"}, "Skipped": {"x"}}, expected: kinds{}},
|
|
{name: "UntaggedFieldUsesFieldName", query: map[string][]string{"Untagged": {"x"}}, expected: kinds{Untagged: "x"}},
|
|
{name: "UnexportedFieldIsSkipped", query: map[string][]string{"hidden": {"x"}}, expected: kinds{}},
|
|
{name: "UnknownKeyIsIgnored", query: map[string][]string{"nope": {"x"}}, expected: kinds{}},
|
|
// `required` is openapi metadata; the binder never reads it, so an empty
|
|
// value on a required field is the zero value and not an error.
|
|
{name: "RequiredEmptyValueIsNotAnError", query: map[string][]string{"required": {""}}, expected: kinds{}},
|
|
{name: "RequiredMissingIsNotAnError", query: map[string][]string{}, expected: kinds{}},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
actual := kinds{}
|
|
assert.NoError(t, Query.BindQuery(testCase.query, &actual))
|
|
assert.Equal(t, testCase.expected, actual)
|
|
})
|
|
}
|
|
}
|
|
|
|
type defaulted struct {
|
|
String string `query:"string,default=fallback"`
|
|
Int int `query:"int,default=10"`
|
|
Custom param `query:"custom,default=seed"`
|
|
Slice []string `query:"slice,default=one"`
|
|
Unused string `query:"unused,other=ignored"`
|
|
}
|
|
|
|
func TestQueryBinding_Defaults(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
query map[string][]string
|
|
expected defaulted
|
|
}{
|
|
{name: "MissingKeysTakeDefaults", query: map[string][]string{}, expected: defaulted{String: "fallback", Int: 10, Custom: param{decoded: "<seed>"}, Slice: []string{"one"}}},
|
|
{name: "EmptyValueTakesDefault", query: map[string][]string{"string": {""}, "int": {""}, "custom": {""}}, expected: defaulted{String: "fallback", Int: 10, Custom: param{decoded: "<seed>"}, Slice: []string{"one"}}},
|
|
{name: "SuppliedValueWins", query: map[string][]string{"string": {"given"}, "int": {"3"}, "custom": {"given"}, "slice": {"a", "b"}}, expected: defaulted{String: "given", Int: 3, Custom: param{decoded: "<given>"}, Slice: []string{"a", "b"}}},
|
|
{name: "UnknownTagOptionIsIgnored", query: map[string][]string{"unused": {"x"}}, expected: defaulted{String: "fallback", Int: 10, Custom: param{decoded: "<seed>"}, Slice: []string{"one"}, Unused: "x"}},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
actual := defaulted{}
|
|
assert.NoError(t, Query.BindQuery(testCase.query, &actual))
|
|
assert.Equal(t, testCase.expected, actual)
|
|
})
|
|
}
|
|
}
|
|
|
|
type unsupported struct {
|
|
Time time.Time `query:"time"`
|
|
}
|
|
|
|
func TestQueryBinding_BindQueryErrors(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
query map[string][]string
|
|
obj any
|
|
additional string
|
|
}{
|
|
{name: "UnparseableInt", query: map[string][]string{"int": {"abc"}}, obj: &kinds{}, additional: `strconv.ParseInt: parsing "abc": invalid syntax`},
|
|
{name: "IntOverflowsWidth", query: map[string][]string{"int8": {"128"}}, obj: &kinds{}, additional: `strconv.ParseInt: parsing "128": value out of range`},
|
|
{name: "NegativeUint", query: map[string][]string{"uint": {"-1"}}, obj: &kinds{}, additional: `strconv.ParseUint: parsing "-1": invalid syntax`},
|
|
{name: "UnparseableBool", query: map[string][]string{"bool": {"yes please"}}, obj: &kinds{}, additional: `strconv.ParseBool: parsing "yes please": invalid syntax`},
|
|
{name: "UnparseableFloat", query: map[string][]string{"float64": {"abc"}}, obj: &kinds{}, additional: `strconv.ParseFloat: parsing "abc": invalid syntax`},
|
|
{name: "UnparseablePointer", query: map[string][]string{"ptrInt": {"abc"}}, obj: &kinds{}, additional: `strconv.ParseInt: parsing "abc": invalid syntax`},
|
|
{name: "UnparseableSliceElement", query: map[string][]string{"ints": {"1", "x"}}, obj: &kinds{}, additional: `strconv.ParseInt: parsing "x": invalid syntax`},
|
|
{name: "UnparseableEmbeddedField", query: map[string][]string{"innerPtr": {"abc"}}, obj: &kinds{}, additional: `strconv.ParseInt: parsing "abc": invalid syntax`},
|
|
{name: "CustomUnmarshalerRejects", query: map[string][]string{"custom": {"boom"}}, obj: &kinds{}, additional: `param: refusing "boom"`},
|
|
{name: "UnsupportedKind", query: map[string][]string{"time": {"now"}}, obj: &unsupported{}, additional: `cannot bind "now" into time.Time`},
|
|
{name: "NonPointerTarget", query: map[string][]string{}, obj: kinds{}, additional: "binding target must be a non-nil pointer, got binding.kinds"},
|
|
{name: "NilPointerTarget", query: map[string][]string{}, obj: (*kinds)(nil), additional: "binding target must be a non-nil pointer, got *binding.kinds"},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
err := Query.BindQuery(testCase.query, testCase.obj)
|
|
assert.Error(t, err)
|
|
|
|
typ, code, message, _, _, _ := errors.Unwrapb(err)
|
|
assert.Equal(t, errors.TypeInvalidInput, typ)
|
|
assert.Equal(t, ErrCodeInvalidRequestQuery, code)
|
|
assert.Equal(t, ErrMessageInvalidQuery, message)
|
|
|
|
messages := []string{}
|
|
for _, additional := range errors.AsJSON(err).Errors {
|
|
messages = append(messages, additional.Message)
|
|
}
|
|
assert.Equal(t, []string{testCase.additional}, messages)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A field that fails to parse must not leave earlier fields half-written under
|
|
// a returned error: binding is all-or-nothing from the caller's point of view.
|
|
func TestQueryBinding_BindQueryStopsAtFirstError(t *testing.T) {
|
|
actual := kinds{}
|
|
assert.Error(t, Query.BindQuery(map[string][]string{"int8": {"128"}}, &actual))
|
|
assert.Equal(t, 0, int(actual.Int8))
|
|
}
|