topdown: Add json.marshal_with_options() builtin for indented/"pretty-printed" and/or line-prefixed JSON (#6636)

Fixes #6630

Signed-off-by: Sean Williams <72675818+sean-r-williams@users.noreply.github.com>
This commit is contained in:
Sean Williams
2024-04-05 01:11:01 -07:00
committed by GitHub
parent a1cefe20cd
commit e0ee7418b0
6 changed files with 336 additions and 0 deletions
+22
View File
@@ -142,6 +142,7 @@ var DefaultBuiltins = [...]*Builtin{
// Encoding
JSONMarshal,
JSONMarshalWithOptions,
JSONUnmarshal,
JSONIsValid,
Base64Encode,
@@ -1707,6 +1708,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.",
+26
View File
@@ -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": [
{
+45
View File
@@ -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": {
+12
View File
@@ -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, <br/>`false` otherwise | Enables multi-line, human-readable JSON output ("pretty-printing"). <br/>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"`` <br/> (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`` | ``""`` <br/> (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.
@@ -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
+87
View File
@@ -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)