From 7e2325dd9fad743fcecf10aa5c51d13f37c8e7ab Mon Sep 17 00:00:00 2001 From: Drake Tetreault Date: Mon, 14 Jun 2021 12:58:57 -0700 Subject: [PATCH] Implement parse() function. The parse function operates on JSON string properties whose contents are serialized JSON entities. The function outputs the parsed JSON entity, for further selection within the serialized object. For example, given the following JSON: { "serialized": "{\"foo\": \"bar\"}" } The search expression $.serialized.parse().foo would return "bar". --- README.md | 23 +++---- .../function/PathFunctionFactory.java | 2 + .../internal/function/text/Parse.java | 27 ++++++++ .../internal/path/FunctionPathToken.java | 7 ++- .../jsonpath/internal/path/PathCompiler.java | 3 +- .../jsonpath/spi/json/GsonJsonProvider.java | 10 ++- .../internal/function/BaseFunctionTest.java | 2 +- .../internal/function/NestedFunctionTest.java | 61 +++++++++++++++++++ 8 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 json-path/src/main/java/com/jayway/jsonpath/internal/function/text/Parse.java diff --git a/README.md b/README.md index ea1bdd2e..fb828eb7 100644 --- a/README.md +++ b/README.md @@ -81,17 +81,18 @@ Functions Functions can be invoked at the tail end of a path - the input to a function is the output of the path expression. The function output is dictated by the function itself. -| Function | Description | Output type | -| :------------------------ | :------------------------------------------------------------------ |:----------- | -| min() | Provides the min value of an array of numbers | Double | -| max() | Provides the max value of an array of numbers | Double | -| avg() | Provides the average value of an array of numbers | Double | -| stddev() | Provides the standard deviation value of an array of numbers | Double | -| length() | Provides the length of an array | Integer | -| sum() | Provides the sum value of an array of numbers | Double | -| keys() | Provides the property keys (An alternative for terminal tilde `~`) | `Set` | -| concat(X) | Provides a concatinated version of the path output with a new item | like input | -| append(X) | add an item to the json path output array | like input | +| Function | Description | Output type | +| :------------------------ | :-------------------------------------------------------------------- |:------------- | +| min() | Provides the min value of an array of numbers | Double | +| max() | Provides the max value of an array of numbers | Double | +| avg() | Provides the average value of an array of numbers | Double | +| stddev() | Provides the standard deviation value of an array of numbers | Double | +| length() | Provides the length of an array | Integer | +| sum() | Provides the sum value of an array of numbers | Double | +| keys() | Provides the property keys (An alternative for terminal tilde `~`) | `Set` | +| concat(X) | Provides a concatinated version of the path output with a new item | like input | +| append(X) | add an item to the json path output array | like input | +| parse() | Parse a string property as JSON. Non-string inputs are passed through | Parsed object | Filter Operators ----------------- diff --git a/json-path/src/main/java/com/jayway/jsonpath/internal/function/PathFunctionFactory.java b/json-path/src/main/java/com/jayway/jsonpath/internal/function/PathFunctionFactory.java index 2fcd3a33..5da621d2 100644 --- a/json-path/src/main/java/com/jayway/jsonpath/internal/function/PathFunctionFactory.java +++ b/json-path/src/main/java/com/jayway/jsonpath/internal/function/PathFunctionFactory.java @@ -10,6 +10,7 @@ import com.jayway.jsonpath.internal.function.numeric.StandardDeviation; import com.jayway.jsonpath.internal.function.numeric.Sum; import com.jayway.jsonpath.internal.function.text.Concatenate; import com.jayway.jsonpath.internal.function.text.Length; +import com.jayway.jsonpath.internal.function.text.Parse; import java.util.Collections; import java.util.HashMap; @@ -40,6 +41,7 @@ public class PathFunctionFactory { // Text Functions map.put("concat", Concatenate.class); + map.put("parse", Parse.class); // JSON Entity Functions map.put(Length.TOKEN_NAME, Length.class); diff --git a/json-path/src/main/java/com/jayway/jsonpath/internal/function/text/Parse.java b/json-path/src/main/java/com/jayway/jsonpath/internal/function/text/Parse.java new file mode 100644 index 00000000..091a3aaf --- /dev/null +++ b/json-path/src/main/java/com/jayway/jsonpath/internal/function/text/Parse.java @@ -0,0 +1,27 @@ +package com.jayway.jsonpath.internal.function.text; + +import com.jayway.jsonpath.InvalidJsonException; +import com.jayway.jsonpath.internal.EvaluationContext; +import com.jayway.jsonpath.internal.PathRef; +import com.jayway.jsonpath.internal.function.Parameter; +import com.jayway.jsonpath.internal.function.PathFunction; + +import java.util.List; + +/** + * Parses a string field containing a serialized JSON object into the object itself. + */ +public class Parse implements PathFunction { + @Override + public Object invoke(String currentPath, PathRef parent, Object model, EvaluationContext ctx, List parameters) { + final Object unwrapped = ctx.configuration().jsonProvider().unwrap(model); + if (unwrapped instanceof String) { + try { + return ctx.configuration().jsonProvider().parse((String) unwrapped); + } catch (InvalidJsonException e) { + throw new InvalidJsonException(String.format("String property at path %s did not parse as valid JSON", currentPath), e); + } + } + return model; + } +} diff --git a/json-path/src/main/java/com/jayway/jsonpath/internal/path/FunctionPathToken.java b/json-path/src/main/java/com/jayway/jsonpath/internal/path/FunctionPathToken.java index 2d24b70d..9a6c74c3 100644 --- a/json-path/src/main/java/com/jayway/jsonpath/internal/path/FunctionPathToken.java +++ b/json-path/src/main/java/com/jayway/jsonpath/internal/path/FunctionPathToken.java @@ -38,8 +38,11 @@ public class FunctionPathToken extends PathToken { PathFunction pathFunction = PathFunctionFactory.newFunction(functionName); evaluateParameters(currentPath, parent, model, ctx); Object result = pathFunction.invoke(currentPath, parent, model, ctx, functionParams); - ctx.addResult(currentPath + "." + functionName, parent, result); - if (!isLeaf()) { + // If the function is the leaf token, then its output is emitted as a result. Otherwise, there are still more + // tokens to evaluate, so its output is not a result. + if (isLeaf()) { + ctx.addResult(currentPath + "." + functionName, parent, result); + } else { next().evaluate(currentPath, parent, result, ctx); } } diff --git a/json-path/src/main/java/com/jayway/jsonpath/internal/path/PathCompiler.java b/json-path/src/main/java/com/jayway/jsonpath/internal/path/PathCompiler.java index b7f4a91e..2ad8a082 100644 --- a/json-path/src/main/java/com/jayway/jsonpath/internal/path/PathCompiler.java +++ b/json-path/src/main/java/com/jayway/jsonpath/internal/path/PathCompiler.java @@ -235,7 +235,8 @@ public class PathCompiler { String functionName = path.subSequence(startPosition, endPosition).toString(); functionParameters = parseFunctionParameters(functionName); } else { - path.setPosition(readPosition + 1); + // Consume the close parenthesis at readPosition + 1 by advancing the position to readPosition + 2. + path.setPosition(readPosition + 2); } } else { diff --git a/json-path/src/main/java/com/jayway/jsonpath/spi/json/GsonJsonProvider.java b/json-path/src/main/java/com/jayway/jsonpath/spi/json/GsonJsonProvider.java index d28e7cc7..cd36b090 100644 --- a/json-path/src/main/java/com/jayway/jsonpath/spi/json/GsonJsonProvider.java +++ b/json-path/src/main/java/com/jayway/jsonpath/spi/json/GsonJsonProvider.java @@ -18,8 +18,10 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; +import com.google.gson.stream.MalformedJsonException; import com.jayway.jsonpath.InvalidJsonException; import com.jayway.jsonpath.JsonPathException; @@ -121,7 +123,11 @@ public class GsonJsonProvider extends AbstractJsonProvider { @Override public Object parse(final String json) throws InvalidJsonException { - return PARSER.parse(json); + try { + return PARSER.parse(json); + } catch (JsonParseException e) { + throw new InvalidJsonException(e); + } } @Override @@ -131,6 +137,8 @@ public class GsonJsonProvider extends AbstractJsonProvider { return PARSER.parse(new InputStreamReader(jsonStream, charset)); } catch (UnsupportedEncodingException e) { throw new JsonPathException(e); + } catch (JsonParseException e) { + throw new InvalidJsonException(e); } } diff --git a/json-path/src/test/java/com/jayway/jsonpath/internal/function/BaseFunctionTest.java b/json-path/src/test/java/com/jayway/jsonpath/internal/function/BaseFunctionTest.java index b26a2b8f..aebd4f07 100644 --- a/json-path/src/test/java/com/jayway/jsonpath/internal/function/BaseFunctionTest.java +++ b/json-path/src/test/java/com/jayway/jsonpath/internal/function/BaseFunctionTest.java @@ -13,7 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat; */ public class BaseFunctionTest { protected static final String NUMBER_SERIES = "{\"empty\": [], \"numbers\" : [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}"; - protected static final String TEXT_SERIES = "{\"urls\": [\"http://api.worldbank.org/countries/all/?format=json\", \"http://api.worldbank.org/countries/all/?format=json\"], \"text\" : [ \"a\", \"b\", \"c\", \"d\", \"e\", \"f\" ]}"; + protected static final String TEXT_SERIES = "{\"urls\": [\"http://api.worldbank.org/countries/all/?format=json\", \"http://api.worldbank.org/countries/all/?format=json\"], \"text\" : [ \"a\", \"b\", \"c\", \"d\", \"e\", \"f\" ], \"parseable\": \"{ \\\"foo\\\": \\\"bar\\\", \\\"array\\\": [1, 2, 4] }\"}"; protected static final String TEXT_AND_NUMBER_SERIES = "{\"text\" : [ \"a\", \"b\", \"c\", \"d\", \"e\", \"f\" ], \"numbers\" : [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}"; /** diff --git a/json-path/src/test/java/com/jayway/jsonpath/internal/function/NestedFunctionTest.java b/json-path/src/test/java/com/jayway/jsonpath/internal/function/NestedFunctionTest.java index 2ccc38a7..7054deb9 100644 --- a/json-path/src/test/java/com/jayway/jsonpath/internal/function/NestedFunctionTest.java +++ b/json-path/src/test/java/com/jayway/jsonpath/internal/function/NestedFunctionTest.java @@ -2,12 +2,18 @@ package com.jayway.jsonpath.internal.function; import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.Configurations; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.InvalidJsonException; +import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Iterator; +import java.util.List; + import static com.jayway.jsonpath.JsonPath.using; import static org.junit.Assert.assertTrue; @@ -125,4 +131,59 @@ public class NestedFunctionTest extends BaseFunctionTest { } } + @Test + public void testParseString() { + verifyTextFunction(conf, "$.parseable.parse().foo", "bar"); + verifyTextFunction(conf, "$.parseable.parse().array.sum()", 7.0); + } + + @Test + public void testIndefiniteParseString() { + String json = "{ \"array\": [ { \"document\": \"{\\\"name\\\": \\\"foo\\\"}\" }, { \"document\": \"{\\\"name\\\": \\\"bar\\\"}\" } ] }"; + Object result = using(conf).parse(json).read("$.array[*].document.parse().name"); + Assert.assertTrue(conf.jsonProvider().isArray(result)); + Iterator resultIter = conf.jsonProvider().toIterable(result).iterator(); + Assert.assertTrue(resultIter.hasNext()); + Assert.assertEquals("foo", conf.jsonProvider().unwrap(resultIter.next())); + Assert.assertTrue(resultIter.hasNext()); + Assert.assertEquals("bar", conf.jsonProvider().unwrap(resultIter.next())); + Assert.assertFalse(resultIter.hasNext()); + } + + @Test + public void testParseOnNonStrings() { + String json = "{ \"boolean\": true, \"number\": 12.34, \"object\": { \"foo\" : \"bar\" }, \"array\": [ 1, 2 ] }"; + DocumentContext doc = using(conf).parse(json); + Assert.assertTrue((boolean) conf.jsonProvider().unwrap(doc.read("$.boolean.parse()"))); + Assert.assertEquals(12.34, (double) conf.jsonProvider().unwrap(doc.read("$.number.parse()")), 0.0); + Assert.assertEquals("bar", conf.jsonProvider().unwrap(doc.read("$.object.parse().foo"))); + Assert.assertEquals(1, (int) conf.jsonProvider().unwrap(doc.read("$.array.parse()[0]"))); + Assert.assertEquals(2, (int) conf.jsonProvider().unwrap(doc.read("$.array.parse()[1]"))); + } + + @Test + public void testParseOnMalformedJsonString() { + String json = "{\"malformed\": \"{]\"}"; + try { + using(conf).parse(json).read("$.malformed.parse()"); + Assert.fail("Should have thrown an InvalidJsonException"); + } catch (InvalidJsonException e) { + Assert.assertEquals("String property at path $['malformed'] did not parse as valid JSON", e.getMessage()); + } + } + + @Test + public void testParseOnWellFormedJsonStringsThatAreNotObjects() { + String json = "{\"string\": \"\\\"foo\\\"\", \"number\": \"123\", \"boolean\": \"true\", \"array\": \"[1, 2]\"}"; + DocumentContext doc = using(conf).parse(json); + Assert.assertEquals("foo", conf.jsonProvider().unwrap(doc.read("$.string.parse()"))); + Assert.assertEquals(123, conf.jsonProvider().unwrap(doc.read("$.number.parse()"))); + Assert.assertEquals(true, conf.jsonProvider().unwrap(doc.read("$.boolean.parse()"))); + Iterator arrayIter = conf.jsonProvider().toIterable(doc.read("$.array.parse()")).iterator(); + Assert.assertTrue(arrayIter.hasNext()); + Assert.assertEquals(1, arrayIter.next()); + Assert.assertTrue(arrayIter.hasNext()); + Assert.assertEquals(2, arrayIter.next()); + Assert.assertFalse(arrayIter.hasNext()); + } }