From 2d90f84603f30dab8cc119d6b43eb8787e8c7e36 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 15 Apr 2026 17:23:53 +0200 Subject: [PATCH] feat(jsonname): added new json name provider more respectful of go conventions for JSON Signed-off-by: Frederic BIDON --- jsonname/go_name_provider.go | 286 +++++++++++++++++++++++++++ jsonname/go_name_provider_test.go | 318 ++++++++++++++++++++++++++++++ jsonname/ifaces.go | 14 ++ jsonname/name_provider.go | 2 + 4 files changed, 620 insertions(+) create mode 100644 jsonname/go_name_provider.go create mode 100644 jsonname/go_name_provider_test.go create mode 100644 jsonname/ifaces.go diff --git a/jsonname/go_name_provider.go b/jsonname/go_name_provider.go new file mode 100644 index 00000000..adc44268 --- /dev/null +++ b/jsonname/go_name_provider.go @@ -0,0 +1,286 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonname + +import ( + "reflect" + "strings" + "sync" +) + +var _ providerIface = (*GoNameProvider)(nil) + +// GoNameProvider resolves json property names to go struct field names following +// the same rules as the standard library's [encoding/json] package. +// +// Contrary to [NameProvider], it considers exported fields without a json tag, +// and promotes fields from anonymous embedded struct types. +// +// Rules (aligned with encoding/json): +// +// - unexported fields are ignored; +// - a field tagged `json:"-"` is ignored; +// - a field tagged `json:"-,"` is kept under the json name "-" (stdlib quirk); +// - a field tagged `json:""` or with no json tag at all keeps its Go name as json name; +// - anonymous struct fields without an explicit json tag have their fields +// promoted into the parent, following breadth-first depth rules: +// a shallower field wins over a deeper one; at equal depth, a conflict +// discards all conflicting fields unless exactly one has an explicit json tag. +// +// This type is safe for concurrent use. +type GoNameProvider struct { + lock sync.Mutex + index map[reflect.Type]nameIndex +} + +// NewGoNameProvider creates a new [GoNameProvider]. +func NewGoNameProvider() *GoNameProvider { + return &GoNameProvider{ + index: make(map[reflect.Type]nameIndex), + } +} + +// GetJSONNames gets all the json property names for a type. +func (n *GoNameProvider) GetJSONNames(subject any) []string { + n.lock.Lock() + defer n.lock.Unlock() + + tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() + names := n.nameIndexFor(tpe) + + res := make([]string, 0, len(names.jsonNames)) + for k := range names.jsonNames { + res = append(res, k) + } + + return res +} + +// GetJSONName gets the json name for a go property name. +func (n *GoNameProvider) GetJSONName(subject any, name string) (string, bool) { + tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() + + return n.GetJSONNameForType(tpe, name) +} + +// GetJSONNameForType gets the json name for a go property name on a given type. +func (n *GoNameProvider) GetJSONNameForType(tpe reflect.Type, name string) (string, bool) { + n.lock.Lock() + defer n.lock.Unlock() + + names := n.nameIndexFor(tpe) + nme, ok := names.goNames[name] + + return nme, ok +} + +// GetGoName gets the go name for a json property name. +func (n *GoNameProvider) GetGoName(subject any, name string) (string, bool) { + tpe := reflect.Indirect(reflect.ValueOf(subject)).Type() + + return n.GetGoNameForType(tpe, name) +} + +// GetGoNameForType gets the go name for a given type for a json property name. +func (n *GoNameProvider) GetGoNameForType(tpe reflect.Type, name string) (string, bool) { + n.lock.Lock() + defer n.lock.Unlock() + + names := n.nameIndexFor(tpe) + nme, ok := names.jsonNames[name] + + return nme, ok +} + +func (n *GoNameProvider) nameIndexFor(tpe reflect.Type) nameIndex { + if names, ok := n.index[tpe]; ok { + return names + } + + names := buildGoNameIndex(tpe) + n.index[tpe] = names + + return names +} + +// fieldEntry captures a candidate field discovered while walking a struct +// along with the indirection path from the root type (used to resolve conflicts +// by depth in the same way encoding/json does). +type fieldEntry struct { + goName string + jsonName string + index []int + tagged bool +} + +func buildGoNameIndex(tpe reflect.Type) nameIndex { + fields := collectGoFields(tpe) + + idx := make(map[string]string, len(fields)) + reverseIdx := make(map[string]string, len(fields)) + for _, f := range fields { + idx[f.jsonName] = f.goName + reverseIdx[f.goName] = f.jsonName + } + + return nameIndex{jsonNames: idx, goNames: reverseIdx} +} + +// collectGoFields walks tpe breadth-first along anonymous struct fields, +// reproducing the field selection performed by encoding/json.typeFields. +func collectGoFields(tpe reflect.Type) []fieldEntry { + if tpe.Kind() != reflect.Struct { + return nil + } + + type queued struct { + typ reflect.Type + index []int + } + + current := []queued{} + next := []queued{{typ: tpe}} + visited := map[reflect.Type]bool{tpe: true} + + var ( + candidates []fieldEntry + count = map[string]int{} + nextCount = map[string]int{} + ) + + for len(next) > 0 { + current, next = next, current[:0] + count, nextCount = nextCount, count + for k := range nextCount { + delete(nextCount, k) + } + + for _, q := range current { + for i := 0; i < q.typ.NumField(); i++ { + sf := q.typ.Field(i) + + if sf.Anonymous { + ft := sf.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if !sf.IsExported() && ft.Kind() != reflect.Struct { + continue + } + } else if !sf.IsExported() { + continue + } + + tag := sf.Tag.Get("json") + if tag == "-" { + continue + } + jsonName, _ := parseJSONTag(tag) + tagged := jsonName != "" + + ft := sf.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + + if sf.Anonymous && ft.Kind() == reflect.Struct && !tagged { + if visited[ft] { + continue + } + visited[ft] = true + + index := make([]int, len(q.index)+1) + copy(index, q.index) + index[len(q.index)] = i + next = append(next, queued{typ: ft, index: index}) + + continue + } + + name := jsonName + if name == "" { + name = sf.Name + } + + index := make([]int, len(q.index)+1) + copy(index, q.index) + index[len(q.index)] = i + + candidates = append(candidates, fieldEntry{ + goName: sf.Name, + jsonName: name, + index: index, + tagged: tagged, + }) + nextCount[name]++ + } + } + } + + return dominantFields(candidates) +} + +// dominantFields applies the Go encoding/json conflict resolution rules: +// at each JSON name, the shallowest field wins; at equal depth, a uniquely +// tagged candidate wins; otherwise all candidates for that name are dropped. +func dominantFields(candidates []fieldEntry) []fieldEntry { + byName := make(map[string][]fieldEntry, len(candidates)) + for _, c := range candidates { + byName[c.jsonName] = append(byName[c.jsonName], c) + } + + out := make([]fieldEntry, 0, len(byName)) + for _, group := range byName { + if len(group) == 1 { + out = append(out, group[0]) + + continue + } + + minDepth := len(group[0].index) + for _, c := range group[1:] { + if len(c.index) < minDepth { + minDepth = len(c.index) + } + } + + var shallow []fieldEntry + for _, c := range group { + if len(c.index) == minDepth { + shallow = append(shallow, c) + } + } + + if len(shallow) == 1 { + out = append(out, shallow[0]) + + continue + } + + var tagged []fieldEntry + for _, c := range shallow { + if c.tagged { + tagged = append(tagged, c) + } + } + if len(tagged) == 1 { + out = append(out, tagged[0]) + } + } + + return out +} + +// parseJSONTag returns the name component of a json struct tag and whether +// it carried any non-name option (kept for future-proofing, e.g. "omitempty"). +func parseJSONTag(tag string) (string, string) { + if tag == "" { + return "", "" + } + if idx := strings.IndexByte(tag, ','); idx >= 0 { + return tag[:idx], tag[idx+1:] + } + + return tag, "" +} diff --git a/jsonname/go_name_provider_test.go b/jsonname/go_name_provider_test.go new file mode 100644 index 00000000..eb015336 --- /dev/null +++ b/jsonname/go_name_provider_test.go @@ -0,0 +1,318 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonname + +import ( + "encoding/json" + "reflect" + "sort" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +type testAltEmbedded struct { + Nested string `json:"nested"` +} + +type testAltDeep struct { + Deep string `json:"deep"` +} + +type testAltMiddle struct { + testAltDeep + + Middle string `json:"middle"` +} + +// testAltStruct exercises the stdlib-aligned field discovery rules: +// - Name: explicitly tagged +// - NotTheSame: tagged with a different json name +// - Ignored: fully excluded via `json:"-"` +// - DashField: stdlib quirk, literally named "-" in json +// - Untagged: empty name in tag → keeps Go name +// - Optional: options-only tag → keeps Go name +// - NoTag: no tag at all → keeps Go name +// - unexported: excluded +// - testAltEmbedded: fields promoted to the parent +// - testAltMiddle: embedded struct itself embedding another → transitively promoted +type testAltStruct struct { + testAltEmbedded + testAltMiddle + + Name string `json:"name"` + NotTheSame int64 `json:"plain"` + Ignored string `json:"-"` + DashField string `json:"-,"` //nolint:staticcheck // deliberate: exercise stdlib "-," quirk + Untagged string `json:""` + Optional string `json:",omitempty"` + NoTag string + unexported string //nolint:unused // exercised to confirm it is filtered out +} + +// testAltShadow verifies the depth-based conflict resolution: the outer field +// must win over one promoted from an embedded type. +type testAltShadow struct { + testAltEmbedded + + Nested string `json:"nested"` +} + +func TestGoNameProvider(t *testing.T) { + provider := NewGoNameProvider() + obj := testAltStruct{} + tpe := reflect.TypeOf(obj) + ptr := &obj + + t.Run("GetGoName resolves tagged fields", func(t *testing.T) { + for _, tc := range []struct { + jsonName string + goName string + }{ + {"name", "Name"}, + {"plain", "NotTheSame"}, + {"-", "DashField"}, // stdlib `json:"-,"` quirk + {"Untagged", "Untagged"}, + {"Optional", "Optional"}, + {"NoTag", "NoTag"}, + {"nested", "Nested"}, + {"middle", "Middle"}, + {"deep", "Deep"}, + } { + nm, ok := provider.GetGoName(obj, tc.jsonName) + assert.TrueT(t, ok, "expected json name %q to resolve", tc.jsonName) + assert.EqualT(t, tc.goName, nm) + } + }) + + t.Run("GetGoName rejects excluded or unknown names", func(t *testing.T) { + for _, bad := range []string{"ignored", "Ignored", "unexported", "doesNotExist"} { + nm, ok := provider.GetGoName(obj, bad) + assert.FalseT(t, ok, "did not expect %q to resolve", bad) + assert.Empty(t, nm) + } + }) + + t.Run("GetGoNameForType mirrors GetGoName", func(t *testing.T) { + nm, ok := provider.GetGoNameForType(tpe, "plain") + assert.TrueT(t, ok) + assert.EqualT(t, "NotTheSame", nm) + + _, ok = provider.GetGoNameForType(tpe, "doesNotExist") + assert.FalseT(t, ok) + }) + + t.Run("GetGoName accepts pointer subjects", func(t *testing.T) { + nm, ok := provider.GetGoName(ptr, "name") + assert.TrueT(t, ok) + assert.EqualT(t, "Name", nm) + + nm, ok = provider.GetGoName(ptr, "nested") + assert.TrueT(t, ok) + assert.EqualT(t, "Nested", nm) + }) + + t.Run("GetJSONName is the inverse mapping", func(t *testing.T) { + for _, tc := range []struct { + goName string + jsonName string + }{ + {"Name", "name"}, + {"NotTheSame", "plain"}, + {"DashField", "-"}, + {"Untagged", "Untagged"}, + {"Optional", "Optional"}, + {"NoTag", "NoTag"}, + {"Nested", "nested"}, + {"Middle", "middle"}, + {"Deep", "deep"}, + } { + nm, ok := provider.GetJSONName(obj, tc.goName) + assert.TrueT(t, ok, "expected go name %q to resolve", tc.goName) + assert.EqualT(t, tc.jsonName, nm) + } + + _, ok := provider.GetJSONName(obj, "Ignored") + assert.FalseT(t, ok) + + _, ok = provider.GetJSONNameForType(tpe, "DoesNotExist") + assert.FalseT(t, ok) + }) + + t.Run("GetJSONNames lists every discoverable field exactly once", func(t *testing.T) { + names := provider.GetJSONNames(ptr) + sort.Strings(names) + assert.Equal(t, []string{ + "-", + "NoTag", + "Optional", + "Untagged", + "deep", + "middle", + "name", + "nested", + "plain", + }, names) + }) + + t.Run("index caches per type", func(t *testing.T) { + // Re-query to confirm no duplicate entries are created on repeat access. + _, _ = provider.GetGoName(obj, "name") + _, _ = provider.GetGoName(ptr, "name") + assert.Len(t, provider.index, 1) + }) +} + +// TestGoNameProvider_ShadowingMatchesStdlib pins our field selection to the +// behavior of encoding/json for shadowed promoted fields. +func TestGoNameProvider_ShadowingMatchesStdlib(t *testing.T) { + provider := NewGoNameProvider() + payload := `{"nested":"outer"}` + + var s testAltShadow + require.NoError(t, json.Unmarshal([]byte(payload), &s)) + assert.Equal(t, "outer", s.Nested) + assert.Empty(t, s.testAltEmbedded.Nested) + + goName, ok := provider.GetGoName(s, "nested") + require.True(t, ok) + // The outer field wins, exactly like encoding/json would pick s.Nested. + assert.Equal(t, "Nested", goName) + + names := provider.GetJSONNames(s) + assert.Len(t, names, 1) +} + +// TestGoNameProvider_ImplementsInterface is a compile-time-ish guard that both +// providers agree on the core lookup shape expected by consumers. +func TestGoNameProvider_ImplementsInterface(t *testing.T) { + var p providerIface = NewGoNameProvider() + _, ok := p.GetGoName(testAltStruct{}, "name") + assert.True(t, ok) +} + +// Fixtures for the embedded-type promotion scenarios. + +type testAltInner struct { + Foo string `json:"foo"` + Bar string +} + +type testAltPromoted struct { + testAltInner + + Baz string `json:"baz"` +} + +type testAltTaggedEmbed struct { + testAltInner `json:"inner"` + + Baz string `json:"baz"` +} + +type testAltPtrEmbed struct { + *testAltInner + + Baz string `json:"baz"` +} + +type testAltUnexportedEmbed struct { + testAltInner // exported type, will still promote + + inner testAltInner //nolint:unused // regular unexported field, must be ignored +} + +// TestGoNameProvider_EmbeddedPromotion validates how the provider resolves +// fields coming from an exported embedded type, mirroring encoding/json. +func TestGoNameProvider_EmbeddedPromotion(t *testing.T) { + t.Run("untagged embedded struct promotes its fields", func(t *testing.T) { + provider := NewGoNameProvider() + obj := testAltPromoted{} + + for _, tc := range []struct { + jsonName string + goName string + }{ + {"foo", "Foo"}, // promoted, tagged on Inner + {"Bar", "Bar"}, // promoted, untagged on Inner -> Go name kept + {"baz", "Baz"}, // declared on Outer + } { + nm, ok := provider.GetGoName(obj, tc.jsonName) + assert.TrueT(t, ok, "expected %q to resolve", tc.jsonName) + assert.EqualT(t, tc.goName, nm) + } + + // "Inner" must NOT appear as its own json name: its fields were promoted. + _, ok := provider.GetJSONName(obj, "testAltInner") + assert.False(t, ok) + + names := provider.GetJSONNames(obj) + sort.Strings(names) + assert.Equal(t, []string{"Bar", "baz", "foo"}, names) + }) + + t.Run("tagged embedded struct is treated as a regular named field", func(t *testing.T) { + provider := NewGoNameProvider() + obj := testAltTaggedEmbed{} + + nm, ok := provider.GetGoName(obj, "inner") + assert.TrueT(t, ok) + assert.EqualT(t, "testAltInner", nm) + + // With the tag in place, Inner's fields are NOT promoted. + _, ok = provider.GetGoName(obj, "foo") + assert.False(t, ok) + _, ok = provider.GetGoName(obj, "Bar") + assert.False(t, ok) + + names := provider.GetJSONNames(obj) + sort.Strings(names) + assert.Equal(t, []string{"baz", "inner"}, names) + }) + + t.Run("pointer-to-struct embedded is promoted like its elem", func(t *testing.T) { + provider := NewGoNameProvider() + obj := testAltPtrEmbed{} + + nm, ok := provider.GetGoName(obj, "foo") + assert.TrueT(t, ok) + assert.EqualT(t, "Foo", nm) + + names := provider.GetJSONNames(obj) + sort.Strings(names) + assert.Equal(t, []string{"Bar", "baz", "foo"}, names) + }) + + t.Run("regular unexported field alongside promotion does not leak", func(t *testing.T) { + provider := NewGoNameProvider() + obj := testAltUnexportedEmbed{} + + // Promotion still works for the exported embedded type. + nm, ok := provider.GetGoName(obj, "foo") + assert.TrueT(t, ok) + assert.EqualT(t, "Foo", nm) + + // The regular unexported "inner" field must be invisible. + _, ok = provider.GetGoName(obj, "inner") + assert.False(t, ok) + }) + + t.Run("agrees with encoding/json on roundtrip", func(t *testing.T) { + provider := NewGoNameProvider() + payload := `{"foo":"f","Bar":"b","baz":"z"}` + + var stdVal testAltPromoted + require.NoError(t, json.Unmarshal([]byte(payload), &stdVal)) + assert.Equal(t, "f", stdVal.Foo) + assert.Equal(t, "b", stdVal.Bar) + assert.Equal(t, "z", stdVal.Baz) + + // For every json key encoding/json accepted, the provider must resolve it too. + for _, key := range []string{"foo", "Bar", "baz"} { + _, ok := provider.GetGoName(stdVal, key) + assert.TrueT(t, ok, "provider should resolve %q like encoding/json", key) + } + }) +} diff --git a/jsonname/ifaces.go b/jsonname/ifaces.go new file mode 100644 index 00000000..812ace56 --- /dev/null +++ b/jsonname/ifaces.go @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonname + +import "reflect" + +// providerIface is an unexported compile-time contract that every name provider +// in this package is expected to satisfy. +// It mirrors the interface declared by the main consumer of this module: [github.com/go-openapi/jsonpointer.NameProvider]. +type providerIface interface { + GetGoName(subject any, name string) (string, bool) + GetGoNameForType(tpe reflect.Type, name string) (string, bool) +} diff --git a/jsonname/name_provider.go b/jsonname/name_provider.go index 8eaf1bec..9f5da7a0 100644 --- a/jsonname/name_provider.go +++ b/jsonname/name_provider.go @@ -12,6 +12,8 @@ import ( // DefaultJSONNameProvider is the default cache for types. var DefaultJSONNameProvider = NewNameProvider() +var _ providerIface = (*NameProvider)(nil) + // NameProvider represents an object capable of translating from go property names // to json property names. //