Skip to content

Commit

Permalink
[confmap] Add validation facilities from component
Browse files Browse the repository at this point in the history
  • Loading branch information
evan-bradley committed Jan 31, 2025
1 parent ef7456e commit aa1b14c
Show file tree
Hide file tree
Showing 14 changed files with 604 additions and 18 deletions.
25 changes: 25 additions & 0 deletions .chloggen/create-confmap-validate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: enhancement

# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver)
component: confmap

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Create the `Validator` interface and `Validate` function to facilitate config validation

# One or more tracking issues or pull requests related to the change
issues: [11524]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext:

# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Include 'api' if there is a change to a library API.
# Default: '[user]'
change_logs: []
25 changes: 25 additions & 0 deletions .chloggen/deprecate-component-validate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: deprecation

# The name of the component, or a single word describing the area of concern, (e.g. otlpreceiver)
component: component

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Deprecate `ConfigValidator` and `ValidateConfig`

# One or more tracking issues or pull requests related to the change
issues: [11524]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext: Please use `Validator` and `Validate` respectively from `confmap`.

# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Include 'api' if there is a change to a library API.
# Default: '[user]'
change_logs: []
6 changes: 5 additions & 1 deletion component/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
// Config defines the configuration for a component.Component.
//
// Implementations and/or any sub-configs (other types embedded or included in the Config implementation)
// MUST implement the ConfigValidator if any validation is required for that part of the configuration
// MUST implement confmap.Validator if any validation is required for that part of the configuration
// (e.g. check if a required field is present).
//
// A valid implementation MUST pass the check componenttest.CheckConfigStruct (return nil error).
Expand All @@ -25,13 +25,17 @@ type Config any
var configValidatorType = reflect.TypeOf((*ConfigValidator)(nil)).Elem()

// ConfigValidator defines an optional interface for configurations to implement to do validation.
//
// Deprecated: [v0.121.0] use confmap.Validator.
type ConfigValidator interface {
// Validate the configuration and returns an error if invalid.
Validate() error
}

// ValidateConfig validates a config, by doing this:
// - Call Validate on the config itself if the config implements ConfigValidator.
//
// Deprecated: [v0.121.0] use confmap.Validate.
func ValidateConfig(cfg Config) error {
var err error

Expand Down
206 changes: 206 additions & 0 deletions confmap/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package confmap // import "go.opentelemetry.io/collector/confmap"

import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
)

// Config represents an interface for a configuration struct.
//
// Implementations and/or any sub-configs (other types embedded or included in the Config implementation)
// MUST implement the Validator if any validation is required for that part of the configuration
// (e.g. check if a required field is present).
//
// A valid implementation MUST pass the check componenttest.CheckConfigStruct (return nil error).
type Config any

// As interface types are only used for static typing, a common idiom to find the reflection Type
// for an interface type Foo is to use a *Foo value.
var configValidatorType = reflect.TypeOf((*Validator)(nil)).Elem()

// Validator defines an optional interface for configurations to implement to do validation.
type Validator interface {
// Validate the configuration and returns an error if invalid.
Validate() error
}

// Validate validates a config, by doing this:
// - Call Validate on the config itself if the config implements ConfigValidator.
func Validate(cfg Config) error {
var err error

for _, validationErr := range validate(reflect.ValueOf(cfg)) {
err = errors.Join(err, validationErr)
}

return err
}

type pathError struct {
err error
path []string
}

func (pe pathError) Error() string {
if len(pe.path) > 0 {
var path string
sb := strings.Builder{}

_, _ = sb.WriteString(pe.path[len(pe.path)-1])
for i := len(pe.path) - 2; i >= 0; i-- {
_, _ = sb.WriteString(KeyDelimiter)
_, _ = sb.WriteString(pe.path[i])
}
path = sb.String()

return fmt.Sprintf("%s: %s", path, pe.err)
}

return pe.err.Error()
}

func (pe pathError) Unwrap() error {
return pe.err

Check warning on line 69 in confmap/config.go

View check run for this annotation

Codecov / codecov/patch

confmap/config.go#L68-L69

Added lines #L68 - L69 were not covered by tests
}

func validate(v reflect.Value) []pathError {
errs := []pathError{}
// Validate the value itself.
switch v.Kind() {
case reflect.Invalid:
return nil
case reflect.Ptr, reflect.Interface:
return validate(v.Elem())
case reflect.Struct:
err := callValidateIfPossible(v)
if err != nil {
errs = append(errs, pathError{err: err})
}

// Reflect on the pointed data and check each of its fields.
for i := 0; i < v.NumField(); i++ {
if !v.Type().Field(i).IsExported() {
continue
}
field := v.Type().Field(i)
path := fieldName(field)

subpathErrs := validate(v.Field(i))
for _, err := range subpathErrs {
errs = append(errs, pathError{
err: err.err,
path: append(err.path, path),
})
}
}
return errs
case reflect.Slice, reflect.Array:
err := callValidateIfPossible(v)
if err != nil {
errs = append(errs, pathError{err: err})
}

// Reflect on the pointed data and check each of its fields.
for i := 0; i < v.Len(); i++ {
subPathErrs := validate(v.Index(i))

for _, err := range subPathErrs {
errs = append(errs, pathError{
err: err.err,
path: append(err.path, strconv.Itoa(i)),
})
}
}
return errs
case reflect.Map:
err := callValidateIfPossible(v)
if err != nil {
errs = append(errs, pathError{err: err})
}

iter := v.MapRange()
for iter.Next() {
keyErrs := validate(iter.Key())
valueErrs := validate(iter.Value())
key := stringifyMapKey(iter.Key())

for _, err := range keyErrs {
errs = append(errs, pathError{err: err.err, path: append(err.path, key)})
}

for _, err := range valueErrs {
errs = append(errs, pathError{err: err.err, path: append(err.path, key)})
}
}
return errs
default:
err := callValidateIfPossible(v)
if err != nil {
return []pathError{{err: err}}
}

return nil
}
}

func callValidateIfPossible(v reflect.Value) error {
// If the value type implements ConfigValidator just call Validate
if v.Type().Implements(configValidatorType) {
return v.Interface().(Validator).Validate()
}

// If the pointer type implements ConfigValidator call Validate on the pointer to the current value.
if reflect.PointerTo(v.Type()).Implements(configValidatorType) {
// If not addressable, then create a new *V pointer and set the value to current v.
if !v.CanAddr() {
pv := reflect.New(reflect.PointerTo(v.Type()).Elem())
pv.Elem().Set(v)
v = pv.Elem()
}
return v.Addr().Interface().(Validator).Validate()
}

return nil
}

func fieldName(field reflect.StructField) string {
var fieldName string
if tag, ok := field.Tag.Lookup(mapstructureTag); ok {
tags := strings.Split(tag, ",")
if len(tags) > 0 {
fieldName = tags[0]
}
}
// Even if the mapstructure tag exists, the field name may not
// be available, so set it if it is still blank.
if len(fieldName) == 0 {
fieldName = strings.ToLower(field.Name)
}

return fieldName
}

func stringifyMapKey(val reflect.Value) string {
var key string

if str, ok := val.Interface().(string); ok {
key = str
} else if stringer, ok := val.Interface().(fmt.Stringer); ok {
key = stringer.String()
} else {
switch val.Kind() {
case reflect.Ptr, reflect.Interface, reflect.Struct, reflect.Slice, reflect.Array, reflect.Map:
key = fmt.Sprintf("[%T key]", val.Interface())
default:
key = fmt.Sprintf("%v", val.Interface())
}
}

return key
}
Loading

0 comments on commit aa1b14c

Please sign in to comment.