Skip to content

Commit f12e44b

Browse files
committed
hcldec: add ValidateFuncSpec
This adds ValidateFuncSpec, a new decoder Spec that allows one to add custom validations to work with values at decode-time. The validation is run on the value after the wrapped spec is applied to the expression in question. Diagnostics are expected to be returned, with the author having flexibility over whether or not they want to specify a range; if one is not supplied, the range of the wrapped expression is used.
1 parent 50eda8b commit f12e44b

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

hcldec/spec.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1565,6 +1565,58 @@ func (s *TransformFuncSpec) sourceRange(content *hcl.BodyContent, blockLabels []
15651565
return s.Wrapped.sourceRange(content, blockLabels)
15661566
}
15671567

1568+
// ValidateFuncSpec is a spec that allows for extended
1569+
// developer-defined validation. The validation function receives the
1570+
// result of the wrapped spec.
1571+
//
1572+
// The Subject field of the returned Diagnostic is optional. If not
1573+
// specified, it is automatically populated with the range covered by
1574+
// the wrapped spec.
1575+
//
1576+
type ValidateFuncSpec struct {
1577+
Wrapped Spec
1578+
Func func(value cty.Value) hcl.Diagnostics
1579+
}
1580+
1581+
func (s *ValidateFuncSpec) visitSameBodyChildren(cb visitFunc) {
1582+
cb(s.Wrapped)
1583+
}
1584+
1585+
func (s *ValidateFuncSpec) decode(content *hcl.BodyContent, blockLabels []blockLabel, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) {
1586+
wrappedVal, diags := s.Wrapped.decode(content, blockLabels, ctx)
1587+
if diags.HasErrors() {
1588+
// We won't try to run our function in this case, because it'll probably
1589+
// generate confusing additional errors that will distract from the
1590+
// root cause.
1591+
return cty.UnknownVal(s.impliedType()), diags
1592+
}
1593+
1594+
validateDiags := s.Func(wrappedVal)
1595+
// Auto-populate the Subject fields if they weren't set.
1596+
for i := range validateDiags {
1597+
if validateDiags[i].Subject == nil {
1598+
validateDiags[i].Subject = s.sourceRange(content, blockLabels).Ptr()
1599+
}
1600+
}
1601+
1602+
diags = append(diags, validateDiags...)
1603+
return wrappedVal, diags
1604+
}
1605+
1606+
func (s *ValidateFuncSpec) impliedType() cty.Type {
1607+
return s.Wrapped.impliedType()
1608+
}
1609+
1610+
func (s *ValidateFuncSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockLabel) hcl.Range {
1611+
// We'll just pass through our wrapped range here, even though that's
1612+
// not super-accurate, because there's nothing better to return.
1613+
//
1614+
// Depending on the context that the validation function is built in,
1615+
// it may return a more accurate range, in which point this will not
1616+
// be used anyway.
1617+
return s.Wrapped.sourceRange(content, blockLabels)
1618+
}
1619+
15681620
// noopSpec is a placeholder spec that does nothing, used in situations where
15691621
// a non-nil placeholder spec is required. It is not exported because there is
15701622
// no reason to use it directly; it is always an implementation detail only.

hcldec/spec_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package hcldec
22

33
import (
4+
"fmt"
45
"reflect"
56
"testing"
67

@@ -26,6 +27,7 @@ var _ Spec = (*BlockLabelSpec)(nil)
2627
var _ Spec = (*DefaultSpec)(nil)
2728
var _ Spec = (*TransformExprSpec)(nil)
2829
var _ Spec = (*TransformFuncSpec)(nil)
30+
var _ Spec = (*ValidateFuncSpec)(nil)
2931

3032
var _ attrSpec = (*AttrSpec)(nil)
3133
var _ attrSpec = (*DefaultSpec)(nil)
@@ -139,3 +141,69 @@ bar = barval
139141
}
140142
})
141143
}
144+
145+
func TestValidateFuncSpec(t *testing.T) {
146+
config := `
147+
foo = "invalid"
148+
`
149+
f, diags := hclsyntax.ParseConfig([]byte(config), "", hcl.Pos{Line: 1, Column: 1})
150+
if diags.HasErrors() {
151+
t.Fatal(diags.Error())
152+
}
153+
154+
expectRange := map[string]*hcl.Range{
155+
"without_range": nil,
156+
"with_range": &hcl.Range{
157+
Filename: "foobar",
158+
Start: hcl.Pos{Line: 99, Column: 99},
159+
End: hcl.Pos{Line: 999, Column: 999},
160+
},
161+
}
162+
163+
for name := range expectRange {
164+
t.Run(name, func(t *testing.T) {
165+
spec := &ValidateFuncSpec{
166+
Wrapped: &AttrSpec{
167+
Name: "foo",
168+
Type: cty.String,
169+
},
170+
Func: func(value cty.Value) hcl.Diagnostics {
171+
if value.AsString() != "invalid" {
172+
return hcl.Diagnostics{
173+
&hcl.Diagnostic{
174+
Severity: hcl.DiagError,
175+
Summary: "incorrect value",
176+
Detail: fmt.Sprintf("invalid value passed in: %s", value.GoString()),
177+
},
178+
}
179+
}
180+
181+
return hcl.Diagnostics{
182+
&hcl.Diagnostic{
183+
Severity: hcl.DiagWarning,
184+
Summary: "OK",
185+
Detail: "validation called correctly",
186+
Subject: expectRange[name],
187+
},
188+
}
189+
},
190+
}
191+
192+
_, diags = Decode(f.Body, spec, nil)
193+
if len(diags) != 1 ||
194+
diags[0].Severity != hcl.DiagWarning ||
195+
diags[0].Summary != "OK" ||
196+
diags[0].Detail != "validation called correctly" {
197+
t.Fatalf("unexpected diagnostics: %s", diags.Error())
198+
}
199+
200+
if expectRange[name] == nil && diags[0].Subject == nil {
201+
t.Fatal("returned diagnostic subject missing")
202+
}
203+
204+
if expectRange[name] != nil && !reflect.DeepEqual(expectRange[name], diags[0].Subject) {
205+
t.Fatalf("expected range %s, got range %s", expectRange[name], diags[0].Subject)
206+
}
207+
})
208+
}
209+
}

0 commit comments

Comments
 (0)