Skip to content

Commit a9869b4

Browse files
authored
fix: entries() use names as keys - name to key escape rules (#62)
* feat: paths() method to iterate/stream absolute member paths * fix: map/object entries must use names as keys * fix: path name to key special case handling
1 parent a846b23 commit a9869b4

File tree

6 files changed

+122
-19
lines changed

6 files changed

+122
-19
lines changed

src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import org.hisp.dhis.jsontree.Validation.Rule;
44
import org.hisp.dhis.jsontree.validation.JsonValidator;
55

6-
import java.util.ArrayList;
76
import java.util.Collection;
87
import java.util.List;
98
import java.util.Map;
@@ -111,12 +110,15 @@ default Stream<E> values() {
111110
}
112111

113112
/**
114-
* @return a stream of map/object entries in order of their declaration
113+
* @return a stream of map/object entries in order of their declaration. the entry keys are the raw {@link #names()}
114+
* as given in the original JSON document (not the {@link #keys()})
115115
* @throws JsonTreeException in case this node does exist but is not an object node
116116
* @since 0.11
117117
*/
118118
default Stream<Map.Entry<String, E>> entries() {
119-
return keys().map( key -> Map.entry( key, get( key ) ) );
119+
if ( isUndefined() || isEmpty() ) return Stream.empty();
120+
return stream( node().names().spliterator(), false ).map(
121+
name -> Map.entry( name, get( JsonPath.keyOf( name ) ) ) );
120122
}
121123

122124
/**
@@ -130,6 +132,15 @@ default List<String> names() {
130132
return isUndefined() || isEmpty() ? List.of() : stream( node().names().spliterator(), false ).toList();
131133
}
132134

135+
/**
136+
* @return a stream of the absolute paths of the map/object members in oder of their declaration
137+
* @throws JsonTreeException in case this node does exist but is not an object node
138+
* @since 1.2
139+
*/
140+
default Stream<JsonPath> paths() {
141+
return isUndefined() || isEmpty() ? Stream.empty() : stream( node().paths().spliterator(), false );
142+
}
143+
133144
/**
134145
* @param action call with each entry in the map/object in order of their declaration
135146
* @throws JsonTreeException in case this node does exist but is not an object node

src/main/java/org/hisp/dhis/jsontree/JsonNode.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,17 @@ default Iterable<String> keys() {
402402
throw new JsonTreeException( getType() + " node has no keys property." );
403403
}
404404

405+
/**
406+
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
407+
*
408+
* @return the absolute paths of the members of this object in order of declaration
409+
* @throws JsonTreeException if this node is not an object node that could have members
410+
* @since 1.2
411+
*/
412+
default Iterable<JsonPath> paths() {
413+
throw new JsonTreeException( getType() + " node has no paths property." );
414+
}
415+
405416
/**
406417
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
407418
* <p>

src/main/java/org/hisp/dhis/jsontree/JsonPath.java

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import java.util.List;
77

88
import static java.lang.Integer.parseInt;
9+
import static java.util.Objects.requireNonNull;
910
import static java.util.stream.Stream.concat;
1011

1112
/**
@@ -35,6 +36,8 @@
3536
*/
3637
public record JsonPath(List<String> segments) {
3738

39+
private static final System.Logger log = System.getLogger( JsonPath.class.getName() );
40+
3841
/**
3942
* A path pointing to the root or self
4043
*/
@@ -79,9 +82,61 @@ public static String keyOf( String name ) {
7982
* @return the plain name when possible and no segment is forced, otherwise the corresponding segment key
8083
*/
8184
private static String keyOf( String name, boolean forceSegment ) {
82-
if ( name.startsWith( "{" ) || name.startsWith( "[" ) ) return "." + name;
83-
if ( name.indexOf( '.' ) >= 0 ) return "{" + name + "}";
84-
return forceSegment ? "." + name : name;
85+
boolean hasCurly = name.indexOf( '{' ) >= 0;
86+
boolean hasSquare = name.indexOf( '[' ) >= 0;
87+
boolean hasDot = name.indexOf( '.' ) >= 0;
88+
// default case: no special characters in name
89+
if (!hasCurly && !hasSquare && !hasDot) return forceSegment ? "." + name : name;
90+
// common special case: has a dot (and possibly square) => needs curly escape
91+
if ( !hasCurly && hasDot ) return curlyEscapeWithCheck( name );
92+
// common special case: has a square but no curly or dot => only needs escaping when open + close square
93+
if ( !hasCurly ) return hasInnerSquareSegment( name ) ? "{"+name+"}" : "." + name;
94+
// edge special case: [...] but only opens at the start => dot works
95+
if ( !hasDot && name.charAt( 0 ) == '[' && name.indexOf( '[', 1 ) < 0 ) return "."+name;
96+
// edge special case: {...} but only opens at the start => dot works
97+
if ( !hasDot && name.charAt( 0 ) == '{' && name.indexOf( '{', 1 ) < 0 ) return "."+name;
98+
// special case: has curly open but no valid curly close => plain or dot works
99+
if (indexOfInnerCurlySegmentEnd( name ) < 1) return name.charAt( 0 ) == '{' ? "."+name : name;
100+
return curlyEscapeWithCheck( name );
101+
}
102+
103+
private static boolean hasInnerSquareSegment(String name) {
104+
int i = name.indexOf( '[', 1 );
105+
while ( i >= 0 ) {
106+
if (isSquareSegmentOpen( name, i )) return true;
107+
i = name.indexOf( '[', i+1 );
108+
}
109+
return false;
110+
}
111+
112+
/**
113+
* Searches for the end since possibly a curly escape is used and a valid inner curly end would be misunderstood.
114+
*/
115+
private static int indexOfInnerCurlySegmentEnd(String name) {
116+
int i = name.indexOf( '}', 1 );
117+
while ( i >= 0 ) {
118+
if (isCurlySegmentClose( name, i )) return i;
119+
i = name.indexOf( '}', i+1 );
120+
}
121+
return -1;
122+
}
123+
124+
private static String curlyEscapeWithCheck( String name ) {
125+
int end = indexOfInnerCurlySegmentEnd( name );
126+
if (end > 0) {
127+
// a } at the very end is ok since escaping that again {...} makes it an invalid end
128+
// so then effectively there is no valid on in the escaped name
129+
if (end < name.length()-1) {
130+
log.log( System.Logger.Level.WARNING,
131+
"Path segment escape required but not supported for name `%s`, character at %d will be misunderstood as segment end".formatted(
132+
name, end ) );
133+
}
134+
}
135+
return "{"+name+"}";
136+
}
137+
138+
public JsonPath {
139+
requireNonNull( segments );
85140
}
86141

87142
/**
@@ -235,7 +290,7 @@ private static List<String> splitIntoSegments( String path )
235290
if ( isDotSegmentOpen( path, i ) ) {
236291
i++; // advance past the .
237292
if ( i < len && path.charAt( i ) != '.' ) {
238-
i++; // if it is not a dot the first char after the . is never the end
293+
i++; // if it is not a dot the first char after the . is never a start of next segment
239294
while ( i < len && !isDotSegmentClose( path, i ) ) i++;
240295
}
241296
} else if ( isSquareSegmentOpen( path, i ) ) {

src/main/java/org/hisp/dhis/jsontree/JsonTree.java

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,21 +27,19 @@
2727
*/
2828
package org.hisp.dhis.jsontree;
2929

30-
import org.hisp.dhis.jsontree.JsonNodeOperation.Insert;
3130
import org.hisp.dhis.jsontree.internal.Maybe;
3231
import org.hisp.dhis.jsontree.internal.Surly;
3332

3433
import java.io.Serializable;
35-
import java.util.AbstractMap.SimpleEntry;
3634
import java.util.Arrays;
3735
import java.util.HashMap;
3836
import java.util.Iterator;
39-
import java.util.List;
4037
import java.util.Map;
4138
import java.util.Map.Entry;
4239
import java.util.NoSuchElementException;
4340
import java.util.Optional;
4441
import java.util.function.Consumer;
42+
import java.util.function.Function;
4543
import java.util.function.IntConsumer;
4644
import java.util.function.Predicate;
4745
import java.util.stream.StreamSupport;
@@ -352,25 +350,30 @@ public Entry<String, JsonNode> next() {
352350
} else if ( member.endIndex() < startIndexVal ) {
353351
// duplicate keys case: just skip the duplicate
354352
startIndex = expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndexVal ), '}' );
355-
return new SimpleEntry<>( name, member );
353+
return Map.entry( name, member );
356354
}
357355
startIndex = expectCommaSeparatorOrEnd( json, member.endIndex(), '}' );
358-
return new SimpleEntry<>( name, member );
356+
return Map.entry( name, member );
359357
}
360358
};
361359
}
362360

361+
@Override
362+
public Iterable<JsonPath> paths() {
363+
return keys(path::extendedWith);
364+
}
365+
363366
@Override
364367
public Iterable<String> names() {
365-
return keys(false);
368+
return keys(name -> name);
366369
}
367370

368371
@Override
369372
public Iterable<String> keys() {
370-
return keys(true);
373+
return keys(JsonPath::keyOf);
371374
}
372375

373-
private Iterable<String> keys(boolean escape) {
376+
private <E> Iterable<E> keys( Function<String, E> toKey) {
374377
return () -> new Iterator<>() {
375378
private final char[] json = tree.json;
376379
private final Map<JsonPath, JsonNode> nodesByPath = tree.nodesByPath;
@@ -382,7 +385,7 @@ public boolean hasNext() {
382385
}
383386

384387
@Override
385-
public String next() {
388+
public E next() {
386389
if ( !hasNext() )
387390
throw new NoSuchElementException( "next() called without checking hasNext()" );
388391
LazyJsonString.Span property = LazyJsonString.parseString( json, startIndex );
@@ -395,7 +398,7 @@ public String next() {
395398
startIndex = member == null || member.endIndex() < startIndex // (duplicates)
396399
? expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndex ), '}' )
397400
: expectCommaSeparatorOrEnd( json, member.endIndex(), '}' );
398-
return escape ? JsonPath.keyOf( name ) : name;
401+
return toKey.apply( name );
399402
}
400403
};
401404
}

src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ void testEntries_Special() {
158158
String json = """
159159
{".":1, "{uid}":2, "[6]":3, "x{y}z": 4}""";
160160
JsonMap<JsonNumber> map = JsonMixed.of( json ).asMap( JsonNumber.class );
161-
assertEquals( List.of( entry( "{.}", 1 ), entry( ".{uid}", 2 ),
162-
entry( ".[6]", 3 ), entry( "x{y}z", 4 ) ),
161+
assertEquals( List.of( entry( ".", 1 ), entry( "{uid}", 2 ),
162+
entry( "[6]", 3 ), entry( "x{y}z", 4 ) ),
163163
map.entries().map( e -> entry( e.getKey(), e.getValue().intValue() ) ).toList() );
164164
}
165165
}

src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,29 @@ void testNames_Special() {
5959
assertEquals( List.of( ".", "{uid}", "[0]" ), value.names() );
6060
}
6161

62+
@Test
63+
void testPaths_Special() {
64+
//language=json
65+
String json = """
66+
{"root": {".":1,"{uid}":2,"[0]": 3,"normal":4}}""";
67+
JsonObject value = JsonMixed.of( json ).getObject( "root" );
68+
assertEquals( List.of( JsonPath.of( ".root{.}" ), JsonPath.of( ".root.{uid}" ), JsonPath.of( ".root.[0]" ),
69+
JsonPath.of( ".root.normal" ) ),
70+
value.paths().toList() );
71+
}
72+
73+
@Test
74+
void testPaths_OpenAPI() {
75+
//language=json
76+
String json = """
77+
{"paths": {"/api/dataElements/{uid:[a-zA-Z0-9]{11}}": {"get": {"id": "opx"}, "delete": {"id":"opy"}}}}""";
78+
JsonObject paths = JsonMixed.of( json ).getObject( "paths" );
79+
assertEquals( List.of("/api/dataElements/{uid:[a-zA-Z0-9]{11}}"), paths.names() );
80+
JsonObject ops = paths.getObject( JsonPath.keyOf( "/api/dataElements/{uid:[a-zA-Z0-9]{11}}" ) );
81+
assertEquals( List.of("get", "delete"), ops.keys().toList() );
82+
assertEquals( "opy", ops.getObject( "delete" ).getString( "id" ).string() );
83+
}
84+
6285
@Test
6386
void testProject() {
6487
//language=json

0 commit comments

Comments
 (0)