diff --git a/JUON.md b/JUON.md new file mode 100644 index 0000000..d0b2ce2 --- /dev/null +++ b/JUON.md @@ -0,0 +1,70 @@ +# 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 +Here is a JSON example: +```json +{ + "name": "Freddy", + "age": 30, + "car": null, + "addresses": [{ + "street": "Elm Street", + "zip": 1428, + "city": "Springwood", + "invoice": true + }] +} +``` +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))) +``` +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`) + + +## Specification + diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 2045678..d4be266 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 a value in URL notation + * @return the given URL notation input as {@link JsonNode} + * @since 1.3 + */ + 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/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 new file mode 100644 index 0000000..6f2d1d4 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/Juon.java @@ -0,0 +1,330 @@ +package org.hisp.dhis.jsontree; + +import java.util.function.Consumer; + +/** + * 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.3 + */ +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. + * + * @param juon the JUON input + * @return the equivalent JSON + * @since 1.3 + */ + 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 ( hasChar( index ) && c == ',' ) { + index = toJsonMemberName( index ); + index = toJsonSymbol( index, ':', ':' ); + if (peek( index ) == ')') { // trailing omitted null... + append( "null" ); + } else { + index = toJsonAutoDetect( index ); + index = toJsonWhitespace( index ); + } + c = peek( index ); + if (c == ',') { + index = toJsonSymbol( index, ',', ',' ); + } + } + return toJsonSymbol( index, ')', '}' ); + } + + private int toJsonArray(int index) { + index = toJsonSymbol( index, '(', '[' ); + char c = peek( index ) == ')' ? ')' : ','; + while ( hasChar( index ) && c == ',') { + index = toJsonAutoDetect( index ); + index = toJsonWhitespace( index ); + c = peek( index ); + if (c == ',') { + index = toJsonSymbol( index, ',', ',' ); + if ( peek( index ) == ')' ) { // trailing omitted null... + c = ')'; + append( "null" ); + } + } + } + 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 ( hasChar( index ) ) { + char c = peek( index++ ); + if (c == '\'') { + append( '"' ); + return index; // found end of string literal + } + // 1:1 transfer (default case) + 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( '\'' ); // does not need escaping in JSON + } else { + append( escape ); + if (escape == 'u') { + append( peek( index++ ) ); + append( peek( index++ ) ); + append( peek( index++ ) ); + append( peek( index++ ) ); + } + } + } + } + // 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 ); + // sign + if ( c == '-') { + append( '-' ); + return toJsonNumber( index + 1 ); + } + // integer part + if (c == '.') { + append( '0' ); + } else { + while ( hasChar( index ) && isDigit( peek( index ) ) ) + index = toJsonChar( index ); + } + // fraction part + if ( hasChar( index ) && peek( index ) == '.') { + index = toJsonChar( index ); // transfer . + if (!hasChar( index ) || !isDigit( peek( index ) )) { + append( "0" ); // auto-add zero after decimal point when no digits follow + return 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; + } + + 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) { + // omitted null (we see the comma after) + if (peek( index ) == ',') { + append( "null" ); + return index; // the comma needs to be processed elsewhere + } + return toJsonLiteral( index, "null" ); + } + + private int toJsonWhitespace(int index) { + while ( hasChar( 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 = !hasChar( 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 hasChar(int index) { + return index < juon.length; + } + + private char peek( int index ) { + if (!hasChar( 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/main/java/org/hisp/dhis/jsontree/JuonAppender.java b/src/main/java/org/hisp/dhis/jsontree/JuonAppender.java new file mode 100644 index 0000000..767d51d --- /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.3 + */ +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/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")); 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\"]" ) ) ) ); + } +} 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..7cdcfab --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JuonTest.java @@ -0,0 +1,157 @@ +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, 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( "" ) ); + 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" ) ); + 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, 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, in JUON floating point numbers can end with a dot" ) + 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)" ) ); + 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, 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)" ) ); + 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 + @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( + "(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":"Freddy","age":30,"car":null,"addresses":[{"street":"Elm Street","zip":1428,"city":"Springwood","invoice":true}]}"""; + assertEquals( JsonNode.of( expected ), json ); + } +}