diff --git a/cmd/hcldec/spec-format.md b/cmd/hcldec/spec-format.md index 7ba709b..2126d69 100644 --- a/cmd/hcldec/spec-format.md +++ b/cmd/hcldec/spec-format.md @@ -305,6 +305,31 @@ their usual behavior but are not able to impose validation constraints on the current body since they are not evaluated unless all prior specs produce `null` as their result. +## `transform` spec blocks + +The `transform` spec type evaluates one nested spec and then evaluates a given +expression with that nested spec result to produce a final value. +It creates no validation constraints of its own, but passes on the validation +constraints from its nested block. + +```hcl +transform { + attr { + name = "size_in_mb" + type = number + } + + # Convert result to a size in bytes + result = nested * 1024 * 1024 +} +``` + +`transform` spec blocks accept the following argument: + +* `result` (required) - The expression to evaluate on the result of the nested + spec. The variable `nested` is defined when evaluating this expression, with + the result value of the nested spec. + ## Type Expressions Type expressions are used to describe the expected type of an attribute, as diff --git a/cmd/hcldec/spec.go b/cmd/hcldec/spec.go index 6081d68..fa09663 100644 --- a/cmd/hcldec/spec.go +++ b/cmd/hcldec/spec.go @@ -85,6 +85,9 @@ func decodeSpecBlock(block *hcl.Block) (hcldec.Spec, hcl.Diagnostics) { case "default": return decodeDefaultSpec(block.Body) + case "transform": + return decodeTransformSpec(block.Body) + case "literal": return decodeLiteralSpec(block.Body) @@ -439,6 +442,50 @@ func decodeDefaultSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) { return spec, diags } +func decodeTransformSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) { + type content struct { + Result hcl.Expression `hcl:"result"` + Nested hcl.Body `hcl:",remain"` + } + + var args content + diags := gohcl.DecodeBody(body, nil, &args) + if diags.HasErrors() { + return errSpec, diags + } + + spec := &hcldec.TransformExprSpec{ + Expr: args.Result, + VarName: "nested", + } + + nestedContent, nestedDiags := args.Nested.Content(specSchemaUnlabelled) + diags = append(diags, nestedDiags...) + + if len(nestedContent.Blocks) != 1 { + if nestedDiags.HasErrors() { + // If we already have errors then they probably explain + // why we have the wrong number of blocks, so we'll skip our + // additional error message added below. + return errSpec, diags + } + + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid transform spec", + Detail: "A transform spec block must have exactly one nested spec block.", + Subject: body.MissingItemRange().Ptr(), + }) + return errSpec, diags + } + + nestedSpec, nestedDiags := decodeSpecBlock(nestedContent.Blocks[0]) + diags = append(diags, nestedDiags...) + spec.Wrapped = nestedSpec + + return spec, diags +} + var errSpec = &hcldec.LiteralSpec{ Value: cty.NullVal(cty.DynamicPseudoType), } @@ -457,6 +504,7 @@ var specBlockTypes = []string{ "block_set", "default", + "transform", } var specSchemaUnlabelled *hcl.BodySchema diff --git a/hcldec/spec.go b/hcldec/spec.go index cb98495..25cafcd 100644 --- a/hcldec/spec.go +++ b/hcldec/spec.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/hcl2/hcl" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" + "github.com/zclconf/go-cty/cty/function" ) // A Spec is a description of how to decode a hcl.Body to a cty.Value. @@ -878,3 +879,120 @@ func (s *DefaultSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockL // reasonable source range to return anyway. return s.Primary.sourceRange(content, blockLabels) } + +// TransformExprSpec is a spec that wraps another and then evaluates a given +// hcl.Expression on the result. +// +// The implied type of this spec is determined by evaluating the expression +// with an unknown value of the nested spec's implied type, which may cause +// the result to be imprecise. This spec should not be used in situations where +// precise result type information is needed. +type TransformExprSpec struct { + Wrapped Spec + Expr hcl.Expression + TransformCtx *hcl.EvalContext + VarName string +} + +func (s *TransformExprSpec) visitSameBodyChildren(cb visitFunc) { + cb(s.Wrapped) +} + +func (s *TransformExprSpec) 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 + } + + chiCtx := s.TransformCtx.NewChild() + chiCtx.Variables = map[string]cty.Value{ + s.VarName: wrappedVal, + } + resultVal, resultDiags := s.Expr.Value(chiCtx) + diags = append(diags, resultDiags...) + return resultVal, diags +} + +func (s *TransformExprSpec) impliedType() cty.Type { + wrappedTy := s.Wrapped.impliedType() + chiCtx := s.TransformCtx.NewChild() + chiCtx.Variables = map[string]cty.Value{ + s.VarName: cty.UnknownVal(wrappedTy), + } + resultVal, _ := s.Expr.Value(chiCtx) + return resultVal.Type() +} + +func (s *TransformExprSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockLabel) hcl.Range { + // We'll just pass through our wrapped range here, even though that's + // not super-accurate, because there's nothing better to return. + return s.Wrapped.sourceRange(content, blockLabels) +} + +// TransformFuncSpec is a spec that wraps another and then evaluates a given +// cty function with the result. The given function must expect exactly one +// argument, where the result of the wrapped spec will be passed. +// +// The implied type of this spec is determined by type-checking the function +// with an unknown value of the nested spec's implied type, which may cause +// the result to be imprecise. This spec should not be used in situations where +// precise result type information is needed. +// +// If the given function produces an error when run, this spec will produce +// a non-user-actionable diagnostic message. It's the caller's responsibility +// to ensure that the given function cannot fail for any non-error result +// of the wrapped spec. +type TransformFuncSpec struct { + Wrapped Spec + Func function.Function +} + +func (s *TransformFuncSpec) visitSameBodyChildren(cb visitFunc) { + cb(s.Wrapped) +} + +func (s *TransformFuncSpec) 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 + } + + resultVal, err := s.Func.Call([]cty.Value{wrappedVal}) + if err != nil { + // This is not a good example of a diagnostic because it is reporting + // a programming error in the calling application, rather than something + // an end-user could act on. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Transform function failed", + Detail: fmt.Sprintf("Decoder transform returned an error: %s", err), + Subject: s.sourceRange(content, blockLabels).Ptr(), + }) + return cty.UnknownVal(s.impliedType()), diags + } + + return resultVal, diags +} + +func (s *TransformFuncSpec) impliedType() cty.Type { + wrappedTy := s.Wrapped.impliedType() + resultTy, err := s.Func.ReturnType([]cty.Type{wrappedTy}) + if err != nil { + // Should never happen with a correctly-configured spec + return cty.DynamicPseudoType + } + + return resultTy +} + +func (s *TransformFuncSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockLabel) hcl.Range { + // We'll just pass through our wrapped range here, even though that's + // not super-accurate, because there's nothing better to return. + return s.Wrapped.sourceRange(content, blockLabels) +} diff --git a/hcldec/spec_test.go b/hcldec/spec_test.go index 6ad147a..4331ba6 100644 --- a/hcldec/spec_test.go +++ b/hcldec/spec_test.go @@ -12,6 +12,8 @@ var _ Spec = (*BlockSetSpec)(nil) var _ Spec = (*BlockMapSpec)(nil) var _ Spec = (*BlockLabelSpec)(nil) var _ Spec = (*DefaultSpec)(nil) +var _ Spec = (*TransformExprSpec)(nil) +var _ Spec = (*TransformFuncSpec)(nil) var _ attrSpec = (*AttrSpec)(nil)