ooni-probe-cli/internal/engine/experimentbuilder.go
Simone Basso 086ae43b15
refactor(engine): set options from any value (#837)
This diff refactors how we set options for experiments to accept
in input an any value or a map[string]any, depending on which method
we choose to actually set options.

There should be no functional change, except that now we're not
guessing the type and then attempting to set the value of the selected
field: now, instead, we match the provided type and the field's type
as part of the same function (i.e., SetOptionAny).

This diff is functional to https://github.com/ooni/probe/issues/2184,
because it will allow us to load options from a map[string]any,
which will be part of the OONI Run v2 JSON descriptor.

If we didn't apply this change, we would only have been to set options
from a map[string]string, which is good enough as a solution for the
CLI but is definitely clumsy when you have to write stuff like:

```JSON
{
  "options": {
    "HTTP3Enabled": "true"
  }
}
```

when you could instead more naturally write:

```JSON
{
  "options": {
    "HTTP3Enabled": true
  }
}
```
2022-07-08 11:51:59 +02:00

267 lines
8.3 KiB
Go

package engine
import (
"errors"
"fmt"
"reflect"
"strconv"
"github.com/iancoleman/strcase"
"github.com/ooni/probe-cli/v3/internal/model"
)
// InputPolicy describes the experiment policy with respect to input. That is
// whether it requires input, optionally accepts input, does not want input.
type InputPolicy string
const (
// InputOrQueryBackend indicates that the experiment requires
// external input to run and that this kind of input is URLs
// from the citizenlab/test-lists repository. If this input
// not provided to the experiment, then the code that runs the
// experiment is supposed to fetch from URLs from OONI's backends.
InputOrQueryBackend = InputPolicy("or_query_backend")
// InputStrictlyRequired indicates that the experiment
// requires input and we currently don't have an API for
// fetching such input. Therefore, either the user specifies
// input or the experiment will fail for the lack of input.
InputStrictlyRequired = InputPolicy("strictly_required")
// InputOptional indicates that the experiment handles input,
// if any; otherwise it fetchs input/uses a default.
InputOptional = InputPolicy("optional")
// InputNone indicates that the experiment does not want any
// input and ignores the input if provided with it.
InputNone = InputPolicy("none")
// We gather input from StaticInput and SourceFiles. If there is
// input, we return it. Otherwise, we return an internal static
// list of inputs to be used with this experiment.
InputOrStaticDefault = InputPolicy("or_static_default")
)
// ExperimentBuilder is an experiment builder.
type ExperimentBuilder struct {
build func(interface{}) *Experiment
callbacks model.ExperimentCallbacks
config interface{}
inputPolicy InputPolicy
interruptible bool
}
// Interruptible tells you whether this is an interruptible experiment. This kind
// of experiments (e.g. ndt7) may be interrupted mid way.
func (b *ExperimentBuilder) Interruptible() bool {
return b.interruptible
}
// InputPolicy returns the experiment input policy
func (b *ExperimentBuilder) InputPolicy() InputPolicy {
return b.inputPolicy
}
// OptionInfo contains info about an option.
type OptionInfo struct {
// Doc contains the documentation.
Doc string
// Type contains the type.
Type string
}
var (
// ErrConfigIsNotAStructPointer indicates we expected a pointer to struct.
ErrConfigIsNotAStructPointer = errors.New("config is not a struct pointer")
// ErrNoSuchField indicates there's no field with the given name.
ErrNoSuchField = errors.New("no such field")
// ErrCannotSetIntegerOption means SetOptionAny couldn't set an integer option.
ErrCannotSetIntegerOption = errors.New("cannot set integer option")
// ErrInvalidStringRepresentationOfBool indicates the string you passed
// to SetOptionaAny is not a valid string representation of a bool.
ErrInvalidStringRepresentationOfBool = errors.New("invalid string representation of bool")
// ErrCannotSetBoolOption means SetOptionAny couldn't set a bool option.
ErrCannotSetBoolOption = errors.New("cannot set bool option")
// ErrCannotSetStringOption means SetOptionAny couldn't set a string option.
ErrCannotSetStringOption = errors.New("cannot set string option")
// ErrUnsupportedOptionType means we don't support the type passed to
// the SetOptionAny method as an opaque any type.
ErrUnsupportedOptionType = errors.New("unsupported option type")
)
// Options returns info about all options
func (b *ExperimentBuilder) Options() (map[string]OptionInfo, error) {
result := make(map[string]OptionInfo)
ptrinfo := reflect.ValueOf(b.config)
if ptrinfo.Kind() != reflect.Ptr {
return nil, ErrConfigIsNotAStructPointer
}
structinfo := ptrinfo.Elem().Type()
if structinfo.Kind() != reflect.Struct {
return nil, ErrConfigIsNotAStructPointer
}
for i := 0; i < structinfo.NumField(); i++ {
field := structinfo.Field(i)
result[field.Name] = OptionInfo{
Doc: field.Tag.Get("ooni"),
Type: field.Type.String(),
}
}
return result, nil
}
// setOptionBool sets a bool option.
func (b *ExperimentBuilder) setOptionBool(field reflect.Value, value any) error {
switch v := value.(type) {
case bool:
field.SetBool(v)
return nil
case string:
if v != "true" && v != "false" {
return fmt.Errorf("%w: %s", ErrInvalidStringRepresentationOfBool, v)
}
field.SetBool(v == "true")
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetBoolOption, value)
}
}
// setOptionInt sets an int option
func (b *ExperimentBuilder) setOptionInt(field reflect.Value, value any) error {
switch v := value.(type) {
case int64:
field.SetInt(v)
return nil
case int32:
field.SetInt(int64(v))
return nil
case int16:
field.SetInt(int64(v))
return nil
case int8:
field.SetInt(int64(v))
return nil
case int:
field.SetInt(int64(v))
return nil
case string:
number, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fmt.Errorf("%w: %s", ErrCannotSetIntegerOption, err.Error())
}
field.SetInt(number)
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetIntegerOption, value)
}
}
// setOptionString sets a string option
func (b *ExperimentBuilder) setOptionString(field reflect.Value, value any) error {
switch v := value.(type) {
case string:
field.SetString(v)
return nil
default:
return fmt.Errorf("%w from a value of type %T", ErrCannotSetStringOption, value)
}
}
// SetOptionAny sets an option whose value is an any value. We will use reasonable
// heuristics to convert the any value to the proper type of the field whose name is
// contained by the key variable. If we cannot convert the provided any value to
// the proper type, then this function returns an error.
func (b *ExperimentBuilder) SetOptionAny(key string, value any) error {
field, err := b.fieldbyname(b.config, key)
if err != nil {
return err
}
switch field.Kind() {
case reflect.Int64:
return b.setOptionInt(field, value)
case reflect.Bool:
return b.setOptionBool(field, value)
case reflect.String:
return b.setOptionString(field, value)
default:
return fmt.Errorf("%w: %T", ErrUnsupportedOptionType, value)
}
}
// SetOptionsAny sets options from a map[string]any. See the documentation of
// the SetOptionAny function for more information.
func (b *ExperimentBuilder) SetOptionsAny(options map[string]any) error {
for key, value := range options {
if err := b.SetOptionAny(key, value); err != nil {
return err
}
}
return nil
}
// SetCallbacks sets the interactive callbacks
func (b *ExperimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) {
b.callbacks = callbacks
}
func (b *ExperimentBuilder) fieldbyname(v interface{}, key string) (reflect.Value, error) {
// See https://stackoverflow.com/a/6396678/4354461
ptrinfo := reflect.ValueOf(v)
if ptrinfo.Kind() != reflect.Ptr {
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
}
structinfo := ptrinfo.Elem()
if structinfo.Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
}
field := structinfo.FieldByName(key)
if !field.IsValid() || !field.CanSet() {
return reflect.Value{}, fmt.Errorf("%w: %s", ErrNoSuchField, key)
}
return field, nil
}
// NewExperiment creates the experiment
func (b *ExperimentBuilder) NewExperiment() *Experiment {
experiment := b.build(b.config)
experiment.callbacks = b.callbacks
return experiment
}
// canonicalizeExperimentName allows code to provide experiment names
// in a more flexible way, where we have aliases.
//
// Because we allow for uppercase experiment names for backwards
// compatibility with MK, we need to add some exceptions here when
// mapping (e.g., DNSCheck => dnscheck).
func canonicalizeExperimentName(name string) string {
switch name = strcase.ToSnake(name); name {
case "ndt_7":
name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default
case "dns_check":
name = "dnscheck"
case "stun_reachability":
name = "stunreachability"
default:
}
return name
}
func newExperimentBuilder(session *Session, name string) (*ExperimentBuilder, error) {
factory := experimentsByName[canonicalizeExperimentName(name)]
if factory == nil {
return nil, fmt.Errorf("no such experiment: %s", name)
}
builder := factory(session)
builder.callbacks = model.NewPrinterCallbacks(session.Logger())
return builder, nil
}