Skip to content

Commit 4893d4c

Browse files
authored
Merge pull request #14 from neticdk/feat/xstructs-custom-tag-order
feat(xstructs): custom tags
2 parents 362eea4 + 89aa285 commit 4893d4c

File tree

2 files changed

+244
-29
lines changed

2 files changed

+244
-29
lines changed

xstructs/map.go

Lines changed: 104 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,78 @@ import (
1111
// tagCategories is a list of tag categories to check for.
1212
var tagCategories = []string{"json", "yaml"}
1313

14+
// WithTags allows you to specify custom tag categories to check for.
15+
// It can be used to override the default "json" and "yaml" tags.
16+
// The tags are checked in the order they are provided.
17+
func WithTags(tags ...string) ToMapOption {
18+
return func(h *handler) {
19+
h.tags = tags
20+
}
21+
}
22+
23+
// WithAllowNoTags allows you to specify whether to allow fields without tags.
24+
// If used, fields without tags will be included in the output map.
25+
func WithAllowNoTags() ToMapOption {
26+
return func(h *handler) {
27+
h.allowNoTags = true
28+
}
29+
}
30+
31+
// ToMapOption is a function that modifies the handler.
32+
type ToMapOption func(*handler)
33+
34+
// handler is a struct that contains the options for the ToMap function.
35+
// It contains a list of tags to check for and a flag to allow fields
36+
// without tags.
37+
type handler struct {
38+
tags []string
39+
allowNoTags bool
40+
}
41+
42+
// tagWrapper is a struct that contains the name and options of a tag.
43+
// It is used to store the tag information for a field.
44+
// The name is the key name to use in the output map.
45+
// The options are the options specified in the tag.
46+
type tagWrapper struct {
47+
Name string
48+
Options []string
49+
}
50+
51+
// newHandler creates a new handler with the default options.
52+
// It initializes the tags to the default "json" and "yaml" tags.
53+
// It also initializes the allowNoTags flag to false.
54+
// It can be modified using the ToMapOptions functions.
55+
// It returns a pointer to the handler.
56+
func newHandler(opts ...ToMapOption) *handler {
57+
h := &handler{
58+
tags: tagCategories,
59+
allowNoTags: false,
60+
}
61+
62+
for _, opt := range opts {
63+
opt(h)
64+
}
65+
66+
return h
67+
}
68+
1469
// ToMap converts a struct or map to a map[string]any.
1570
// It handles nested structs, maps, and slices.
16-
// It uses the "json" and "yaml" tags to determine the key names.
71+
// By default, it uses the "json" and "yaml" tags
72+
// to determine the key names in that order.
1773
// It respects the `omitempty` tag for fields.
1874
// It respects the `inline` tag for nested structs.
1975
// It respects the `-` tag to omit fields.
2076
//
2177
// If the input is nil, it returns nil.
2278
// If the input is not a struct or map, it returns an error.
23-
func ToMap(obj any) (map[string]any, error) {
79+
func ToMap(obj any, opts ...ToMapOption) (map[string]any, error) {
80+
handler := newHandler(opts...)
81+
2482
if obj == nil {
2583
return nil, nil
2684
}
27-
res := handle(obj)
85+
res := handler.handle(obj)
2886
if v, ok := res.(map[string]any); ok {
2987
return v, nil
3088
}
@@ -33,7 +91,7 @@ func ToMap(obj any) (map[string]any, error) {
3391

3492
// handle is a helper function that recursively handles
3593
// the conversion of structs, maps, and slices to a map[string]any.
36-
func handle(obj any) any {
94+
func (h *handler) handle(obj any) any {
3795
if obj == nil {
3896
return nil
3997
}
@@ -45,19 +103,19 @@ func handle(obj any) any {
45103

46104
switch val.Kind() {
47105
case reflect.Map:
48-
return handleMap(obj)
106+
return h.handleMap(obj)
49107
case reflect.Struct:
50-
return handleStruct(obj)
108+
return h.handleStruct(obj)
51109
case reflect.Slice:
52-
return handleSlice(obj)
110+
return h.handleSlice(obj)
53111
default:
54112
return obj
55113
}
56114
}
57115

58116
// handleStruct handles the conversion of a struct to a map[string]any.
59-
// It uses the "json" and "yaml" tags to determine the key names.
60-
func handleStruct(obj any) any {
117+
// It uses the tags from the handler to determine the key names.
118+
func (h *handler) handleStruct(obj any) any {
61119
res := map[string]any{}
62120
val := reflect.ValueOf(obj)
63121
if val.Kind() == reflect.Ptr {
@@ -74,19 +132,30 @@ func handleStruct(obj any) any {
74132

75133
name := field.Name
76134
value := val.Field(i)
77-
tagName, tagOpts := getTag(field)
78-
if tagName != "" {
79-
name = tagName
135+
tagInfo, err := h.getTag(field)
136+
if err != nil && !h.allowNoTags {
137+
continue
138+
}
139+
140+
if h.allowNoTags && tagInfo == nil {
141+
tagInfo = &tagWrapper{
142+
Name: "",
143+
Options: []string{},
144+
}
145+
}
146+
147+
if tagInfo.Name != "" {
148+
name = tagInfo.Name
80149
}
81150

82151
// Omit struct tag "-"
83-
if _, ok := xslices.FindFunc(tagOpts, func(s string) bool {
152+
if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool {
84153
return s == "-"
85-
}); ok || (name == "-" && len(tagOpts) == 0) {
154+
}); ok || (name == "-" && len(tagInfo.Options) == 0) {
86155
continue
87156
}
88157

89-
if _, ok := xslices.FindFunc(tagOpts, func(s string) bool {
158+
if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool {
90159
return s == "omitempty"
91160
}); ok {
92161
if reflect.DeepEqual(value.Interface(), reflect.Zero(val.Field(i).Type()).Interface()) {
@@ -102,10 +171,10 @@ func handleStruct(obj any) any {
102171
value = value.Elem()
103172
}
104173
if value.Kind() == reflect.Struct || value.Kind() == reflect.Map {
105-
if _, ok := xslices.FindFunc(tagOpts, func(s string) bool {
174+
if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool {
106175
return s == "inline"
107176
}); ok {
108-
if nestedValues, ok := handle(value.Interface()).(map[string]any); ok {
177+
if nestedValues, ok := h.handle(value.Interface()).(map[string]any); ok {
109178
for k, v := range nestedValues {
110179
if _, ok := res[k]; !ok {
111180
res[k] = v
@@ -116,15 +185,15 @@ func handleStruct(obj any) any {
116185
}
117186
}
118187

119-
res[name] = handle(value.Interface())
188+
res[name] = h.handle(value.Interface())
120189
}
121190

122191
return res
123192
}
124193

125194
// handleMap handles the conversion of a map to a map[string]any,
126195
// recursively converting nested maps, slices and structs.
127-
func handleMap(obj any) any {
196+
func (h *handler) handleMap(obj any) any {
128197
m := map[string]any{}
129198
val := reflect.ValueOf(obj)
130199
for _, key := range val.MapKeys() {
@@ -136,32 +205,39 @@ func handleMap(obj any) any {
136205
if v == nil {
137206
continue
138207
}
139-
m[fmt.Sprintf("%v", k)] = handle(v)
208+
m[fmt.Sprintf("%v", k)] = h.handle(v)
140209
}
141210
return m
142211
}
143212

144213
// handleSlice handles the conversion of a slice to a slice of any,
145214
// recursively converting nested maps, slices and structs.
146-
func handleSlice(obj any) any {
215+
func (h *handler) handleSlice(obj any) any {
147216
s := []any{}
148217
val := reflect.ValueOf(obj)
149218
for i := range val.Len() {
150-
s = append(s, handle(val.Index(i).Interface()))
219+
s = append(s, h.handle(val.Index(i).Interface()))
151220
}
152221
return s
153222
}
154223

155224
// getTag retrieves the tag name and options from a struct field.
156-
// It checks for the "json" and "yaml" tags in that order.
225+
// It checks the tags provided by the handler one by one.
157226
// If one tag is empty, it will return the other tag.
158-
// If both tags are empty, it returns an empty string and an empty slice.
159-
func getTag(field reflect.StructField) (string, []string) {
160-
for _, category := range tagCategories {
227+
// If all tags are empty, it returns an error.
228+
func (h *handler) getTag(field reflect.StructField) (*tagWrapper, error) {
229+
for _, category := range h.tags {
161230
if tag := field.Tag.Get(category); tag != "" {
162231
splitTag := strings.Split(tag, ",")
163-
return splitTag[0], splitTag[1:]
232+
// Test if tag is solitary comma, i.e. `json:","`
233+
if splitTag[0] == "" && len(splitTag[1]) == 0 {
234+
continue
235+
}
236+
return &tagWrapper{
237+
Name: splitTag[0],
238+
Options: splitTag[1:],
239+
}, nil
164240
}
165241
}
166-
return "", []string{}
242+
return nil, fmt.Errorf("no tag of %s found for field %s", strings.Join(h.tags, ", "), field.Name)
167243
}

xstructs/map_test.go

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func TestToMap(t *testing.T) {
1313
tests := []struct {
1414
name string
1515
data any
16+
opts []xstructs.ToMapOption
1617
expected map[string]any
1718
wantErr bool
1819
}{
@@ -244,11 +245,149 @@ func TestToMap(t *testing.T) {
244245
expected: nil,
245246
wantErr: false,
246247
},
248+
{
249+
name: "unexported field",
250+
data: struct {
251+
A int `json:"a"`
252+
b string
253+
}{
254+
A: 1,
255+
b: "test",
256+
},
257+
expected: map[string]any{
258+
"a": 1,
259+
},
260+
wantErr: false,
261+
},
262+
{
263+
name: "with custom tag order",
264+
data: struct {
265+
A int `json:"-" yaml:"a"`
266+
B string `json:"-" yaml:"b"`
267+
C struct {
268+
D int `json:"-" yaml:"d"`
269+
} `json:"-" yaml:"c"`
270+
E []string `json:"-" yaml:"e,omitempty"`
271+
}{
272+
A: 1,
273+
B: "test",
274+
C: struct {
275+
D int `json:"-" yaml:"d"`
276+
}{
277+
D: 2,
278+
},
279+
E: []string{"one", "two"},
280+
},
281+
opts: []xstructs.ToMapOption{
282+
xstructs.WithTags("yaml", "json"),
283+
},
284+
expected: map[string]any{
285+
"a": 1,
286+
"b": "test",
287+
"c": map[string]any{
288+
"d": 2,
289+
},
290+
"e": []any{"one", "two"},
291+
},
292+
wantErr: false,
293+
},
294+
{
295+
name: "with custom tags none exist",
296+
data: struct {
297+
A int `json:"-" yaml:"a"`
298+
B string `json:"-" yaml:"b"`
299+
C struct {
300+
D int `json:"-" yaml:"d"`
301+
} `json:"-" yaml:"c"`
302+
E []string `json:"-" yaml:"e,omitempty"`
303+
}{
304+
A: 1,
305+
B: "test",
306+
C: struct {
307+
D int `json:"-" yaml:"d"`
308+
}{
309+
D: 2,
310+
},
311+
E: []string{"one", "two"},
312+
},
313+
opts: []xstructs.ToMapOption{
314+
xstructs.WithTags("custom"),
315+
},
316+
expected: map[string]any{},
317+
wantErr: false,
318+
},
319+
{
320+
name: "with allow no tags",
321+
data: struct {
322+
A int
323+
B string
324+
}{
325+
A: 1,
326+
B: "test",
327+
},
328+
opts: []xstructs.ToMapOption{
329+
xstructs.WithAllowNoTags(),
330+
},
331+
expected: map[string]any{
332+
"A": 1,
333+
"B": "test",
334+
},
335+
wantErr: false,
336+
},
337+
{
338+
name: "with pointer value",
339+
data: &struct {
340+
A int `json:"a"`
341+
B *struct {
342+
C int `json:"c"`
343+
} `json:"b"`
344+
}{
345+
A: 1,
346+
B: &struct {
347+
C int `json:"c"`
348+
}{
349+
C: 2,
350+
},
351+
},
352+
expected: map[string]any{
353+
"a": 1,
354+
"b": map[string]any{
355+
"c": 2,
356+
},
357+
},
358+
wantErr: false,
359+
},
360+
{
361+
name: "with nil pointer value",
362+
data: &struct {
363+
A int `json:"a"`
364+
B *struct {
365+
C int `json:"c"`
366+
} `json:"b"`
367+
}{
368+
A: 1,
369+
B: nil,
370+
},
371+
expected: map[string]any{
372+
"a": 1,
373+
},
374+
wantErr: false,
375+
},
376+
{
377+
name: "comma in tag",
378+
data: struct {
379+
A int `json:","`
380+
}{
381+
A: 1,
382+
},
383+
expected: map[string]any{},
384+
wantErr: false,
385+
},
247386
}
248387

249388
for _, tt := range tests {
250389
t.Run(tt.name, func(t *testing.T) {
251-
got, err := xstructs.ToMap(tt.data)
390+
got, err := xstructs.ToMap(tt.data, tt.opts...)
252391
if tt.wantErr {
253392
assert.Error(t, err)
254393
}

0 commit comments

Comments
 (0)