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 );
+ }
+}