diff --git a/ast/builtins.go b/ast/builtins.go index 37d3201790..413c7dc368 100644 --- a/ast/builtins.go +++ b/ast/builtins.go @@ -142,6 +142,7 @@ var DefaultBuiltins = [...]*Builtin{ // Encoding JSONMarshal, + JSONMarshalWithOptions, JSONUnmarshal, JSONIsValid, Base64Encode, @@ -1710,6 +1711,27 @@ var JSONMarshal = &Builtin{ Categories: encoding, } +var JSONMarshalWithOptions = &Builtin{ + Name: "json.marshal_with_options", + Description: "Serializes the input term JSON, with additional formatting options via the `opts` parameter. " + + "`opts` accepts keys `pretty` (enable multi-line/formatted JSON), `prefix` (string to prefix lines with, default empty string) and `indent` (string to indent with, default `\\t`).", + Decl: types.NewFunction( + types.Args( + types.Named("x", types.A).Description("the term to serialize"), + types.Named("opts", types.NewObject( + []*types.StaticProperty{ + types.NewStaticProperty("pretty", types.B), + types.NewStaticProperty("indent", types.S), + types.NewStaticProperty("prefix", types.S), + }, + types.NewDynamicProperty(types.S, types.A), + )).Description("encoding options"), + ), + types.Named("y", types.S).Description("the JSON string representation of `x`, with configured prefix/indent string(s) as appropriate"), + ), + Categories: encoding, +} + var JSONUnmarshal = &Builtin{ Name: "json.unmarshal", Description: "Deserializes the input string.", diff --git a/builtin_metadata.json b/builtin_metadata.json index 87e8c51ec6..0d5ddd63ef 100644 --- a/builtin_metadata.json +++ b/builtin_metadata.json @@ -60,6 +60,7 @@ "hex.encode", "json.is_valid", "json.marshal", + "json.marshal_with_options", "json.unmarshal", "urlquery.decode", "urlquery.decode_object", @@ -10476,6 +10477,31 @@ }, "wasm": true }, + "json.marshal_with_options": { + "args": [ + { + "description": "the term to serialize", + "name": "x", + "type": "any" + }, + { + "description": "encoding options", + "name": "opts", + "type": "object\u003cindent: string, prefix: string, pretty: boolean\u003e[string: any]" + } + ], + "available": [ + "edge" + ], + "description": "Serializes the input term JSON, with additional formatting options via the `opts` parameter. `opts` accepts keys `pretty` (enable multi-line/formatted JSON), `prefix` (string to prefix lines with, default empty string) and `indent` (string to indent with, default `\\t`).", + "introduced": "edge", + "result": { + "description": "the JSON string representation of `x`, with configured prefix/indent string(s) as appropriate", + "name": "y", + "type": "string" + }, + "wasm": false + }, "json.match_schema": { "args": [ { diff --git a/capabilities.json b/capabilities.json index f3d03f5f4c..06c04773c1 100644 --- a/capabilities.json +++ b/capabilities.json @@ -2191,6 +2191,51 @@ "type": "function" } }, + { + "name": "json.marshal_with_options", + "decl": { + "args": [ + { + "type": "any" + }, + { + "dynamic": { + "key": { + "type": "string" + }, + "value": { + "type": "any" + } + }, + "static": [ + { + "key": "indent", + "value": { + "type": "string" + } + }, + { + "key": "prefix", + "value": { + "type": "string" + } + }, + { + "key": "pretty", + "value": { + "type": "boolean" + } + } + ], + "type": "object" + } + ], + "result": { + "type": "string" + }, + "type": "function" + } + }, { "name": "json.match_schema", "decl": { diff --git a/docs/content/policy-reference.md b/docs/content/policy-reference.md index 5fe72f5d50..4fccb934af 100644 --- a/docs/content/policy-reference.md +++ b/docs/content/policy-reference.md @@ -414,6 +414,18 @@ The following table shows examples of how ``glob.match`` works: {{< builtin-table types >}} {{< builtin-table encoding >}} +The `json.marshal_with_options` builtin's `opts` parameter accepts the following properties: + +| Field | Required | Type | Default | Description | +| :---- | :------- | :--- | :------ | :---------- | +| ``pretty`` | No | ``bool`` | `true` if `indent` or `prefix` are declared,
`false` otherwise | Enables multi-line, human-readable JSON output ("pretty-printing").
If this property is `true`, then objects will be marshaled into multi-line JSON with either user-specified or default indent/prefix options. If this property is `false`, `indent`/`prefix` will be ignored and this builtin functions identically to `json.marshal()`. | +| ``indent`` | No | ``string`` | ``"\t"``
(Horizontal tab, character 0x09) | The string to use when indenting nested keys in the emitted JSON. One or more copies of this string will be included before child elements in every object or array. | +| ``prefix`` | No | ``string`` | ``""``
(empty) | The string to prefix lines with in the emitted JSON. One copy of this string will be prepended to each line. | + +Default values will be used if: +* `opts` is an empty object. +* `opts` does not contain the named property. + {{< builtin-table cat=tokensign title="Token Signing" >}} OPA provides two builtins that implement JSON Web Signature [RFC7515](https://tools.ietf.org/html/rfc7515) functionality. diff --git a/test/cases/testdata/jsonbuiltins/test-json-marshal-with-options.yaml b/test/cases/testdata/jsonbuiltins/test-json-marshal-with-options.yaml new file mode 100644 index 0000000000..f4ad2b6dbe --- /dev/null +++ b/test/cases/testdata/jsonbuiltins/test-json-marshal-with-options.yaml @@ -0,0 +1,144 @@ +--- +cases: + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([1234500000 + 67890, 1e6 * 2, 1e109 / 1e100], {"indent": " "}) + } + note: jsonbuiltins/marshal_with_options-explicit-indent + query: data.test.p = x + want_result: + - x: |- + [ + 1234567890, + 2000000, + 1000000000 + ] + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([1234500000 + 67890, 1e6 * 2, 1e109 / 1e100], {}) + } + note: jsonbuiltins/marshal_with_options-empty-object + query: data.test.p = x + want_result: + - x: "[1234567890,2000000,1000000000]" + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([1234500000 + 67890, 1e6 * 2, 1e109 / 1e100], {"pretty": true}) + } + note: jsonbuiltins/marshal_with_options-defaults + query: data.test.p = x + want_result: + - x: |- + [ + 1234567890, + 2000000, + 1000000000 + ] + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([1234500000 + 67890, 1e6 * 2, 1e109 / 1e100], {"pretty": false, "prefix": "NO!", "indent": "BAD!"}) + } + note: jsonbuiltins/marshal_with_options-explicit-disable + query: data.test.p = x + want_result: + - x: "[1234567890,2000000,1000000000]" + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([1234500000 + 67890, 1e6 * 2, 1e109 / 1e100], {"prefix": "JSON => "}) + } + note: jsonbuiltins/marshal_with_options-prefix + query: data.test.p = x + want_result: + - x: |- + JSON => [ + JSON => 1234567890, + JSON => 2000000, + JSON => 1000000000 + JSON => ] + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options({"foo": "bar", "bar": "baz"}, {"prefix": "JSON => "}) + } + note: jsonbuiltins/marshal_with_options-object + query: data.test.p = x + want_result: + - x: |- + JSON => { + JSON => "bar": "baz", + JSON => "foo": "bar" + JSON => } + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([], {"indent": " ", "prefix": "---"}) + } + note: jsonbuiltins/marshal_with_options-empty-array + query: data.test.p = x + want_result: + - x: |- + ---[] + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([[[[[[[]]]]]]], {"indent": " "}) + } + note: jsonbuiltins/marshal_with_options-deep-array + query: data.test.p = x + want_result: + - x: |- + [ + [ + [ + [ + [ + [ + [] + ] + ] + ] + ] + ] + ] + - data: {} + modules: + - | + package test + + p = x { + x := json.marshal_with_options([], {"indent": " ", "include_winning_lottery_numbers": true}) + } + note: jsonbuiltins/marshal_with_options-invalid-key + query: data.test.p = x + strict_error: true + want_error: object contained unknown key "include_winning_lottery_numbers" + want_error_code: eval_type_error diff --git a/topdown/encoding.go b/topdown/encoding.go index 19ba323d19..f3475a60d0 100644 --- a/topdown/encoding.go +++ b/topdown/encoding.go @@ -35,6 +35,92 @@ func builtinJSONMarshal(_ BuiltinContext, operands []*ast.Term, iter func(*ast.T return iter(ast.StringTerm(string(bs))) } +func builtinJSONMarshalWithOpts(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { + + asJSON, err := ast.JSON(operands[0].Value) + if err != nil { + return err + } + + indentWith := "\t" + prefixWith := "" + implicitPrettyPrint := false + userDeclaredExplicitPrettyPrint := false + shouldPrettyPrint := false + + marshalOpts, err := builtins.ObjectOperand(operands[1].Value, 2) + if err != nil { + return err + } + + for idx, k := range marshalOpts.Keys() { + + val := marshalOpts.Get(k) + + key, err := builtins.StringOperand(k.Value, idx) + if err != nil { + return builtins.NewOperandErr(2, "failed to stringify key %v at index %d: %v", k, idx, err) + } + + switch key { + + case "prefix": + prefixOpt, err := builtins.StringOperand(val.Value, idx) + if err != nil { + return builtins.NewOperandErr(2, "key %s failed cast to string: %v", key, err) + } + prefixWith = string(prefixOpt) + implicitPrettyPrint = true + + case "indent": + indentOpt, err := builtins.StringOperand(val.Value, idx) + if err != nil { + return builtins.NewOperandErr(2, "key %s failed cast to string: %v", key, err) + + } + indentWith = string(indentOpt) + implicitPrettyPrint = true + + case "pretty": + userDeclaredExplicitPrettyPrint = true + explicitPrettyPrint, ok := val.Value.(ast.Boolean) + if !ok { + return builtins.NewOperandErr(2, "key %s failed cast to bool", key) + } + + shouldPrettyPrint = bool(explicitPrettyPrint) + + default: + return builtins.NewOperandErr(2, "object contained unknown key %s", key) + } + + } + + if !userDeclaredExplicitPrettyPrint { + shouldPrettyPrint = implicitPrettyPrint + } + + var bs []byte + + if shouldPrettyPrint { + bs, err = json.MarshalIndent(asJSON, prefixWith, indentWith) + } else { + bs, err = json.Marshal(asJSON) + } + + if err != nil { + return err + } + + if shouldPrettyPrint { + // json.MarshalIndent() function will not prefix the first line of emitted JSON + return iter(ast.StringTerm(prefixWith + string(bs))) + } + + return iter(ast.StringTerm(string(bs))) + +} + func builtinJSONUnmarshal(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Term) error) error { str, err := builtins.StringOperand(operands[0].Value, 1) @@ -299,6 +385,7 @@ func builtinHexDecode(_ BuiltinContext, operands []*ast.Term, iter func(*ast.Ter func init() { RegisterBuiltinFunc(ast.JSONMarshal.Name, builtinJSONMarshal) + RegisterBuiltinFunc(ast.JSONMarshalWithOptions.Name, builtinJSONMarshalWithOpts) RegisterBuiltinFunc(ast.JSONUnmarshal.Name, builtinJSONUnmarshal) RegisterBuiltinFunc(ast.JSONIsValid.Name, builtinJSONIsValid) RegisterBuiltinFunc(ast.Base64Encode.Name, builtinBase64Encode)