diff --git a/hcldec/spec.go b/hcldec/spec.go index b31ec175..7fc1ffbf 100644 --- a/hcldec/spec.go +++ b/hcldec/spec.go @@ -1606,7 +1606,52 @@ func (s *TransformFuncSpec) sourceRange(content *hcl.BodyContent, blockLabels [] return s.Wrapped.sourceRange(content, blockLabels) } -// ValidateFuncSpec is a spec that allows for extended +// RefineValueSpec is a spec that wraps another and applies a fixed set of [cty] +// value refinements to whatever value it produces. +// +// Refinements serve to constrain the range of any unknown values, and act as +// assertions for known values by panicking if the final value does not meet +// the refinement. Therefore applications using this spec must guarantee that +// any value passing through the RefineValueSpec will always be consistent with +// the refinements; if not then that is a bug in the application. +// +// The wrapped spec should typically be a [ValidateSpec], a [TransformFuncSpec], +// or some other adapter that guarantees that the inner result cannot possibly +// violate the refinements. +type RefineValueSpec struct { + Wrapped Spec + + // Refine is a function which accepts a builder for a refinement in + // progress and uses the builder pattern to add extra refinements to it, + // finally returning the same builder with those modifications applied. + Refine func(*cty.RefinementBuilder) *cty.RefinementBuilder +} + +func (s *RefineValueSpec) visitSameBodyChildren(cb visitFunc) { + cb(s.Wrapped) +} + +func (s *RefineValueSpec) decode(content *hcl.BodyContent, blockLabels []blockLabel, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { + wrappedVal, diags := s.Wrapped.decode(content, blockLabels, ctx) + if diags.HasErrors() { + // We won't try to run our function in this case, because it'll probably + // generate confusing additional errors that will distract from the + // root cause. + return cty.UnknownVal(s.impliedType()), diags + } + + return wrappedVal.RefineWith(s.Refine), diags +} + +func (s *RefineValueSpec) impliedType() cty.Type { + return s.Wrapped.impliedType() +} + +func (s *RefineValueSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockLabel) hcl.Range { + return s.Wrapped.sourceRange(content, blockLabels) +} + +// ValidateSpec is a spec that allows for extended // developer-defined validation. The validation function receives the // result of the wrapped spec. // diff --git a/hcldec/spec_test.go b/hcldec/spec_test.go index d59b6050..093b032b 100644 --- a/hcldec/spec_test.go +++ b/hcldec/spec_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/apparentlymart/go-dump/dump" + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/hcl/v2" @@ -210,3 +212,72 @@ foo = "invalid" }) } } + +func TestRefineValueSpec(t *testing.T) { + config := ` +foo = "hello" +bar = unk +` + + f, diags := hclsyntax.ParseConfig([]byte(config), "", hcl.InitialPos) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + attrSpec := func(name string) Spec { + return &RefineValueSpec{ + // RefineValueSpec should typically have a ValidateSpec wrapped + // inside it to catch any values that are outside of the required + // range and return a helpful error message about it. In this + // case our refinement is .NotNull so the validation function + // must reject null values. + Wrapped: &ValidateSpec{ + Wrapped: &AttrSpec{ + Name: name, + Required: true, + Type: cty.String, + }, + Func: func(value cty.Value) hcl.Diagnostics { + var diags hcl.Diagnostics + if value.IsNull() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Cannot be null", + Detail: "Argument is required.", + }) + } + return diags + }, + }, + Refine: func(rb *cty.RefinementBuilder) *cty.RefinementBuilder { + return rb.NotNull() + }, + } + } + spec := &ObjectSpec{ + "foo": attrSpec("foo"), + "bar": attrSpec("bar"), + } + + got, diags := Decode(f.Body, spec, &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "unk": cty.UnknownVal(cty.String), + }, + }) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + + want := cty.ObjectVal(map[string]cty.Value{ + // This argument had a known value, so it's unchanged but the + // RefineValueSpec still checks that it isn't null to catch + // bugs in the application's validation function. + "foo": cty.StringVal("hello"), + + // The final value of bar is unknown but refined as non-null. + "bar": cty.UnknownVal(cty.String).RefineNotNull(), + }) + if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong result\n%s", diff) + } +}