From 427213f79eb56d94057f552d36a2b3b925b55095 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Sat, 29 Jun 2024 22:23:02 +0200 Subject: [PATCH 1/7] feat: JUON parsing --- .../java/org/hisp/dhis/jsontree/JsonNode.java | 15 +- .../java/org/hisp/dhis/jsontree/Juon.java | 251 ++++++++++++++++++ .../java/org/hisp/dhis/jsontree/JuonTest.java | 107 ++++++++ 3 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/hisp/dhis/jsontree/Juon.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/JuonTest.java diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index f5def39..feaa829 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -38,7 +38,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Iterator; -import java.util.List; import java.util.Map.Entry; import java.util.Optional; import java.util.Set; @@ -128,6 +127,20 @@ static JsonNode ofNonStandard( String json ) { return JsonTree.ofNonStandard( json ).get( JsonPath.ROOT ); } + /** + * Creates a new lazily parsed {@link JsonNode} tree from special URL object notation. + *

+ * Note that the {@link JsonNode}'s {@link JsonNode#getDeclaration()} will be the equivalent JSON, not the original + * URL notation. + * + * @param juon + * @return the given URL notation input as {@link JsonNode} + * @since 1.2 + */ + static JsonNode ofUrlObjectNotation(String juon) { + return of( Juon.toJSON( juon )); + } + /** * Create a new lazily parsed {@link JsonNode} tree. * diff --git a/src/main/java/org/hisp/dhis/jsontree/Juon.java b/src/main/java/org/hisp/dhis/jsontree/Juon.java new file mode 100644 index 0000000..ccef56b --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/Juon.java @@ -0,0 +1,251 @@ +package org.hisp.dhis.jsontree; + +/** + * JUON is short for JS URL Object Notation. + *

+ * JS here is used in the same vain as it is used in JSON. Meaning the fundamental types of JUON are the same as those used by JSON. + * + * @author Jan Bernitt + * @since 1.2 + */ +record Juon(char[] juon, StringBuilder json) { + + public static String toJSON(String juon) { + if (juon == null || juon.isEmpty() || juon.isBlank()) + return "null"; + StringBuilder json = new StringBuilder( juon.length() * 2 ); + new Juon(juon.toCharArray(), json).toJsonAutoDetect( 0 ); + return json.toString(); + } + + private int toJsonAutoDetect(int index) { + char c = peek( index ); + return switch ( c ) { + case '(' -> toJsonObjectOrArray( index ); + case '\'' -> toJsonString( index ); + case 't', 'f' -> toJsonBoolean( index ); + case 'n', ',' -> toJsonNull( index ); + case '-', '.' -> toJsonNumber( index ); + default -> { + if ( isDigit( c ) ) yield toJsonNumber( index ); + throw new JsonFormatException( "Expected start of a value at index %d but was: %s".formatted( index, c ) ); + } + }; + } + + private int toJsonObjectOrArray(int index) { + int i = index; + i++; // skip ( + while ( isWhitespace( peek( i ) ) ) i++; + char c = peek( i ); + // empty array or omitted null at start of an array or another level of array + if (c == ')' || c == ',' || c == '(') return toJsonArray( index ); + // a number value => array element + if (c == '-' || c == '.' || isDigit( c )) return toJsonArray( index ); + // a string value => array element + if (c == '\'') return toJsonArray( index ); + // not a boolean or null value => assume object member name + if (c != 't' && c != 'f' && c != 'n') return toJsonObject( index ); + // might be true/false/null 1st element in an array + while ( isLowerLetter( peek( i ) ) ) i++; + c = peek( i ); // what followed the lower letter word? + if (c == ',') return toJsonArray( index ); + if (c == ':') return toJsonObject( index ); + throw new JsonFormatException( + "Expected object or array at index %d but found %s at index %d".formatted( index, c, i ) ); + } + + private int toJsonObject(int index) { + index = toJsonSymbol( index, '(', '{' ); + char c = ','; + while ( hasNext( index ) && c == ',' ) { + index = toJsonMemberName( index ); + index = toJsonSymbol( index, ':', ':' ); + index = toJsonAutoDetect( index ); + index = toJsonWhitespace( index ); + c = peek( index ); + if (c == ',') + index = toJsonSymbol( index, ',', ','); + } + return toJsonSymbol( index, ')', '}' ); + } + + private int toJsonMemberName(int index) { + char c = peek( index ); + if ( !isNameFirstChar( c ) ) + throw new JsonFormatException( "Expected start of a name at index %d but was: %s".formatted( index, c ) ); + append( '"' ); + index = toJsonChar( index ); // add start letter + while ( isNameChar( peek( index ) ) ) index = toJsonChar( index ); + append( '"' ); + return index; + } + + private int toJsonArray(int index) { + index = toJsonSymbol( index, '(', '[' ); + char c = peek( index ) == ')' ? ')' : ','; + while (c == ',') { + index = toJsonAutoDetect( index ); + index = toJsonWhitespace( index ); + c = peek( index ); + if (c == ',') { + index = toJsonSymbol( index, ',', ',' ); + if ( peek( index ) == ')' ) { // tailing omitted null... + c = ')'; + append( "null" ); + } + } + } + return toJsonSymbol( index, ')', ']' ); + } + + private int toJsonString(int index) { + index = toJsonSymbol( index, '\'', '"' ); + while ( hasNext( index ) ) { + char c = peek( index++ ); + if (c == '\'') { + append( '"' ); + return index; // found end of string literal + } + if (c == '\\') { + char escape = peek( index ); + index++; // move beyond the first escaped char + if (escape == '\'') { + append( '"' ); + } else { + append( escape ); + if (escape == 'u') { + append( peek( index++ ) ); + append( peek( index++ ) ); + append( peek( index++ ) ); + append( peek( index++ ) ); + } + } + } else { + // 1:1 transfer (default case) + append( c ); + } + } + // this is only to fail at end of input from exiting the while loop + return toJsonSymbol( index, '\'', '"' ); + } + + private int toJsonNumber(int index) { + char c = peek( index ); + if ( c == '-') { + append( '-' ); + return toJsonNumber( index + 1 ); + } + if (c == '.') { + append( '0' ); + } else { + while ( hasNext( index ) && isDigit( peek( index ) ) ) + index = toJsonChar( index ); + } + if (hasNext( index ) && peek( index ) == '.') { + index = toJsonChar( index ); // transfer . + if (!hasNext( index ) || !isDigit( peek( index ) )) { + append( "0" ); // auto-add zero after decimal point when no digits follow + return index; + } + while ( hasNext( index ) && isDigit( peek( index ) ) ) + index = toJsonChar( index ); + } + return index; + } + + private int toJsonBoolean(int index) { + char c = peek( index ); + if (c == 't') return toJsonLiteral( index, "true" ); + if (c == 'f') return toJsonLiteral( index, "false" ); + throw new JsonFormatException( "Expected true/false at index %d but found: %s".formatted( index, c ) ); + } + + private int toJsonNull(int index) { + if (peek( index ) == ',') { + append( "null" ); + return index; + } + return toJsonLiteral( index, "null" ); + } + + private int toJsonWhitespace(int index) { + while (hasNext( index ) && isWhitespace( peek( index ) ) ) append( peek( index++ ) ); + return index; + } + + private int toJsonSymbol( int index, char inJuon, char inJson ) { + if ( peek( index ) != inJuon ) throw new JsonFormatException( + "Expected %s at %d but found: %s".formatted( inJuon, index, peek( index ) ) ); + append( inJson ); + return toJsonWhitespace( index+1 ); + } + + private int toJsonChar( int index ) { + append( peek( index ) ); + return index+1; + } + + private int toJsonLiteral( int index, String literal ) { + boolean isShort = !hasNext( index+1 ) || !isLowerLetter( juon[index+1]); + if (isShort) { + append( literal ); + return toJsonWhitespace( index+1 ); + } + int l = literal.length(); + for ( int i = 0; i < l; i++ ) { + char c = peek( index + i ); + if ( c != literal.charAt( i ) ) { + throw new JsonFormatException( + "Expected %s at index %d but found: %s".formatted( literal, index, c ) ); + } + append( c ); + } + return toJsonWhitespace( index + l ); + } + + private boolean hasNext(int index) { + return index < juon.length; + } + + private char peek( int index ) { + if (!hasNext( index )) throw new JsonFormatException( "Unexpected end of input at index: %d".formatted( index ) ); + return juon[index]; + } + + private void append(char c) { + json.append( c ); + } + + private void append(String str) { + json.append( str ); + } + + private static boolean isWhitespace( char c ) { + return c == ' ' || c == '\n' || c == '\t' || c == '\r'; + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isLetter(char c) { + return isLowerLetter( c ) || isUpperLetter( c ); + } + + private static boolean isUpperLetter( char c ) { + return c >= 'A' && c <= 'Z'; + } + + private static boolean isLowerLetter( char c ) { + return c >= 'a' && c <= 'z'; + } + + private static boolean isNameFirstChar(char c) { + return isLetter( c ) || c == '@'; + } + + private static boolean isNameChar(char c) { + return isLetter( c ) || isDigit( c ) || c == '@' || c == '-' || c == '.' || c == '_'; + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/JuonTest.java b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java new file mode 100644 index 0000000..aeffdf7 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java @@ -0,0 +1,107 @@ +package org.hisp.dhis.jsontree; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Test the {@link Juon} parser accessible via {@link JsonNode#ofUrlObjectNotation(String)}. + * + * @author Jan Bernitt + */ +class JuonTest { + + @Test + void testBoolean() { + assertEquals( JsonNode.of( "true" ), JsonNode.ofUrlObjectNotation( "true" ) ); + assertEquals( JsonNode.of( "false" ), JsonNode.ofUrlObjectNotation( "false" ) ); + } + + @Test + void testBoolean_Shorthand() { + assertEquals( JsonNode.of( "true" ), JsonNode.ofUrlObjectNotation( "t" ) ); + assertEquals( JsonNode.of( "false" ), JsonNode.ofUrlObjectNotation( "f" ) ); + } + + @Test + void testNull() { + assertEquals( JsonNode.of( "null" ), JsonNode.ofUrlObjectNotation( "null" ) ); + } + + @Test + void testNull_Shorthand() { + assertEquals( JsonNode.of( "null" ), JsonNode.ofUrlObjectNotation( "n" ) ); + } + + @Test + @DisplayName( "In contrast to JSON an undefined, empty or blank value is considered null" ) + void testNull_Omit() { + assertEquals( JsonNode.of( "null" ), JsonNode.ofUrlObjectNotation( null ) ); + assertEquals( JsonNode.of( "null" ), JsonNode.ofUrlObjectNotation( "" ) ); + assertEquals( JsonNode.of( "null" ), JsonNode.ofUrlObjectNotation( " " ) ); + } + + @Test + void testNumber() { + assertEquals( JsonNode.of( "1234" ), JsonNode.ofUrlObjectNotation( "1234" ) ); + assertEquals( JsonNode.of( "42.12" ), JsonNode.ofUrlObjectNotation( "42.12" ) ); + assertEquals( JsonNode.of( "-0.12" ), JsonNode.ofUrlObjectNotation( "-0.12" ) ); + } + + @Test + @DisplayName( "In contrast to JSON floating point numbers can start with a dot" ) + void testNumber_OmitLeadingZero() { + assertEquals( JsonNode.of( "0.12" ), JsonNode.ofUrlObjectNotation( ".12" ) ); + } + + @Test + @DisplayName( "In contrast to JSON floating point numbers can end with a dot" ) + void testNumber_OmitTailingZero() { + assertEquals( JsonNode.of( "0.0" ), JsonNode.ofUrlObjectNotation( "0." ) ); + } + + @Test + void testArray() { + assertEquals( JsonNode.of( "[]" ), JsonNode.ofUrlObjectNotation( "()" ) ); + assertEquals( JsonNode.of( "[1,2,3]" ), JsonNode.ofUrlObjectNotation( "(1,2,3)" ) ); + assertEquals( JsonNode.of( "[true,false]" ), JsonNode.ofUrlObjectNotation( "(true,false)" ) ); + assertEquals( JsonNode.of( "[\"a\",\"b\",\"c\",\"d\"]" ), JsonNode.ofUrlObjectNotation( "('a','b','c','d')" ) ); + } + + @Test + void testArray_Array() { + assertEquals( JsonNode.of( "[[]]" ), JsonNode.ofUrlObjectNotation( "(())" ) ); + assertEquals( JsonNode.of( "[[],[]]" ), JsonNode.ofUrlObjectNotation( "((),())" ) ); + assertEquals( JsonNode.of( "[[[]]]" ), JsonNode.ofUrlObjectNotation( "((()))" ) ); + assertEquals( JsonNode.of( "[[[1,2],[3,4]],[5,6]]" ), JsonNode.ofUrlObjectNotation( "(((1,2),(3,4)),(5,6))" ) ); + } + + @Test + @DisplayName( "In contrast to JSON nulls in arrays can be omitted (left empty)" ) + void testArray_OmitNulls() { + assertEquals( JsonNode.of( "[null,null]" ), JsonNode.ofUrlObjectNotation( "(,)" ) ); + assertEquals( JsonNode.of( "[null,null,3]" ), JsonNode.ofUrlObjectNotation( "(,,3)" ) ); + assertEquals( JsonNode.of( "[1,null,3]" ), JsonNode.ofUrlObjectNotation( "(1,,3)" ) ); + assertEquals( JsonNode.of( "[1,null,null]" ), JsonNode.ofUrlObjectNotation( "(1,,)" ) ); + assertEquals( JsonNode.of( "[1,null,0.3,null,5]" ), JsonNode.ofUrlObjectNotation( "(1,,.3,,5)" ) ); + } + + @Test + void testObject() { + assertEquals( JsonNode.of( "{\"hi\":\"ho\"}" ), JsonNode.ofUrlObjectNotation( "(hi:'ho')" ) ); + assertEquals( JsonNode.of( "{\"no\":1,\"surprises\":{\"please\":true}}" ), + JsonNode.ofUrlObjectNotation( "(no:1,surprises:(please:true))" ) ); + } + + @Test + void testMixed_Minimal() { + JsonNode json = JsonNode.ofUrlObjectNotation( """ + (name:'John',age:42,license:false,keywords:('hello','world'),void:null) + """ ); + String expected = """ + {"name":"John","age":42,"license":false,"keywords":["hello","world"],"void":null} + """; + assertEquals( JsonNode.of( expected ), json ); + } +} From 3afa4724b365cad20083979b9886c30380ecda87 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Sun, 30 Jun 2024 10:38:18 +0200 Subject: [PATCH 2/7] feat: numbers with exponent, object tests --- .../java/org/hisp/dhis/jsontree/Juon.java | 81 ++++++++++++------- .../java/org/hisp/dhis/jsontree/JuonTest.java | 31 ++++++- 2 files changed, 80 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/Juon.java b/src/main/java/org/hisp/dhis/jsontree/Juon.java index ccef56b..d30c7da 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Juon.java +++ b/src/main/java/org/hisp/dhis/jsontree/Juon.java @@ -10,6 +10,13 @@ */ record Juon(char[] juon, StringBuilder json) { + /** + * Converts JUON to JSON. + * + * @param juon the JUON input + * @return the equivalent JSON + * @since 1.2 + */ public static String toJSON(String juon) { if (juon == null || juon.isEmpty() || juon.isBlank()) return "null"; @@ -58,39 +65,33 @@ private int toJsonObjectOrArray(int index) { private int toJsonObject(int index) { index = toJsonSymbol( index, '(', '{' ); char c = ','; - while ( hasNext( index ) && c == ',' ) { + while ( hasChar( index ) && c == ',' ) { index = toJsonMemberName( index ); index = toJsonSymbol( index, ':', ':' ); - index = toJsonAutoDetect( index ); - index = toJsonWhitespace( index ); + if (peek( index ) == ')') { // trailing omitted null... + append( "null" ); + } else { + index = toJsonAutoDetect( index ); + index = toJsonWhitespace( index ); + } c = peek( index ); - if (c == ',') - index = toJsonSymbol( index, ',', ','); + if (c == ',') { + index = toJsonSymbol( index, ',', ',' ); + } } return toJsonSymbol( index, ')', '}' ); } - private int toJsonMemberName(int index) { - char c = peek( index ); - if ( !isNameFirstChar( c ) ) - throw new JsonFormatException( "Expected start of a name at index %d but was: %s".formatted( index, c ) ); - append( '"' ); - index = toJsonChar( index ); // add start letter - while ( isNameChar( peek( index ) ) ) index = toJsonChar( index ); - append( '"' ); - return index; - } - private int toJsonArray(int index) { index = toJsonSymbol( index, '(', '[' ); char c = peek( index ) == ')' ? ')' : ','; - while (c == ',') { + while ( hasChar( index ) && c == ',') { index = toJsonAutoDetect( index ); index = toJsonWhitespace( index ); c = peek( index ); if (c == ',') { index = toJsonSymbol( index, ',', ',' ); - if ( peek( index ) == ')' ) { // tailing omitted null... + if ( peek( index ) == ')' ) { // trailing omitted null... c = ')'; append( "null" ); } @@ -99,9 +100,20 @@ private int toJsonArray(int index) { return toJsonSymbol( index, ')', ']' ); } + private int toJsonMemberName(int index) { + char c = peek( index ); + if ( !isNameFirstChar( c ) ) + throw new JsonFormatException( "Expected start of a name at index %d but was: %s".formatted( index, c ) ); + append( '"' ); + index = toJsonChar( index ); // add start letter + while ( isNameChar( peek( index ) ) ) index = toJsonChar( index ); + append( '"' ); + return index; + } + private int toJsonString(int index) { index = toJsonSymbol( index, '\'', '"' ); - while ( hasNext( index ) ) { + while ( hasChar( index ) ) { char c = peek( index++ ); if (c == '\'') { append( '"' ); @@ -132,23 +144,35 @@ private int toJsonString(int index) { private int toJsonNumber(int index) { char c = peek( index ); + // sign if ( c == '-') { append( '-' ); return toJsonNumber( index + 1 ); } + // integer part if (c == '.') { append( '0' ); } else { - while ( hasNext( index ) && isDigit( peek( index ) ) ) + while ( hasChar( index ) && isDigit( peek( index ) ) ) index = toJsonChar( index ); } - if (hasNext( index ) && peek( index ) == '.') { + // fraction part + if ( hasChar( index ) && peek( index ) == '.') { index = toJsonChar( index ); // transfer . - if (!hasNext( index ) || !isDigit( peek( index ) )) { + if (!hasChar( index ) || !isDigit( peek( index ) )) { append( "0" ); // auto-add zero after decimal point when no digits follow return index; } - while ( hasNext( index ) && isDigit( peek( index ) ) ) + while ( hasChar( index ) && isDigit( peek( index ) ) ) + index = toJsonChar( index ); + } + // exponent part + c = hasChar( index ) ? peek( index ) : 'x'; + if (c == 'e' || c == 'E') { + index = toJsonChar( index ); // e/E + if (peek( index ) == '-') + index = toJsonChar( index ); // - + while ( hasChar( index ) && isDigit( peek( index ) ) ) index = toJsonChar( index ); } return index; @@ -162,15 +186,16 @@ private int toJsonBoolean(int index) { } private int toJsonNull(int index) { + // omitted null (we see the comma after) if (peek( index ) == ',') { append( "null" ); - return index; + return index; // the comma needs to be processed elsewhere } return toJsonLiteral( index, "null" ); } private int toJsonWhitespace(int index) { - while (hasNext( index ) && isWhitespace( peek( index ) ) ) append( peek( index++ ) ); + while ( hasChar( index ) && isWhitespace( peek( index ) ) ) append( peek( index++ ) ); return index; } @@ -187,7 +212,7 @@ private int toJsonChar( int index ) { } private int toJsonLiteral( int index, String literal ) { - boolean isShort = !hasNext( index+1 ) || !isLowerLetter( juon[index+1]); + boolean isShort = !hasChar( index+1 ) || !isLowerLetter( juon[index+1]); if (isShort) { append( literal ); return toJsonWhitespace( index+1 ); @@ -204,12 +229,12 @@ private int toJsonLiteral( int index, String literal ) { return toJsonWhitespace( index + l ); } - private boolean hasNext(int index) { + private boolean hasChar(int index) { return index < juon.length; } private char peek( int index ) { - if (!hasNext( index )) throw new JsonFormatException( "Unexpected end of input at index: %d".formatted( index ) ); + if (!hasChar( index )) throw new JsonFormatException( "Unexpected end of input at index: %d".formatted( index ) ); return juon[index]; } diff --git a/src/test/java/org/hisp/dhis/jsontree/JuonTest.java b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java index aeffdf7..6d18c89 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JuonTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java @@ -35,7 +35,7 @@ void testNull_Shorthand() { } @Test - @DisplayName( "In contrast to JSON an undefined, empty or blank value is considered null" ) + @DisplayName( "In contrast to JSON, in JUON an undefined, empty or blank value is considered null" ) void testNull_Omit() { assertEquals( JsonNode.of( "null" ), JsonNode.ofUrlObjectNotation( null ) ); assertEquals( JsonNode.of( "null" ), JsonNode.ofUrlObjectNotation( "" ) ); @@ -47,16 +47,18 @@ void testNumber() { assertEquals( JsonNode.of( "1234" ), JsonNode.ofUrlObjectNotation( "1234" ) ); assertEquals( JsonNode.of( "42.12" ), JsonNode.ofUrlObjectNotation( "42.12" ) ); assertEquals( JsonNode.of( "-0.12" ), JsonNode.ofUrlObjectNotation( "-0.12" ) ); + assertEquals( JsonNode.of( "-0.12e-3" ), JsonNode.ofUrlObjectNotation( "-0.12e-3" ) ); + assertEquals( JsonNode.of( "0.12E12" ), JsonNode.ofUrlObjectNotation( "0.12E12" ) ); } @Test - @DisplayName( "In contrast to JSON floating point numbers can start with a dot" ) + @DisplayName( "In contrast to JSON, in JUON floating point numbers can start with a dot" ) void testNumber_OmitLeadingZero() { assertEquals( JsonNode.of( "0.12" ), JsonNode.ofUrlObjectNotation( ".12" ) ); } @Test - @DisplayName( "In contrast to JSON floating point numbers can end with a dot" ) + @DisplayName( "In contrast to JSON, in JUON floating point numbers can end with a dot" ) void testNumber_OmitTailingZero() { assertEquals( JsonNode.of( "0.0" ), JsonNode.ofUrlObjectNotation( "0." ) ); } @@ -78,7 +80,7 @@ void testArray_Array() { } @Test - @DisplayName( "In contrast to JSON nulls in arrays can be omitted (left empty)" ) + @DisplayName( "In contrast to JSON, in JUON nulls in arrays can be omitted (left empty)" ) void testArray_OmitNulls() { assertEquals( JsonNode.of( "[null,null]" ), JsonNode.ofUrlObjectNotation( "(,)" ) ); assertEquals( JsonNode.of( "[null,null,3]" ), JsonNode.ofUrlObjectNotation( "(,,3)" ) ); @@ -94,6 +96,27 @@ void testObject() { JsonNode.ofUrlObjectNotation( "(no:1,surprises:(please:true))" ) ); } + @Test + @DisplayName( "In contrast to JSON, in JUON nulls in objects can be omitted (left empty)" ) + void testObject_OmitNulls() { + assertEquals( JsonNode.of( "{\"a\":null}" ), JsonNode.ofUrlObjectNotation( "(a:)" ) ); + assertEquals( JsonNode.of( "{\"a\":null,\"b\":null}" ), JsonNode.ofUrlObjectNotation( "(a:,b:)" ) ); + assertEquals( JsonNode.of( "{\"a\":null,\"b\":null,\"c\":3}" ), JsonNode.ofUrlObjectNotation( "(a:,b:,c:3)" ) ); + assertEquals( JsonNode.of( "{\"a\":1,\"b\":null,\"c\":3}" ), JsonNode.ofUrlObjectNotation( "(a:1,b:,c:3)" ) ); + assertEquals( JsonNode.of( "{\"a\":1,\"b\":null,\"c\":null}" ), JsonNode.ofUrlObjectNotation( "(a:1,b:,c:)" ) ); + assertEquals( JsonNode.of( "{\"a\":1,\"b\":null,\"c\":0.3,\"d\":null,\"e\":5}" ), JsonNode.ofUrlObjectNotation( "(a:1,b:,c:.3,d:,e:5)" ) ); + } + + @Test + void testObject_Object() { + assertEquals( JsonNode.of( "{\"a\":{\"b\":null}}" ), JsonNode.ofUrlObjectNotation( "(a:(b:))" ) ); + assertEquals( JsonNode.of( "{\"a\":{\"b\":null},\"c\":{\"d\":null}}" ), JsonNode.ofUrlObjectNotation( "(a:(b:),c:(d:))" ) ); + assertEquals( JsonNode.of( "{\"a\":{\"b\":{\"c\":null}}}" ), JsonNode.ofUrlObjectNotation( "(a:(b:(c:)))" ) ); + String json = """ + {"a":{"b":{"c":1,"d":2},"e":{"f":3,"g":4}},"h":{"i":5,"k":6}}"""; + assertEquals( JsonNode.of( json ), JsonNode.ofUrlObjectNotation( "(a:(b:(c:1,d:2),e:(f:3,g:4)),h:(i:5,k:6))" ) ); + } + @Test void testMixed_Minimal() { JsonNode json = JsonNode.ofUrlObjectNotation( """ From 85eb69e5985e48042f6a650e9ae22041dc076104 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 1 Jul 2024 09:18:08 +0200 Subject: [PATCH 3/7] chore: add readme for JUON and more tests --- JUON.md | 63 +++++++++++++++++++ .../java/org/hisp/dhis/jsontree/Juon.java | 5 +- .../java/org/hisp/dhis/jsontree/JuonTest.java | 37 +++++++++-- 3 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 JUON.md diff --git a/JUON.md b/JUON.md new file mode 100644 index 0000000..6407902 --- /dev/null +++ b/JUON.md @@ -0,0 +1,63 @@ +# JUON + +JUON is short for JS URL Object Notation. +Think of it as JSON values for URLs. +That means the syntax does not use characters that are not safe to use in URLs. + +It is not only inspired by JSON but designed to be easily +translatable to JSON. For that, it shares the same types: +_object_, _array_, _string_, _number_, _boolean_ and _null_. + +Where possible the JSON syntax was kept in JUON. +This should help both translate but also remember the syntax for everyone +already familiar with JSON. + +The key syntax differences are: + +* object and arrays both use round brackets `(...)` +* strings are quoted in single upticks `'...'` +* positive numbers or positive exponents must not use `+` (omit it) +* object member names are not quoted +* object member names can only use `A-Z a-z 0-9 - . _ @` +* object member names must start with a letter or `@` +* empty array is `()`, empty object cannot be expressed (approximate with `null`) +* as strings are quoted with single upticks `"` does not need escaping + but instead `'` is escaped as `\'` + +Furthermore, JUON is more "lenient" than JSON both to be more user-friendly and +to allow shorter URLs though the use of shorthands and omissions: + +* `null` can be encoded as the empty string or shortened to `n` +* `true` can be shortened to `t` +* `false` can be shortened to `f` +* numbers can start with `.` (= `0.`) +* numbers can end with `.` (= `.0`) + +In summary, JUON can express everything JSON can, except the empty object `{}`. +Some characters in strings obviously will need URL encoding to be allowed in URLs. + +## Example +The following JSON +```json +{ + "name": "Freddy", + "age": 30, + "car": null, + "addresses": [{ + "street": "Elm Street", + "zip": 1428, + "city": "Springwood", + "invoice": true + }] +} +``` +would look like this in JUON +(all non-significant whitespace removed as it is not valid in URLs and easier to read without %-escaping) +``` +(name:'Freddy',age:30,car:null,addresses:((street:'Elm+Street',zip:1428,city:'Springwood',invoice:true))) +``` +Note that the `+` could also be `%20`. +This could be further shortened by using shorthands and omitting `null` +``` +(name:'Freddy',age:30,car:,addresses:((street:'Elm+Street',zip:1428,city:'Springwood',invoice:t))) +``` diff --git a/src/main/java/org/hisp/dhis/jsontree/Juon.java b/src/main/java/org/hisp/dhis/jsontree/Juon.java index d30c7da..b32cc24 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Juon.java +++ b/src/main/java/org/hisp/dhis/jsontree/Juon.java @@ -119,6 +119,8 @@ private int toJsonString(int index) { append( '"' ); return index; // found end of string literal } + // 1:1 transfer (default case) + append( c ); if (c == '\\') { char escape = peek( index ); index++; // move beyond the first escaped char @@ -133,9 +135,6 @@ private int toJsonString(int index) { append( peek( index++ ) ); } } - } else { - // 1:1 transfer (default case) - append( c ); } } // this is only to fail at end of input from exiting the while loop diff --git a/src/test/java/org/hisp/dhis/jsontree/JuonTest.java b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java index 6d18c89..b303bea 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JuonTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java @@ -63,7 +63,27 @@ void testNumber_OmitTailingZero() { assertEquals( JsonNode.of( "0.0" ), JsonNode.ofUrlObjectNotation( "0." ) ); } + + @Test + void testString() { + assertEquals( JsonNode.of( "\"\"" ), JsonNode.ofUrlObjectNotation( "''" ) ); + assertEquals( JsonNode.of( "\"a\"" ), JsonNode.ofUrlObjectNotation( "'a'" ) ); + assertEquals( JsonNode.of( "\"hello world\"" ), JsonNode.ofUrlObjectNotation( "'hello world'" ) ); + } + + @Test + void testString_Unicode() { + assertEquals( JsonNode.of( "\"Star \\uD83D\\uDE80 ship\"" ), + JsonNode.ofUrlObjectNotation( "'Star \\uD83D\\uDE80 ship'" ) ); + } + @Test + void testString_Escapes() { + assertEquals( JsonNode.of( "\"\\\\\\/\\t\\r\\n\\f\\b\\\"\"" ), + JsonNode.ofUrlObjectNotation( "'\\\\\\/\\t\\r\\n\\f\\b\\''" ) ); + } + + @Test void testArray() { assertEquals( JsonNode.of( "[]" ), JsonNode.ofUrlObjectNotation( "()" ) ); assertEquals( JsonNode.of( "[1,2,3]" ), JsonNode.ofUrlObjectNotation( "(1,2,3)" ) ); @@ -119,12 +139,19 @@ void testObject_Object() { @Test void testMixed_Minimal() { - JsonNode json = JsonNode.ofUrlObjectNotation( """ - (name:'John',age:42,license:false,keywords:('hello','world'),void:null) - """ ); + JsonNode json = JsonNode.ofUrlObjectNotation( + "(name:'John',age:42,license:false,keywords:('hello','world'),void:null)" ); + String expected = """ + {"name":"John","age":42,"license":false,"keywords":["hello","world"],"void":null}"""; + assertEquals( JsonNode.of( expected ), json ); + } + + @Test + void testMixed_Example() { + JsonNode json = JsonNode.ofUrlObjectNotation( + "(name:'Freddy',age:30,car:,addresses:((street:'Elm Street',zip:1428,city:'Springwood',invoice:t)))" ); String expected = """ - {"name":"John","age":42,"license":false,"keywords":["hello","world"],"void":null} - """; + {"name":"Freddy","age":30,"car":null,"addresses":[{"street":"Elm Street","zip":1428,"city":"Springwood","invoice":true}]}"""; assertEquals( JsonNode.of( expected ), json ); } } From 0d35e23057dfb27de0ebf61f87c8f42c00626eda Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 1 Jul 2024 09:56:29 +0200 Subject: [PATCH 4/7] chore: improved README text --- JUON.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/JUON.md b/JUON.md index 6407902..02b2c00 100644 --- a/JUON.md +++ b/JUON.md @@ -37,7 +37,7 @@ In summary, JUON can express everything JSON can, except the empty object `{}`. Some characters in strings obviously will need URL encoding to be allowed in URLs. ## Example -The following JSON +Here is a JSON example: ```json { "name": "Freddy", @@ -51,13 +51,16 @@ The following JSON }] } ``` -would look like this in JUON -(all non-significant whitespace removed as it is not valid in URLs and easier to read without %-escaping) +In JUON the equivalent Java `String` would be (formatting whitespace removed): ``` -(name:'Freddy',age:30,car:null,addresses:((street:'Elm+Street',zip:1428,city:'Springwood',invoice:true))) +(name:'Freddy',age:30,car:null,addresses:((street:'Elm Street',zip:1428,city:'Springwood',invoice:true))) ``` -Note that the `+` could also be `%20`. -This could be further shortened by using shorthands and omitting `null` +This could be further shortened by using shorthands and omitting `null` values: +``` +(name:'Freddy',age:30,car:,addresses:((street:'Elm Street',zip:1428,city:'Springwood',invoice:t))) +``` +In a URL parameter the entire value would be URL encoded, resulting in: ``` (name:'Freddy',age:30,car:,addresses:((street:'Elm+Street',zip:1428,city:'Springwood',invoice:t))) ``` +(Note: the `+` could also be `%20`) \ No newline at end of file From 9515fd3fe06958cd5852d913726ace5905630d5f Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 1 Jul 2024 16:29:35 +0200 Subject: [PATCH 5/7] feat: JUON builder --- JUON.md | 6 +- .../java/org/hisp/dhis/jsontree/JsonNode.java | 2 +- .../java/org/hisp/dhis/jsontree/Juon.java | 53 +++- .../org/hisp/dhis/jsontree/JuonAppender.java | 224 ++++++++++++++ .../hisp/dhis/jsontree/JuonAppenderTest.java | 278 ++++++++++++++++++ 5 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/hisp/dhis/jsontree/JuonAppender.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/JuonAppenderTest.java diff --git a/JUON.md b/JUON.md index 02b2c00..d0b2ce2 100644 --- a/JUON.md +++ b/JUON.md @@ -63,4 +63,8 @@ In a URL parameter the entire value would be URL encoded, resulting in: ``` (name:'Freddy',age:30,car:,addresses:((street:'Elm+Street',zip:1428,city:'Springwood',invoice:t))) ``` -(Note: the `+` could also be `%20`) \ No newline at end of file +(Note: the `+` could also be `%20`) + + +## Specification + diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index feaa829..0da3e7d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -138,7 +138,7 @@ static JsonNode ofNonStandard( String json ) { * @since 1.2 */ static JsonNode ofUrlObjectNotation(String juon) { - return of( Juon.toJSON( juon )); + return of( Juon.toJson( juon )); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/Juon.java b/src/main/java/org/hisp/dhis/jsontree/Juon.java index b32cc24..06fca77 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Juon.java +++ b/src/main/java/org/hisp/dhis/jsontree/Juon.java @@ -1,5 +1,7 @@ package org.hisp.dhis.jsontree; +import java.util.function.Consumer; + /** * JUON is short for JS URL Object Notation. *

@@ -10,6 +12,55 @@ */ record Juon(char[] juon, StringBuilder json) { + record Format(boolean booleanShorthands, Nulls nullsInArrays, Nulls nullsInObjects) { + enum Nulls { PLAIN("null"), SHORTHAND("n"), EMPTY(""), OMIT(null); + final String value; + + Nulls( String value ) { + this.value = value; + } + } + } + + public static final Format MINIMAL = new Format(true, Format.Nulls.EMPTY, Format.Nulls.OMIT); + public static final Format PLAIN = new Format(false, Format.Nulls.PLAIN, Format.Nulls.PLAIN); + + public static String of(JsonValue value) { + return of( value.node() ); + } + + public static String of(JsonNode value) { + return of( value, MINIMAL ); + } + + public static String of(JsonValue value, Format format) { + return of( value.node(), format ); + } + + public static String of(JsonNode value, Format format) { + return JuonAppender.toJuon( format, value ); + } + + static String createObject(Consumer obj) { + return createObject( MINIMAL, obj ); + } + + static String createObject(Format format, Consumer obj) { + JuonAppender bld = new JuonAppender(format); + bld.addObject( obj ); + return bld.toString(); + } + + static String createArray(Consumer arr) { + return createArray( MINIMAL, arr ); + } + + static String createArray(Format format, Consumer arr) { + JuonAppender bld = new JuonAppender(format); + bld.addArray( arr ); + return bld.toString(); + } + /** * Converts JUON to JSON. * @@ -17,7 +68,7 @@ record Juon(char[] juon, StringBuilder json) { * @return the equivalent JSON * @since 1.2 */ - public static String toJSON(String juon) { + public static String toJson(String juon) { if (juon == null || juon.isEmpty() || juon.isBlank()) return "null"; StringBuilder json = new StringBuilder( juon.length() * 2 ); diff --git a/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java b/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java new file mode 100644 index 0000000..b8f0de6 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java @@ -0,0 +1,224 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.JsonBuilder.JsonArrayBuilder; +import org.hisp.dhis.jsontree.JsonBuilder.JsonObjectBuilder; +import org.hisp.dhis.jsontree.internal.Surly; + +import java.util.function.Consumer; + +/** + * Builder for JUON + * + * @author Jan Bernitt + * @since 1.2 + */ +final class JuonAppender implements JsonObjectBuilder, JsonArrayBuilder { + + private final Juon.Format format; + private final StringBuilder out = new StringBuilder(); + private final boolean[] hasChildrenAtLevel = new boolean[128]; + + private int level = 0; + + JuonAppender( Juon.Format format ) { + this.format = format; + } + + @Override public String toString() { + return out.toString(); + } + + static String toJuon( Juon.Format format, JsonNode value ) { + JuonAppender bld = new JuonAppender(format); + bld.addElement( value ); + return bld.out.toString(); + } + + @Override public JsonObjectBuilder addMember( String name, JsonNode value ) { + JsonNodeType type = value.getType(); + if ( type == JsonNodeType.NULL ) + return addRawMember( name, format.nullsInObjects().value ); + return switch ( type ) { + case OBJECT -> addObject( name, obj -> value.members().forEach( e -> obj.addMember( e.getKey(), e.getValue() ) ) ); + case ARRAY -> addArray( name, arr -> value.elements().forEach( arr::addElement ) ); + case STRING -> addString( name, (String)value.value() ); + case NUMBER -> addNumber( name, (Number) value.value() ); + case BOOLEAN -> addBoolean( name, (Boolean) value.value() ); + case NULL -> addBoolean( name, null ); + }; + } + + @Override public JsonObjectBuilder addBoolean( String name, boolean value ) { + String raw = String.valueOf( value ); + return addRawMember( name, format.booleanShorthands() ? raw.substring( 0, 1 ) : raw ); + } + + @Override public JsonObjectBuilder addBoolean( String name, Boolean value ) { + if (value == null) return addRawMember( name, format.nullsInObjects().value ); + return addBoolean( name, value.booleanValue() ); + } + + @Override public JsonObjectBuilder addNumber( String name, int value ) { + return addRawMember( name, String.valueOf( value ) ); + } + + @Override public JsonObjectBuilder addNumber( String name, long value ) { + return addRawMember( name, String.valueOf( value ) ); + } + + @Override public JsonObjectBuilder addNumber( String name, double value ) { + JsonBuilder.checkValid( value ); + return addRawMember( name, String.valueOf( value ) ); + } + + @Override public JsonObjectBuilder addNumber( String name, Number value ) { + if (value == null) return addRawMember( name, format.nullsInObjects().value ); + JsonBuilder.checkValid( value ); + return addRawMember( name, String.valueOf( value ) ); + } + + @Override public JsonObjectBuilder addString( String name, String value ) { + if (value == null) return addRawMember( name, format.nullsInObjects().value ); + addMember( name ); + appendEscaped( value ); + return this; + } + + @Override public JsonObjectBuilder addArray( String name, Consumer arr ) { + addMember( name ); + addArray( arr ); + return this; + } + + @Override public JsonObjectBuilder addObject( String name, Consumer obj ) { + addMember( name ); + addObject( obj ); + return this; + } + + private void addMember( String name ) { + appendCommaWhenNeeded(); + append( name ); + append( ':' ); + } + + private JsonObjectBuilder addRawMember(String name, CharSequence rawValue ) { + if (rawValue == null) return this; + addMember( name ); + append( rawValue ); + return this; + } + + private JsonArrayBuilder addRawElement( CharSequence rawValue ) { + appendCommaWhenNeeded(); + if (rawValue != null && !rawValue.isEmpty()) + append( rawValue ); + return this; + } + + @Override public JsonArrayBuilder addElement( JsonNode value ) { + JsonNodeType type = value.getType(); + if ( type == JsonNodeType.NULL ) + return addRawElement( format.nullsInArrays().value ); + return switch ( type ) { + case OBJECT -> addObject(obj -> value.members().forEach( e -> obj.addMember( e.getKey(), e.getValue() ) ) ); + case ARRAY -> addArray( arr -> value.elements().forEach( arr::addElement ) ); + case STRING -> addString( (String)value.value() ); + case NUMBER -> addNumber( (Number) value.value() ); + case BOOLEAN -> addBoolean( (Boolean) value.value() ); + case NULL -> addBoolean( null ); + }; + } + + @Override public JsonArrayBuilder addBoolean( boolean value ) { + String raw = String.valueOf( value ); + return addRawElement( format.booleanShorthands() ? raw.substring( 0, 1 ) : raw ); + } + + @Override public JsonArrayBuilder addBoolean( Boolean value ) { + if (value == null) return addRawElement( format.nullsInArrays().value ); + return addBoolean( value.booleanValue() ); + } + + @Override public JsonArrayBuilder addNumber( int value ) { + return addRawElement( String.valueOf( value ) ); + } + + @Override public JsonArrayBuilder addNumber( long value ) { + return addRawElement( String.valueOf( value ) ); + } + + @Override public JsonArrayBuilder addNumber( double value ) { + JsonBuilder.checkValid( value ); + return addRawElement( String.valueOf( value ) ); + } + + @Override public JsonArrayBuilder addNumber( Number value ) { + if (value == null) return addRawElement( format.nullsInArrays().value ); + JsonBuilder.checkValid( value ); + return addRawElement( value.toString() ); + } + + @Override public JsonArrayBuilder addString( String value ) { + if (value == null) return addRawElement( format.nullsInArrays().value ); + appendCommaWhenNeeded(); + appendEscaped( value ); + return this; + } + + @Override public JsonArrayBuilder addArray( Consumer arr ) { + beginLevel(); + arr.accept( this ); + endLevel(); + return this; + } + + @Override public JsonArrayBuilder addObject( Consumer obj ) { + int l0 = out.length(); + beginLevel(); + obj.accept( this ); + endLevel(); + if ( out.length() == l0 + 2) { + out.setLength( l0 ); // undo object + append( "null" ); // approximate the empty object with null + } + return this; + } + + private void beginLevel() { + append( '(' ); + hasChildrenAtLevel[++level] = false; + } + + private void endLevel() { + level--; + append( ')' ); + } + + private void appendCommaWhenNeeded() { + if ( !hasChildrenAtLevel[level] ) { + hasChildrenAtLevel[level] = true; + } else { + append( ',' ); + } + } + + private void append(CharSequence str) { + out.append( str ); + } + + private void append(char c) { + out.append( c ); + } + + void appendEscaped(@Surly CharSequence str ) { + append( '\'' ); + str.chars().forEachOrdered( c -> { + if ( c == '\'' || c == '\\' || c < ' ' ) { + append( '\\' ); + } + append( (char) c ); + } ); + append( '\'' ); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/JuonAppenderTest.java b/src/test/java/org/hisp/dhis/jsontree/JuonAppenderTest.java new file mode 100644 index 0000000..74f63cc --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JuonAppenderTest.java @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2004-2021, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * Neither the name of the HISP project nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.JsonBuilder.JsonArrayBuilder; +import org.hisp.dhis.jsontree.JsonBuilder.JsonObjectBuilder; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.RetentionPolicy; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests the {@link JuonAppender} implementation of {@link Juon}. + * + * @author Jan Bernitt + */ +class JuonAppenderTest { + + @Test + void testObject_Boolean() { + assertEquals( "(a:t,b:f)", Juon.createObject( obj -> obj + .addBoolean( "a", true ) + .addBoolean( "b", false ) + .addBoolean( "c", null ) ) ); + } + + @Test + void testArray_Boolean() { + Consumer builder = arr -> arr + .addBoolean( true ) + .addBoolean( false ) + .addBoolean( null ); + assertEquals( "(t,f,)", Juon.createArray( builder ) ); + assertEquals( "(true,false,null)", Juon.createArray(Juon.PLAIN, builder ) ); + } + + @Test + void testObject_Int() { + assertEquals( "(int:42)", Juon.createObject( obj -> obj + .addNumber( "int", 42 ) ) ); + } + + @Test + void testArray_Int() { + assertEquals( "(42)", Juon.createArray( arr -> arr + .addNumber( 42 ) ) ); + } + + @Test + void testObject_Double() { + assertEquals( "(double:42.42)", Juon.createObject( obj -> obj + .addNumber( "double", 42.42 ) ) ); + } + + @Test + void testArray_Double() { + assertEquals( "(42.42)", Juon.createArray( arr -> arr + .addNumber( 42.42 ) ) ); + } + + @Test + void testObject_Long() { + assertEquals( "(long:" + Long.MAX_VALUE + ")", Juon.createObject( obj -> obj + .addNumber( "long", Long.MAX_VALUE ) ) ); + } + + @Test + void testArray_Long() { + assertEquals( "(" + Long.MAX_VALUE + ")", Juon.createArray( arr -> arr + .addNumber( Long.MAX_VALUE ) ) ); + } + + @Test + void testObject_BigInteger() { + assertEquals( "(bint:42)", Juon.createObject( obj -> obj + .addNumber( "bint", BigInteger.valueOf( 42L ) ) ) ); + } + + @Test + void testArray_BigInteger() { + assertEquals( "(42)", Juon.createArray( arr -> arr + .addNumber( BigInteger.valueOf( 42L ) ) ) ); + } + + @Test + void testObject_String() { + assertEquals( "(s:'hello')", Juon.createObject( obj -> obj + .addString( "s", "hello" ) ) ); + } + + @Test + void testObject_StringNull() { + assertEquals( "null", Juon.createObject( obj -> obj + .addString( "s", null ) ) ); + } + + @Test + void testObject_StringEscapes() { + assertEquals( "(s:'\"oh yes\"')", Juon.createObject( obj -> obj + .addString( "s", "\"oh yes\"" ) ) ); + } + + @Test + void testArray_String() { + assertEquals( "('hello')", Juon.createArray( arr -> arr + .addString( "hello" ) ) ); + } + + @Test + void testArray_StringEscapes() { + assertEquals( "('hello\\\\ world')", Juon.createArray( arr -> arr + .addString( "hello\\ world" ) ) ); + } + + @Test + void testObject_IntArray() { + assertEquals( "(array:(1,2))", Juon.createObject( obj -> obj + .addArray( "array", arr -> arr.addNumbers( 1, 2 ) ) ) ); + } + + @Test + void testArray_IntArray() { + assertEquals( "((1,2))", Juon.createArray( arr -> arr + .addArray( a -> a.addNumbers( 1, 2 ) ) ) ); + } + + @Test + void testObject_DoubleArray() { + assertEquals( "(array:(1.5,2.5))", Juon.createObject( obj -> obj + .addArray( "array", arr -> arr.addNumbers( 1.5d, 2.5d ) ) ) ); + } + + @Test + void testArray_DoubleArray() { + assertEquals( "((1.5,2.5))", Juon.createArray( arr -> arr + .addArray( a -> a.addNumbers( 1.5d, 2.5d ) ) ) ); + } + + @Test + void testObject_LongArray() { + assertEquals( "(array:(" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "))", Juon.createObject( obj -> obj + .addArray( "array", arr -> arr.addNumbers( Long.MIN_VALUE, Long.MAX_VALUE ) ) ) ); + } + + @Test + void testArray_LongArray() { + assertEquals( "((" + Long.MIN_VALUE + "," + Long.MAX_VALUE + "))", Juon.createArray( arr -> arr + .addArray( a -> a.addNumbers( Long.MIN_VALUE, Long.MAX_VALUE ) ) ) ); + } + + @Test + void testObject_StringArray() { + assertEquals( "(array:('a','b'))", Juon.createObject( obj -> obj + .addArray( "array", arr -> arr.addStrings( "a", "b" ) ) ) ); + } + + @Test + void testArray_StringArray() { + assertEquals( "(('a','b'))", Juon.createArray( arr -> arr + .addArray( a -> a.addStrings( "a", "b" ) ) ) ); + } + + @Test + void testObject_OtherArray() { + assertEquals( "(array:('SOURCE','CLASS','RUNTIME'))", Juon.createObject( obj -> obj + .addArray( "array", arr -> arr.addElements( RetentionPolicy.values(), JsonArrayBuilder::addString, + RetentionPolicy::name ) ) ) ); + } + + @Test + void testArray_OtherArray() { + assertEquals( "(('SOURCE','CLASS','RUNTIME'))", Juon.createArray( arr -> arr + .addArray( a -> a.addElements( RetentionPolicy.values(), JsonArrayBuilder::addString, + RetentionPolicy::name ) ) ) ); + } + + @Test + void testArray_OtherCollection() { + assertEquals( "(('SOURCE','CLASS','RUNTIME'))", Juon.createArray( arr -> arr + .addArray( a -> a.addElements( List.of( RetentionPolicy.values() ), JsonArrayBuilder::addString, + RetentionPolicy::name ) ) ) ); + } + + @Test + void testObject_ObjectBuilder() { + assertEquals( "(obj:(inner:42))", Juon.createObject( outer -> outer + .addObject( "obj", obj -> obj.addNumber( "inner", 42 ) ) ) ); + } + + @Test + void testArray_ObjectBuilder() { + assertEquals( "((42,14))", Juon.createArray( arr -> arr + .addArray( arr2 -> arr2.addNumber( 42 ).addNumber( 14 ) ) ) ); + } + + @Test + void testArray_ArrayBuilder() { + assertEquals( "((inner:42))", Juon.createArray( arr -> arr + .addObject( obj -> obj.addNumber( "inner", 42 ) ) ) ); + } + + @Test + void testObject_ObjectMap() { + assertEquals( "(obj:(field:42))", Juon.createObject( outer -> outer + .addObject( "obj", inner -> inner.addMembers( + Map.of( "field", 42 ).entrySet(), JsonObjectBuilder::addNumber ) ) ) ); + } + + @Test + void testArray_ObjectMap() { + assertEquals( "((field:42))", Juon.createArray( arr -> arr + .addObject( obj -> obj.addMembers( Map.of( "field", 42 ), JsonObjectBuilder::addNumber ) ) ) ); + } + + @Test + void testObject_MembersMap() { + assertEquals( "(field:42)", Juon.createObject( outer -> outer + .addMembers( Map.of( "field", 42 ).entrySet(), JsonObjectBuilder::addNumber ) ) ); + } + + @Test + void testArray_ElementsCollection() { + assertEquals( "((42))", Juon.createArray( arr -> arr + .addArray( a -> a.addElements( List.of( 42 ), JsonArrayBuilder::addNumber ) ) ) ); + } + + @Test + void testObject_JsonNode() { + assertEquals( "(node:('a','b'))", Juon.createObject( obj -> obj + .addMember( "node", JsonNode.of( "[\"a\",\"b\"]" ) ) ) ); + } + + @Test + void testObject_JsonNodeNull() { + Consumer builder = obj -> obj + .addMember( "node", JsonNode.NULL ); + assertEquals( "null", Juon.createObject( builder ), + "when the 'node' member is omitted the entire object is empty and approximated to null" ); + assertEquals( "(node:null)", Juon.createObject(Juon.PLAIN, builder ), + "but when null members are included plain the object no longer is empty"); + } + + @Test + void testArray_JsonNode() { + assertEquals( "(('a','b'))", Juon.createArray( arr -> arr + .addElement( JsonNode.of( "[\"a\",\"b\"]" ) ) ) ); + } +} From b9a33527578d2f695fbf8f5a671e40e7104cd92a Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 8 Jul 2024 10:09:34 +0200 Subject: [PATCH 6/7] fix: JUON <=> JSON should not change quoting content --- src/main/java/org/hisp/dhis/jsontree/Juon.java | 8 ++++++-- src/test/java/org/hisp/dhis/jsontree/JuonTest.java | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/Juon.java b/src/main/java/org/hisp/dhis/jsontree/Juon.java index 06fca77..c617792 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Juon.java +++ b/src/main/java/org/hisp/dhis/jsontree/Juon.java @@ -171,12 +171,16 @@ private int toJsonString(int index) { return index; // found end of string literal } // 1:1 transfer (default case) - append( c ); + if (c == '"') { + append( "\\\"" ); // needs escaping in JSON + } else { + append( c ); + } if (c == '\\') { char escape = peek( index ); index++; // move beyond the first escaped char if (escape == '\'') { - append( '"' ); + append( '\'' ); // does not need escaping in JSON } else { append( escape ); if (escape == 'u') { diff --git a/src/test/java/org/hisp/dhis/jsontree/JuonTest.java b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java index b303bea..7cdcfab 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JuonTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java @@ -79,8 +79,8 @@ void testString_Unicode() { @Test void testString_Escapes() { - assertEquals( JsonNode.of( "\"\\\\\\/\\t\\r\\n\\f\\b\\\"\"" ), - JsonNode.ofUrlObjectNotation( "'\\\\\\/\\t\\r\\n\\f\\b\\''" ) ); + assertEquals( JsonNode.of( "\"\\\\\\/\\t\\r\\n\\f\\b\\'\\\"\"" ), + JsonNode.ofUrlObjectNotation( "'\\\\\\/\\t\\r\\n\\f\\b\\'\"'" ) ); } @Test From 83442605296dd9b67aeb9c253453e79a77b9cb5b Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Wed, 24 Jul 2024 16:17:01 +0200 Subject: [PATCH 7/7] fix: JsonPath with dot always needs curly escape --- src/main/java/org/hisp/dhis/jsontree/JsonNode.java | 4 ++-- src/main/java/org/hisp/dhis/jsontree/JsonPath.java | 2 +- src/main/java/org/hisp/dhis/jsontree/Juon.java | 4 ++-- src/main/java/org/hisp/dhis/jsontree/JuonAppender.java | 2 +- src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java | 5 +++++ 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 31a0a0e..d4be266 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -133,9 +133,9 @@ static JsonNode ofNonStandard( String json ) { * Note that the {@link JsonNode}'s {@link JsonNode#getDeclaration()} will be the equivalent JSON, not the original * URL notation. * - * @param juon + * @param juon a value in URL notation * @return the given URL notation input as {@link JsonNode} - * @since 1.2 + * @since 1.3 */ static JsonNode ofUrlObjectNotation(String juon) { return of( Juon.toJson( juon )); diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java index a47d05f..842dad3 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -96,7 +96,7 @@ private static String keyOf( String name, boolean forceSegment ) { // edge special case: {...} but only opens at the start => dot works if ( !hasDot && name.charAt( 0 ) == '{' && name.indexOf( '{', 1 ) < 0 ) return "."+name; // special case: has curly open but no valid curly close => plain or dot works - if (indexOfInnerCurlySegmentEnd( name ) < 1) return name.charAt( 0 ) == '{' ? "."+name : name; + if (!hasDot && indexOfInnerCurlySegmentEnd( name ) < 1) return name.charAt( 0 ) == '{' ? "."+name : name; return curlyEscapeWithCheck( name ); } diff --git a/src/main/java/org/hisp/dhis/jsontree/Juon.java b/src/main/java/org/hisp/dhis/jsontree/Juon.java index c617792..6f2d1d4 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Juon.java +++ b/src/main/java/org/hisp/dhis/jsontree/Juon.java @@ -8,7 +8,7 @@ * JS here is used in the same vain as it is used in JSON. Meaning the fundamental types of JUON are the same as those used by JSON. * * @author Jan Bernitt - * @since 1.2 + * @since 1.3 */ record Juon(char[] juon, StringBuilder json) { @@ -66,7 +66,7 @@ static String createArray(Format format, Consumer * * @param juon the JUON input * @return the equivalent JSON - * @since 1.2 + * @since 1.3 */ public static String toJson(String juon) { if (juon == null || juon.isEmpty() || juon.isBlank()) diff --git a/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java b/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java index b8f0de6..767d51d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java +++ b/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java @@ -10,7 +10,7 @@ * Builder for JUON * * @author Jan Bernitt - * @since 1.2 + * @since 1.3 */ final class JuonAppender implements JsonObjectBuilder, JsonArrayBuilder { diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java index d62379b..a5fbb0d 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java @@ -18,6 +18,11 @@ */ class JsonPathTest { + @Test + void testKeyOf() { + assertEquals( "{/api/openapi/{path}/openapi.html}", JsonPath.keyOf( "/api/openapi/{path}/openapi.html" ) ); + } + @Test void testSegments_Dot_Uniform() { assertSegments(".xxx", List.of(".xxx"));