github.com/Jeffail/benthos/v3@v3.65.0/internal/template/config.go (about)

     1  package template
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  
     9  	"github.com/Jeffail/benthos/v3/internal/bloblang"
    10  	"github.com/Jeffail/benthos/v3/internal/bloblang/parser"
    11  	"github.com/Jeffail/benthos/v3/internal/component/metrics"
    12  	"github.com/Jeffail/benthos/v3/internal/docs"
    13  	"github.com/Jeffail/benthos/v3/lib/log"
    14  	"github.com/Jeffail/benthos/v3/lib/types"
    15  	"github.com/fatih/color"
    16  	"github.com/nsf/jsondiff"
    17  	"gopkg.in/yaml.v3"
    18  )
    19  
    20  // FieldConfig describes a configuration field used in the template.
    21  type FieldConfig struct {
    22  	Name        string       `yaml:"name"`
    23  	Description string       `yaml:"description"`
    24  	Type        *string      `yaml:"type,omitempty"`
    25  	Kind        *string      `yaml:"kind,omitempty"`
    26  	Default     *interface{} `yaml:"default,omitempty"`
    27  	Advanced    bool         `yaml:"advanced"`
    28  }
    29  
    30  // TestConfig defines a unit test for the template.
    31  type TestConfig struct {
    32  	Name     string    `yaml:"name"`
    33  	Config   yaml.Node `yaml:"config"`
    34  	Expected yaml.Node `yaml:"expected,omitempty"`
    35  }
    36  
    37  // Config describes a Benthos component template.
    38  type Config struct {
    39  	Name           string        `yaml:"name"`
    40  	Type           string        `yaml:"type"`
    41  	Status         string        `yaml:"status"`
    42  	Categories     []string      `yaml:"categories"`
    43  	Summary        string        `yaml:"summary"`
    44  	Description    string        `yaml:"description"`
    45  	Fields         []FieldConfig `yaml:"fields"`
    46  	Mapping        string        `yaml:"mapping"`
    47  	MetricsMapping string        `yaml:"metrics_mapping"`
    48  	Tests          []TestConfig  `yaml:"tests"`
    49  }
    50  
    51  // FieldSpec creates a documentation field spec from a template field config.
    52  func (c FieldConfig) FieldSpec() (docs.FieldSpec, error) {
    53  	f := docs.FieldCommon(c.Name, c.Description)
    54  	f.IsAdvanced = c.Advanced
    55  	if c.Default != nil {
    56  		f = f.HasDefault(*c.Default)
    57  	}
    58  	if c.Type == nil {
    59  		return f, errors.New("missing type field")
    60  	}
    61  	f = f.HasType(docs.FieldType(*c.Type))
    62  	if c.Kind != nil {
    63  		switch *c.Kind {
    64  		case "map":
    65  			f = f.Map()
    66  		case "list":
    67  			f = f.Array()
    68  		case "scalar":
    69  		default:
    70  			return f, fmt.Errorf("unrecognised scalar type: %v", *c.Kind)
    71  		}
    72  	}
    73  	return f, nil
    74  }
    75  
    76  // ComponentSpec creates a documentation component spec from a template config.
    77  func (c Config) ComponentSpec() (docs.ComponentSpec, error) {
    78  	fields := make([]docs.FieldSpec, len(c.Fields))
    79  	for i, fieldConf := range c.Fields {
    80  		var err error
    81  		if fields[i], err = fieldConf.FieldSpec(); err != nil {
    82  			return docs.ComponentSpec{}, fmt.Errorf("field %v: %w", i, err)
    83  		}
    84  	}
    85  	config := docs.FieldComponent().WithChildren(fields...)
    86  
    87  	status := docs.StatusStable
    88  	if c.Status != "" {
    89  		status = docs.Status(c.Status)
    90  	}
    91  
    92  	return docs.ComponentSpec{
    93  		Name:        c.Name,
    94  		Type:        docs.Type(c.Type),
    95  		Status:      status,
    96  		Plugin:      true,
    97  		Categories:  c.Categories,
    98  		Summary:     c.Summary,
    99  		Description: c.Description,
   100  		Config:      config,
   101  	}, nil
   102  }
   103  
   104  func (c Config) compile() (*compiled, error) {
   105  	spec, err := c.ComponentSpec()
   106  	if err != nil {
   107  		return nil, err
   108  	}
   109  	mapping, err := bloblang.GlobalEnvironment().NewMapping(c.Mapping)
   110  	if err != nil {
   111  		var perr *parser.Error
   112  		if errors.As(err, &perr) {
   113  			return nil, fmt.Errorf("parse mapping: %v", perr.ErrorAtPositionStructured("", []rune(c.Mapping)))
   114  		}
   115  		return nil, fmt.Errorf("parse mapping: %w", err)
   116  	}
   117  	var metricsMapping *metrics.Mapping
   118  	if c.MetricsMapping != "" {
   119  		if metricsMapping, err = metrics.NewMapping(types.NoopMgr(), c.MetricsMapping, log.Noop()); err != nil {
   120  			return nil, fmt.Errorf("parse metrics mapping: %w", err)
   121  		}
   122  	}
   123  	return &compiled{spec, mapping, metricsMapping}, nil
   124  }
   125  
   126  func diffYAMLNodesAsJSON(expNode, actNode *yaml.Node) (string, error) {
   127  	var iexp, iact interface{}
   128  	if err := expNode.Decode(&iexp); err != nil {
   129  		return "", fmt.Errorf("failed to marshal expected %w", err)
   130  	}
   131  	if err := actNode.Decode(&iact); err != nil {
   132  		return "", fmt.Errorf("failed to marshal actual %w", err)
   133  	}
   134  
   135  	expBytes, err := json.Marshal(iexp)
   136  	if err != nil {
   137  		return "", fmt.Errorf("failed to marshal expected %w", err)
   138  	}
   139  	actBytes, err := json.Marshal(iact)
   140  	if err != nil {
   141  		return "", fmt.Errorf("failed to marshal actual %w", err)
   142  	}
   143  
   144  	jdopts := jsondiff.DefaultConsoleOptions()
   145  	diff, explanation := jsondiff.Compare(expBytes, actBytes, &jdopts)
   146  	if diff != jsondiff.FullMatch {
   147  		return explanation, nil
   148  	}
   149  	return "", nil
   150  }
   151  
   152  // Test ensures that the template compiles, and executes any unit test
   153  // definitions within the config.
   154  func (c Config) Test() ([]string, error) {
   155  	compiled, err := c.compile()
   156  	if err != nil {
   157  		return nil, err
   158  	}
   159  
   160  	var failures []string
   161  	for _, test := range c.Tests {
   162  		outConf, err := compiled.ExpandToNode(&test.Config)
   163  		if err != nil {
   164  			return nil, fmt.Errorf("test '%v': %w", test.Name, err)
   165  		}
   166  		for _, lint := range docs.LintYAML(docs.NewLintContext(), docs.Type(c.Type), outConf) {
   167  			failures = append(failures, fmt.Sprintf("test '%v': lint error in resulting config: line %v: %v", test.Name, lint.Line, lint.What))
   168  		}
   169  		if len(test.Expected.Content) > 0 {
   170  			diff, err := diffYAMLNodesAsJSON(&test.Expected, outConf)
   171  			if err != nil {
   172  				return nil, fmt.Errorf("test '%v': %w", test.Name, err)
   173  			}
   174  			if diff != "" {
   175  				diff = color.New(color.Reset).SprintFunc()(diff)
   176  				return nil, fmt.Errorf("test '%v': mismatch between expected and actual resulting config: %v", test.Name, diff)
   177  			}
   178  		}
   179  	}
   180  	return failures, nil
   181  }
   182  
   183  // ReadConfig attempts to read a template configuration file.
   184  func ReadConfig(path string) (conf Config, lints []string, err error) {
   185  	var templateBytes []byte
   186  	if templateBytes, err = os.ReadFile(path); err != nil {
   187  		return
   188  	}
   189  
   190  	if err = yaml.Unmarshal(templateBytes, &conf); err != nil {
   191  		return
   192  	}
   193  
   194  	var node yaml.Node
   195  	if err = yaml.Unmarshal(templateBytes, &node); err != nil {
   196  		return
   197  	}
   198  
   199  	for _, l := range ConfigSpec().LintYAML(docs.NewLintContext(), &node) {
   200  		if l.Level == docs.LintError {
   201  			lints = append(lints, fmt.Sprintf("line %v: %v", l.Line, l.What))
   202  		}
   203  	}
   204  	return
   205  }
   206  
   207  //------------------------------------------------------------------------------
   208  
   209  // FieldConfigSpec returns a configuration spec for a field of a template.
   210  func FieldConfigSpec() docs.FieldSpecs {
   211  	return docs.FieldSpecs{
   212  		docs.FieldString("name", "The name of the field."),
   213  		docs.FieldString("description", "A description of the field.").HasDefault(""),
   214  		docs.FieldString("type", "The scalar type of the field.").HasOptions(
   215  			"string", "int", "float", "bool",
   216  		).LintOptions(),
   217  		docs.FieldString("kind", "The kind of the field.").HasOptions(
   218  			"scalar", "map", "list",
   219  		).HasDefault("scalar").LintOptions(),
   220  		docs.FieldCommon("default", "An optional default value for the field. If a default value is not specified then a configuration without the field is considered incorrect.").HasType(docs.FieldTypeUnknown).Optional(),
   221  		docs.FieldBool("advanced", "Whether this field is considered advanced.").HasDefault(false),
   222  	}
   223  }
   224  
   225  // ConfigSpec returns a configuration spec for a template.
   226  func ConfigSpec() docs.FieldSpecs {
   227  	return docs.FieldSpecs{
   228  		docs.FieldString("name", "The name of the component this template will create."),
   229  		docs.FieldString(
   230  			"type", "The type of the component this template will create.",
   231  		).HasOptions(
   232  			"cache", "input", "output", "processor", "rate_limit",
   233  		).LintOptions(),
   234  		docs.FieldString(
   235  			"status", "The stability of the template describing the likelihood that the configuration spec of the template, or it's behaviour, will change.",
   236  		).HasAnnotatedOptions(
   237  			"stable", "This template is stable and will therefore not change in a breaking way outside of major version releases.",
   238  			"beta", "This template is beta and will therefore not change in a breaking way unless a major problem is found.",
   239  			"experimental", "This template is experimental and therefore subject to breaking changes outside of major version releases.",
   240  		).HasDefault("stable").LintOptions(),
   241  		docs.FieldString(
   242  			"categories", "An optional list of tags, which are used for arbitrarily grouping components in documentation.",
   243  		).Array().HasDefault([]string{}),
   244  		docs.FieldString("summary", "A short summary of the component.").HasDefault(""),
   245  		docs.FieldString("description", "A longer form description of the component and how to use it.").HasDefault(""),
   246  		docs.FieldCommon("fields", "The configuration fields of the template, fields specified here will be parsed from a Benthos config and will be accessible from the template mapping.").Array().WithChildren(FieldConfigSpec()...),
   247  		docs.FieldBloblang(
   248  			"mapping", "A [Bloblang](/docs/guides/bloblang/about) mapping that translates the fields of the template into a valid Benthos configuration for the target component type.",
   249  		),
   250  		metrics.MappingFieldSpec(),
   251  		docs.FieldCommon(
   252  			"tests", "Optional unit test definitions for the template that verify certain configurations produce valid configs. These tests are executed with the command `benthos template lint`.",
   253  		).Array().WithChildren(
   254  			docs.FieldString("name", "A name to identify the test."),
   255  			docs.FieldCommon("config", "A configuration to run this test with, the config resulting from applying the template with this config will be linted.").HasType(docs.FieldTypeObject),
   256  			docs.FieldCommon("expected", "An optional configuration describing the expected result of applying the template, when specified the result will be diffed and any mismatching fields will be reported as a test error.").HasType(docs.FieldTypeObject).Optional(),
   257  		).HasDefault([]interface{}{}),
   258  	}
   259  }