Skip to content

Commit

Permalink
topdown: Add json.marshal_with_options() builtin for indented/"pret…
Browse files Browse the repository at this point in the history
…ty-printed" and/or line-prefixed JSON (open-policy-agent#6636)

Fixes open-policy-agent#6630

Signed-off-by: Sean Williams <72675818+sean-r-williams@users.noreply.github.com>
Signed-off-by: Thomas Sidebottom <thomas.sidebottom@va.gov>
  • Loading branch information
sean-r-williams authored and tsidebottom committed Apr 17, 2024
1 parent 322ad38 commit 18af050
Show file tree
Hide file tree
Showing 6 changed files with 336 additions and 0 deletions.
22 changes: 22 additions & 0 deletions ast/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ var DefaultBuiltins = [...]*Builtin{

// Encoding
JSONMarshal,
JSONMarshalWithOptions,
JSONUnmarshal,
JSONIsValid,
Base64Encode,
Expand Down Expand Up @@ -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.",
Expand Down
26 changes: 26 additions & 0 deletions builtin_metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"hex.encode",
"json.is_valid",
"json.marshal",
"json.marshal_with_options",
"json.unmarshal",
"urlquery.decode",
"urlquery.decode_object",
Expand Down Expand Up @@ -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": [
{
Expand Down
45 changes: 45 additions & 0 deletions capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions docs/content/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
144 changes: 144 additions & 0 deletions test/cases/testdata/jsonbuiltins/test-json-marshal-with-options.yaml
Original file line number Diff line number Diff line change
@@ -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 changes: 87 additions & 0 deletions topdown/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 18af050

Please sign in to comment.