nifi-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From o...@apache.org
Subject [nifi] branch main updated: NIFI-8137 Record Path EscapeJson/UnescapeJson functions (#4756)
Date Fri, 04 Jun 2021 13:19:37 GMT
This is an automated email from the ASF dual-hosted git repository.

otto pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new bc5204d  NIFI-8137 Record Path EscapeJson/UnescapeJson functions (#4756)
bc5204d is described below

commit bc5204d4df8017cbde5f2d050d466cdb04e7f969
Author: Chris Sampson <12159006+ChrisSamo632@users.noreply.github.com>
AuthorDate: Fri Jun 4 14:19:24 2021 +0100

    NIFI-8137 Record Path EscapeJson/UnescapeJson functions (#4756)
    
    * NIFI-8137 Record Path EscapeJson/UnescapeJson functions
    
    * Correct jackson-databind dependency version
    
    * Add negative tests for RecordPath JSON handling; rename RecordPath JSON classes to better
match existing functions
    
    Signed-off-by: Otto Fowler <ottobackwards@gmail.com>
    
    This closes #4756.
---
 .../language/compile/ExpressionCompiler.java       |  18 +--
 nifi-commons/nifi-record-path/pom.xml              |   5 +
 .../nifi/record/path/functions/JsonEscape.java     |  65 ++++++++++
 .../nifi/record/path/functions/JsonUnescape.java   |  91 ++++++++++++++
 .../nifi/record/path/paths/RecordPathCompiler.java |  10 ++
 .../apache/nifi/record/path/TestRecordPath.java    | 132 +++++++++++++++++++++
 nifi-docs/src/main/asciidoc/record-path-guide.adoc |  90 ++++++++++++++
 7 files changed, 402 insertions(+), 9 deletions(-)

diff --git a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
index 4699e60..767928a 100644
--- a/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
+++ b/nifi-commons/nifi-expression-language/src/main/java/org/apache/nifi/attribute/expression/language/compile/ExpressionCompiler.java
@@ -599,15 +599,15 @@ public class ExpressionCompiler {
             }
             case ESCAPE_CSV: {
                 verifyArgCount(argEvaluators, 0, "escapeCsv");
-                return addToken(CharSequenceTranslatorEvaluator.csvEscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.csvEscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeCsv");
             }
             case ESCAPE_HTML3: {
                 verifyArgCount(argEvaluators, 0, "escapeHtml3");
-                return addToken(CharSequenceTranslatorEvaluator.html3EscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.html3EscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeHtml3");
             }
             case ESCAPE_HTML4: {
                 verifyArgCount(argEvaluators, 0, "escapeHtml4");
-                return addToken(CharSequenceTranslatorEvaluator.html4EscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.html4EscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeHtml4");
             }
             case ESCAPE_JSON: {
                 verifyArgCount(argEvaluators, 0, "escapeJson");
@@ -615,27 +615,27 @@ public class ExpressionCompiler {
             }
             case ESCAPE_XML: {
                 verifyArgCount(argEvaluators, 0, "escapeXml");
-                return addToken(CharSequenceTranslatorEvaluator.xmlEscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.xmlEscapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeXml");
             }
             case UNESCAPE_CSV: {
                 verifyArgCount(argEvaluators, 0, "unescapeCsv");
-                return addToken(CharSequenceTranslatorEvaluator.csvUnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.csvUnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"unescapeCsv");
             }
             case UNESCAPE_HTML3: {
                 verifyArgCount(argEvaluators, 0, "unescapeHtml3");
-                return addToken(CharSequenceTranslatorEvaluator.html3UnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.html3UnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"unescapeHtml3");
             }
             case UNESCAPE_HTML4: {
                 verifyArgCount(argEvaluators, 0, "unescapeHtml4");
-                return addToken(CharSequenceTranslatorEvaluator.html4UnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.html4UnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"unescapeHtml4");
             }
             case UNESCAPE_JSON: {
                 verifyArgCount(argEvaluators, 0, "unescapeJson");
-                return addToken(CharSequenceTranslatorEvaluator.jsonUnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.jsonUnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"unescapeJson");
             }
             case UNESCAPE_XML: {
                 verifyArgCount(argEvaluators, 0, "unescapeXml");
-                return addToken(CharSequenceTranslatorEvaluator.xmlUnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"escapeJson");
+                return addToken(CharSequenceTranslatorEvaluator.xmlUnescapeEvaluator(toStringEvaluator(subjectEvaluator)),
"unescapeXml");
             }
             case SUBSTRING_BEFORE: {
                 verifyArgCount(argEvaluators, 1, "substringBefore");
diff --git a/nifi-commons/nifi-record-path/pom.xml b/nifi-commons/nifi-record-path/pom.xml
index b4658d6..334eeeb 100644
--- a/nifi-commons/nifi-record-path/pom.xml
+++ b/nifi-commons/nifi-record-path/pom.xml
@@ -91,5 +91,10 @@
             <artifactId>commons-codec</artifactId>
             <version>1.14</version>
         </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+            <version>${jackson.version}</version>
+        </dependency>
     </dependencies>
 </project>
diff --git a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/JsonEscape.java
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/JsonEscape.java
new file mode 100644
index 0000000..b452c6c
--- /dev/null
+++ b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/JsonEscape.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.nifi.record.path.functions;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.record.path.FieldValue;
+import org.apache.nifi.record.path.RecordPathEvaluationContext;
+import org.apache.nifi.record.path.StandardFieldValue;
+import org.apache.nifi.record.path.exception.RecordPathException;
+import org.apache.nifi.record.path.paths.RecordPathSegment;
+import org.apache.nifi.serialization.record.Record;
+import org.apache.nifi.serialization.record.RecordFieldType;
+import org.apache.nifi.serialization.record.util.DataTypeUtils;
+
+import java.util.stream.Stream;
+
+public class JsonEscape extends RecordPathSegment {
+    private final RecordPathSegment recordPath;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public JsonEscape(final RecordPathSegment recordPath, final boolean absolute) {
+        super("jsonEscape", null, absolute);
+        this.recordPath = recordPath;
+    }
+
+    @Override
+    public Stream<FieldValue> evaluate(final RecordPathEvaluationContext context) {
+        final Stream<FieldValue> fieldValues = recordPath.evaluate(context);
+        return fieldValues.filter(fv -> fv.getValue() != null)
+                .map(fv -> {
+                    Object value = fv.getValue();
+                    if (value == null) {
+                        return new StandardFieldValue(null, fv.getField(), fv.getParent().orElse(null));
+                    } else {
+                        if (value instanceof Record) {
+                            value = DataTypeUtils.convertRecordFieldtoObject(value, RecordFieldType.RECORD.getDataType());
+                        }
+
+                        try {
+                            return new StandardFieldValue(objectMapper.writeValueAsString(value),
fv.getField(), fv.getParent().orElse(null));
+                        } catch (JsonProcessingException e) {
+                            throw new RecordPathException("Unable to serialise Record Path
value as JSON String", e);
+                        }
+                    }
+                });
+    }
+
+}
diff --git a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/JsonUnescape.java
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/JsonUnescape.java
new file mode 100644
index 0000000..d16a280
--- /dev/null
+++ b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/functions/JsonUnescape.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.nifi.record.path.functions;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.record.path.FieldValue;
+import org.apache.nifi.record.path.RecordPathEvaluationContext;
+import org.apache.nifi.record.path.StandardFieldValue;
+import org.apache.nifi.record.path.exception.RecordPathException;
+import org.apache.nifi.record.path.paths.RecordPathSegment;
+import org.apache.nifi.serialization.record.DataType;
+import org.apache.nifi.serialization.record.type.ArrayDataType;
+import org.apache.nifi.serialization.record.type.ChoiceDataType;
+import org.apache.nifi.serialization.record.type.RecordDataType;
+import org.apache.nifi.serialization.record.util.DataTypeUtils;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Stream;
+
+public class JsonUnescape extends RecordPathSegment {
+    private final RecordPathSegment recordPath;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    public JsonUnescape(final RecordPathSegment recordPath, final boolean absolute) {
+        super("jsonUnescape", null, absolute);
+        this.recordPath = recordPath;
+    }
+
+    @Override
+    public Stream<FieldValue> evaluate(final RecordPathEvaluationContext context) {
+        final Stream<FieldValue> fieldValues = recordPath.evaluate(context);
+        return fieldValues.filter(fv -> fv.getValue() != null)
+                .map(fv -> {
+                    Object value = fv.getValue();
+
+                    if (value instanceof String) {
+                        try {
+                            DataType dataType = fv.getField().getDataType();
+                            if (fv.getField().getDataType() instanceof ChoiceDataType) {
+                                dataType = DataTypeUtils.chooseDataType(value, (ChoiceDataType)
fv.getField().getDataType());
+                            }
+
+                            return new StandardFieldValue(convertFieldValue(value, fv.getField().getFieldName(),
dataType), fv.getField(), fv.getParent().orElse(null));
+                        } catch (IOException e) {
+                            throw new RecordPathException("Unable to deserialise JSON String
into Record Path value", e);
+                        }
+                    } else {
+                        throw new IllegalArgumentException("Argument supplied to jsonUnescape
must be a String");
+                    }
+                });
+    }
+
+    @SuppressWarnings("unchecked")
+    private Object convertFieldValue(final Object value, final String fieldName, final DataType
dataType) throws IOException {
+        if (dataType instanceof RecordDataType) {
+            // convert Maps to Records
+            final Map<String, Object> map = objectMapper.readValue(value.toString(),
Map.class);
+            return DataTypeUtils.toRecord(map, ((RecordDataType) dataType).getChildSchema(),
fieldName);
+        } else if (dataType instanceof ArrayDataType) {
+            final DataType elementDataType = ((ArrayDataType) dataType).getElementType();
+
+            // convert Arrays of Maps to Records
+            Object[] arr = objectMapper.readValue(value.toString(), Object[].class);
+            if (elementDataType instanceof RecordDataType) {
+                arr = Arrays.stream(arr).map(e -> DataTypeUtils.toRecord(e, ((RecordDataType)
elementDataType).getChildSchema(), fieldName)).toArray();
+            }
+            return arr;
+        } else {
+            // generic conversion for simpler fields
+            return objectMapper.readValue(value.toString(), Object.class);
+        }
+    }
+}
diff --git a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
index 7cb1ead..cd46a40 100644
--- a/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
+++ b/nifi-commons/nifi-record-path/src/main/java/org/apache/nifi/record/path/paths/RecordPathCompiler.java
@@ -39,6 +39,7 @@ import org.apache.nifi.record.path.functions.Base64Decode;
 import org.apache.nifi.record.path.functions.Base64Encode;
 import org.apache.nifi.record.path.functions.Coalesce;
 import org.apache.nifi.record.path.functions.Concat;
+import org.apache.nifi.record.path.functions.JsonEscape;
 import org.apache.nifi.record.path.functions.FieldName;
 import org.apache.nifi.record.path.functions.Format;
 import org.apache.nifi.record.path.functions.Hash;
@@ -59,6 +60,7 @@ import org.apache.nifi.record.path.functions.ToString;
 import org.apache.nifi.record.path.functions.ToUpperCase;
 import org.apache.nifi.record.path.functions.TrimString;
 import org.apache.nifi.record.path.functions.UUID5;
+import org.apache.nifi.record.path.functions.JsonUnescape;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -308,6 +310,14 @@ public class RecordPathCompiler {
                         final RecordPathSegment[] args = getArgPaths(argumentListTree, 1,
functionName, absolute);
                         return new Base64Decode(args[0], absolute);
                     }
+                    case "jsonEscape": {
+                        final RecordPathSegment[] args = getArgPaths(argumentListTree, 1,
functionName, absolute);
+                        return new JsonEscape(args[0], absolute);
+                    }
+                    case "jsonUnescape": {
+                        final RecordPathSegment[] args = getArgPaths(argumentListTree, 1,
functionName, absolute);
+                        return new JsonUnescape(args[0], absolute);
+                    }
                     case "hash":{
                         final RecordPathSegment[] args = getArgPaths(argumentListTree, 2,
functionName, absolute);
                         return new Hash(args[0], args[1], absolute);
diff --git a/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
b/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
index 682ab7f..f5a105d 100644
--- a/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
+++ b/nifi-commons/nifi-record-path/src/test/java/org/apache/nifi/record/path/TestRecordPath.java
@@ -38,6 +38,7 @@ import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -51,6 +52,7 @@ import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
 
 public class TestRecordPath {
 
@@ -67,6 +69,7 @@ public class TestRecordPath {
         // substring is not a filter function so cannot be used as a predicate
         try {
             RecordPath.compile("/name[substring(., 1, 2)]");
+            fail("Expected RecordPathException");
         } catch (final RecordPathException e) {
             // expected
         }
@@ -1644,6 +1647,135 @@ public class TestRecordPath {
     }
 
     @Test
+    public void testJsonEscape() {
+        final RecordSchema address = new SimpleRecordSchema(Collections.singletonList(
+                new RecordField("address_1", RecordFieldType.STRING.getDataType())
+        ));
+
+        final RecordSchema person = new SimpleRecordSchema(Arrays.asList(
+                new RecordField("firstName", RecordFieldType.STRING.getDataType()),
+                new RecordField("age", RecordFieldType.INT.getDataType()),
+                new RecordField("nicknames", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.STRING.getDataType())),
+                new RecordField("addresses", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.RECORD.getRecordDataType(address)))
+        ));
+
+        final RecordSchema schema = new SimpleRecordSchema(Collections.singletonList(
+                new RecordField("person", RecordFieldType.RECORD.getRecordDataType(person))
+        ));
+
+        final Map<String, Object> values = new HashMap<String, Object>(){{
+            put("person", new MapRecord(person, new HashMap<String, Object>(){{
+                put("firstName", "John");
+                put("age", 30);
+                put("nicknames", new String[] {"J", "Johnny"});
+                put("addresses", new MapRecord[]{
+                        new MapRecord(address, Collections.singletonMap("address_1", "123
Somewhere Street")),
+                        new MapRecord(address, Collections.singletonMap("address_1", "456
Anywhere Road"))
+                });
+            }}));
+        }};
+
+        final Record record = new MapRecord(schema, values);
+
+        assertEquals("\"John\"", RecordPath.compile("jsonEscape(/person/firstName)").evaluate(record).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue());
+        assertEquals("30", RecordPath.compile("jsonEscape(/person/age)").evaluate(record).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue());
+        assertEquals(
+                "{\"firstName\":\"John\",\"age\":30,\"nicknames\":[\"J\",\"Johnny\"],\"addresses\":[{\"address_1\":\"123
Somewhere Street\"},{\"address_1\":\"456 Anywhere Road\"}]}",
+                RecordPath.compile("jsonEscape(/person)").evaluate(record).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue()
+        );
+    }
+
+    @Test
+    public void testJsonUnescape() {
+        final RecordSchema address = new SimpleRecordSchema(Collections.singletonList(
+                new RecordField("address_1", RecordFieldType.STRING.getDataType())
+        ));
+
+        final RecordSchema person = new SimpleRecordSchema(Arrays.asList(
+                new RecordField("firstName", RecordFieldType.STRING.getDataType()),
+                new RecordField("age", RecordFieldType.INT.getDataType()),
+                new RecordField("nicknames", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.STRING.getDataType())),
+                new RecordField("addresses", RecordFieldType.CHOICE.getChoiceDataType(
+                        RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.RECORD.getRecordDataType(address)),
+                        RecordFieldType.RECORD.getRecordDataType(address)
+                ))
+        ));
+
+        final RecordSchema schema = new SimpleRecordSchema(Arrays.asList(
+                new RecordField("person", RecordFieldType.RECORD.getRecordDataType(person)),
+                new RecordField("json_str", RecordFieldType.STRING.getDataType())
+        ));
+
+        // test CHOICE resulting in nested ARRAY of RECORDs
+        final Record recordAddressesArray = new MapRecord(schema,
+                Collections.singletonMap(
+                        "json_str",
+                        "{\"firstName\":\"John\",\"age\":30,\"nicknames\":[\"J\",\"Johnny\"],\"addresses\":[{\"address_1\":\"123
Somewhere Street\"},{\"address_1\":\"456 Anywhere Road\"}]}")
+        );
+        assertEquals(
+                new HashMap<String, Object>(){{
+                    put("firstName", "John");
+                    put("age", 30);
+                    put("nicknames", Arrays.asList("J", "Johnny"));
+                    put("addresses", Arrays.asList(
+                            Collections.singletonMap("address_1", "123 Somewhere Street"),
+                            Collections.singletonMap("address_1", "456 Anywhere Road")
+                    ));
+                }},
+                RecordPath.compile("jsonUnescape(/json_str)").evaluate(recordAddressesArray).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue()
+        );
+
+        // test CHOICE resulting in nested single RECORD
+        final Record recordAddressesSingle = new MapRecord(schema,
+                Collections.singletonMap(
+                        "json_str",
+                        "{\"firstName\":\"John\",\"age\":30,\"nicknames\":[\"J\",\"Johnny\"],\"addresses\":{\"address_1\":\"123
Somewhere Street\"}}")
+        );
+        assertEquals(
+                new HashMap<String, Object>(){{
+                    put("firstName", "John");
+                    put("age", 30);
+                    put("nicknames", Arrays.asList("J", "Johnny"));
+                    put("addresses", Collections.singletonMap("address_1", "123 Somewhere
Street"));
+                }},
+                RecordPath.compile("jsonUnescape(/json_str)").evaluate(recordAddressesSingle).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue()
+        );
+
+        // test simple String field
+        final Record recordJustName = new MapRecord(schema, Collections.singletonMap("json_str",
"{\"firstName\":\"John\"}"));
+        assertEquals(
+                new HashMap<String, Object>(){{put("firstName", "John");}},
+                RecordPath.compile("jsonUnescape(/json_str)").evaluate(recordJustName).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue()
+        );
+
+        // test simple String
+        final Record recordJustString = new MapRecord(schema, Collections.singletonMap("json_str",
"\"John\""));
+        assertEquals("John", RecordPath.compile("jsonUnescape(/json_str)").evaluate(recordJustString).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue());
+
+        // test simple Int
+        final Record recordJustInt = new MapRecord(schema, Collections.singletonMap("json_str",
"30"));
+        assertEquals(30, RecordPath.compile("jsonUnescape(/json_str)").evaluate(recordJustInt).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue());
+
+        // test invalid JSON
+        final Record recordInvalidJson = new MapRecord(schema, Collections.singletonMap("json_str",
"{\"invalid\": \"json"));
+        try {
+            RecordPath.compile("jsonUnescape(/json_str)").evaluate(recordInvalidJson).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue();
+            fail("Expected a RecordPathException for invalid JSON");
+        } catch (RecordPathException rpe) {
+            assertEquals("Unable to deserialise JSON String into Record Path value", rpe.getMessage());
+        }
+
+        // test not String
+        final Record recordNotString = new MapRecord(schema, Collections.singletonMap("person",
new MapRecord(person, Collections.singletonMap("age", 30))));
+        try {
+            RecordPath.compile("jsonUnescape(/person/age)").evaluate(recordNotString).getSelectedFields().findFirst().orElseThrow(IllegalStateException::new).getValue();
+            fail("Expected IllegalArgumentException for non-String input");
+        } catch (IllegalArgumentException iae) {
+            assertEquals("Argument supplied to jsonUnescape must be a String", iae.getMessage());
+        }
+    }
+
+    @Test
     public void testHash() {
         final Record record = getCaseTestRecord();
         assertEquals("61409aa1fd47d4a5332de23cbf59a36f", RecordPath.compile("hash(/firstName,
'MD5')").evaluate(record).getSelectedFields().findFirst().get().getValue());
diff --git a/nifi-docs/src/main/asciidoc/record-path-guide.adoc b/nifi-docs/src/main/asciidoc/record-path-guide.adoc
index 0f2f2dc..f75c2b5 100644
--- a/nifi-docs/src/main/asciidoc/record-path-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/record-path-guide.adoc
@@ -851,6 +851,96 @@ The following record path expression would decode the String using Base64:
 | `base64Decode(/name)` | John
 |==========================================================
 
+=== jsonEscape
+
+JSON Stringifies a Record, Array or simple field (e.g. String), using the UTF-8 character
set. For example, given a schema such as:
+
+----
+{
+  "type": "record",
+  "name": "events",
+  "fields": [{
+    "name": "person",
+    "type": "record",
+    "fields": [
+      { "name": "name", "type": "string" },
+      { "name": "age", "type": "int" }
+    ]
+  }]
+}
+----
+
+and a record such as:
+
+----
+{
+  "person": {
+    "name" : "John",
+    "age" : 30
+  }
+}
+----
+
+The following record path expression would convert the record into an escaped JSON String:
+
+|==========================================================
+| RecordPath | Return value
+| `jsonEscape(/person)` | "{\"person\":{\"name\":\"John\",\"age\":30}}"
+| `jsonEscape(/person/firstName)` | "\"John\""
+| `jsonEscape(/person/age)` | "30"
+|==========================================================
+
+=== jsonUnescape
+
+Converts a stringified JSON element to a Record, Array or simple field (e.g. String), using
the UTF-8 character set. For example, given a schema such as:
+
+----
+{
+  "type": "record",
+  "name": "events",
+  "fields": [{
+    "name": "person",
+    "type": "record",
+    "fields": [
+      { "name": "name", "type": "string" },
+      { "name": "age", "type": "int" }
+    ]
+  }]
+}
+----
+
+and a record such as:
+
+----
+{
+  "json_str": "{\"person\":{\"name\":\"John\",\"age\":30}}"
+}
+----
+
+The following record path expression would populate the record with unescaped JSON fields:
+
+|==========================================================
+| RecordPath | Return value
+| `jsonUnescape(/json_str)` | {"person": {"name": "John", "age": 30}}"
+|==========================================================
+
+Given a record such as:
+
+----
+{
+  "json_str": "\"John\""
+}
+----
+
+The following record path expression would return:
+
+|==========================================================
+| RecordPath | Return value
+| `jsonUnescape(/json_str)` | "John"
+|==========================================================
+
+Note that the target schema must be pre-defined if the unescaped JSON is to be set in a Record's
fields - Infer Schema will not currently do this automatically.
+
 === hash
 
 Converts a String using a hash algorithm.  For example, given a schema such as:

Mime
View raw message