Stuct Reflection to Dot Notation

Recently we were building an API that needed to expose an entire struct for customizable input. Through the curation of two methods, ToDotNotation, a function that converts structs to dot notation, and the second, Get, a function that converts that dot notation to get the value of a given instance, we are able to provide a package for external consumption.

Building Dot Notation

Take the struct, MyStruct, where it contains a field FieldA of type MyFields, and a string field of StringA. In this example we want to convert the struct to a slice for the end consumer: Input: MyStruct{} -> Output: [FieldA.Public, StringA]

type MyStruct struct {
	FieldA MyFields `json:"a"`
	StringA *string
}

type MyFields struct {
	Redacted *string `json:"-"`
	Public  string  `json:"public"`
}

func main() {
	res := ToDotNotation(&MyStruct{}, "", nil)
	fmt.Println("Dot Notated", res) // returns [FieldA.Public, StringA]
}

Using the built-in reflect package, the TLDR; is the code will loop all fields and determine if the struct has exported members, and if so, will recursively traverse until it reaches one with no exports. Once it reaches that field, it will append a concatenation of the prefix and the name of the field to a list to use in its output.

type DotPath string
func (d DotPath) String() string {
 	return string(d)
 }

func hasExportedFields(s reflect.Type) bool {
	if s.Kind() != reflect.Struct {
		return false
	}
	i := 0
	for _, f := range reflect.VisibleFields(s) {
		if f.IsExported() {
			i++
		}
	}
	return i > 0
}

func ToDotNotation(T any, prefix string, res []DotPath) []DotPath {
 	if res == nil {
		res = make([]DotPath, 0)
	}

	t := reflect.TypeOf(T)
	v := reflect.Indirect(reflect.ValueOf(T))

	if t.Kind() == reflect.Pointer {
		t = t.Elem()
	}

	if t.Kind() == reflect.Struct {
		for i := 0; i < v.NumField(); i++ {
			// Only work on exported fields
			if v.Field(i).CanInterface() {
				currField := reflect.Indirect(v.Field(i))

				// Set dot notation prefix for children entities
				currName := t.Field(i).Name
				if prefix != "" {
					currName = strings.Join([]string{prefix, t.Field(i).Name}, ".")
				}

				// Check for struct field tag for JSON omit
				add := true
				if jsonTag, ok := t.Field(i).Tag.Lookup("json"); ok {
					for _, val := range strings.Split(jsonTag, ",") {
						if val == "-" {
							add = false
						}
					}
				}

				// If a field has a json tag to omit then we dont want to leak data, so exclude it
				if add {
					if currField.Kind() == reflect.Struct && hasExportedFields(currField.Type()) {
						// Recursively move through struct fields adding their values
						res = ToDotNotation(currField.Interface(), currName, res)
					} else {
						res = append(res, DotPath(currName))
					}
				}
			}
		}
	}

	return res
 }

Parsing a struct dot notation

Using the same example before if we have an instance of that same struct of MyStruct, we can get the value of the Public FieldA string: Input: FieldA.Public -> Output: My String

func main() {
	val := MyStruct{
		FieldA: MyFields{
			Public:   "My String",
		},
	}
	v, _ := Get(val, "FieldA.Public")
	fmt.Println(v) // prints "My String"
}

Utilizing the dot notation format, reading in the struct can be done using the FieldByName function to take a reflected value and return the interface. With the input being dot notated, we split on the period and recursvily iterate to get the final value in the path of a given struct.

var ErrNotStruct = errors.New("value must be a struct type")
var ErrInvalidPath = errors.New("invalid path provided")

func Get(T any, path DotPath) (any, error) {
	seg := strings.SplitN(path.String(), ".", 2)

	v := reflect.Indirect(reflect.ValueOf(T))

	fmt.Println(v.Type())

	if v.Kind() != reflect.Struct {
		fmt.Println(v.Kind())
		return nil, ErrNotStruct
	}

	curr := v.FieldByName(seg[0])
	if !curr.IsValid() {
		return nil, ErrInvalidPath
	}

	if len(seg) == 1 {
		return curr.Interface(), nil
	}

	if curr.Kind() == reflect.Struct {
		return Get(curr.Interface(), DotPath(seg[1]))
	}

	return nil, ErrInvalidPath
}