diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 42919bcd5..6cf8e31f0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: buildjet/setup-go@v5 with: - go-version: 1.22.x + go-version: 1.25.x - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 381e26971..c92b7c106 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: - name: Setup Go uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 with: - go-version: v1.22.x + go-version: v1.25.x - name: Create commits run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a0e83b9a1..da0daece6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - name: Install Go uses: buildjet/setup-go@v5 with: - go-version: 1.22.x + go-version: 1.25.x - name: Checkout code uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - name: Test diff --git a/JSONATA.md b/JSONATA.md new file mode 100644 index 000000000..f3cae6dd9 --- /dev/null +++ b/JSONATA.md @@ -0,0 +1,507 @@ +# JSONata Functions Reference + +This document lists every function available in gomplate JSONata expressions. Functions are divided into two groups: + +1. **Built-in** -- provided natively by the [JSONata specification](https://docs.jsonata.org/) via [gnata](https://github.com/recolabs/gnata). These always take precedence when a name collision exists. +2. **Custom** -- gomplate-specific extensions prefixed with `$` in expressions (e.g. `$sha256("hello")`). + +> **Type note:** JSONata passes all numbers as `float64`. Functions like `$mathIsInt` work best with string arguments (e.g. `$mathIsInt("42")`). + +--- + +## Built-in Functions + +These are native JSONata functions. See the [JSONata docs](https://docs.jsonata.org/overview) for full details. + +### String + +| Function | Description | +|----------|-------------| +| `$string(value)` | Cast to string | +| `$length(str)` | String length | +| `$substring(str, start[, length])` | Extract substring | +| `$substringBefore(str, chars)` | Text before first occurrence | +| `$substringAfter(str, chars)` | Text after first occurrence | +| `$uppercase(str)` | Convert to uppercase | +| `$lowercase(str)` | Convert to lowercase | +| `$trim(str)` | Remove leading/trailing whitespace | +| `$pad(str, width[, char])` | Pad string to width | +| `$contains(str, pattern)` | Test if string contains pattern | +| `$split(str, separator[, limit])` | Split string into array | +| `$join(array[, separator])` | Join array into string | +| `$match(str, pattern[, limit])` | Regex match returning objects | +| `$replace(str, pattern, replacement[, limit])` | Regex replace | +| `$base64encode(str)` | Base64 encode | +| `$base64decode(str)` | Base64 decode | +| `$encodeUrl(str)` | URL encode (full URL) | +| `$encodeUrlComponent(str)` | URL encode (component) | +| `$decodeUrl(str)` | URL decode (full URL) | +| `$decodeUrlComponent(str)` | URL decode (component) | + +### Numeric + +| Function | Description | +|----------|-------------| +| `$number(value)` | Cast to number | +| `$abs(n)` | Absolute value | +| `$floor(n)` | Round down | +| `$ceil(n)` | Round up | +| `$round(n[, precision])` | Round to precision | +| `$power(base, exp)` | Exponentiation | +| `$sqrt(n)` | Square root | +| `$random()` | Random float in [0, 1) | +| `$formatNumber(n, picture[, options])` | Format number as string | +| `$formatBase(n, radix)` | Format number in given base | +| `$formatInteger(n, picture)` | Format integer | +| `$parseInteger(str, picture)` | Parse integer from string | + +### Array / Aggregation + +| Function | Description | +|----------|-------------| +| `$count(array)` | Number of elements | +| `$sum(array)` | Sum of numeric elements | +| `$max(array)` | Maximum value | +| `$min(array)` | Minimum value | +| `$average(array)` | Arithmetic mean | +| `$append(arr1, arr2)` | Concatenate arrays | +| `$reverse(array)` | Reverse order | +| `$sort(array[, comparator])` | Sort elements | +| `$shuffle(array)` | Randomise order | +| `$distinct(array)` | Remove duplicates | +| `$flatten(array)` | Flatten nested arrays | +| `$zip(arr1, arr2, ...)` | Zip arrays together | +| `$filter(array, fn)` | Filter elements by predicate | +| `$map(array, fn)` | Transform each element | +| `$reduce(array, fn[, init])` | Reduce to a single value | +| `$sift(object, fn)` | Filter object entries | +| `$each(object, fn)` | Map over object entries | +| `$single(array, fn)` | Find exactly one match | + +### Object + +| Function | Description | +|----------|-------------| +| `$keys(object)` | Array of keys | +| `$values(object)` | Array of values | +| `$spread(object)` | Spread object into array of single-key objects | +| `$merge(array)` | Merge array of objects into one | +| `$lookup(object, key)` | Lookup a key | + +### Boolean / Control + +| Function | Description | +|----------|-------------| +| `$boolean(value)` | Cast to boolean | +| `$not(value)` | Logical NOT | +| `$exists(value)` | Test if value exists | +| `$assert(condition, message)` | Assert a condition | +| `$type(value)` | Return type name | +| `$error(message)` | Throw an error | +| `$eval(expr[, context])` | Evaluate a JSONata expression at runtime | + +### Date / Time + +| Function | Description | +|----------|-------------| +| `$now()` | Current timestamp (ISO 8601) | +| `$millis()` | Current time in epoch milliseconds | +| `$fromMillis(ms[, picture])` | Convert epoch ms to string | +| `$toMillis(str[, picture])` | Convert string to epoch ms | + +--- + +## Custom Functions (gomplate extensions) + +### Strings + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$replaceAll` | `(old, new, str)` | Replace all occurrences of `old` with `new` | +| `$trimPrefix` | `(prefix, str)` | Remove leading prefix | +| `$trimSuffix` | `(suffix, str)` | Remove trailing suffix | +| `$title` | `(str)` | Title Case conversion | +| `$toUpper` | `(str)` | Uppercase (via gomplate, not JSONata `$uppercase`) | +| `$toLower` | `(str)` | Lowercase (via gomplate, not JSONata `$lowercase`) | +| `$trimSpace` | `(str)` | Remove leading/trailing whitespace | +| `$trunc` | `(length, str)` | Truncate to length | +| `$slug` | `(str)` | URL-safe slug (`"Hello World!" -> "hello-world"`) | +| `$quote` | `(str)` | Double-quote a string | +| `$squote` | `(str)` | Single-quote a string | +| `$shellQuote` | `(str)` | Shell-safe quoting | +| `$snakeCase` | `(str)` | Convert to `snake_case` | +| `$camelCase` | `(str)` | Convert to `camelCase` | +| `$kebabCase` | `(str)` | Convert to `kebab-case` | +| `$abbrev` | `(maxWidth, str)` or `(offset, maxWidth, str)` | Abbreviate with ellipsis | +| `$repeat` | `(count, str)` | Repeat string N times | +| `$sortStrings` | `(list)` | Sort a list of strings | +| `$splitN` | `(sep, n, str)` | Split with limit | +| `$indent` | `(str)` or `(width, str)` or `(width, pad, str)` | Indent text | +| `$wordWrap` | `(str)` or `(width, str)` or `(width, lbseq, str)` | Wrap text to width | +| `$runeCount` | `(str, ...)` | Count Unicode runes | +| `$humanDuration` | `(value)` | Human-readable duration (e.g. `"3 hours"`) | +| `$humanSize` | `(bytes)` | Human-readable byte size (e.g. `"1.5 GiB"`) | +| `$semver` | `(str)` | Parse semver returning `{major, minor, patch, prerelease, metadata, original}` | +| `$semverCompare` | `(v1, v2)` | `true` if `v1` constraint satisfied by `v2` | +| `$hasPrefix` | `(prefix, str)` | Test if string starts with prefix | +| `$hasSuffix` | `(suffix, str)` | Test if string ends with suffix | + +#### Examples + +```jsonata +$slug("Hello World!") /* "hello-world" */ +$replaceAll("o", "0", "foo") /* "f00" */ +$trimPrefix("v", "v1.2.3") /* "1.2.3" */ +$humanDuration(3600) /* "1 hour" */ +$semver("1.2.3-beta+build").prerelease /* "beta" */ +``` + +### Collections + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$has` | `(object, key)` | Check if key exists in map/struct | +| `$dict` | `(key1, val1, ...)` | Create a map from key/value pairs | +| `$prepend` | `(value, list)` | Prepend element to list | +| `$uniq` | `(list)` | Remove duplicates (gomplate impl) | +| `$sortBy` | `(key, list)` | Sort list of maps by a key | +| `$pick` | `(key1, key2, ..., map)` | Select specific keys from map | +| `$omit` | `(key1, key2, ..., map)` | Exclude specific keys from map | +| `$coalesce` | `(val1, val2, ...)` | Return first non-nil, non-empty value | +| `$first` | `(list)` | First element | +| `$last` | `(list)` | Last element | +| `$matchLabel` | `(labels, key, patterns)` | Match Kubernetes-style label selectors | +| `$keyValToMap` | `(str)` | Parse `"key=val,key2=val2"` into a map | +| `$mapToKeyVal` | `(map)` | Convert map to `"key=val,key2=val2"` string | + +#### Examples + +```jsonata +$pick("name", "age", {"name": "Alice", "age": 30, "email": "a@b.c"}) +/* {"name": "Alice", "age": 30} */ + +$coalesce(null, "", "hello") /* "hello" */ +$sortBy("name", [{"name": "B"}, {"name": "A"}]) +/* [{"name": "A"}, {"name": "B"}] */ +``` + +### Query Languages + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$jq` | `(expr, data)` | Execute a [jq](https://stedolan.github.io/jq/) query | +| `$jmespath` | `(expr, data)` | Execute a [JMESPath](https://jmespath.org/) query | +| `$jsonpath` | `(expr, data)` | Execute a [JSONPath](https://goessner.net/articles/JsonPath/) query | + +#### Examples + +```jsonata +$jq(".items[].name", data) +$jmespath("items[?status=='active'].name", data) +$jsonpath("$.items[*].name", data) +``` + +### Time / Duration + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$timeNow` | `()` | Current time as RFC 3339 string | +| `$timeZoneName` | `()` | Local timezone name | +| `$timeZoneOffset` | `()` | Local timezone UTC offset (seconds) | +| `$timeParse` | `(layout, value)` | Parse time using Go layout | +| `$timeParseLocal` | `(layout, value)` | Parse time in local timezone | +| `$timeParseInLocation` | `(layout, location, value)` | Parse time in named timezone | +| `$timeUnix` | `(seconds)` | Convert Unix timestamp to RFC 3339 | +| `$parseDuration` | `(str)` | Parse duration string (e.g. `"1h30m"`, `"2d"`) | +| `$parseDateTime` | `(str)` | Parse various datetime formats including datemath (`"now-1h"`) | +| `$inTimeRange` | `(timestamp, start, end)` | Check if time-of-day falls within range | +| `$inBusinessHours` | `(timestamp)` | Check if time is within configured business hours | + +#### Examples + +```jsonata +$timeNow() /* "2024-01-15T14:30:00Z" */ +$timeParse("2006-01-02", "2024-01-15") /* "2024-01-15T00:00:00Z" */ +$parseDuration("1h30m") /* "1h30m0s" */ +$inTimeRange("2024-01-15T12:00:00Z", "09:00", "17:00") /* true */ +$timeUnix("1705312200") /* "2024-01-15T10:30:00Z" */ +``` + +### Math + +> Built-in JSONata math functions (`$abs`, `$floor`, `$ceil`, `$round`, `$power`, `$sqrt`, `$sum`, `$max`, `$min`) always take precedence. +> The `$math*` prefixed functions use gomplate's implementation which handles type coercion differently. + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$mathIsInt` | `(value)` | Check if value is an integer (use string input) | +| `$mathIsFloat` | `(value)` | Check if value is a float | +| `$mathIsNum` | `(value)` | Check if value is numeric | +| `$mathAdd` | `(n1, n2, ...)` | Addition (variadic) | +| `$mathSub` | `(a, b)` | Subtraction | +| `$mathMul` | `(n1, n2, ...)` | Multiplication (variadic) | +| `$mathDiv` | `(a, b)` | Division | +| `$mathRem` | `(a, b)` | Remainder / modulo | +| `$mathPow` | `(base, exp)` | Power | +| `$mathSeq` | `(end)` or `(start, end)` or `(start, end, step)` | Generate integer sequence | + +#### Examples + +```jsonata +$mathAdd(1, 2, 3) /* 6 */ +$mathDiv(10, 4) /* 2.5 */ +$mathSeq(1, 5) /* [1, 2, 3, 4, 5] */ +$mathRem(10, 3) /* 1 */ +``` + +### Data Formats + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$toJSON` | `(value)` | Serialize to JSON string | +| `$fromJSON` | `(str)` | Parse JSON string to object | +| `$toJSONPretty` | `(indent, value)` | Serialize to pretty-printed JSON | +| `$fromJSONArray` | `(str)` | Parse JSON array string | +| `$toYAML` | `(value)` | Serialize to YAML string | +| `$fromYAML` | `(str)` | Parse YAML string to object | +| `$fromYAMLArray` | `(str)` | Parse YAML array string | +| `$toTOML` | `(value)` | Serialize to TOML string | +| `$fromTOML` | `(str)` | Parse TOML string | +| `$toCSV` | `(value)` | Serialize to CSV string | +| `$fromCSV` | `(str)` | Parse CSV string | +| `$csvByRow` | `(str)` | Parse CSV as array of row maps | +| `$csvByColumn` | `(str)` | Parse CSV as map of column arrays | + +#### Examples + +```jsonata +$toJSON({"name": "Alice"}) /* '{"name":"Alice"}' */ +$fromJSON('{"name":"Alice"}').name /* "Alice" */ +$toJSONPretty(" ", {"a": 1}) /* pretty-printed JSON with 2-space indent */ +``` + +### Cryptographic Hashing + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$sha1` | `(input)` | SHA-1 hash (hex) | +| `$sha224` | `(input)` | SHA-224 hash (hex) | +| `$sha256` | `(input)` | SHA-256 hash (hex) | +| `$sha384` | `(input)` | SHA-384 hash (hex) | +| `$sha512` | `(input)` | SHA-512 hash (hex) | +| `$sha512_224` | `(input)` | SHA-512/224 hash (hex) | +| `$sha512_256` | `(input)` | SHA-512/256 hash (hex) | + +#### Examples + +```jsonata +$sha256("hello") /* "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" */ +``` + +### Regular Expressions + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$regexpFind` | `(pattern, input)` | Find first match | +| `$regexpFindAll` | `(pattern, input[, limit])` | Find all matches | +| `$regexpMatch` | `(pattern, input)` | Test if pattern matches | +| `$regexpReplace` | `(pattern, replacement, input)` | Replace all matches | +| `$regexpReplaceLiteral` | `(pattern, replacement, input)` | Replace with literal string | +| `$regexpSplit` | `(pattern, input[, limit])` | Split by pattern | +| `$regexpQuoteMeta` | `(str)` | Escape regex metacharacters | + +#### Examples + +```jsonata +$regexpMatch("[0-9]+", "abc123") /* true */ +$regexpFind("[0-9]+", "abc123def") /* "123" */ +$regexpReplace("[0-9]+", "NUM", "abc123def456") /* "abcNUMdefNUM" */ +$regexpQuoteMeta("a.b") /* "a\.b" */ +``` + +### UUID + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$uuidV1` | `()` | Generate UUID v1 (MAC + time) | +| `$uuidV4` | `()` | Generate UUID v4 (random) | +| `$uuidNil` | `()` | Nil UUID (`00000000-0000-0000-0000-000000000000`) | +| `$uuidIsValid` | `(str)` | Check if string is a valid UUID | +| `$uuidParse` | `(str)` | Parse and normalize a UUID string | +| `$uuidHash` | `(val1, val2, ...)` | Deterministic UUID from SHA-256 of inputs | + +#### Examples + +```jsonata +$uuidV4() /* "550e8400-e29b-41d4-a716-446655440000" */ +$uuidIsValid("550e8400-e29b-41d4-a716-446655440000") /* true */ +$uuidHash("my-resource", "namespace") /* same UUID every time for same inputs */ +``` + +### Random + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$randomASCII` | `(count)` | Random printable ASCII string | +| `$randomAlpha` | `(count)` | Random alphabetic string | +| `$randomAlphaNum` | `(count)` | Random alphanumeric string | +| `$randomString` | `(count[, pattern])` or `(count, lower, upper)` | Random string from pattern or range | +| `$randomItem` | `(list)` | Random element from list | +| `$randomNumber` | `([min, max])` | Random integer (default 0--100) | +| `$randomFloat` | `([min, max])` | Random float (default 0.0--1.0) | + +#### Examples + +```jsonata +$randomAlpha(10) /* "aBcDeFgHiJ" */ +$randomNumber(1, 100) /* 42 */ +$randomItem(["a", "b", "c"]) /* "b" */ +``` + +### File Path + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$filepathBase` | `(path)` | Filename from path | +| `$filepathDir` | `(path)` | Directory from path | +| `$filepathExt` | `(path)` | File extension | +| `$filepathClean` | `(path)` | Clean path (resolve `..`, extra slashes) | +| `$filepathJoin` | `(elem1, elem2, ...)` | Join path elements | +| `$filepathIsAbs` | `(path)` | Check if absolute path | +| `$filepathMatch` | `(pattern, name)` | Glob-match a path | +| `$filepathRel` | `(basepath, targpath)` | Relative path between two paths | +| `$filepathSplit` | `(path)` | Split into `[dir, file]` | +| `$filepathFromSlash` | `(path)` | Convert `/` to OS separator | +| `$filepathToSlash` | `(path)` | Convert OS separator to `/` | + +#### Examples + +```jsonata +$filepathBase("/foo/bar/baz.txt") /* "baz.txt" */ +$filepathDir("/foo/bar/baz.txt") /* "/foo/bar" */ +$filepathExt("file.tar.gz") /* ".gz" */ +$filepathJoin("/foo", "bar", "baz.txt") /* "/foo/bar/baz.txt" */ +$filepathClean("/foo/../bar") /* "/bar" */ +``` + +### Network + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$netIsValidIP` | `(ip)` | Check if string is a valid IPv4 or IPv6 address | +| `$netContainsCIDR` | `(cidr, ip)` | Check if IP is within CIDR block | + +#### Examples + +```jsonata +$netIsValidIP("192.168.1.1") /* true */ +$netContainsCIDR("10.0.0.0/8", "10.1.2.3") /* true */ +$netContainsCIDR("10.0.0.0/8", "192.168.1.1") /* false */ +``` + +### Kubernetes + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$isHealthy` | `(resource)` | Check if K8s resource is healthy | +| `$isReady` | `(resource)` | Check if K8s resource is ready | +| `$getStatus` | `(resource)` | Get status string | +| `$getHealth` | `(resource)` | Get health as map `{status, health, ready, message}` | +| `$neat` | `(yamlStr)` | Pretty-print K8s YAML (remove managed fields, etc.) | +| `$k8sIsHealthy` | `(resource)` | Alias for `$isHealthy` | +| `$k8sIsReady` | `(resource)` | Alias for `$isReady` | +| `$k8sGetStatus` | `(resource)` | Alias for `$getStatus` | +| `$k8sGetHealth` | `(resource)` | Alias for `$getHealth` | +| `$k8sNeat` | `(yamlStr)` | Alias for `$neat` | +| `$k8sCPUAsMillicores` | `(str)` | Convert CPU string to millicores (`"500m"` -> `500`, `"1"` -> `1000`) | +| `$k8sMemoryAsBytes` | `(str)` | Convert memory string to bytes (`"1Gi"` -> `1073741824`) | + +#### Examples + +```jsonata +$isHealthy(pod) /* true */ +$getStatus(deployment) /* "Running" */ +$k8sCPUAsMillicores("500m") /* 500 */ +$k8sMemoryAsBytes("2Gi") /* 2147483648 */ +``` + +### AWS + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$arnToMap` | `(arnStr)` | Parse ARN into `{service, region, account, resource}` | +| `$fromAWSMap` | `(tagList)` | Convert `[{Name: "k", Value: "v"}, ...]` to `{k: v}` | + +#### Examples + +```jsonata +$arnToMap("arn:aws:s3:us-east-1:123456789:my-bucket") +/* {"service": "s3", "region": "us-east-1", "account": "123456789", "resource": "my-bucket"} */ + +$fromAWSMap([{"Name": "env", "Value": "prod"}, {"Name": "team", "Value": "platform"}]) +/* {"env": "prod", "team": "platform"} */ +``` + +### Type Conversion + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$toBool` | `(value)` | Convert to boolean | +| `$toInt` | `(value)` | Convert to integer | +| `$toFloat64` | `(value)` | Convert to float64 | +| `$toString` | `(value)` | Convert to string | +| `$default` | `(fallback, value)` | Return `value` if truthy, else `fallback` | +| `$join` | `(list, separator)` | Join list elements with separator | + +#### Examples + +```jsonata +$toBool("true") /* true */ +$toInt(3.7) /* 3 */ +$default("N/A", "") /* "N/A" */ +$default("N/A", "hello") /* "hello" */ +$join(["a", "b"], ",") /* "a,b" */ +``` + +### Test / Assert + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$testFail` | `([message])` | Fail with optional message | +| `$testRequired` | `([message,] value)` | Fail if value is nil/empty | +| `$testTernary` | `(trueVal, falseVal, condition)` | Ternary operator | +| `$testKind` | `(value)` | Return Go type kind (e.g. `"string"`, `"float64"`) | +| `$testIsKind` | `(kind, value)` | Check if value is of given kind | + +#### Examples + +```jsonata +$testTernary("yes", "no", true) /* "yes" */ +$testKind("hello") /* "string" */ +$testIsKind("string", "hello") /* true */ +``` + +### Go Template Formatting + +| Function | Signature | Description | +|----------|-----------|-------------| +| `$f` | `(template, data)` | Evaluate a Go template string against data | + +Supports both `{{...}}` and `$(...)` delimiters. + +#### Examples + +```jsonata +$f("Hello {{.name}}", {"name": "World"}) /* "Hello World" */ +$f("ID: $(.id)", {"id": 123}) /* "ID: 123" */ +``` + +--- + +## Builtin Precedence + +When a custom function has the same name as a JSONata built-in, the **built-in always wins**. This affects the following names where gomplate also provides an implementation: + +`append`, `assert`, `boolean`, `contains`, `error`, `exists`, `flatten`, `join`, `keys`, `lookup`, `merge`, `not`, `reverse`, `sort`, `split`, `type`, `values` + +For these, use the JSONata syntax. Gomplate-specific alternatives are available with different names where needed (e.g. `$sortBy` for sorting by key, `$uniq` for deduplication). diff --git a/go.mod b/go.mod index b8053803f..2e9712681 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/flanksource/gomplate/v3 -go 1.25.1 +go 1.25.6 require ( github.com/Masterminds/goutils v1.1.1 @@ -21,6 +21,7 @@ require ( github.com/onsi/gomega v1.39.1 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 + github.com/recolabs/gnata v0.1.1 github.com/robertkrimen/otto v0.5.1 github.com/samber/oops v1.21.0 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index d813b38db..8aa471654 100644 --- a/go.sum +++ b/go.sum @@ -144,6 +144,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.20.0 h1:AA7aCvjxwAquZAlonN7888f2u4IN8WVeFgBi4k82M4Q= github.com/prometheus/procfs v0.20.0/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/recolabs/gnata v0.1.1 h1:Bv6/ElJvPX1QDo+GaZQNTq4LEtQoGJDCcmVE1cvUbfM= +github.com/recolabs/gnata v0.1.1/go.mod h1:MJXGl6SyIuadbXJglDFc2vK2n+Rmtr/8bXyOedGOUxc= github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0= github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= diff --git a/jsonata.go b/jsonata.go new file mode 100644 index 000000000..8c083f4f6 --- /dev/null +++ b/jsonata.go @@ -0,0 +1,112 @@ +package gomplate + +import ( + gocontext "context" + "encoding/json" + "fmt" + "time" + + "github.com/patrickmn/go-cache" + "github.com/recolabs/gnata" +) + +var jsonataExpressionCache = cache.New(time.Hour, time.Hour) + +// gnataBuiltinFunctions is the set of function names natively provided by gnata/JSONata. +// These take precedence over custom gomplate functions; any custom function whose name +// matches an entry here is silently skipped during registration. +var gnataBuiltinFunctions = map[string]struct{}{ + "string": {}, "length": {}, "substring": {}, "substringBefore": {}, + "substringAfter": {}, "trim": {}, "pad": {}, "contains": {}, + "split": {}, "join": {}, "base64encode": {}, "base64decode": {}, + "encodeUrl": {}, "encodeUrlComponent": {}, "decodeUrl": {}, "decodeUrlComponent": {}, + "formatNumber": {}, "formatBase": {}, "formatInteger": {}, "parseInteger": {}, + "number": {}, "abs": {}, "floor": {}, "ceil": {}, "round": {}, "power": {}, + "sqrt": {}, "random": {}, "sum": {}, "max": {}, "min": {}, "average": {}, + "count": {}, "append": {}, "reverse": {}, "shuffle": {}, "distinct": {}, + "flatten": {}, "zip": {}, + "keys": {}, "values": {}, "spread": {}, "merge": {}, "error": {}, + "lookup": {}, + "boolean": {}, "not": {}, "exists": {}, "assert": {}, "type": {}, + "now": {}, "millis": {}, "fromMillis": {}, "toMillis": {}, + "uppercase": {}, "lowercase": {}, "match": {}, "replace": {}, + "eval": {}, "sort": {}, "sift": {}, "each": {}, "map": {}, "filter": {}, + "single": {}, "reduce": {}, +} + +// customJSONataEnv is the shared, reusable gnata environment that combines +// JSONata's native built-ins with gomplate-specific custom functions. +// Built-ins always take precedence: any custom function whose name conflicts +// with a gnata built-in is skipped during construction (see getJSONataCustomFuncs). +// The type is inferred from gnata.NewCustomEnv which returns an internal evaluator.Environment. +var customJSONataEnv = gnata.NewCustomEnv(map[string]gnata.CustomFunc{}) //nolint:gochecknoglobals + +func init() { + customJSONataEnv = gnata.NewCustomEnv(getJSONataCustomFuncs()) +} + +// getJSONataCustomFuncs returns a map of gomplate-specific functions for use in +// JSONata expressions. Functions whose names match gnata built-ins are excluded +// so that the native implementation always takes precedence when there is a name +// collision. +func getJSONataCustomFuncs() map[string]gnata.CustomFunc { + allFuncs := jsonataCustomFuncs() + + result := make(map[string]gnata.CustomFunc, len(allFuncs)) + for name, fn := range allFuncs { + if _, isBuiltin := gnataBuiltinFunctions[name]; !isBuiltin { + result[name] = fn + } + } + return result +} + +// RunJSONata evaluates a JSONata expression against the supplied environment. +// The environment keys become the top-level fields of the input document +// (identical to how CEL variables are exposed). +// Results are normalised to standard Go types via gnata.NormalizeValue. +func RunJSONata(environment map[string]any, expression string) (any, error) { + cacheKey := expression + if compiled, ok := jsonataExpressionCache.Get(cacheKey); ok { + expr := compiled.(*gnata.Expression) + result, err := expr.EvalWithCustomFuncs(gocontext.Background(), environment, customJSONataEnv) + if err != nil { + return nil, fmt.Errorf("jsonata: %w", err) + } + return gnata.NormalizeValue(result), nil + } + + expr, err := gnata.Compile(expression) + if err != nil { + return nil, fmt.Errorf("jsonata: compile error: %w", err) + } + + jsonataExpressionCache.SetDefault(cacheKey, expr) + + result, err := expr.EvalWithCustomFuncs(gocontext.Background(), environment, customJSONataEnv) + if err != nil { + return nil, fmt.Errorf("jsonata: %w", err) + } + return gnata.NormalizeValue(result), nil +} + +// jsonataToString converts a JSONata result to its string representation, +// matching the behaviour of RunTemplateContext for other template types. +func jsonataToString(v any) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case bool: + if val { + return "true" + } + return "false" + case json.Number: + return val.String() + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/jsonata_funcs.go b/jsonata_funcs.go new file mode 100644 index 000000000..be9fcf983 --- /dev/null +++ b/jsonata_funcs.go @@ -0,0 +1,464 @@ +package gomplate + +import ( + "context" + "fmt" + "strings" + + "github.com/flanksource/gomplate/v3/coll" + "github.com/flanksource/gomplate/v3/conv" + "github.com/flanksource/gomplate/v3/funcs" + "github.com/flanksource/gomplate/v3/kubernetes" + "github.com/recolabs/gnata" +) + +// jsonataCustomFuncs returns all gomplate-specific functions for JSONata. +// Functions whose names collide with gnata built-ins are excluded in getJSONataCustomFuncs. +func jsonataCustomFuncs() map[string]gnata.CustomFunc { + sf := &funcs.StringFuncs{} + collFuncs := &funcs.CollFuncs{} + timeFuncs := funcs.TimeNS() + mathFuncs := &funcs.MathFuncs{} + dataFuncs := &funcs.DataFuncs{} + cryptoFuncs := &funcs.CryptoFuncs{} + reFuncs := &funcs.ReFuncs{} + randomFuncs := &funcs.RandomFuncs{} + uuidFuncs := &funcs.UUIDFuncs{} + fpFuncs := &funcs.FilePathFuncs{} + netFuncs := &funcs.NetFuncs{} + testFuncs := &funcs.TestFuncs{} + k8sFuncs := &funcs.KubernetesFuncs{} + convFuncs := &funcs.ConvFuncs{} + + m := make(map[string]gnata.CustomFunc) + + addStringFuncs(m, sf) + addCollFuncs(m, collFuncs) + addTimeFuncs(m, timeFuncs) + addMathFuncs(m, mathFuncs) + addDataFuncs(m, dataFuncs) + addCryptoFuncs(m, cryptoFuncs) + addRegexpFuncs(m, reFuncs) + addRandomFuncs(m, randomFuncs) + addUUIDFuncs(m, uuidFuncs) + addFilepathFuncs(m, fpFuncs) + addNetFuncs(m, netFuncs) + addTestFuncs(m, testFuncs) + addK8sFuncs(m, k8sFuncs) + addAWSFuncs(m) + addConvFuncs(m, convFuncs) + addTemplateFuncs(m) + + return m +} + +func addStringFuncs(m map[string]gnata.CustomFunc, sf *funcs.StringFuncs) { + m["humanDuration"] = jsonata1(func(a any) (any, error) { return sf.HumanDuration(a) }) + m["humanSize"] = jsonata1(func(a any) (any, error) { return sf.HumanSize(a) }) + m["semver"] = jsonata1(func(a any) (any, error) { + v, err := sf.SemverMap(conv.ToString(a)) + if err != nil { + return nil, err + } + return v, nil + }) + m["semverCompare"] = jsonata2(func(a, b any) (any, error) { + return sf.SemverCompare(conv.ToString(a), conv.ToString(b)) + }) + m["abbrev"] = jsonataV(func(args []any) (any, error) { return sf.Abbrev(args...) }) + m["replaceAll"] = jsonata3(func(old, new_, s any) (any, error) { + return sf.ReplaceAll(conv.ToString(old), conv.ToString(new_), s), nil + }) + m["contains"] = jsonata2(func(substr, s any) (any, error) { + return sf.Contains(conv.ToString(substr), s), nil + }) + m["hasPrefix"] = jsonata2(func(prefix, s any) (any, error) { + return sf.HasPrefix(conv.ToString(prefix), s), nil + }) + m["hasSuffix"] = jsonata2(func(suffix, s any) (any, error) { + return sf.HasSuffix(conv.ToString(suffix), s), nil + }) + m["repeat"] = jsonata2(func(count, s any) (any, error) { + return sf.Repeat(conv.ToInt(count), s) + }) + m["sortStrings"] = jsonata1(func(a any) (any, error) { return sf.Sort(a) }) + m["splitN"] = jsonata3(func(sep, n, s any) (any, error) { + return sf.SplitN(conv.ToString(sep), conv.ToInt(n), s), nil + }) + m["trimPrefix"] = jsonata2(func(cutset, s any) (any, error) { + return sf.TrimPrefix(conv.ToString(cutset), s), nil + }) + m["trimSuffix"] = jsonata2(func(cutset, s any) (any, error) { + return sf.TrimSuffix(conv.ToString(cutset), s), nil + }) + m["title"] = jsonata1(func(a any) (any, error) { return sf.Title(a), nil }) + m["toUpper"] = jsonata1(func(a any) (any, error) { return sf.ToUpper(a), nil }) + m["toLower"] = jsonata1(func(a any) (any, error) { return sf.ToLower(a), nil }) + m["trimSpace"] = jsonata1(func(a any) (any, error) { return sf.TrimSpace(a), nil }) + m["trunc"] = jsonata2(func(length, s any) (any, error) { + return sf.Trunc(conv.ToInt(length), s), nil + }) + m["indent"] = jsonataV(func(args []any) (any, error) { return sf.Indent(args...) }) + m["slug"] = jsonata1(func(a any) (any, error) { return sf.Slug(a), nil }) + m["quote"] = jsonata1(func(a any) (any, error) { return sf.Quote(a), nil }) + m["shellQuote"] = jsonata1(func(a any) (any, error) { return sf.ShellQuote(a), nil }) + m["squote"] = jsonata1(func(a any) (any, error) { return sf.Squote(a), nil }) + m["snakeCase"] = jsonata1(func(a any) (any, error) { return sf.SnakeCase(a) }) + m["camelCase"] = jsonata1(func(a any) (any, error) { return sf.CamelCase(a) }) + m["kebabCase"] = jsonata1(func(a any) (any, error) { return sf.KebabCase(a) }) + m["wordWrap"] = jsonataV(func(args []any) (any, error) { return sf.WordWrap(args...) }) + m["runeCount"] = jsonataV(func(args []any) (any, error) { return sf.RuneCount(args...) }) +} + +func addCollFuncs(m map[string]gnata.CustomFunc, cf *funcs.CollFuncs) { + m["has"] = jsonata2(func(in, key any) (any, error) { + return cf.Has(in, conv.ToString(key)), nil + }) + m["dict"] = jsonataV(func(args []any) (any, error) { return cf.Dict(args...) }) + m["prepend"] = jsonata2(func(v, list any) (any, error) { return cf.Prepend(v, list) }) + m["uniq"] = jsonata1(func(a any) (any, error) { return cf.Uniq(a) }) + m["sortBy"] = jsonata2(func(key, list any) (any, error) { + return cf.Sort(key, list) + }) + m["pick"] = jsonataV(func(args []any) (any, error) { return cf.Pick(args...) }) + m["omit"] = jsonataV(func(args []any) (any, error) { return cf.Omit(args...) }) + m["coalesce"] = jsonataV(func(args []any) (any, error) { return cf.Coalesce(args...), nil }) + m["first"] = jsonata1(func(a any) (any, error) { return cf.First(a), nil }) + m["last"] = jsonata1(func(a any) (any, error) { return cf.Last(a), nil }) + m["matchLabel"] = jsonata3(func(labels, key, patterns any) (any, error) { + labelsMap, ok := labels.(map[string]any) + if !ok { + return nil, fmt.Errorf("$matchLabel: expected map[string]any, got %T", labels) + } + valuePatterns := strings.Split(conv.ToString(patterns), ",") + return coll.MatchLabel(labelsMap, conv.ToString(key), valuePatterns...), nil + }) + m["keyValToMap"] = jsonata1(func(a any) (any, error) { + return coll.KeyValToMap(conv.ToString(a)) + }) + m["mapToKeyVal"] = jsonata1(func(a any) (any, error) { + m, ok := a.(map[string]any) + if !ok { + return nil, fmt.Errorf("$mapToKeyVal: expected map[string]any, got %T", a) + } + return coll.MapToKeyVal(m), nil + }) + m["jq"] = jsonata2(func(expr, data any) (any, error) { + return coll.JQ(context.Background(), conv.ToString(expr), data) + }) + m["jmespath"] = jsonata2(func(expr, data any) (any, error) { + return coll.JMESPath(conv.ToString(expr), data) + }) + m["jsonpath"] = jsonata2(func(expr, data any) (any, error) { + return coll.JSONPath(conv.ToString(expr), data) + }) +} + +func addTimeFuncs(m map[string]gnata.CustomFunc, tf *funcs.TimeFuncs) { + m["timeNow"] = jsonata0(func() (any, error) { return tf.Now().Format("2006-01-02T15:04:05Z07:00"), nil }) + m["timeZoneName"] = jsonata0(func() (any, error) { return tf.ZoneName(), nil }) + m["timeZoneOffset"] = jsonata0(func() (any, error) { return tf.ZoneOffset(), nil }) + m["timeParse"] = jsonata2(func(layout, value any) (any, error) { + t, err := tf.Parse(conv.ToString(layout), value) + if err != nil { + return nil, err + } + return t.Format("2006-01-02T15:04:05Z07:00"), nil + }) + m["timeParseLocal"] = jsonata2(func(layout, value any) (any, error) { + t, err := tf.ParseLocal(conv.ToString(layout), value) + if err != nil { + return nil, err + } + return t.Format("2006-01-02T15:04:05Z07:00"), nil + }) + m["timeParseInLocation"] = jsonata3(func(layout, location, value any) (any, error) { + t, err := tf.ParseInLocation(conv.ToString(layout), conv.ToString(location), value) + if err != nil { + return nil, err + } + return t.Format("2006-01-02T15:04:05Z07:00"), nil + }) + m["timeUnix"] = jsonata1(func(a any) (any, error) { + t, err := tf.Unix(a) + if err != nil { + return nil, err + } + return t.Format("2006-01-02T15:04:05Z07:00"), nil + }) + m["parseDuration"] = jsonata1(func(a any) (any, error) { + d, err := tf.ParseDuration(a) + if err != nil { + return nil, err + } + return d.String(), nil + }) + m["parseDateTime"] = jsonata1(func(a any) (any, error) { + t := funcs.ParseDateTime(conv.ToString(a)) + if t == nil { + return nil, nil + } + return t.Format("2006-01-02T15:04:05Z07:00"), nil + }) + m["inTimeRange"] = jsonata3(func(t, start, end any) (any, error) { + return tf.InTimeRange(t, conv.ToString(start), conv.ToString(end)) + }) + m["inBusinessHours"] = jsonata1(func(a any) (any, error) { + return tf.InBusinessHour(conv.ToString(a)) + }) +} + +func addMathFuncs(m map[string]gnata.CustomFunc, mf *funcs.MathFuncs) { + m["mathIsInt"] = jsonata1(func(a any) (any, error) { return mf.IsInt(a), nil }) + m["mathIsFloat"] = jsonata1(func(a any) (any, error) { return mf.IsFloat(a), nil }) + m["mathIsNum"] = jsonata1(func(a any) (any, error) { return mf.IsNum(a), nil }) + m["mathAdd"] = jsonataV(func(args []any) (any, error) { return mf.Add(args...), nil }) + m["mathMul"] = jsonataV(func(args []any) (any, error) { return mf.Mul(args...), nil }) + m["mathSub"] = jsonata2(func(a, b any) (any, error) { return mf.Sub(a, b), nil }) + m["mathDiv"] = jsonata2(func(a, b any) (any, error) { return mf.Div(a, b) }) + m["mathRem"] = jsonata2(func(a, b any) (any, error) { return mf.Rem(a, b), nil }) + m["mathPow"] = jsonata2(func(a, b any) (any, error) { return mf.Pow(a, b), nil }) + m["mathSeq"] = jsonataV(func(args []any) (any, error) { return mf.Seq(args...) }) +} + +func addDataFuncs(m map[string]gnata.CustomFunc, df *funcs.DataFuncs) { + m["toJSON"] = jsonata1(func(a any) (any, error) { return df.ToJSON(a) }) + m["fromJSON"] = jsonata1(func(a any) (any, error) { return df.JSON(a) }) + m["toYAML"] = jsonata1(func(a any) (any, error) { return df.ToYAML(a) }) + m["fromYAML"] = jsonata1(func(a any) (any, error) { return df.YAML(a) }) + m["toJSONPretty"] = jsonata2(func(indent, in any) (any, error) { + return df.ToJSONPretty(conv.ToString(indent), in) + }) + m["fromJSONArray"] = jsonata1(func(a any) (any, error) { return df.JSONArray(a) }) + m["fromYAMLArray"] = jsonata1(func(a any) (any, error) { return df.YAMLArray(a) }) + m["toTOML"] = jsonata1(func(a any) (any, error) { return df.ToTOML(a) }) + m["fromTOML"] = jsonata1(func(a any) (any, error) { return df.TOML(a) }) + m["toCSV"] = jsonataV(func(args []any) (any, error) { return df.ToCSV(args...) }) + m["fromCSV"] = jsonataV(func(args []any) (any, error) { + strs := conv.ToStrings(args...) + return df.CSV(strs...) + }) + m["csvByRow"] = jsonataV(func(args []any) (any, error) { + strs := conv.ToStrings(args...) + return df.CSVByRow(strs...) + }) + m["csvByColumn"] = jsonataV(func(args []any) (any, error) { + strs := conv.ToStrings(args...) + return df.CSVByColumn(strs...) + }) +} + +func addCryptoFuncs(m map[string]gnata.CustomFunc, cf *funcs.CryptoFuncs) { + m["sha1"] = jsonata1(func(a any) (any, error) { return cf.SHA1(a), nil }) + m["sha224"] = jsonata1(func(a any) (any, error) { return cf.SHA224(a), nil }) + m["sha256"] = jsonata1(func(a any) (any, error) { return cf.SHA256(a), nil }) + m["sha384"] = jsonata1(func(a any) (any, error) { return cf.SHA384(a), nil }) + m["sha512"] = jsonata1(func(a any) (any, error) { return cf.SHA512(a), nil }) + m["sha512_224"] = jsonata1(func(a any) (any, error) { return cf.SHA512_224(a), nil }) + m["sha512_256"] = jsonata1(func(a any) (any, error) { return cf.SHA512_256(a), nil }) +} + +func addRegexpFuncs(m map[string]gnata.CustomFunc, rf *funcs.ReFuncs) { + m["regexpFind"] = jsonata2(func(re, input any) (any, error) { return rf.Find(re, input) }) + m["regexpFindAll"] = jsonataV(func(args []any) (any, error) { return rf.FindAll(args...) }) + m["regexpMatch"] = jsonata2(func(re, input any) (any, error) { return rf.Match(re, input), nil }) + m["regexpReplace"] = jsonata3(func(re, repl, input any) (any, error) { + return rf.Replace(re, repl, input), nil + }) + m["regexpReplaceLiteral"] = jsonata3(func(re, repl, input any) (any, error) { + return rf.ReplaceLiteral(re, repl, input) + }) + m["regexpSplit"] = jsonataV(func(args []any) (any, error) { return rf.Split(args...) }) + m["regexpQuoteMeta"] = jsonata1(func(a any) (any, error) { return rf.QuoteMeta(a), nil }) +} + +func addRandomFuncs(m map[string]gnata.CustomFunc, rf *funcs.RandomFuncs) { + m["randomASCII"] = jsonata1(func(a any) (any, error) { return rf.ASCII(a) }) + m["randomAlpha"] = jsonata1(func(a any) (any, error) { return rf.Alpha(a) }) + m["randomAlphaNum"] = jsonata1(func(a any) (any, error) { return rf.AlphaNum(a) }) + m["randomString"] = jsonataV(func(args []any) (any, error) { + if len(args) < 1 { + return nil, fmt.Errorf("$randomString: expected at least 1 argument") + } + return rf.String(args[0], args[1:]...) + }) + m["randomItem"] = jsonata1(func(a any) (any, error) { return rf.Item(a) }) + m["randomNumber"] = jsonataV(func(args []any) (any, error) { return rf.Number(args...) }) + m["randomFloat"] = jsonataV(func(args []any) (any, error) { return rf.Float(args...) }) +} + +func addUUIDFuncs(m map[string]gnata.CustomFunc, uf *funcs.UUIDFuncs) { + m["uuidV1"] = jsonata0(func() (any, error) { return uf.V1() }) + m["uuidV4"] = jsonata0(func() (any, error) { return uf.V4() }) + m["uuidNil"] = jsonata0(func() (any, error) { return uf.Nil() }) + m["uuidIsValid"] = jsonata1(func(a any) (any, error) { return uf.IsValid(a) }) + m["uuidParse"] = jsonata1(func(a any) (any, error) { return uf.Parse(a) }) + m["uuidHash"] = jsonataV(func(args []any) (any, error) { return uf.HashUUID(args...) }) +} + +func addFilepathFuncs(m map[string]gnata.CustomFunc, fp *funcs.FilePathFuncs) { + m["filepathBase"] = jsonata1(func(a any) (any, error) { return fp.Base(a), nil }) + m["filepathClean"] = jsonata1(func(a any) (any, error) { return fp.Clean(a), nil }) + m["filepathDir"] = jsonata1(func(a any) (any, error) { return fp.Dir(a), nil }) + m["filepathExt"] = jsonata1(func(a any) (any, error) { return fp.Ext(a), nil }) + m["filepathFromSlash"] = jsonata1(func(a any) (any, error) { return fp.FromSlash(a), nil }) + m["filepathToSlash"] = jsonata1(func(a any) (any, error) { return fp.ToSlash(a), nil }) + m["filepathIsAbs"] = jsonata1(func(a any) (any, error) { return fp.IsAbs(a), nil }) + m["filepathJoin"] = jsonataV(func(args []any) (any, error) { return fp.Join(args...), nil }) + m["filepathMatch"] = jsonata2(func(pattern, name any) (any, error) { return fp.Match(pattern, name) }) + m["filepathRel"] = jsonata2(func(base, targ any) (any, error) { return fp.Rel(base, targ) }) + m["filepathSplit"] = jsonata1(func(a any) (any, error) { return fp.Split(a), nil }) +} + +func addNetFuncs(m map[string]gnata.CustomFunc, nf *funcs.NetFuncs) { + m["netContainsCIDR"] = jsonata2(func(cidr, ip any) (any, error) { + return nf.ContainsCIDR(conv.ToString(cidr), conv.ToString(ip)), nil + }) + m["netIsValidIP"] = jsonata1(func(a any) (any, error) { + return nf.IsValidIP(conv.ToString(a)), nil + }) +} + +func addTestFuncs(m map[string]gnata.CustomFunc, tf *funcs.TestFuncs) { + m["testFail"] = jsonataV(func(args []any) (any, error) { return tf.Fail(args...) }) + m["testRequired"] = jsonataV(func(args []any) (any, error) { return tf.Required(args...) }) + m["testTernary"] = jsonata3(func(tval, fval, b any) (any, error) { return tf.Ternary(tval, fval, b), nil }) + m["testKind"] = jsonata1(func(a any) (any, error) { return tf.Kind(a), nil }) + m["testIsKind"] = jsonata2(func(kind, arg any) (any, error) { + return tf.IsKind(conv.ToString(kind), arg), nil + }) +} + +func addK8sFuncs(m map[string]gnata.CustomFunc, kf *funcs.KubernetesFuncs) { + m["isHealthy"] = jsonata1(func(a any) (any, error) { return kf.IsHealthy(a), nil }) + m["isReady"] = jsonata1(func(a any) (any, error) { return kf.IsReady(a), nil }) + m["getStatus"] = jsonata1(func(a any) (any, error) { return kf.GetStatus(a), nil }) + m["getHealth"] = jsonata1(func(a any) (any, error) { return kf.GetHealthMap(a), nil }) + m["neat"] = jsonata1(func(a any) (any, error) { return kf.Neat(conv.ToString(a)) }) + m["k8sIsHealthy"] = m["isHealthy"] + m["k8sIsReady"] = m["isReady"] + m["k8sGetStatus"] = m["getStatus"] + m["k8sGetHealth"] = m["getHealth"] + m["k8sNeat"] = m["neat"] + m["k8sCPUAsMillicores"] = jsonata1(func(a any) (any, error) { + return kubernetes.CPUAsMillicores(conv.ToString(a)), nil + }) + m["k8sMemoryAsBytes"] = jsonata1(func(a any) (any, error) { + return kubernetes.MemoryAsBytes(conv.ToString(a)), nil + }) +} + +func addAWSFuncs(m map[string]gnata.CustomFunc) { + m["arnToMap"] = jsonata1(func(a any) (any, error) { + arn := conv.ToString(a) + parts := strings.Split(arn, ":") + if len(parts) < 6 { + return nil, fmt.Errorf("$arnToMap: invalid ARN %q: expected at least 6 colon-separated parts", arn) + } + return map[string]any{ + "service": parts[2], + "region": parts[3], + "account": parts[4], + "resource": parts[5], + }, nil + }) + m["fromAWSMap"] = jsonata1(func(a any) (any, error) { + list, ok := a.([]any) + if !ok { + return nil, fmt.Errorf("$fromAWSMap: expected []any, got %T", a) + } + out := make(map[string]any) + for _, item := range list { + m, ok := item.(map[string]any) + if !ok { + continue + } + out[conv.ToString(m["Name"])] = m["Value"] + } + return out, nil + }) +} + +func addConvFuncs(m map[string]gnata.CustomFunc, cf *funcs.ConvFuncs) { + m["toBool"] = jsonata1(func(a any) (any, error) { return cf.ToBool(a), nil }) + m["toInt"] = jsonata1(func(a any) (any, error) { return cf.ToInt(a), nil }) + m["toFloat64"] = jsonata1(func(a any) (any, error) { return cf.ToFloat64(a), nil }) + m["toString"] = jsonata1(func(a any) (any, error) { return cf.ToString(a), nil }) + m["default"] = jsonata2(func(def, in any) (any, error) { return cf.Default(def, in), nil }) + m["join"] = jsonata2(func(in, sep any) (any, error) { return cf.Join(in, conv.ToString(sep)) }) +} + +func addTemplateFuncs(m map[string]gnata.CustomFunc) { + m["f"] = func(args []any, _ any) (any, error) { + if len(args) != 2 { + return nil, fmt.Errorf("$f: expected 2 arguments (format, data), got %d", len(args)) + } + format := conv.ToString(args[0]) + data := args[1] + env := map[string]any{} + switch v := data.(type) { + case map[string]any: + env = v + case map[string]string: + for k, val := range v { + env[k] = val + } + default: + env["data"] = v + } + st := StructTemplater{ + Context: newContext(), + Values: env, + ValueFunctions: true, + DelimSets: []Delims{ + {Left: "$(", Right: ")"}, + {Left: "{{", Right: "}}"}, + }, + } + return st.Template(format) + } +} + +// jsonata0 wraps a 0-arg function as a gnata.CustomFunc. +func jsonata0(fn func() (any, error)) gnata.CustomFunc { + return func(args []any, _ any) (any, error) { + return fn() + } +} + +// jsonata1 wraps a 1-arg function as a gnata.CustomFunc. +func jsonata1(fn func(any) (any, error)) gnata.CustomFunc { + return func(args []any, _ any) (any, error) { + if len(args) != 1 { + return nil, fmt.Errorf("expected 1 argument, got %d", len(args)) + } + return fn(args[0]) + } +} + +// jsonata2 wraps a 2-arg function as a gnata.CustomFunc. +func jsonata2(fn func(any, any) (any, error)) gnata.CustomFunc { + return func(args []any, _ any) (any, error) { + if len(args) != 2 { + return nil, fmt.Errorf("expected 2 arguments, got %d", len(args)) + } + return fn(args[0], args[1]) + } +} + +// jsonata3 wraps a 3-arg function as a gnata.CustomFunc. +func jsonata3(fn func(any, any, any) (any, error)) gnata.CustomFunc { + return func(args []any, _ any) (any, error) { + if len(args) != 3 { + return nil, fmt.Errorf("expected 3 arguments, got %d", len(args)) + } + return fn(args[0], args[1], args[2]) + } +} + +// jsonataV wraps a variadic function as a gnata.CustomFunc. +func jsonataV(fn func([]any) (any, error)) gnata.CustomFunc { + return func(args []any, _ any) (any, error) { + return fn(args) + } +} diff --git a/kubernetes/quantity.go b/kubernetes/quantity.go index b84398363..e162c490d 100644 --- a/kubernetes/quantity.go +++ b/kubernetes/quantity.go @@ -9,7 +9,7 @@ import ( "github.com/google/cel-go/common/types/ref" ) -func _k8sCPUAsMillicores(objVal string) int64 { +func CPUAsMillicores(objVal string) int64 { if strings.HasSuffix(objVal, "m") { return conv.ToInt64(strings.ReplaceAll(objVal, "m", "")) } @@ -28,13 +28,13 @@ func k8sCPUAsMillicores() cel.EnvOption { cel.IntType, cel.UnaryBinding(func(obj ref.Val) ref.Val { objVal := conv.ToString(obj.Value()) - return types.Int(_k8sCPUAsMillicores(objVal)) + return types.Int(CPUAsMillicores(objVal)) }), ), ) } -func _k8sMemoryAsBytes(objVal string) int64 { +func MemoryAsBytes(objVal string) int64 { objVal = strings.ToLower(objVal) var memory int64 @@ -58,7 +58,7 @@ func k8sMemoryAsBytes() cel.EnvOption { cel.IntType, cel.UnaryBinding(func(obj ref.Val) ref.Val { objVal := conv.ToString(obj.Value()) - return types.Int(_k8sMemoryAsBytes(objVal)) + return types.Int(MemoryAsBytes(objVal)) }), ), ) diff --git a/kubernetes/topology.go b/kubernetes/topology.go index 4d150e7d8..26a791caf 100644 --- a/kubernetes/topology.go +++ b/kubernetes/topology.go @@ -103,7 +103,7 @@ func getPodResources(input any, resourceType string, allocType string) int64 { for _, container := range pod.Spec.Containers { mem := container.Resources.Limits.Memory() if mem != nil { - totalMemBytes += _k8sMemoryAsBytes(mem.String()) + totalMemBytes += MemoryAsBytes(mem.String()) } } return totalMemBytes @@ -114,7 +114,7 @@ func getPodResources(input any, resourceType string, allocType string) int64 { for _, container := range pod.Spec.Containers { mem := container.Resources.Requests.Memory() if mem != nil { - totalMemBytes += _k8sMemoryAsBytes(mem.String()) + totalMemBytes += MemoryAsBytes(mem.String()) } } return totalMemBytes @@ -125,7 +125,7 @@ func getPodResources(input any, resourceType string, allocType string) int64 { for _, container := range pod.Spec.Containers { cpu := container.Resources.Limits.Cpu() if cpu != nil { - totalCPU += _k8sCPUAsMillicores(cpu.String()) + totalCPU += CPUAsMillicores(cpu.String()) } } return totalCPU @@ -137,7 +137,7 @@ func getPodResources(input any, resourceType string, allocType string) int64 { for _, container := range pod.Spec.Containers { cpu := container.Resources.Requests.Cpu() if cpu != nil { - totalCPU += _k8sCPUAsMillicores(cpu.String()) + totalCPU += CPUAsMillicores(cpu.String()) } } return totalCPU @@ -162,7 +162,7 @@ func PodComponentProperties(input any) []map[string]any { for _, container := range pod.Spec.Containers { cpu := container.Resources.Limits.Cpu() if cpu != nil { - totalCPU += _k8sCPUAsMillicores(cpu.String()) + totalCPU += CPUAsMillicores(cpu.String()) } } @@ -170,7 +170,7 @@ func PodComponentProperties(input any) []map[string]any { for _, container := range pod.Spec.Containers { mem := container.Resources.Limits.Memory() if mem != nil { - totalMemBytes += _k8sMemoryAsBytes(mem.String()) + totalMemBytes += MemoryAsBytes(mem.String()) } } @@ -210,9 +210,9 @@ func NodeComponentProperties(input any) []map[string]any { return nil } - totalCPU := _k8sCPUAsMillicores(node.Status.Allocatable.Cpu().String()) - totalMemBytes := _k8sMemoryAsBytes(node.Status.Allocatable.Memory().String()) - totalStorage := _k8sMemoryAsBytes(node.Status.Allocatable.StorageEphemeral().String()) + totalCPU := CPUAsMillicores(node.Status.Allocatable.Cpu().String()) + totalMemBytes := MemoryAsBytes(node.Status.Allocatable.Memory().String()) + totalStorage := MemoryAsBytes(node.Status.Allocatable.StorageEphemeral().String()) return []map[string]any{ {"name": "cpu", "max": totalCPU, "unit": "millicores", "headline": true}, diff --git a/template.go b/template.go index 1e5bbe38d..ef2db60f0 100644 --- a/template.go +++ b/template.go @@ -44,6 +44,7 @@ type Template struct { JSONPath string `yaml:"jsonPath,omitempty" json:"jsonPath,omitempty"` Expression string `yaml:"expr,omitempty" json:"expr,omitempty"` // A cel-go expression Javascript string `yaml:"javascript,omitempty" json:"javascript,omitempty"` + Jsonata string `yaml:"jsonata,omitempty" json:"jsonata,omitempty"` // A JSONata expression RightDelim string `yaml:"-" json:"-"` LeftDelim string `yaml:"-" json:"-"` @@ -72,6 +73,9 @@ func (t Template) String() string { if t.JSONPath != "" { return "jsonpath: " + t.JSONPath } + if t.Jsonata != "" { + return "jsonata: " + t.Jsonata + } return "" } @@ -88,6 +92,9 @@ func (t Template) ShortString() string { if t.JSONPath != "" { return "jsonpath: " + short(t.JSONPath) } + if t.Jsonata != "" { + return "jsonata: " + short(t.Jsonata) + } return "" } @@ -116,7 +123,8 @@ func (t Template) CacheKey(env map[string]any) string { t.Expression + t.Javascript + t.JSONPath + - t.Template + t.Template + + t.Jsonata } func (t Template) IsCacheable() bool { @@ -133,7 +141,7 @@ func (t Template) IsCacheable() bool { } func (t Template) IsEmpty() bool { - return t.Template == "" && t.JSONPath == "" && t.Expression == "" && t.Javascript == "" + return t.Template == "" && t.JSONPath == "" && t.Expression == "" && t.Javascript == "" && t.Jsonata == "" } func RunExpression(_environment map[string]any, template Template) (any, error) { @@ -270,6 +278,15 @@ func RunTemplateContext(ctx commonsContext.Context, environment map[string]any, return fmt.Sprintf("%v", out), nil } + // JSONata + if template.Jsonata != "" { + out, err := RunJSONata(environment, template.Jsonata) + if err != nil { + return "", err + } + return jsonataToString(out), nil + } + return "", nil } diff --git a/tests/jsonata_test.go b/tests/jsonata_test.go new file mode 100644 index 000000000..84c357d22 --- /dev/null +++ b/tests/jsonata_test.go @@ -0,0 +1,445 @@ +package tests + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/flanksource/gomplate/v3" +) + +// runJSONataTests is a helper that runs a slice of Test cases using the Jsonata field. +func runJSONataTests(t *testing.T, tests []Test) { + t.Helper() + for _, tc := range tests { + tc := tc + t.Run(tc.expression, func(t *testing.T) { + out, err := gomplate.RunTemplate(tc.env, gomplate.Template{ + Jsonata: tc.expression, + }) + assert.NoError(t, err) + assert.Equal(t, tc.out, out) + }) + } +} + +// TestJSONataBasic covers basic field-access, arithmetic, and comparisons. +func TestJSONataBasic(t *testing.T) { + m := map[string]any{ + "x": map[string]any{ + "a": "b", + "c": 1, + "d": true, + }, + } + + runJSONataTests(t, []Test{ + {m, "x.a", "b"}, + {m, "x.d", "true"}, + {nil, "1 + 2", "3"}, + {nil, "10 - 3", "7"}, + {nil, "3 * 4", "12"}, + {nil, "10 / 4", "2.5"}, + {nil, "1 = 1", "true"}, + {nil, "1 != 2", "true"}, + {nil, "1 < 2", "true"}, + {nil, `"hello" & " " & "world"`, "hello world"}, + }) +} + +// TestJSONataStrings exercises built-in JSONata string functions. +func TestJSONataStrings(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$length("hello")`, "5"}, + {nil, `$substring("hello world", 6)`, "world"}, + {nil, `$substringBefore("hello world", " ")`, "hello"}, + {nil, `$substringAfter("hello world", " ")`, "world"}, + {nil, `$trim(" hello ")`, "hello"}, + {nil, `$uppercase("hello")`, "HELLO"}, + {nil, `$lowercase("HELLO")`, "hello"}, + {nil, `$join(["a", "b", "c"], ",")`, "a,b,c"}, + {nil, `$contains("hello world", "world")`, "true"}, + {nil, `$split("a,b,c", ",")`, "[a b c]"}, + }) +} + +// TestJSONataStringsFuncs tests gomplate-specific string functions. +func TestJSONataStringsFuncs(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$replaceAll("o", "0", "foo bar")`, "f00 bar"}, + {nil, `$trimPrefix("hello-", "hello-world")`, "world"}, + {nil, `$trimSuffix("-world", "hello-world")`, "hello"}, + {nil, `$title("hello world")`, "Hello World"}, + {nil, `$toUpper("hello")`, "HELLO"}, + {nil, `$toLower("HELLO")`, "hello"}, + {nil, `$trimSpace(" hello ")`, "hello"}, + {nil, `$trunc(5, "hello world")`, "hello"}, + {nil, `$slug("Hello World!")`, "hello-world"}, + {nil, `$quote("hello")`, `"hello"`}, + {nil, `$squote("hello")`, "'hello'"}, + {nil, `$snakeCase("Hello World")`, "Hello_world"}, + {nil, `$camelCase("hello world")`, "helloWorld"}, + {nil, `$kebabCase("Hello World")`, "Hello-world"}, + {nil, `$hasPrefix("hello", "hello world")`, "true"}, + {nil, `$hasSuffix("world", "hello world")`, "true"}, + {nil, `$repeat(3, "ab")`, "ababab"}, + }) +} + +// TestJSONataArrays exercises built-in JSONata array functions. +func TestJSONataArrays(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$count([1, 2, 3])`, "3"}, + {nil, `$sum([1, 2, 3, 4, 5])`, "15"}, + {nil, `$max([1, 2, 3])`, "3"}, + {nil, `$min([1, 2, 3])`, "1"}, + {nil, `$reverse([1, 2, 3])`, "[3 2 1]"}, + {nil, `$sort([3, 1, 2])`, "[1 2 3]"}, + {nil, `$distinct([1, 2, 2, 3, 3])`, "[1 2 3]"}, + {nil, `$flatten([1, [2, 3], [4, [5]]])`, "[1 2 3 4 5]"}, + }) +} + +// TestJSONataCollFuncs tests gomplate-specific collection functions. +func TestJSONataCollFuncs(t *testing.T) { + env := map[string]any{ + "m": map[string]any{"a": 1, "b": 2, "c": 3}, + "l": []any{3, 1, 2}, + } + + runJSONataTests(t, []Test{ + {env, `$has(m, "a")`, "true"}, + {env, `$has(m, "z")`, "false"}, + {env, `$first(l)`, "3"}, + {env, `$last(l)`, "2"}, + {env, `$coalesce(null, "", "hello")`, "hello"}, + }) +} + +// TestJSONataMath exercises built-in JSONata numeric functions. +func TestJSONataMath(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$abs(-5)`, "5"}, + {nil, `$floor(5.6)`, "5"}, + {nil, `$ceil(5.4)`, "6"}, + {nil, `$round(5.5)`, "6"}, + {nil, `$power(2, 8)`, "256"}, + {nil, `$sqrt(9)`, "3"}, + }) +} + +// TestJSONataMathFuncs tests gomplate-specific math functions. +func TestJSONataMathFuncs(t *testing.T) { + runJSONataTests(t, []Test{ + // Note: JSONata passes all numbers as float64, so mathIsInt sees float64 + {nil, `$mathIsInt("42")`, "true"}, + {nil, `$mathIsFloat(3.14)`, "true"}, + {nil, `$mathIsNum(42)`, "true"}, + {nil, `$mathAdd(1, 2, 3)`, "6"}, + {nil, `$mathSub(10, 3)`, "7"}, + {nil, `$mathMul(2, 3)`, "6"}, + {nil, `$mathDiv(10, 4)`, "2.5"}, + {nil, `$mathRem(10, 3)`, "1"}, + {nil, `$mathPow(2, 8)`, "256"}, + }) +} + +// TestJSONataObjects exercises built-in JSONata object functions. +func TestJSONataObjects(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$count($keys({"a": 1, "b": 2}))`, "2"}, + {nil, `$sum($values({"a": 1, "b": 2}))`, "3"}, + {nil, `$merge([{"a": 1}, {"b": 2}]).a`, "1"}, + }) +} + +// TestJSONataCrypto covers the gomplate-specific crypto functions. +func TestJSONataCrypto(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$sha1("hello")`, "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"}, + {nil, `$sha224("hello")`, "ea09ae9cc6768c50fcee903ed054556e5bfc8347907f12598aa24193"}, + {nil, `$sha256("hello")`, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}, + {nil, `$sha384("hello")`, "59e1748777448c69de6b800d7a33bbfb9ff1b463e44354c3553bcdb9c666fa90125a3c79f90397bdf5f6a13de828684f"}, + {nil, `$sha512("hello")`, "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"}, + }) +} + +// TestJSONataData covers data serialisation functions. +func TestJSONataData(t *testing.T) { + runJSONataTests(t, []Test{ + {map[string]any{"name": "Alice"}, `$toJSON({"key": "val"})`, `{"key":"val"}`}, + {nil, `$fromJSON("{\"name\":\"Alice\"}").name`, "Alice"}, + {nil, `$toYAML({"key": "val"})`, "key: val\n"}, + }) +} + +// TestJSONataDataExtra covers additional data format functions. +func TestJSONataDataExtra(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$fromJSONArray("[1,2,3]")`, "[1 2 3]"}, + }) +} + +// TestJSONataRegexp covers the gomplate-specific regexp functions. +func TestJSONataRegexp(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$regexpMatch("[0-9]+", "abc123")`, "true"}, + {nil, `$regexpMatch("[0-9]+", "abc")`, "false"}, + {nil, `$regexpFind("[0-9]+", "abc123def")`, "123"}, + {nil, `$regexpReplace("[0-9]+", "NUM", "abc123def456")`, "abcNUMdefNUM"}, + {nil, `$regexpQuoteMeta("a.b")`, `a\.b`}, + }) +} + +// TestJSONataJQ covers the $jq custom function. +func TestJSONataJQ(t *testing.T) { + env := map[string]any{ + "i": map[string]any{ + "name": "Aditya", + "Address": map[string]any{ + "city_name": "Kathmandu", + }, + }, + } + runJSONataTests(t, []Test{ + {env, `$jq(".Address.city_name", i)`, "Kathmandu"}, + }) +} + +// TestJSONataGoTemplateFunc covers the $f function. +func TestJSONataGoTemplateFunc(t *testing.T) { + runJSONataTests(t, []Test{ + { + map[string]any{"row": map[string]any{"id": 123, "name": "test"}}, + `$f("{{id}}", row)`, + "123", + }, + { + map[string]any{"row": map[string]any{"user": map[string]string{"name": "john"}}}, + `$f("Hello $(.user.name)", row)`, + "Hello john", + }, + }) +} + +// TestJSONataUUID tests UUID functions. +func TestJSONataUUID(t *testing.T) { + t.Run("uuidV4 returns valid UUID", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$uuidV4()`}) + assert.NoError(t, err) + assert.Len(t, out, 36) + assert.Contains(t, out, "-") + }) + + t.Run("uuidNil returns nil UUID", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$uuidNil()`}) + assert.NoError(t, err) + assert.Equal(t, "00000000-0000-0000-0000-000000000000", out) + }) + + t.Run("uuidIsValid", func(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$uuidIsValid("550e8400-e29b-41d4-a716-446655440000")`, "true"}, + {nil, `$uuidIsValid("not-a-uuid")`, "false"}, + }) + }) + + t.Run("uuidHash is deterministic", func(t *testing.T) { + out1, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$uuidHash("hello")`}) + assert.NoError(t, err) + out2, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$uuidHash("hello")`}) + assert.NoError(t, err) + assert.Equal(t, out1, out2) + assert.Len(t, out1, 36) + }) +} + +// TestJSONataFilepath tests filepath functions. +func TestJSONataFilepath(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$filepathBase("/foo/bar/baz.txt")`, "baz.txt"}, + {nil, `$filepathDir("/foo/bar/baz.txt")`, "/foo/bar"}, + {nil, `$filepathExt("/foo/bar/baz.txt")`, ".txt"}, + {nil, `$filepathJoin("/foo", "bar", "baz.txt")`, "/foo/bar/baz.txt"}, + {nil, `$filepathIsAbs("/foo/bar")`, "true"}, + {nil, `$filepathIsAbs("foo/bar")`, "false"}, + {nil, `$filepathClean("/foo/../bar")`, "/bar"}, + }) +} + +// TestJSONataNet tests network functions. +func TestJSONataNet(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$netIsValidIP("192.168.1.1")`, "true"}, + {nil, `$netIsValidIP("not-an-ip")`, "false"}, + {nil, `$netContainsCIDR("192.168.1.0/24", "192.168.1.100")`, "true"}, + {nil, `$netContainsCIDR("192.168.1.0/24", "192.168.2.100")`, "false"}, + }) +} + +// TestJSONataConv tests conversion functions. +func TestJSONataConv(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$toBool("true")`, "true"}, + {nil, `$toBool("false")`, "false"}, + {nil, `$toInt(3.7)`, "3"}, + {nil, `$toFloat64("3.14")`, "3.14"}, + {nil, `$toString(42)`, "42"}, + {nil, `$default("fallback", "")`, "fallback"}, + {nil, `$default("fallback", "value")`, "value"}, + {nil, `$join(["a", "b", "c"], ",")`, "a,b,c"}, + }) +} + +// TestJSONataTest tests test/assert functions. +func TestJSONataTestFuncs(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$testTernary("yes", "no", true)`, "yes"}, + {nil, `$testTernary("yes", "no", false)`, "no"}, + // Note: JSONata passes all numbers as float64 + {nil, `$testKind(42)`, "float64"}, + {nil, `$testKind("hello")`, "string"}, + {nil, `$testIsKind("string", "hello")`, "true"}, + {nil, `$testIsKind("int", "hello")`, "false"}, + }) +} + +// TestJSONataRandom tests random functions (just verifying they don't error). +func TestJSONataRandom(t *testing.T) { + t.Run("randomAlpha returns string of correct length", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$randomAlpha(10)`}) + assert.NoError(t, err) + assert.Len(t, out, 10) + }) + + t.Run("randomAlphaNum returns string of correct length", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$randomAlphaNum(8)`}) + assert.NoError(t, err) + assert.Len(t, out, 8) + }) + + t.Run("randomNumber returns a number", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$randomNumber(1, 100)`}) + assert.NoError(t, err) + assert.NotEmpty(t, out) + }) +} + +// TestJSONataK8s tests Kubernetes functions. +func TestJSONataK8s(t *testing.T) { + env := map[string]any{ + "pod": map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "test-pod", + "namespace": "default", + }, + "status": map[string]any{ + "phase": "Running", + "conditions": []any{ + map[string]any{ + "type": "Ready", + "status": "True", + }, + }, + }, + }, + } + + runJSONataTests(t, []Test{ + {env, `$isHealthy(pod)`, "true"}, + {env, `$isReady(pod)`, "true"}, + {env, `$getStatus(pod)`, "Running: "}, + }) + + t.Run("k8sCPUAsMillicores", func(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$k8sCPUAsMillicores("500m")`, "500"}, + {nil, `$k8sCPUAsMillicores("1")`, "1000"}, + }) + }) + + t.Run("k8sMemoryAsBytes", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$k8sMemoryAsBytes("1Gi")`}) + assert.NoError(t, err) + assert.Equal(t, "1073741824", out) + }) +} + +// TestJSONataAWS tests AWS functions. +func TestJSONataAWS(t *testing.T) { + runJSONataTests(t, []Test{ + {nil, `$arnToMap("arn:aws:s3:us-east-1:123456789:my-bucket").service`, "s3"}, + {nil, `$arnToMap("arn:aws:s3:us-east-1:123456789:my-bucket").region`, "us-east-1"}, + {nil, `$arnToMap("arn:aws:s3:us-east-1:123456789:my-bucket").account`, "123456789"}, + {nil, `$arnToMap("arn:aws:s3:us-east-1:123456789:my-bucket").resource`, "my-bucket"}, + }) +} + +// TestJSONataTemplate verifies that the Jsonata field on Template is honoured. +func TestJSONataTemplate(t *testing.T) { + tests := []struct { + name string + env map[string]any + jsonata string + expected string + }{ + {name: "simple arithmetic", jsonata: "1 + 2", expected: "3"}, + {name: "field access", env: map[string]any{"greeting": "hello"}, jsonata: "greeting", expected: "hello"}, + {name: "boolean result", jsonata: "1 = 1", expected: "true"}, + {name: "string concat", jsonata: `"foo" & "bar"`, expected: "foobar"}, + {name: "null / missing returns empty string", jsonata: "missing_field", expected: ""}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + out, err := gomplate.RunTemplate(tc.env, gomplate.Template{Jsonata: tc.jsonata}) + assert.NoError(t, err) + assert.Equal(t, tc.expected, out) + }) + } +} + +// TestJSONataBuiltinPrecedence verifies gnata built-ins take precedence. +func TestJSONataBuiltinPrecedence(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$string(42)`}) + assert.NoError(t, err) + assert.Equal(t, "42", out) + + out, err = gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$count([1, 2, 3])`}) + assert.NoError(t, err) + assert.Equal(t, "3", out) +} + +// TestJSONataTime tests time-related functions. +func TestJSONataTime(t *testing.T) { + t.Run("timeNow returns non-empty", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$timeNow()`}) + assert.NoError(t, err) + assert.NotEmpty(t, out) + assert.True(t, strings.Contains(out, "T")) + }) + + t.Run("inTimeRange", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{ + Jsonata: `$inTimeRange("2024-01-15T12:00:00Z", "09:00", "17:00")`, + }) + assert.NoError(t, err) + assert.Equal(t, "true", out) + + out, err = gomplate.RunTemplate(nil, gomplate.Template{ + Jsonata: `$inTimeRange("2024-01-15T22:00:00Z", "09:00", "17:00")`, + }) + assert.NoError(t, err) + assert.Equal(t, "false", out) + }) + + t.Run("parseDuration", func(t *testing.T) { + out, err := gomplate.RunTemplate(nil, gomplate.Template{Jsonata: `$parseDuration("1h30m")`}) + assert.NoError(t, err) + assert.Equal(t, "1h30m0s", out) + }) +}