diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index c5671c7..29e4cc5 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -3,7 +3,6 @@ import org.hisp.dhis.jsontree.Validation.Rule; import org.hisp.dhis.jsontree.validation.JsonValidator; -import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -111,12 +110,15 @@ default Stream values() { } /** - * @return a stream of map/object entries in order of their declaration + * @return a stream of map/object entries in order of their declaration. the entry keys are the raw {@link #names()} + * as given in the original JSON document (not the {@link #keys()}) * @throws JsonTreeException in case this node does exist but is not an object node * @since 0.11 */ default Stream> entries() { - return keys().map( key -> Map.entry( key, get( key ) ) ); + if ( isUndefined() || isEmpty() ) return Stream.empty(); + return stream( node().names().spliterator(), false ).map( + name -> Map.entry( name, get( JsonPath.keyOf( name ) ) ) ); } /** @@ -130,6 +132,15 @@ default List names() { return isUndefined() || isEmpty() ? List.of() : stream( node().names().spliterator(), false ).toList(); } + /** + * @return a stream of the absolute paths of the map/object members in oder of their declaration + * @throws JsonTreeException in case this node does exist but is not an object node + * @since 1.2 + */ + default Stream paths() { + return isUndefined() || isEmpty() ? Stream.empty() : stream( node().paths().spliterator(), false ); + } + /** * @param action call with each entry in the map/object in order of their declaration * @throws JsonTreeException in case this node does exist but is not an object node diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index f5def39..2045678 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -402,6 +402,17 @@ default Iterable keys() { throw new JsonTreeException( getType() + " node has no keys property." ); } + /** + * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). + * + * @return the absolute paths of the members of this object in order of declaration + * @throws JsonTreeException if this node is not an object node that could have members + * @since 1.2 + */ + default Iterable paths() { + throw new JsonTreeException( getType() + " node has no paths property." ); + } + /** * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). *

diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java index 493efb2..a47d05f 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -6,6 +6,7 @@ import java.util.List; import static java.lang.Integer.parseInt; +import static java.util.Objects.requireNonNull; import static java.util.stream.Stream.concat; /** @@ -35,6 +36,8 @@ */ public record JsonPath(List segments) { + private static final System.Logger log = System.getLogger( JsonPath.class.getName() ); + /** * A path pointing to the root or self */ @@ -79,9 +82,61 @@ public static String keyOf( String name ) { * @return the plain name when possible and no segment is forced, otherwise the corresponding segment key */ private static String keyOf( String name, boolean forceSegment ) { - if ( name.startsWith( "{" ) || name.startsWith( "[" ) ) return "." + name; - if ( name.indexOf( '.' ) >= 0 ) return "{" + name + "}"; - return forceSegment ? "." + name : name; + boolean hasCurly = name.indexOf( '{' ) >= 0; + boolean hasSquare = name.indexOf( '[' ) >= 0; + boolean hasDot = name.indexOf( '.' ) >= 0; + // default case: no special characters in name + if (!hasCurly && !hasSquare && !hasDot) return forceSegment ? "." + name : name; + // common special case: has a dot (and possibly square) => needs curly escape + if ( !hasCurly && hasDot ) return curlyEscapeWithCheck( name ); + // common special case: has a square but no curly or dot => only needs escaping when open + close square + if ( !hasCurly ) return hasInnerSquareSegment( name ) ? "{"+name+"}" : "." + name; + // edge special case: [...] but only opens at the start => dot works + if ( !hasDot && name.charAt( 0 ) == '[' && name.indexOf( '[', 1 ) < 0 ) return "."+name; + // 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; + return curlyEscapeWithCheck( name ); + } + + private static boolean hasInnerSquareSegment(String name) { + int i = name.indexOf( '[', 1 ); + while ( i >= 0 ) { + if (isSquareSegmentOpen( name, i )) return true; + i = name.indexOf( '[', i+1 ); + } + return false; + } + + /** + * Searches for the end since possibly a curly escape is used and a valid inner curly end would be misunderstood. + */ + private static int indexOfInnerCurlySegmentEnd(String name) { + int i = name.indexOf( '}', 1 ); + while ( i >= 0 ) { + if (isCurlySegmentClose( name, i )) return i; + i = name.indexOf( '}', i+1 ); + } + return -1; + } + + private static String curlyEscapeWithCheck( String name ) { + int end = indexOfInnerCurlySegmentEnd( name ); + if (end > 0) { + // a } at the very end is ok since escaping that again {...} makes it an invalid end + // so then effectively there is no valid on in the escaped name + if (end < name.length()-1) { + log.log( System.Logger.Level.WARNING, + "Path segment escape required but not supported for name `%s`, character at %d will be misunderstood as segment end".formatted( + name, end ) ); + } + } + return "{"+name+"}"; + } + + public JsonPath { + requireNonNull( segments ); } /** @@ -235,7 +290,7 @@ private static List splitIntoSegments( String path ) if ( isDotSegmentOpen( path, i ) ) { i++; // advance past the . if ( i < len && path.charAt( i ) != '.' ) { - i++; // if it is not a dot the first char after the . is never the end + i++; // if it is not a dot the first char after the . is never a start of next segment while ( i < len && !isDotSegmentClose( path, i ) ) i++; } } else if ( isSquareSegmentOpen( path, i ) ) { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 0b84d16..86090f9 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -27,21 +27,19 @@ */ package org.hisp.dhis.jsontree; -import org.hisp.dhis.jsontree.JsonNodeOperation.Insert; import org.hisp.dhis.jsontree.internal.Maybe; import org.hisp.dhis.jsontree.internal.Surly; import java.io.Serializable; -import java.util.AbstractMap.SimpleEntry; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.Optional; import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.IntConsumer; import java.util.function.Predicate; import java.util.stream.StreamSupport; @@ -352,25 +350,30 @@ public Entry next() { } else if ( member.endIndex() < startIndexVal ) { // duplicate keys case: just skip the duplicate startIndex = expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndexVal ), '}' ); - return new SimpleEntry<>( name, member ); + return Map.entry( name, member ); } startIndex = expectCommaSeparatorOrEnd( json, member.endIndex(), '}' ); - return new SimpleEntry<>( name, member ); + return Map.entry( name, member ); } }; } + @Override + public Iterable paths() { + return keys(path::extendedWith); + } + @Override public Iterable names() { - return keys(false); + return keys(name -> name); } @Override public Iterable keys() { - return keys(true); + return keys(JsonPath::keyOf); } - private Iterable keys(boolean escape) { + private Iterable keys( Function toKey) { return () -> new Iterator<>() { private final char[] json = tree.json; private final Map nodesByPath = tree.nodesByPath; @@ -382,7 +385,7 @@ public boolean hasNext() { } @Override - public String next() { + public E next() { if ( !hasNext() ) throw new NoSuchElementException( "next() called without checking hasNext()" ); LazyJsonString.Span property = LazyJsonString.parseString( json, startIndex ); @@ -395,7 +398,7 @@ public String next() { startIndex = member == null || member.endIndex() < startIndex // (duplicates) ? expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndex ), '}' ) : expectCommaSeparatorOrEnd( json, member.endIndex(), '}' ); - return escape ? JsonPath.keyOf( name ) : name; + return toKey.apply( name ); } }; } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java index d656ad2..36e1253 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java @@ -158,8 +158,8 @@ void testEntries_Special() { String json = """ {".":1, "{uid}":2, "[6]":3, "x{y}z": 4}"""; JsonMap map = JsonMixed.of( json ).asMap( JsonNumber.class ); - assertEquals( List.of( entry( "{.}", 1 ), entry( ".{uid}", 2 ), - entry( ".[6]", 3 ), entry( "x{y}z", 4 ) ), + assertEquals( List.of( entry( ".", 1 ), entry( "{uid}", 2 ), + entry( "[6]", 3 ), entry( "x{y}z", 4 ) ), map.entries().map( e -> entry( e.getKey(), e.getValue().intValue() ) ).toList() ); } } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java index 044a259..429d6b2 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java @@ -59,6 +59,29 @@ void testNames_Special() { assertEquals( List.of( ".", "{uid}", "[0]" ), value.names() ); } + @Test + void testPaths_Special() { + //language=json + String json = """ + {"root": {".":1,"{uid}":2,"[0]": 3,"normal":4}}"""; + JsonObject value = JsonMixed.of( json ).getObject( "root" ); + assertEquals( List.of( JsonPath.of( ".root{.}" ), JsonPath.of( ".root.{uid}" ), JsonPath.of( ".root.[0]" ), + JsonPath.of( ".root.normal" ) ), + value.paths().toList() ); + } + + @Test + void testPaths_OpenAPI() { + //language=json + String json = """ + {"paths": {"/api/dataElements/{uid:[a-zA-Z0-9]{11}}": {"get": {"id": "opx"}, "delete": {"id":"opy"}}}}"""; + JsonObject paths = JsonMixed.of( json ).getObject( "paths" ); + assertEquals( List.of("/api/dataElements/{uid:[a-zA-Z0-9]{11}}"), paths.names() ); + JsonObject ops = paths.getObject( JsonPath.keyOf( "/api/dataElements/{uid:[a-zA-Z0-9]{11}}" ) ); + assertEquals( List.of("get", "delete"), ops.keys().toList() ); + assertEquals( "opy", ops.getObject( "delete" ).getString( "id" ).string() ); + } + @Test void testProject() { //language=json