Skip to content

feat(xstructs): custom tags #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 104 additions & 28 deletions xstructs/map.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,78 @@ import (
// tagCategories is a list of tag categories to check for.
var tagCategories = []string{"json", "yaml"}

// WithTags allows you to specify custom tag categories to check for.
// It can be used to override the default "json" and "yaml" tags.
// The tags are checked in the order they are provided.
func WithTags(tags ...string) ToMapOption {
return func(h *handler) {
h.tags = tags
}
}

// WithAllowNoTags allows you to specify whether to allow fields without tags.
// If used, fields without tags will be included in the output map.
func WithAllowNoTags() ToMapOption {
return func(h *handler) {
h.allowNoTags = true
}
}

// ToMapOption is a function that modifies the handler.
type ToMapOption func(*handler)

// handler is a struct that contains the options for the ToMap function.
// It contains a list of tags to check for and a flag to allow fields
// without tags.
type handler struct {
tags []string
allowNoTags bool
}

// tagWrapper is a struct that contains the name and options of a tag.
// It is used to store the tag information for a field.
// The name is the key name to use in the output map.
// The options are the options specified in the tag.
type tagWrapper struct {
Name string
Options []string
}

// newHandler creates a new handler with the default options.
// It initializes the tags to the default "json" and "yaml" tags.
// It also initializes the allowNoTags flag to false.
// It can be modified using the ToMapOptions functions.
// It returns a pointer to the handler.
func newHandler(opts ...ToMapOption) *handler {
h := &handler{
tags: tagCategories,
allowNoTags: false,
}

for _, opt := range opts {
opt(h)
}

return h
}

// ToMap converts a struct or map to a map[string]any.
// It handles nested structs, maps, and slices.
// It uses the "json" and "yaml" tags to determine the key names.
// By default, it uses the "json" and "yaml" tags
// to determine the key names in that order.
// It respects the `omitempty` tag for fields.
// It respects the `inline` tag for nested structs.
// It respects the `-` tag to omit fields.
//
// If the input is nil, it returns nil.
// If the input is not a struct or map, it returns an error.
func ToMap(obj any) (map[string]any, error) {
func ToMap(obj any, opts ...ToMapOption) (map[string]any, error) {
handler := newHandler(opts...)

if obj == nil {
return nil, nil
}
res := handle(obj)
res := handler.handle(obj)
if v, ok := res.(map[string]any); ok {
return v, nil
}
Expand All @@ -33,7 +91,7 @@ func ToMap(obj any) (map[string]any, error) {

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

switch val.Kind() {
case reflect.Map:
return handleMap(obj)
return h.handleMap(obj)
case reflect.Struct:
return handleStruct(obj)
return h.handleStruct(obj)
case reflect.Slice:
return handleSlice(obj)
return h.handleSlice(obj)
default:
return obj
}
}

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

name := field.Name
value := val.Field(i)
tagName, tagOpts := getTag(field)
if tagName != "" {
name = tagName
tagInfo, err := h.getTag(field)
if err != nil && !h.allowNoTags {
continue
}

if h.allowNoTags && tagInfo == nil {
tagInfo = &tagWrapper{
Name: "",
Options: []string{},
}
}

if tagInfo.Name != "" {
name = tagInfo.Name
}

// Omit struct tag "-"
if _, ok := xslices.FindFunc(tagOpts, func(s string) bool {
if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool {
return s == "-"
}); ok || (name == "-" && len(tagOpts) == 0) {
}); ok || (name == "-" && len(tagInfo.Options) == 0) {
continue
}

if _, ok := xslices.FindFunc(tagOpts, func(s string) bool {
if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool {
return s == "omitempty"
}); ok {
if reflect.DeepEqual(value.Interface(), reflect.Zero(val.Field(i).Type()).Interface()) {
Expand All @@ -102,10 +171,10 @@ func handleStruct(obj any) any {
value = value.Elem()
}
if value.Kind() == reflect.Struct || value.Kind() == reflect.Map {
if _, ok := xslices.FindFunc(tagOpts, func(s string) bool {
if _, ok := xslices.FindFunc(tagInfo.Options, func(s string) bool {
return s == "inline"
}); ok {
if nestedValues, ok := handle(value.Interface()).(map[string]any); ok {
if nestedValues, ok := h.handle(value.Interface()).(map[string]any); ok {
for k, v := range nestedValues {
if _, ok := res[k]; !ok {
res[k] = v
Expand All @@ -116,15 +185,15 @@ func handleStruct(obj any) any {
}
}

res[name] = handle(value.Interface())
res[name] = h.handle(value.Interface())
}

return res
}

// handleMap handles the conversion of a map to a map[string]any,
// recursively converting nested maps, slices and structs.
func handleMap(obj any) any {
func (h *handler) handleMap(obj any) any {
m := map[string]any{}
val := reflect.ValueOf(obj)
for _, key := range val.MapKeys() {
Expand All @@ -136,32 +205,39 @@ func handleMap(obj any) any {
if v == nil {
continue
}
m[fmt.Sprintf("%v", k)] = handle(v)
m[fmt.Sprintf("%v", k)] = h.handle(v)
}
return m
}

// handleSlice handles the conversion of a slice to a slice of any,
// recursively converting nested maps, slices and structs.
func handleSlice(obj any) any {
func (h *handler) handleSlice(obj any) any {
s := []any{}
val := reflect.ValueOf(obj)
for i := range val.Len() {
s = append(s, handle(val.Index(i).Interface()))
s = append(s, h.handle(val.Index(i).Interface()))
}
return s
}

// getTag retrieves the tag name and options from a struct field.
// It checks for the "json" and "yaml" tags in that order.
// It checks the tags provided by the handler one by one.
// If one tag is empty, it will return the other tag.
// If both tags are empty, it returns an empty string and an empty slice.
func getTag(field reflect.StructField) (string, []string) {
for _, category := range tagCategories {
// If all tags are empty, it returns an error.
func (h *handler) getTag(field reflect.StructField) (*tagWrapper, error) {
for _, category := range h.tags {
if tag := field.Tag.Get(category); tag != "" {
splitTag := strings.Split(tag, ",")
return splitTag[0], splitTag[1:]
// Test if tag is solitary comma, i.e. `json:","`
if splitTag[0] == "" && len(splitTag[1]) == 0 {
continue
}
return &tagWrapper{
Name: splitTag[0],
Options: splitTag[1:],
}, nil
}
}
return "", []string{}
return nil, fmt.Errorf("no tag of %s found for field %s", strings.Join(h.tags, ", "), field.Name)
}
141 changes: 140 additions & 1 deletion xstructs/map_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestToMap(t *testing.T) {
tests := []struct {
name string
data any
opts []xstructs.ToMapOption
expected map[string]any
wantErr bool
}{
Expand Down Expand Up @@ -244,11 +245,149 @@ func TestToMap(t *testing.T) {
expected: nil,
wantErr: false,
},
{
name: "unexported field",
data: struct {
A int `json:"a"`
b string
}{
A: 1,
b: "test",
},
expected: map[string]any{
"a": 1,
},
wantErr: false,
},
{
name: "with custom tag order",
data: struct {
A int `json:"-" yaml:"a"`
B string `json:"-" yaml:"b"`
C struct {
D int `json:"-" yaml:"d"`
} `json:"-" yaml:"c"`
E []string `json:"-" yaml:"e,omitempty"`
}{
A: 1,
B: "test",
C: struct {
D int `json:"-" yaml:"d"`
}{
D: 2,
},
E: []string{"one", "two"},
},
opts: []xstructs.ToMapOption{
xstructs.WithTags("yaml", "json"),
},
expected: map[string]any{
"a": 1,
"b": "test",
"c": map[string]any{
"d": 2,
},
"e": []any{"one", "two"},
},
wantErr: false,
},
{
name: "with custom tags none exist",
data: struct {
A int `json:"-" yaml:"a"`
B string `json:"-" yaml:"b"`
C struct {
D int `json:"-" yaml:"d"`
} `json:"-" yaml:"c"`
E []string `json:"-" yaml:"e,omitempty"`
}{
A: 1,
B: "test",
C: struct {
D int `json:"-" yaml:"d"`
}{
D: 2,
},
E: []string{"one", "two"},
},
opts: []xstructs.ToMapOption{
xstructs.WithTags("custom"),
},
expected: map[string]any{},
wantErr: false,
},
{
name: "with allow no tags",
data: struct {
A int
B string
}{
A: 1,
B: "test",
},
opts: []xstructs.ToMapOption{
xstructs.WithAllowNoTags(),
},
expected: map[string]any{
"A": 1,
"B": "test",
},
wantErr: false,
},
{
name: "with pointer value",
data: &struct {
A int `json:"a"`
B *struct {
C int `json:"c"`
} `json:"b"`
}{
A: 1,
B: &struct {
C int `json:"c"`
}{
C: 2,
},
},
expected: map[string]any{
"a": 1,
"b": map[string]any{
"c": 2,
},
},
wantErr: false,
},
{
name: "with nil pointer value",
data: &struct {
A int `json:"a"`
B *struct {
C int `json:"c"`
} `json:"b"`
}{
A: 1,
B: nil,
},
expected: map[string]any{
"a": 1,
},
wantErr: false,
},
{
name: "comma in tag",
data: struct {
A int `json:","`
}{
A: 1,
},
expected: map[string]any{},
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := xstructs.ToMap(tt.data)
got, err := xstructs.ToMap(tt.data, tt.opts...)
if tt.wantErr {
assert.Error(t, err)
}
Expand Down