Skip to content

Commit d138cf8

Browse files
committed
feat(jsonname): added new json name provider more respectful of go conventions for JSON (#195)
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent e8f76b3 commit d138cf8

4 files changed

Lines changed: 620 additions & 0 deletions

File tree

jsonname/go_name_provider.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jsonname
5+
6+
import (
7+
"reflect"
8+
"strings"
9+
"sync"
10+
)
11+
12+
var _ providerIface = (*GoNameProvider)(nil)
13+
14+
// GoNameProvider resolves json property names to go struct field names following
15+
// the same rules as the standard library's [encoding/json] package.
16+
//
17+
// Contrary to [NameProvider], it considers exported fields without a json tag,
18+
// and promotes fields from anonymous embedded struct types.
19+
//
20+
// Rules (aligned with encoding/json):
21+
//
22+
// - unexported fields are ignored;
23+
// - a field tagged `json:"-"` is ignored;
24+
// - a field tagged `json:"-,"` is kept under the json name "-" (stdlib quirk);
25+
// - a field tagged `json:""` or with no json tag at all keeps its Go name as json name;
26+
// - anonymous struct fields without an explicit json tag have their fields
27+
// promoted into the parent, following breadth-first depth rules:
28+
// a shallower field wins over a deeper one; at equal depth, a conflict
29+
// discards all conflicting fields unless exactly one has an explicit json tag.
30+
//
31+
// This type is safe for concurrent use.
32+
type GoNameProvider struct {
33+
lock sync.Mutex
34+
index map[reflect.Type]nameIndex
35+
}
36+
37+
// NewGoNameProvider creates a new [GoNameProvider].
38+
func NewGoNameProvider() *GoNameProvider {
39+
return &GoNameProvider{
40+
index: make(map[reflect.Type]nameIndex),
41+
}
42+
}
43+
44+
// GetJSONNames gets all the json property names for a type.
45+
func (n *GoNameProvider) GetJSONNames(subject any) []string {
46+
n.lock.Lock()
47+
defer n.lock.Unlock()
48+
49+
tpe := reflect.Indirect(reflect.ValueOf(subject)).Type()
50+
names := n.nameIndexFor(tpe)
51+
52+
res := make([]string, 0, len(names.jsonNames))
53+
for k := range names.jsonNames {
54+
res = append(res, k)
55+
}
56+
57+
return res
58+
}
59+
60+
// GetJSONName gets the json name for a go property name.
61+
func (n *GoNameProvider) GetJSONName(subject any, name string) (string, bool) {
62+
tpe := reflect.Indirect(reflect.ValueOf(subject)).Type()
63+
64+
return n.GetJSONNameForType(tpe, name)
65+
}
66+
67+
// GetJSONNameForType gets the json name for a go property name on a given type.
68+
func (n *GoNameProvider) GetJSONNameForType(tpe reflect.Type, name string) (string, bool) {
69+
n.lock.Lock()
70+
defer n.lock.Unlock()
71+
72+
names := n.nameIndexFor(tpe)
73+
nme, ok := names.goNames[name]
74+
75+
return nme, ok
76+
}
77+
78+
// GetGoName gets the go name for a json property name.
79+
func (n *GoNameProvider) GetGoName(subject any, name string) (string, bool) {
80+
tpe := reflect.Indirect(reflect.ValueOf(subject)).Type()
81+
82+
return n.GetGoNameForType(tpe, name)
83+
}
84+
85+
// GetGoNameForType gets the go name for a given type for a json property name.
86+
func (n *GoNameProvider) GetGoNameForType(tpe reflect.Type, name string) (string, bool) {
87+
n.lock.Lock()
88+
defer n.lock.Unlock()
89+
90+
names := n.nameIndexFor(tpe)
91+
nme, ok := names.jsonNames[name]
92+
93+
return nme, ok
94+
}
95+
96+
func (n *GoNameProvider) nameIndexFor(tpe reflect.Type) nameIndex {
97+
if names, ok := n.index[tpe]; ok {
98+
return names
99+
}
100+
101+
names := buildGoNameIndex(tpe)
102+
n.index[tpe] = names
103+
104+
return names
105+
}
106+
107+
// fieldEntry captures a candidate field discovered while walking a struct
108+
// along with the indirection path from the root type (used to resolve conflicts
109+
// by depth in the same way encoding/json does).
110+
type fieldEntry struct {
111+
goName string
112+
jsonName string
113+
index []int
114+
tagged bool
115+
}
116+
117+
func buildGoNameIndex(tpe reflect.Type) nameIndex {
118+
fields := collectGoFields(tpe)
119+
120+
idx := make(map[string]string, len(fields))
121+
reverseIdx := make(map[string]string, len(fields))
122+
for _, f := range fields {
123+
idx[f.jsonName] = f.goName
124+
reverseIdx[f.goName] = f.jsonName
125+
}
126+
127+
return nameIndex{jsonNames: idx, goNames: reverseIdx}
128+
}
129+
130+
// collectGoFields walks tpe breadth-first along anonymous struct fields,
131+
// reproducing the field selection performed by encoding/json.typeFields.
132+
func collectGoFields(tpe reflect.Type) []fieldEntry {
133+
if tpe.Kind() != reflect.Struct {
134+
return nil
135+
}
136+
137+
type queued struct {
138+
typ reflect.Type
139+
index []int
140+
}
141+
142+
current := []queued{}
143+
next := []queued{{typ: tpe}}
144+
visited := map[reflect.Type]bool{tpe: true}
145+
146+
var (
147+
candidates []fieldEntry
148+
count = map[string]int{}
149+
nextCount = map[string]int{}
150+
)
151+
152+
for len(next) > 0 {
153+
current, next = next, current[:0]
154+
count, nextCount = nextCount, count
155+
for k := range nextCount {
156+
delete(nextCount, k)
157+
}
158+
159+
for _, q := range current {
160+
for i := 0; i < q.typ.NumField(); i++ {
161+
sf := q.typ.Field(i)
162+
163+
if sf.Anonymous {
164+
ft := sf.Type
165+
if ft.Kind() == reflect.Ptr {
166+
ft = ft.Elem()
167+
}
168+
if !sf.IsExported() && ft.Kind() != reflect.Struct {
169+
continue
170+
}
171+
} else if !sf.IsExported() {
172+
continue
173+
}
174+
175+
tag := sf.Tag.Get("json")
176+
if tag == "-" {
177+
continue
178+
}
179+
jsonName, _ := parseJSONTag(tag)
180+
tagged := jsonName != ""
181+
182+
ft := sf.Type
183+
if ft.Kind() == reflect.Ptr {
184+
ft = ft.Elem()
185+
}
186+
187+
if sf.Anonymous && ft.Kind() == reflect.Struct && !tagged {
188+
if visited[ft] {
189+
continue
190+
}
191+
visited[ft] = true
192+
193+
index := make([]int, len(q.index)+1)
194+
copy(index, q.index)
195+
index[len(q.index)] = i
196+
next = append(next, queued{typ: ft, index: index})
197+
198+
continue
199+
}
200+
201+
name := jsonName
202+
if name == "" {
203+
name = sf.Name
204+
}
205+
206+
index := make([]int, len(q.index)+1)
207+
copy(index, q.index)
208+
index[len(q.index)] = i
209+
210+
candidates = append(candidates, fieldEntry{
211+
goName: sf.Name,
212+
jsonName: name,
213+
index: index,
214+
tagged: tagged,
215+
})
216+
nextCount[name]++
217+
}
218+
}
219+
}
220+
221+
return dominantFields(candidates)
222+
}
223+
224+
// dominantFields applies the Go encoding/json conflict resolution rules:
225+
// at each JSON name, the shallowest field wins; at equal depth, a uniquely
226+
// tagged candidate wins; otherwise all candidates for that name are dropped.
227+
func dominantFields(candidates []fieldEntry) []fieldEntry {
228+
byName := make(map[string][]fieldEntry, len(candidates))
229+
for _, c := range candidates {
230+
byName[c.jsonName] = append(byName[c.jsonName], c)
231+
}
232+
233+
out := make([]fieldEntry, 0, len(byName))
234+
for _, group := range byName {
235+
if len(group) == 1 {
236+
out = append(out, group[0])
237+
238+
continue
239+
}
240+
241+
minDepth := len(group[0].index)
242+
for _, c := range group[1:] {
243+
if len(c.index) < minDepth {
244+
minDepth = len(c.index)
245+
}
246+
}
247+
248+
var shallow []fieldEntry
249+
for _, c := range group {
250+
if len(c.index) == minDepth {
251+
shallow = append(shallow, c)
252+
}
253+
}
254+
255+
if len(shallow) == 1 {
256+
out = append(out, shallow[0])
257+
258+
continue
259+
}
260+
261+
var tagged []fieldEntry
262+
for _, c := range shallow {
263+
if c.tagged {
264+
tagged = append(tagged, c)
265+
}
266+
}
267+
if len(tagged) == 1 {
268+
out = append(out, tagged[0])
269+
}
270+
}
271+
272+
return out
273+
}
274+
275+
// parseJSONTag returns the name component of a json struct tag and whether
276+
// it carried any non-name option (kept for future-proofing, e.g. "omitempty").
277+
func parseJSONTag(tag string) (string, string) {
278+
if tag == "" {
279+
return "", ""
280+
}
281+
if idx := strings.IndexByte(tag, ','); idx >= 0 {
282+
return tag[:idx], tag[idx+1:]
283+
}
284+
285+
return tag, ""
286+
}

0 commit comments

Comments
 (0)