diff --git a/CHANGELOG.md b/CHANGELOG.md index 51ee8a7ba..ff8f2498b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Unreleased changes are available as `avenga/couper:edge` container. +* **Added** + * [`can()` function](https://docs.couper.io/configuration/functions) ([#699](https://github.com/avenga/couper/pull/699)) + * **Fixed** * Erroneously sending an empty [`Server-Timing` header](https://docs.couper.io/configuration/command-line#oberservation-options) ([#700](https://github.com/avenga/couper/pull/700)) diff --git a/docs/website/content/2.configuration/6.functions.md b/docs/website/content/2.configuration/6.functions.md index ba0ffeacb..010e0d1a3 100644 --- a/docs/website/content/2.configuration/6.functions.md +++ b/docs/website/content/2.configuration/6.functions.md @@ -12,20 +12,21 @@ This functions can be used and combined as standalone call with all kind of hcl |:---------------------------|:----------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------|:----------------------------------------------------------------------------------------------------| | `base64_decode` | string | Decodes Base64 data, as specified in RFC 4648. | `encoded` (string) | `base64_decode("Zm9v")` | | `base64_encode` | string | Encodes Base64 data, as specified in RFC 4648. | `decoded` (string) | `base64_encode("foo")` | +| `can` | bool | Tries to evaluate the expression given in its first argument. | `expression` (expression) | `{ for k in ["not_there", "method", "path"] : k => request[k] if can(request[k]) }` | | `contains` | bool | Determines whether a given list contains a given single value as one of its elements. | `list` (tuple or list), `value` (various) | `contains([1,2,3], 2)` | | `default` | string | Returns the first of the given arguments that is not null or an empty string. If no argument matches, the last argument is returned. | `arg...` (various) | `default(request.cookies.foo, "bar")` | | `join` | string | Concatenates together the string elements of one or more lists with a given separator. | `sep` (string), `lists...` (tuples or lists) | `join("-", [0,1,2,3])` | | `json_decode` | various | Parses the given JSON string and, if it is valid, returns the value it represents. | `encoded` (string) | `json_decode("{\"foo\": 1}")` | | `json_encode` | string | Returns a JSON serialization of the given value. | `val` (various) | `json_encode(request.context.myJWT)` | -| `jwt_sign` | string | Creates and signs a JSON Web Token (JWT) from information from a referenced [JWT Signing Profile Block](/configuration/block/jwt_signing_profile) (or [JWT Block](/configuration/block/jwt) with `signing_ttl`) and additional claims provided as a function parameter. | `label` (string), `claims` (object) | `jwt_sign("myJWT")` | +| `jwt_sign` | string | Creates and signs a JSON Web Token (JWT) from information from a referenced [JWT Signing Profile Block](/configuration/block/jwt_signing_profile) (or [JWT Block](/configuration/block/jwt) with `signing_ttl`) and additional claims provided as a function parameter. | `label` (string), `claims` (object) | `jwt_sign("myJWT")` | | `keys` | list | Takes a map and returns a sorted list of the map keys. | `inputMap` (object or map) | `keys(request.headers)` | | `length` | integer | Returns the number of elements in the given collection. | `collection` (tuple, list or map; **no object**) | `length([0,1,2,3])` | | `lookup` | various | Performs a dynamic lookup into a map. The default (third argument) is returned if the key (second argument) is not found in the inputMap (first argument). | `inputMap` (object or map), `key` (string), `default` (various) | `lookup({a = 1}, "b", "def")` | | `merge` | object or tuple | Deep-merges two or more of either objects or tuples. `null` arguments are ignored. An attribute value with a different type than the current value is set as the new value. `merge()` with no parameters returns `null`. | `arg...` (object or tuple) | `merge(request.headers, { x-additional = "myval" })` | -| `oauth2_authorization_url` | string | Creates an OAuth2 authorization URL from a referenced [OAuth2 AC (Beta) Block](/configuration/block/beta_oauth2) or [OIDC Block](/configuration/block/oidc). | `label` (string) | `oauth2_authorization_url("myOAuth2")` | +| `oauth2_authorization_url` | string | Creates an OAuth2 authorization URL from a referenced [OAuth2 AC (Beta) Block](/configuration/block/beta_oauth2) or [OIDC Block](/configuration/block/oidc). | `label` (string) | `oauth2_authorization_url("myOAuth2")` | | `oauth2_verifier` | string | Creates a cryptographically random key as specified in RFC 7636, applicable for all verifier methods; e.g. to be set as a cookie and read into `verifier_value`. Multiple calls of this function in the same client request context return the same value. | | `oauth2_verifier()` | -| `relative_url` | string | Returns a relative URL by retaining `path`, `query` and `fragment` components. The input URL `s` must begin with `/`, `//`, `http://` or `https://`, otherwise an error is thrown. | s (string) | `relative_url("https://httpbin.org/anything?query#fragment") // returns "/anything?query#fragment"` | -| `saml_sso_url` | string | Creates a SAML SingleSignOn URL (including the `SAMLRequest` parameter) from a referenced [SAML Block](/configuration/block/saml). | `label` (string) | `saml_sso_url("mySAML")` | +| `relative_url` | string | Returns a relative URL by retaining `path`, `query` and `fragment` components. The input URL `s` must begin with `/`, `//`, `http://` or `https://`, otherwise an error is thrown. | `s` (string) | `relative_url("https://httpbin.org/anything?query#fragment") // returns "/anything?query#fragment"` | +| `saml_sso_url` | string | Creates a SAML SingleSignOn URL (including the `SAMLRequest` parameter) from a referenced [SAML Block](/configuration/block/saml). | `label` (string) | `saml_sso_url("mySAML")` | | `set_intersection` | list or tuple | Returns a new set containing the elements that exist in all of the given sets. | `sets...` (tuple or list) | `set_intersection(["A", "B", "C"], ["B", D"])` | | `split` | tuple | Divides a given string by a given separator, returning a list of strings containing the characters between the separator sequences. | `sep` (string), `str` (string) | `split(" ", "foo bar qux")` | | `substr` | string | Extracts a sequence of characters from another string and creates a new string. The "`offset`" index may be negative, in which case it is relative to the end of the given string. The "`length`" may be `-1`, in which case the remainder of the string after the given offset will be returned. | `str` (string), `offset` (integer), `length` (integer) | `substr("abcdef", 3, -1)` | diff --git a/eval/context.go b/eval/context.go index 09585bdd7..830c75a1a 100644 --- a/eval/context.go +++ b/eval/context.go @@ -13,6 +13,7 @@ import ( "time" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/tryfunc" "github.com/hashicorp/hcl/v2/hclsyntax" pkce "github.com/jimlambrt/go-oauth-pkce-code-verifier" "github.com/zclconf/go-cty/cty" @@ -663,6 +664,7 @@ func newFunctionsMap() map[string]function.Function { return map[string]function.Function{ "base64_decode": lib.Base64DecodeFunc, "base64_encode": lib.Base64EncodeFunc, + "can": tryfunc.CanFunc, "coalesce": lib.DefaultFunc, "contains": stdlib.ContainsFunc, "default": lib.DefaultFunc, diff --git a/server/http_integration_test.go b/server/http_integration_test.go index cd7950268..30dfcabb4 100644 --- a/server/http_integration_test.go +++ b/server/http_integration_test.go @@ -4215,6 +4215,9 @@ func TestFunctions(t *testing.T) { {"trim", "/v1/trim", map[string]string{ "X-Trim": "foo \tbar", }, http.StatusOK}, + {"can", "/v1/can", map[string]string{ + "X-Can": `{"method":"GET","path":"/v1/can"}`, + }, http.StatusOK}, } { t.Run(tc.path[1:], func(subT *testing.T) { helper := test.New(subT) diff --git a/server/testdata/integration/functions/01_couper.hcl b/server/testdata/integration/functions/01_couper.hcl index 8435fedef..c8b0ef39a 100644 --- a/server/testdata/integration/functions/01_couper.hcl +++ b/server/testdata/integration/functions/01_couper.hcl @@ -212,6 +212,14 @@ server "api" { } } } + + endpoint "/can" { + response { + headers = { + x-can = json_encode({ for k in ["not_there", "method", "path"] : k => request[k] if can(request[k]) }) + } + } + } } } diff --git a/vendor/github.com/hashicorp/hcl/v2/ext/tryfunc/README.md b/vendor/github.com/hashicorp/hcl/v2/ext/tryfunc/README.md new file mode 100644 index 000000000..5d56eeca8 --- /dev/null +++ b/vendor/github.com/hashicorp/hcl/v2/ext/tryfunc/README.md @@ -0,0 +1,44 @@ +# "Try" and "can" functions + +This Go package contains two `cty` functions intended for use in an +`hcl.EvalContext` when evaluating HCL native syntax expressions. + +The first function `try` attempts to evaluate each of its argument expressions +in order until one produces a result without any errors. + +```hcl +try(non_existent_variable, 2) # returns 2 +``` + +If none of the expressions succeed, the function call fails with all of the +errors it encountered. + +The second function `can` is similar except that it ignores the result of +the given expression altogether and simply returns `true` if the expression +produced a successful result or `false` if it produced errors. + +Both of these are primarily intended for working with deep data structures +which might not have a dependable shape. For example, we can use `try` to +attempt to fetch a value from deep inside a data structure but produce a +default value if any step of the traversal fails: + +```hcl +result = try(foo.deep[0].lots.of["traversals"], null) +``` + +The final result to `try` should generally be some sort of constant value that +will always evaluate successfully. + +## Using these functions + +Languages built on HCL can make `try` and `can` available to user code by +exporting them in the `hcl.EvalContext` used for expression evaluation: + +```go +ctx := &hcl.EvalContext{ + Functions: map[string]function.Function{ + "try": tryfunc.TryFunc, + "can": tryfunc.CanFunc, + }, +} +``` diff --git a/vendor/github.com/hashicorp/hcl/v2/ext/tryfunc/tryfunc.go b/vendor/github.com/hashicorp/hcl/v2/ext/tryfunc/tryfunc.go new file mode 100644 index 000000000..2f4862f4a --- /dev/null +++ b/vendor/github.com/hashicorp/hcl/v2/ext/tryfunc/tryfunc.go @@ -0,0 +1,150 @@ +// Package tryfunc contains some optional functions that can be exposed in +// HCL-based languages to allow authors to test whether a particular expression +// can succeed and take dynamic action based on that result. +// +// These functions are implemented in terms of the customdecode extension from +// the sibling directory "customdecode", and so they are only useful when +// used within an HCL EvalContext. Other systems using cty functions are +// unlikely to support the HCL-specific "customdecode" extension. +package tryfunc + +import ( + "errors" + "fmt" + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/customdecode" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// TryFunc is a variadic function that tries to evaluate all of is arguments +// in sequence until one succeeds, in which case it returns that result, or +// returns an error if none of them succeed. +var TryFunc function.Function + +// CanFunc tries to evaluate the expression given in its first argument. +var CanFunc function.Function + +func init() { + TryFunc = function.New(&function.Spec{ + VarParam: &function.Parameter{ + Name: "expressions", + Type: customdecode.ExpressionClosureType, + }, + Type: func(args []cty.Value) (cty.Type, error) { + v, err := try(args) + if err != nil { + return cty.NilType, err + } + return v.Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return try(args) + }, + }) + CanFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "expression", + Type: customdecode.ExpressionClosureType, + }, + }, + Type: function.StaticReturnType(cty.Bool), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return can(args[0]) + }, + }) +} + +func try(args []cty.Value) (cty.Value, error) { + if len(args) == 0 { + return cty.NilVal, errors.New("at least one argument is required") + } + + // We'll collect up all of the diagnostics we encounter along the way + // and report them all if none of the expressions succeed, so that the + // user might get some hints on how to make at least one succeed. + var diags hcl.Diagnostics + for _, arg := range args { + closure := customdecode.ExpressionClosureFromVal(arg) + if dependsOnUnknowns(closure.Expression, closure.EvalContext) { + // We can't safely decide if this expression will succeed yet, + // and so our entire result must be unknown until we have + // more information. + return cty.DynamicVal, nil + } + + v, moreDiags := closure.Value() + diags = append(diags, moreDiags...) + if moreDiags.HasErrors() { + continue // try the next one, if there is one to try + } + return v, nil // ignore any accumulated diagnostics if one succeeds + } + + // If we fall out here then none of the expressions succeeded, and so + // we must have at least one diagnostic and we'll return all of them + // so that the user can see the errors related to whichever one they + // were expecting to have succeeded in this case. + // + // Because our function must return a single error value rather than + // diagnostics, we'll construct a suitable error message string + // that will make sense in the context of the function call failure + // diagnostic HCL will eventually wrap this in. + var buf strings.Builder + buf.WriteString("no expression succeeded:\n") + for _, diag := range diags { + if diag.Subject != nil { + buf.WriteString(fmt.Sprintf("- %s (at %s)\n %s\n", diag.Summary, diag.Subject, diag.Detail)) + } else { + buf.WriteString(fmt.Sprintf("- %s\n %s\n", diag.Summary, diag.Detail)) + } + } + buf.WriteString("\nAt least one expression must produce a successful result") + return cty.NilVal, errors.New(buf.String()) +} + +func can(arg cty.Value) (cty.Value, error) { + closure := customdecode.ExpressionClosureFromVal(arg) + if dependsOnUnknowns(closure.Expression, closure.EvalContext) { + // Can't decide yet, then. + return cty.UnknownVal(cty.Bool), nil + } + + _, diags := closure.Value() + if diags.HasErrors() { + return cty.False, nil + } + return cty.True, nil +} + +// dependsOnUnknowns returns true if any of the variables that the given +// expression might access are unknown values or contain unknown values. +// +// This is a conservative result that prefers to return true if there's any +// chance that the expression might derive from an unknown value during its +// evaluation; it is likely to produce false-positives for more complex +// expressions involving deep data structures. +func dependsOnUnknowns(expr hcl.Expression, ctx *hcl.EvalContext) bool { + for _, traversal := range expr.Variables() { + val, diags := traversal.TraverseAbs(ctx) + if diags.HasErrors() { + // If the traversal returned a definitive error then it must + // not traverse through any unknowns. + continue + } + if !val.IsWhollyKnown() { + // The value will be unknown if either it refers directly to + // an unknown value or if the traversal moves through an unknown + // collection. We're using IsWhollyKnown, so this also catches + // situations where the traversal refers to a compound data + // structure that contains any unknown values. That's important, + // because during evaluation the expression might evaluate more + // deeply into this structure and encounter the unknowns. + return true + } + } + return false +} diff --git a/vendor/modules.txt b/vendor/modules.txt index c391f780c..4dea50fe6 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -89,6 +89,7 @@ github.com/grpc-ecosystem/grpc-gateway/v2/utilities ## explicit; go 1.12 github.com/hashicorp/hcl/v2 github.com/hashicorp/hcl/v2/ext/customdecode +github.com/hashicorp/hcl/v2/ext/tryfunc github.com/hashicorp/hcl/v2/gohcl github.com/hashicorp/hcl/v2/hclparse github.com/hashicorp/hcl/v2/hclsimple