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 }