Skip to content

Commit e4bd3b4

Browse files
hcldec: RefineValueSpec
This new spec type allows adding value refinements to the results of some other spec, as long as the wrapped spec does indeed enforce the constraints described by the refinements.
1 parent 333389d commit e4bd3b4

File tree

2 files changed

+117
-1
lines changed

2 files changed

+117
-1
lines changed

hcldec/spec.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1606,7 +1606,52 @@ func (s *TransformFuncSpec) sourceRange(content *hcl.BodyContent, blockLabels []
16061606
return s.Wrapped.sourceRange(content, blockLabels)
16071607
}
16081608

1609-
// ValidateFuncSpec is a spec that allows for extended
1609+
// RefineValueSpec is a spec that wraps another and applies a fixed set of [cty]
1610+
// value refinements to whatever value it produces.
1611+
//
1612+
// Refinements serve to constrain the range of any unknown values, and act as
1613+
// assertions for known values by panicking if the final value does not meet
1614+
// the refinement. Therefore applications using this spec must guarantee that
1615+
// any value passing through the RefineValueSpec will always be consistent with
1616+
// the refinements; if not then that is a bug in the application.
1617+
//
1618+
// The wrapped spec should typically be a [ValidateSpec], a [TransformFuncSpec],
1619+
// or some other adapter that guarantees that the inner result cannot possibly
1620+
// violate the refinements.
1621+
type RefineValueSpec struct {
1622+
Wrapped Spec
1623+
1624+
// Refine is a function which accepts a builder for a refinement in
1625+
// progress and uses the builder pattern to add extra refinements to it,
1626+
// finally returning the same builder with those modifications applied.
1627+
Refine func(*cty.RefinementBuilder) *cty.RefinementBuilder
1628+
}
1629+
1630+
func (s *RefineValueSpec) visitSameBodyChildren(cb visitFunc) {
1631+
cb(s.Wrapped)
1632+
}
1633+
1634+
func (s *RefineValueSpec) decode(content *hcl.BodyContent, blockLabels []blockLabel, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) {
1635+
wrappedVal, diags := s.Wrapped.decode(content, blockLabels, ctx)
1636+
if diags.HasErrors() {
1637+
// We won't try to run our function in this case, because it'll probably
1638+
// generate confusing additional errors that will distract from the
1639+
// root cause.
1640+
return cty.UnknownVal(s.impliedType()), diags
1641+
}
1642+
1643+
return wrappedVal.RefineWith(s.Refine), diags
1644+
}
1645+
1646+
func (s *RefineValueSpec) impliedType() cty.Type {
1647+
return s.Wrapped.impliedType()
1648+
}
1649+
1650+
func (s *RefineValueSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockLabel) hcl.Range {
1651+
return s.Wrapped.sourceRange(content, blockLabels)
1652+
}
1653+
1654+
// ValidateSpec is a spec that allows for extended
16101655
// developer-defined validation. The validation function receives the
16111656
// result of the wrapped spec.
16121657
//

hcldec/spec_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"testing"
1010

1111
"github.com/apparentlymart/go-dump/dump"
12+
"github.com/google/go-cmp/cmp"
13+
"github.com/zclconf/go-cty-debug/ctydebug"
1214
"github.com/zclconf/go-cty/cty"
1315

1416
"github.com/hashicorp/hcl/v2"
@@ -210,3 +212,72 @@ foo = "invalid"
210212
})
211213
}
212214
}
215+
216+
func TestRefineValueSpec(t *testing.T) {
217+
config := `
218+
foo = "hello"
219+
bar = unk
220+
`
221+
222+
f, diags := hclsyntax.ParseConfig([]byte(config), "", hcl.InitialPos)
223+
if diags.HasErrors() {
224+
t.Fatal(diags.Error())
225+
}
226+
227+
attrSpec := func(name string) Spec {
228+
return &RefineValueSpec{
229+
// RefineValueSpec should typically have a ValidateSpec wrapped
230+
// inside it to catch any values that are outside of the required
231+
// range and return a helpful error message about it. In this
232+
// case our refinement is .NotNull so the validation function
233+
// must reject null values.
234+
Wrapped: &ValidateSpec{
235+
Wrapped: &AttrSpec{
236+
Name: name,
237+
Required: true,
238+
Type: cty.String,
239+
},
240+
Func: func(value cty.Value) hcl.Diagnostics {
241+
var diags hcl.Diagnostics
242+
if value.IsNull() {
243+
diags = diags.Append(&hcl.Diagnostic{
244+
Severity: hcl.DiagError,
245+
Summary: "Cannot be null",
246+
Detail: "Argument is required.",
247+
})
248+
}
249+
return diags
250+
},
251+
},
252+
Refine: func(rb *cty.RefinementBuilder) *cty.RefinementBuilder {
253+
return rb.NotNull()
254+
},
255+
}
256+
}
257+
spec := &ObjectSpec{
258+
"foo": attrSpec("foo"),
259+
"bar": attrSpec("bar"),
260+
}
261+
262+
got, diags := Decode(f.Body, spec, &hcl.EvalContext{
263+
Variables: map[string]cty.Value{
264+
"unk": cty.UnknownVal(cty.String),
265+
},
266+
})
267+
if diags.HasErrors() {
268+
t.Fatal(diags.Error())
269+
}
270+
271+
want := cty.ObjectVal(map[string]cty.Value{
272+
// This argument had a known value, so it's unchanged but the
273+
// RefineValueSpec still checks that it isn't null to catch
274+
// bugs in the application's validation function.
275+
"foo": cty.StringVal("hello"),
276+
277+
// The final value of bar is unknown but refined as non-null.
278+
"bar": cty.UnknownVal(cty.String).RefineNotNull(),
279+
})
280+
if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" {
281+
t.Errorf("wrong result\n%s", diff)
282+
}
283+
}

0 commit comments

Comments
 (0)