From d13b1b6364307c8cc0d121e67fc7ad80b569a08f Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 22 Oct 2026 17:24:22 -0700 Subject: [PATCH 1/2] Add BindJSONBody to bind the request body value kin-openapi validated Strict net/http servers decode a JSON request body a second time after the validation middleware has validated it, and encoding/json matches member names case-insensitively, so a body such as {"role":"user","ROLE":"admin"} passes validation as role=user and reaches the handler as Role="admin", bypassing the schema's constraints. kin-openapi v0.151.0 added openapi3filter.Options.KeepValidatedValue, which leaves the decoded and validated value on the request body behind a ValidatedValue() any method. BindJSONBody binds that value into the request struct when it is present, and otherwise (or when the value does not fit the destination) falls back to json.NewDecoder(r.Body).Decode as before, so error behaviour is unchanged. BindJSONValue does the binding by reflection, following encoding/json's rules for struct tags, embedded struct promotion, json.Unmarshaler and encoding.TextUnmarshaler, base64 []byte and float64 for any, with exact member-name matching. The destination is left untouched when binding fails. Part of oapi-codegen/oapi-codegen#2605. Co-Authored-By: Claude Fable 5.1 --- README.md | 23 ++ bindjson.go | 635 +++++++++++++++++++++++++++++++++++++++++++++++ bindjson_test.go | 401 ++++++++++++++++++++++++++++++ 3 files changed, 1059 insertions(+) create mode 100644 bindjson.go create mode 100644 bindjson_test.go diff --git a/README.md b/README.md index 82b8649..2f6d2a4 100644 --- a/README.md +++ b/README.md @@ -221,3 +221,26 @@ correct, media-type-defined behavior. their original precision, but the round-trip through JSON means struct field tags are honored (not raw Go field names). + +## Request body binding + +`BindJSONBody` is what strict net/http servers generated by `oapi-codegen` call +to decode a JSON request body. When the request has passed through a validation +middleware built on kin-openapi's `openapi3filter` with +`Options.KeepValidatedValue` set (for example +`nethttp-middleware.OapiRequestValidatorWithOptions`), the body carries the +value the validator decoded and validated, and `BindJSONBody` binds that value +into the request struct with `BindJSONValue` instead of decoding the body a +second time. + +Binding follows `encoding/json`'s rules (struct tags, embedded structs, +`json.Unmarshaler` / `encoding.TextUnmarshaler`, base64 for `[]byte`, `float64` +for `any`), with one deliberate difference: object member names are matched to +struct fields **exactly**, whereas `encoding/json` matches them +case-insensitively. This closes the gap where `{"role":"user","ROLE":"admin"}` +validates as `role: user` but a second `encoding/json` decode binds +`Role: "admin"`. + +Without a validated value, or when the value cannot be bound into the +destination type, `BindJSONBody` decodes the body with `encoding/json` exactly +as before, so error responses are unchanged. diff --git a/bindjson.go b/bindjson.go new file mode 100644 index 0000000..3a14b1a --- /dev/null +++ b/bindjson.go @@ -0,0 +1,635 @@ +package runtime + +import ( + "encoding" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strconv" + "strings" + "sync" +) + +// validatedBody is the interface kin-openapi's openapi3filter gives a request +// body when its Options.KeepValidatedValue is set: the body carries the value +// that was decoded from the request and validated against the schema. +type validatedBody interface { + ValidatedValue() any +} + +// BindJSONBody decodes the JSON request body of r into v, which must be a +// non-nil pointer. +// +// When a request validation middleware built on kin-openapi's openapi3filter +// has run on r with Options.KeepValidatedValue set, r.Body carries the value +// the validator decoded and validated. That value is bound into v with +// BindJSONValue, so the handler receives exactly what was validated: member +// names are matched exactly, where encoding/json matches them +// case-insensitively, and the body is not decoded a second time. +// +// Without a validated value, or when the value cannot be bound into v, the +// body is decoded with encoding/json exactly as before, so error behaviour +// (including io.EOF for an empty body) is unchanged. +func BindJSONBody(r *http.Request, v any) error { + if r != nil && r.Body != nil { + if b, ok := r.Body.(validatedBody); ok { + if err := BindJSONValue(b.ValidatedValue(), v); err == nil { + return nil + } + } + } + return json.NewDecoder(r.Body).Decode(v) +} + +// BindJSONValue binds a decoded JSON value into v, which must be a non-nil +// pointer. value is the generic representation produced by decoding JSON into +// an `any`: nil, bool, string, json.Number (or a Go numeric type), []any and +// map[string]any. +// +// Binding follows encoding/json's rules for the destination type (struct tags, +// embedded struct promotion, json.Unmarshaler and encoding.TextUnmarshaler, +// base64 for []byte, float64 for `any`), with one deliberate difference: +// object member names are matched to struct fields exactly, never +// case-insensitively. Unknown members are ignored. +// +// On success *v holds only what value describes; on error *v is left +// unchanged. An error means the value does not fit the destination (a type +// mismatch, an overflow, or a destination type that is not supported) and +// the caller should fall back to decoding the original JSON. +func BindJSONValue(value any, v any) error { + rv := reflect.ValueOf(v) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + return fmt.Errorf("BindJSONValue: destination must be a non-nil pointer, got %T", v) + } + // Bind into a fresh value so a failure part-way through leaves the + // caller's destination untouched. + tmp := reflect.New(rv.Type().Elem()).Elem() + if err := bindJSONValue(value, tmp); err != nil { + return err + } + rv.Elem().Set(tmp) + return nil +} + +var ( + jsonUnmarshalerType = reflect.TypeFor[json.Unmarshaler]() + textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() +) + +// errUnsupportedJSONDestination is returned for destination types the binder +// does not handle; BindJSONBody then falls back to encoding/json. +var errUnsupportedJSONDestination = errors.New("unsupported destination type") + +func bindJSONValue(value any, dst reflect.Value) error { + // Pointers are dereferenced first, so that a json.Unmarshaler implemented + // on the pointee's pointer receiver is found below on an allocated value. + if dst.Kind() == reflect.Pointer { + if value == nil { + dst.SetZero() + return nil + } + if dst.IsNil() { + dst.Set(reflect.New(dst.Type().Elem())) + } + return bindJSONValue(value, dst.Elem()) + } + + if value == nil { + return bindJSONNull(dst) + } + + if u := jsonUnmarshalerOf(dst); u != nil { + data, err := json.Marshal(value) + if err != nil { + return err + } + return u.UnmarshalJSON(data) + } + if s, ok := value.(string); ok { + if u := textUnmarshalerOf(dst); u != nil { + return u.UnmarshalText([]byte(s)) + } + } + + switch dst.Kind() { + case reflect.Interface: + if dst.NumMethod() != 0 { + return fmt.Errorf("%w: %s", errUnsupportedJSONDestination, dst.Type()) + } + conv, err := jsonValueToInterface(value) + if err != nil { + return err + } + dst.Set(reflect.ValueOf(&conv).Elem()) + return nil + + case reflect.Struct: + obj, ok := value.(map[string]any) + if !ok { + return jsonTypeError(value, dst.Type()) + } + fields := cachedJSONFields(dst.Type()) + for name, member := range obj { + f, ok := fields[name] + if !ok { + continue + } + fv, err := jsonFieldByIndex(dst, f.index) + if err != nil { + return err + } + if f.quoted { + if err := bindQuotedJSONValue(member, fv); err != nil { + return err + } + continue + } + if err := bindJSONValue(member, fv); err != nil { + return err + } + } + return nil + + case reflect.Map: + obj, ok := value.(map[string]any) + if !ok { + return jsonTypeError(value, dst.Type()) + } + t := dst.Type() + if dst.IsNil() { + dst.Set(reflect.MakeMapWithSize(t, len(obj))) + } + for name, member := range obj { + key, err := jsonMapKey(name, t.Key()) + if err != nil { + return err + } + elem := reflect.New(t.Elem()).Elem() + if err := bindJSONValue(member, elem); err != nil { + return err + } + dst.SetMapIndex(key, elem) + } + return nil + + case reflect.Slice: + if s, ok := value.(string); ok && dst.Type().Elem().Kind() == reflect.Uint8 { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return err + } + dst.SetBytes(b) + return nil + } + arr, ok := value.([]any) + if !ok { + return jsonTypeError(value, dst.Type()) + } + slice := reflect.MakeSlice(dst.Type(), len(arr), len(arr)) + for i, item := range arr { + if err := bindJSONValue(item, slice.Index(i)); err != nil { + return err + } + } + dst.Set(slice) + return nil + + case reflect.Array: + arr, ok := value.([]any) + if !ok { + return jsonTypeError(value, dst.Type()) + } + n := min(len(arr), dst.Len()) + for i := 0; i < n; i++ { + if err := bindJSONValue(arr[i], dst.Index(i)); err != nil { + return err + } + } + for i := n; i < dst.Len(); i++ { + dst.Index(i).SetZero() + } + return nil + + case reflect.Bool: + b, ok := value.(bool) + if !ok { + return jsonTypeError(value, dst.Type()) + } + dst.SetBool(b) + return nil + + case reflect.String: + s, ok := value.(string) + if !ok { + return jsonTypeError(value, dst.Type()) + } + dst.SetString(s) + return nil + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + num, ok := jsonNumberOf(value) + if !ok { + return jsonTypeError(value, dst.Type()) + } + n, err := strconv.ParseInt(string(num), 10, 64) + if err != nil || dst.OverflowInt(n) { + return jsonTypeError(value, dst.Type()) + } + dst.SetInt(n) + return nil + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + num, ok := jsonNumberOf(value) + if !ok { + return jsonTypeError(value, dst.Type()) + } + n, err := strconv.ParseUint(string(num), 10, 64) + if err != nil || dst.OverflowUint(n) { + return jsonTypeError(value, dst.Type()) + } + dst.SetUint(n) + return nil + + case reflect.Float32, reflect.Float64: + num, ok := jsonNumberOf(value) + if !ok { + return jsonTypeError(value, dst.Type()) + } + f, err := strconv.ParseFloat(string(num), dst.Type().Bits()) + if err != nil { + return jsonTypeError(value, dst.Type()) + } + dst.SetFloat(f) + return nil + } + + return fmt.Errorf("%w: %s", errUnsupportedJSONDestination, dst.Type()) +} + +// bindJSONNull applies a JSON null to a non-pointer destination the way +// encoding/json does: a json.Unmarshaler is handed the literal, nil-able kinds +// are set to nil, and anything else is left untouched. (A nil pointer is set +// to nil by the caller without consulting its Unmarshaler, also as +// encoding/json does.) +func bindJSONNull(dst reflect.Value) error { + if u := jsonUnmarshalerOf(dst); u != nil { + return u.UnmarshalJSON([]byte("null")) + } + switch dst.Kind() { + case reflect.Interface, reflect.Map, reflect.Slice: + dst.SetZero() + } + return nil +} + +// bindQuotedJSONValue handles a field with the `,string` tag option: the JSON +// value is a string whose contents are the JSON literal to decode. +func bindQuotedJSONValue(value any, dst reflect.Value) error { + if value == nil { + return bindJSONNull(dst) + } + s, ok := value.(string) + if !ok || !dst.CanAddr() { + return jsonTypeError(value, dst.Type()) + } + return json.Unmarshal([]byte(s), dst.Addr().Interface()) +} + +func jsonUnmarshalerOf(v reflect.Value) json.Unmarshaler { + if v.Kind() != reflect.Pointer && v.CanAddr() && v.Addr().Type().Implements(jsonUnmarshalerType) { + return v.Addr().Interface().(json.Unmarshaler) + } + if v.Type().Implements(jsonUnmarshalerType) && v.CanInterface() { + if v.Kind() == reflect.Pointer && v.IsNil() { + return nil + } + return v.Interface().(json.Unmarshaler) + } + return nil +} + +func textUnmarshalerOf(v reflect.Value) encoding.TextUnmarshaler { + if v.Kind() != reflect.Pointer && v.CanAddr() && v.Addr().Type().Implements(textUnmarshalerType) { + return v.Addr().Interface().(encoding.TextUnmarshaler) + } + if v.Type().Implements(textUnmarshalerType) && v.CanInterface() { + if v.Kind() == reflect.Pointer && v.IsNil() { + return nil + } + return v.Interface().(encoding.TextUnmarshaler) + } + return nil +} + +// jsonNumberOf returns the JSON literal for a numeric value: a json.Number as +// is, or a Go number formatted as one. +func jsonNumberOf(value any) (json.Number, bool) { + switch n := value.(type) { + case json.Number: + return n, true + case float64: + return json.Number(strconv.FormatFloat(n, 'g', -1, 64)), true + case float32: + return json.Number(strconv.FormatFloat(float64(n), 'g', -1, 32)), true + case int: + return json.Number(strconv.FormatInt(int64(n), 10)), true + case int8: + return json.Number(strconv.FormatInt(int64(n), 10)), true + case int16: + return json.Number(strconv.FormatInt(int64(n), 10)), true + case int32: + return json.Number(strconv.FormatInt(int64(n), 10)), true + case int64: + return json.Number(strconv.FormatInt(n, 10)), true + case uint: + return json.Number(strconv.FormatUint(uint64(n), 10)), true + case uint8: + return json.Number(strconv.FormatUint(uint64(n), 10)), true + case uint16: + return json.Number(strconv.FormatUint(uint64(n), 10)), true + case uint32: + return json.Number(strconv.FormatUint(uint64(n), 10)), true + case uint64: + return json.Number(strconv.FormatUint(n, 10)), true + } + return "", false +} + +// jsonValueToInterface converts a decoded value for an `any` destination the +// way encoding/json would have decoded it there: numbers become float64. +func jsonValueToInterface(value any) (any, error) { + switch v := value.(type) { + case json.Number: + f, err := strconv.ParseFloat(string(v), 64) + if err != nil { + return nil, jsonTypeError(value, reflect.TypeFor[float64]()) + } + return f, nil + case map[string]any: + out := make(map[string]any, len(v)) + for k, member := range v { + conv, err := jsonValueToInterface(member) + if err != nil { + return nil, err + } + out[k] = conv + } + return out, nil + case []any: + out := make([]any, len(v)) + for i, item := range v { + conv, err := jsonValueToInterface(item) + if err != nil { + return nil, err + } + out[i] = conv + } + return out, nil + } + return value, nil +} + +func jsonMapKey(name string, kt reflect.Type) (reflect.Value, error) { + if reflect.PointerTo(kt).Implements(textUnmarshalerType) { + kv := reflect.New(kt) + if err := kv.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(name)); err != nil { + return reflect.Value{}, err + } + return kv.Elem(), nil + } + switch kt.Kind() { + case reflect.String: + return reflect.ValueOf(name).Convert(kt), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + n, err := strconv.ParseInt(name, 10, 64) + if err != nil || reflect.Zero(kt).OverflowInt(n) { + return reflect.Value{}, jsonTypeError(name, kt) + } + return reflect.ValueOf(n).Convert(kt), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + n, err := strconv.ParseUint(name, 10, 64) + if err != nil || reflect.Zero(kt).OverflowUint(n) { + return reflect.Value{}, jsonTypeError(name, kt) + } + return reflect.ValueOf(n).Convert(kt), nil + } + return reflect.Value{}, fmt.Errorf("%w: map key %s", errUnsupportedJSONDestination, kt) +} + +func jsonTypeError(value any, t reflect.Type) error { + return fmt.Errorf("cannot bind JSON %s into Go value of type %s", jsonValueKind(value), t) +} + +func jsonValueKind(value any) string { + switch value.(type) { + case nil: + return "null" + case bool: + return "bool" + case string: + return "string" + case map[string]any: + return "object" + case []any: + return "array" + } + if _, ok := jsonNumberOf(value); ok { + return "number" + } + return fmt.Sprintf("value of type %T", value) +} + +// jsonField describes one JSON-visible struct field. +type jsonField struct { + name string + index []int + tagged bool + quoted bool +} + +var jsonFieldsCache sync.Map // reflect.Type -> map[string]jsonField + +func cachedJSONFields(t reflect.Type) map[string]jsonField { + if f, ok := jsonFieldsCache.Load(t); ok { + return f.(map[string]jsonField) + } + f, _ := jsonFieldsCache.LoadOrStore(t, jsonFields(t)) + return f.(map[string]jsonField) +} + +// jsonFields returns the JSON-visible fields of struct type t keyed by their +// JSON member name, following encoding/json's rules: the `json` tag names the +// member or hides the field with "-", untagged exported fields use their Go +// name, embedded structs are promoted breadth-first, and a name claimed at the +// same depth by several fields goes to the single tagged one or to nobody. +func jsonFields(t reflect.Type) map[string]jsonField { + type queued struct { + typ reflect.Type + index []int + } + current := []queued{} + next := []queued{{typ: t}} + visited := map[reflect.Type]bool{} + depth := 0 + byName := map[string][]struct { + f jsonField + depth int + }{} + + for len(next) > 0 { + current, next = next, current[:0] + for _, q := range current { + if visited[q.typ] { + continue + } + visited[q.typ] = true + for i := 0; i < q.typ.NumField(); i++ { + sf := q.typ.Field(i) + if sf.Anonymous { + ft := sf.Type + if ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if !sf.IsExported() && ft.Kind() != reflect.Struct { + continue + } + } else if !sf.IsExported() { + continue + } + tag := sf.Tag.Get("json") + if tag == "-" { + continue + } + name, opts, _ := strings.Cut(tag, ",") + if !isValidJSONTagName(name) { + name = "" + } + index := make([]int, len(q.index)+1) + copy(index, q.index) + index[len(q.index)] = i + + ft := sf.Type + if ft.Name() == "" && ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + + if name == "" && sf.Anonymous && ft.Kind() == reflect.Struct { + next = append(next, queued{typ: ft, index: index}) + continue + } + f := jsonField{ + name: name, + index: index, + tagged: name != "", + quoted: hasJSONTagOption(opts, "string") && isQuotableJSONKind(ft), + } + if f.name == "" { + f.name = sf.Name + } + byName[f.name] = append(byName[f.name], struct { + f jsonField + depth int + }{f, depth}) + } + } + depth++ + } + + out := make(map[string]jsonField, len(byName)) + for name, group := range byName { + // The shallowest fields dominate; among them, a single tagged field + // wins, otherwise a single field wins, otherwise the name is dropped. + minDepth := group[0].depth + for _, g := range group[1:] { + if g.depth < minDepth { + minDepth = g.depth + } + } + var shallow []jsonField + for _, g := range group { + if g.depth == minDepth { + shallow = append(shallow, g.f) + } + } + if len(shallow) == 1 { + out[name] = shallow[0] + continue + } + var tagged []jsonField + for _, f := range shallow { + if f.tagged { + tagged = append(tagged, f) + } + } + if len(tagged) == 1 { + out[name] = tagged[0] + } + } + return out +} + +func hasJSONTagOption(opts, name string) bool { + for opts != "" { + var opt string + opt, opts, _ = strings.Cut(opts, ",") + if opt == name { + return true + } + } + return false +} + +// isQuotableJSONKind reports whether the `,string` option applies to a type, +// as in encoding/json: only booleans, numbers and strings. +func isQuotableJSONKind(t reflect.Type) bool { + switch t.Kind() { + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, + reflect.Float32, reflect.Float64, + reflect.String: + return true + } + return false +} + +// isValidJSONTagName mirrors encoding/json's rule for tag names: letters, +// digits and a set of punctuation characters; anything else makes the tag +// name ignored in favour of the Go field name. +func isValidJSONTagName(s string) bool { + if s == "" { + return false + } + for _, c := range s { + switch { + case strings.ContainsRune("!#$%&()*+-./:;<=>?@[]^_{|}~ ", c): + case c >= '0' && c <= '9', c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c > 127: + default: + return false + } + } + return true +} + +// jsonFieldByIndex walks an index path into v, allocating nil embedded +// pointers along the way, as encoding/json does when it sets a promoted field. +func jsonFieldByIndex(v reflect.Value, index []int) (reflect.Value, error) { + for i, x := range index { + if i > 0 { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + if !v.CanSet() { + return reflect.Value{}, fmt.Errorf("cannot set embedded pointer to unexported struct: %s", v.Type().Elem()) + } + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + } + v = v.Field(x) + } + return v, nil +} diff --git a/bindjson_test.go b/bindjson_test.go new file mode 100644 index 0000000..5a97229 --- /dev/null +++ b/bindjson_test.go @@ -0,0 +1,401 @@ +package runtime + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/oapi-codegen/nullable" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oapi-codegen/runtime/types" +) + +// decodeGeneric decodes JSON the way kin-openapi's validator does: into an +// `any`, keeping numbers as json.Number. +func decodeGeneric(t *testing.T, doc string) any { + t.Helper() + dec := json.NewDecoder(strings.NewReader(doc)) + dec.UseNumber() + var v any + require.NoError(t, dec.Decode(&v)) + return v +} + +type bindJSONEmbedded struct { + Promoted string `json:"promoted"` + Shadowed string `json:"shadowed"` +} + +// BindJSONEmbeddedPtr is exported because encoding/json refuses to allocate an +// embedded nil pointer to an unexported struct type. +type BindJSONEmbeddedPtr struct { + ViaPointer int `json:"viaPointer"` +} + +type bindJSONNested struct { + Name string `json:"name"` + Score float64 `json:"score"` +} + +type bindJSONEnum string + +// bindJSONAll exercises every field shape oapi-codegen generates for a model. +type bindJSONAll struct { + bindJSONEmbedded + *BindJSONEmbeddedPtr + + Shadowed string `json:"shadowed"` // wins over the embedded one + + Bool bool `json:"bool"` + Int int `json:"int"` + Int8 int8 `json:"int8"` + Int64 int64 `json:"int64"` + Uint32 uint32 `json:"uint32"` + Float32 float32 `json:"float32"` + Float64 float64 `json:"float64"` + String string `json:"string"` + Enum bindJSONEnum + + PtrString *string `json:"ptrString,omitempty"` + PtrInt *int64 `json:"ptrInt,omitempty"` + PtrStruct *bindJSONNested `json:"ptrStruct,omitempty"` + + Strings []string `json:"strings"` + Ints []int32 `json:"ints"` + Structs []bindJSONNested `json:"structs"` + PtrSlice *[]string `json:"ptrSlice,omitempty"` + Array [3]int `json:"array"` + Bytes []byte `json:"bytes"` + Map map[string]int `json:"map"` + MapAny map[string]any `json:"mapAny"` + IntKeys map[int]string `json:"intKeys"` + Any any `json:"any"` + Raw json.RawMessage `json:"raw"` + Nested bindJSONNested `json:"nested"` + Time time.Time `json:"time"` + Date types.Date `json:"date"` + PtrDate *types.Date `json:"ptrDate,omitempty"` + UUID types.UUID `json:"uuid"` + Email types.Email `json:"email"` + Nullable nullable.Nullable[string] `json:"nullable,omitempty"` + Quoted int `json:"quoted,string"` + + Ignored string `json:"-"` + unexported string + NoTag string + Dash string `json:"-,"` +} + +func TestBindJSONValue_MatchesEncodingJSON(t *testing.T) { + cases := []struct { + name string + doc string + }{ + {"empty object", `{}`}, + {"scalars", `{"bool":true,"int":-7,"int8":127,"int64":9007199254740993,"uint32":4294967295,"float32":1.5,"float64":2.25e10,"string":"s","Enum":"a"}`}, + {"pointers set", `{"ptrString":"p","ptrInt":42,"ptrStruct":{"name":"n","score":0.5}}`}, + {"pointers null", `{"ptrString":null,"ptrInt":null,"ptrStruct":null,"ptrSlice":null,"ptrDate":null}`}, + {"collections", `{"strings":["a","b"],"ints":[1,2,3],"structs":[{"name":"x"},{"score":1}],"ptrSlice":["q"],"array":[1,2],"bytes":"aGVsbG8=","map":{"a":1,"b":2},"mapAny":{"n":1,"s":"x","l":[1,"y",null],"o":{"k":false}},"intKeys":{"1":"one","-2":"minus two"}}`}, + {"empty collections", `{"strings":[],"ints":[],"structs":[],"map":{},"mapAny":{},"array":[]}`}, + {"null collections", `{"strings":null,"map":null,"mapAny":null,"any":null,"raw":null,"bytes":null}`}, + {"any", `{"any":{"deep":[1,2.5,"x",true,null,{"n":1e3}]}}`}, + {"any number", `{"any":12345678901234567890}`}, + {"raw", `{"raw":{"keep":["me",1]}}`}, + {"nested", `{"nested":{"name":"inner","score":3}}`}, + {"unmarshalers", `{"time":"2024-01-02T03:04:05Z","date":"2024-01-02","ptrDate":"2023-12-31","uuid":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","email":"a@example.com"}`}, + {"nullable set", `{"nullable":"v"}`}, + {"nullable null", `{"nullable":null}`}, + {"quoted", `{"quoted":"17"}`}, + {"embedded", `{"promoted":"yes","shadowed":"outer","viaPointer":5}`}, + {"ignored and untagged", `{"-":"dash","Ignored":"no","unexported":"no","NoTag":"yes"}`}, + {"unknown members", `{"nope":1,"also":{"x":[1]}}`}, + {"unicode escapes", `{"string":"é😀<>&"}`}, + {"big float into float64", `{"float64":1e308}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var want bindJSONAll + require.NoError(t, json.Unmarshal([]byte(tc.doc), &want)) + + var got bindJSONAll + require.NoError(t, BindJSONValue(decodeGeneric(t, tc.doc), &got)) + + // json.RawMessage is re-encoded by the binder, so compare it by + // JSON value rather than by bytes. + if len(want.Raw) > 0 || len(got.Raw) > 0 { + assert.JSONEq(t, string(want.Raw), string(got.Raw)) + want.Raw, got.Raw = nil, nil + } + assert.Equal(t, want, got) + }) + } +} + +func TestBindJSONValue_ExactMemberNames(t *testing.T) { + type user struct { + Name string `json:"name"` + Role string `json:"role"` + ID int `json:"id,omitempty"` + } + + // encoding/json would bind "ROLE" to Role and "ID" to ID; the binder + // only honours the exact names the validator saw. + var got user + require.NoError(t, BindJSONValue(decodeGeneric(t, `{"name":"bob","role":"user","ROLE":"admin","ID":42,"Name":"BOB"}`), &got)) + assert.Equal(t, user{Name: "bob", Role: "user"}, got) + + var viaJSON user + require.NoError(t, json.Unmarshal([]byte(`{"name":"bob","role":"user","ROLE":"admin","ID":42}`), &viaJSON)) + assert.Equal(t, "admin", viaJSON.Role, "sanity check: encoding/json is case-insensitive") + assert.Equal(t, 42, viaJSON.ID) +} + +func TestBindJSONValue_Errors(t *testing.T) { + type target struct { + Int int `json:"int"` + Uint uint8 `json:"uint"` + Float float32 `json:"float"` + Bool bool `json:"bool"` + String string `json:"string"` + Slice []int `json:"slice"` + Array [1]int `json:"array"` + Map map[string]int `json:"map"` + Nested struct{} `json:"nested"` + Date types.Date `json:"date"` + Bytes []byte `json:"bytes"` + Quoted int `json:"quoted,string"` + } + cases := []struct { + name string + doc string + }{ + {"string into int", `{"int":"1"}`}, + {"fraction into int", `{"int":1.5}`}, + {"overflow int8-ish uint8", `{"uint":256}`}, + {"negative into uint", `{"uint":-1}`}, + {"overflow float32", `{"float":1e300}`}, + {"number into bool", `{"bool":1}`}, + {"number into string", `{"string":1}`}, + {"object into slice", `{"slice":{}}`}, + {"string into array", `{"array":"x"}`}, + {"array into map", `{"map":[]}`}, + {"array into struct", `{"nested":[]}`}, + {"bad date", `{"date":"not-a-date"}`}, + {"bad base64", `{"bytes":"%%%"}`}, + {"quoted non-string", `{"quoted":17}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var viaJSON target + require.Error(t, json.Unmarshal([]byte(tc.doc), &viaJSON), "sanity check: encoding/json rejects it too") + + got := target{Int: 99} + err := BindJSONValue(decodeGeneric(t, tc.doc), &got) + require.Error(t, err) + assert.Equal(t, target{Int: 99}, got, "destination must be untouched on error") + }) + } + + t.Run("embedded nil pointer to unexported struct", func(t *testing.T) { + type inner struct { + V int `json:"v"` + } + type outer struct { + *inner + } + doc := `{"v":1}` + var viaJSON outer + require.Error(t, json.Unmarshal([]byte(doc), &viaJSON)) + var got outer + require.Error(t, BindJSONValue(decodeGeneric(t, doc), &got)) + }) + + t.Run("top-level mismatch", func(t *testing.T) { + var s []string + assert.Error(t, BindJSONValue(decodeGeneric(t, `{"a":1}`), &s)) + var n int + assert.Error(t, BindJSONValue(decodeGeneric(t, `"x"`), &n)) + }) + + t.Run("unsupported destinations", func(t *testing.T) { + var ch chan int + assert.ErrorIs(t, BindJSONValue(decodeGeneric(t, `1`), &ch), errUnsupportedJSONDestination) + var iface io.Reader + assert.ErrorIs(t, BindJSONValue(decodeGeneric(t, `{}`), &iface), errUnsupportedJSONDestination) + var m map[bool]int + assert.ErrorIs(t, BindJSONValue(decodeGeneric(t, `{"true":1}`), &m), errUnsupportedJSONDestination) + }) + + t.Run("bad destination", func(t *testing.T) { + var s string + assert.Error(t, BindJSONValue("x", s)) + assert.Error(t, BindJSONValue("x", nil)) + assert.Error(t, BindJSONValue("x", (*string)(nil))) + }) +} + +func TestBindJSONValue_TopLevelShapes(t *testing.T) { + var s []bindJSONNested + require.NoError(t, BindJSONValue(decodeGeneric(t, `[{"name":"a"},{"score":2}]`), &s)) + assert.Equal(t, []bindJSONNested{{Name: "a"}, {Score: 2}}, s) + + var m map[string]*bindJSONNested + require.NoError(t, BindJSONValue(decodeGeneric(t, `{"x":{"name":"a"},"y":null}`), &m)) + assert.Equal(t, map[string]*bindJSONNested{"x": {Name: "a"}, "y": nil}, m) + + var str string + require.NoError(t, BindJSONValue(decodeGeneric(t, `"text"`), &str)) + assert.Equal(t, "text", str) + + var id uuid.UUID + require.NoError(t, BindJSONValue(decodeGeneric(t, `"6ba7b810-9dad-11d1-80b4-00c04fd430c8"`), &id)) + assert.Equal(t, "6ba7b810-9dad-11d1-80b4-00c04fd430c8", id.String()) + + var anyV any + require.NoError(t, BindJSONValue(decodeGeneric(t, `[1,"a"]`), &anyV)) + assert.Equal(t, []any{float64(1), "a"}, anyV) + + // Go numeric values, not just json.Number, are accepted. + var n int64 + require.NoError(t, BindJSONValue(float64(42), &n)) + assert.Equal(t, int64(42), n) + assert.Error(t, BindJSONValue(42.5, &n)) + + // A top-level null zeroes the destination. + ptr := &bindJSONNested{Name: "x"} + require.NoError(t, BindJSONValue(nil, &ptr)) + assert.Nil(t, ptr) +} + +func TestBindJSONValue_EmbeddedConflicts(t *testing.T) { + type a struct { + X string + Y string + } + type b struct { + X string + Y string `json:"Y"` + } + type outer struct { + a + b + Z string + } + // "X" is claimed by two untagged fields at the same depth and is + // dropped; "Y" at the same depth goes to the single tagged field. + doc := `{"X":"gone","Y":"tagged","Z":"z"}` + var want outer + require.NoError(t, json.Unmarshal([]byte(doc), &want)) + var got outer + require.NoError(t, BindJSONValue(decodeGeneric(t, doc), &got)) + assert.Equal(t, want, got) + assert.Equal(t, "", got.a.X) + assert.Equal(t, "", got.b.X) + assert.Equal(t, "tagged", got.b.Y) + assert.Equal(t, "z", got.Z) +} + +type recursiveNode struct { + Value int `json:"value"` + Children []recursiveNode `json:"children,omitempty"` + Next *recursiveNode `json:"next,omitempty"` +} + +func TestBindJSONValue_Recursive(t *testing.T) { + doc := `{"value":1,"children":[{"value":2,"next":{"value":3}}],"next":{"value":4,"children":[]}}` + var want recursiveNode + require.NoError(t, json.Unmarshal([]byte(doc), &want)) + var got recursiveNode + require.NoError(t, BindJSONValue(decodeGeneric(t, doc), &got)) + assert.Equal(t, want, got) +} + +// validatedReadCloser mimics the body kin-openapi leaves behind with +// Options.KeepValidatedValue set. +type validatedReadCloser struct { + io.ReadCloser + value any +} + +func (b validatedReadCloser) ValidatedValue() any { return b.value } + +func TestBindJSONBody(t *testing.T) { + type user struct { + Name string `json:"name"` + Role string `json:"role"` + } + const doc = `{"name":"bob","role":"user","ROLE":"admin"}` + + newRequest := func(body string) *http.Request { + return httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body)) + } + + t.Run("binds the validated value", func(t *testing.T) { + r := newRequest(doc) + r.Body = validatedReadCloser{ReadCloser: r.Body, value: decodeGeneric(t, doc)} + var got user + require.NoError(t, BindJSONBody(r, &got)) + assert.Equal(t, user{Name: "bob", Role: "user"}, got) + + // The body was not consumed. + rest, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, doc, string(rest)) + }) + + t.Run("decodes the body without a validated value", func(t *testing.T) { + r := newRequest(doc) + var got user + require.NoError(t, BindJSONBody(r, &got)) + assert.Equal(t, user{Name: "bob", Role: "admin"}, got, "plain encoding/json behaviour is kept") + }) + + t.Run("falls back to the body when the value does not bind", func(t *testing.T) { + r := newRequest(doc) + r.Body = validatedReadCloser{ReadCloser: r.Body, value: decodeGeneric(t, `{"name":1}`)} + var got user + require.NoError(t, BindJSONBody(r, &got)) + assert.Equal(t, user{Name: "bob", Role: "admin"}, got) + }) + + t.Run("fallback errors are encoding/json's", func(t *testing.T) { + r := newRequest(`{"name":1}`) + var got user + err := BindJSONBody(r, &got) + var typeErr *json.UnmarshalTypeError + require.ErrorAs(t, err, &typeErr) + + r = newRequest("") + assert.ErrorIs(t, BindJSONBody(r, &got), io.EOF) + }) + + t.Run("nil request decodes nothing", func(t *testing.T) { + var got user + r := &http.Request{Body: io.NopCloser(bytes.NewReader(nil))} + assert.ErrorIs(t, BindJSONBody(r, &got), io.EOF) + }) +} + +func TestBindJSONValue_FieldCacheIsStable(t *testing.T) { + f1 := cachedJSONFields(typeOf[bindJSONAll]()) + f2 := cachedJSONFields(typeOf[bindJSONAll]()) + assert.Equal(t, f1, f2) + _, hasIgnored := f1["Ignored"] + assert.False(t, hasIgnored) + _, hasUnexported := f1["unexported"] + assert.False(t, hasUnexported) + assert.Equal(t, []int{1, 0}, f1["viaPointer"].index) + assert.Equal(t, "-", f1["-"].name) + assert.True(t, f1["quoted"].quoted) +} + +func typeOf[T any]() reflect.Type { return reflect.TypeFor[T]() } From ccbf2e510cac1d994729b22904fb93274892dfe9 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 22 Oct 2026 18:10:11 -0700 Subject: [PATCH 2/2] Fix lint findings in bindjson_test.go golangci-lint flagged two things in the bindJSONAll fixture struct: - staticcheck SA5008 on the `json:"-,"` tag. That tag is intentional: it names the member "-" (the encoding/json documented way to do so), and TestBindJSONValue_FieldCacheIsStable asserts the binder resolves it that way. Keep the tag and silence the check with an explanatory nolint. - unused on the `unexported` field. The field exists so the fixture exercises the binder skipping unexported fields. Reference it with an assertion that it is never set, which both documents the intent and satisfies the linter. Co-Authored-By: Claude Fable 5.1 --- bindjson_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bindjson_test.go b/bindjson_test.go index 5a97229..4a26d1e 100644 --- a/bindjson_test.go +++ b/bindjson_test.go @@ -92,7 +92,7 @@ type bindJSONAll struct { Ignored string `json:"-"` unexported string NoTag string - Dash string `json:"-,"` + Dash string `json:"-,"` //nolint:staticcheck // SA5008: "-," deliberately names the member "-"; the binder must match encoding/json here } func TestBindJSONValue_MatchesEncodingJSON(t *testing.T) { @@ -135,6 +135,7 @@ func TestBindJSONValue_MatchesEncodingJSON(t *testing.T) { assert.JSONEq(t, string(want.Raw), string(got.Raw)) want.Raw, got.Raw = nil, nil } + assert.Empty(t, got.unexported, "unexported fields must never be set") assert.Equal(t, want, got) }) }