001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.marshall.collections; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.*; 022 023/** 024 * An <a class="doclink" href="https://datatracker.ietf.org/doc/html/rfc6901">RFC 6901</a> JSON Pointer that addresses a 025 * single value within a {@link Map}/{@link List} tree (such as the {@link MarshalledMap}/{@link MarshalledList} family). 026 * 027 * <p> 028 * A JSON Pointer is a string of zero or more <c>/</c>-prefixed reference tokens. The empty string <js>""</js> references 029 * the whole document (the root). A non-empty pointer must begin with <c>/</c>; each token is the text between <c>/</c> 030 * separators with <c>~1</c> decoded to <c>/</c> and <c>~0</c> decoded to <c>~</c>. For arrays a token is either a 031 * base-10 non-negative integer index or the single character <c>-</c> (which references the nonexistent element after 032 * the last element, used to append on writes); for objects the decoded token is the member name. 033 * 034 * <h5 class='section'>Example:</h5> 035 * <p class='bjava'> 036 * JsonMap <jv>doc</jv> = JsonMap.<jsm>of</jsm>(<js>"foo"</js>, JsonList.<jsm>of</jsm>(<js>"bar"</js>, <js>"baz"</js>)); 037 * 038 * <jc>// Read.</jc> 039 * Object <jv>v</jv> = JsonPointer.<jsm>of</jsm>(<js>"/foo/0"</js>).eval(<jv>doc</jv>); <jc>// "bar"</jc> 040 * 041 * <jc>// Auto-vivifying write (creates intermediate containers).</jc> 042 * JsonPointer.<jsm>of</jsm>(<js>"/foo/-"</js>).set(<jv>doc</jv>, <js>"qux"</js>); <jc>// appends "qux"</jc> 043 * </p> 044 * 045 * <h5 class='section'>Notes:</h5><ul> 046 * <li class='warn'>This class is not thread safe. 047 * <li>Read-miss (missing key, out-of-range index, or type mismatch) resolves to <jk>null</jk> via {@link #eval(Object)}. 048 * </ul> 049 * 050 * <p> 051 * <b>Beta — API subject to change:</b> This type is part of the next-generation typed tree model layered over the 052 * {@code Marshalled*} collections and may change incompatibly in a future release. 053 */ 054@SuppressWarnings({ 055 "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants 056}) 057public class JsonPointer { 058 059 /** Sentinel distinguishing an absent value from a present <jk>null</jk> value during traversal. */ 060 private static final Object MISSING = new Object(); 061 062 /** 063 * Parses and validates the specified RFC 6901 JSON Pointer string. 064 * 065 * @param pointer The pointer string. The empty string references the whole document; a non-empty pointer must begin with <c>/</c>. 066 * @return A new {@link JsonPointer}, never <jk>null</jk>. 067 * @throws IllegalArgumentException If the pointer is <jk>null</jk> or malformed (non-empty and not beginning with <c>/</c>). 068 */ 069 public static JsonPointer of(String pointer) { 070 return new JsonPointer(parse(pointer)); 071 } 072 073 /** 074 * Encodes a raw object member name into an RFC 6901 reference token (escapes <c>~</c> to <c>~0</c> and <c>/</c> to <c>~1</c>). 075 * 076 * @param rawMemberName The raw member name. Must not be <jk>null</jk>. 077 * @return The encoded reference token. 078 */ 079 public static String encodeToken(String rawMemberName) { 080 reqnn("rawMemberName", rawMemberName); 081 return rawMemberName.replace("~", "~0").replace("/", "~1"); 082 } 083 084 /** 085 * Decodes an RFC 6901 reference token into a raw object member name (unescapes <c>~1</c> to <c>/</c> then <c>~0</c> to <c>~</c>). 086 * 087 * @param token The reference token. Must not be <jk>null</jk>. 088 * @return The decoded member name. 089 */ 090 public static String decodeToken(String token) { 091 reqnn("token", token); 092 return token.replace("~1", "/").replace("~0", "~"); 093 } 094 095 private static List<String> parse(String pointer) { 096 if (pointer == null) 097 throw iaex("JSON Pointer cannot be null."); 098 if (pointer.isEmpty()) 099 return List.of(); 100 if (pointer.charAt(0) != '/') 101 throw iaex("Invalid JSON Pointer '%s': a non-empty pointer must begin with '/'.", pointer); 102 var parts = pointer.split("/", -1); 103 var tokens = new ArrayList<String>(parts.length - 1); 104 for (var i = 1; i < parts.length; i++) 105 tokens.add(decodeToken(parts[i])); 106 return tokens; 107 } 108 109 private final List<String> tokens; 110 111 private JsonPointer(List<String> tokens) { 112 this.tokens = tokens; 113 } 114 115 /** 116 * Reads the value addressed by this pointer from the specified root. 117 * 118 * <p> 119 * Walks the tokens from <c>root</c> (which may be a {@link Map}/{@link List} such as the {@link MarshalledMap}/ 120 * {@link MarshalledList} family). The empty pointer returns <c>root</c>. 121 * 122 * @param root The root document. Can be <jk>null</jk> (treated as an empty document; the addressed value evaluates to <jk>null</jk>). 123 * @return The addressed value, or <jk>null</jk> on any missing key, out-of-range index, or type mismatch. 124 */ 125 public Object eval(Object root) { 126 var o = evalOrMissing(root); 127 return o == MISSING ? null : o; 128 } 129 130 /** 131 * Writes the specified value at the location addressed by this pointer, auto-vivifying missing intermediate containers. 132 * 133 * <p> 134 * Intermediate containers are created based on the next token: a numeric or <c>-</c> token implies a {@link JsonList}, 135 * otherwise a {@link JsonMap}. The <c>-</c> token on a list appends. The empty pointer cannot mutate <c>root</c> in 136 * place for the whole-document case, so it simply returns <c>value</c> as the new root. 137 * 138 * @param root The root document. For a non-empty pointer, must be a mutable {@link Map}/{@link List} tree. 139 * @param value The value to set. 140 * @return The (possibly newly-created) effective root. 141 */ 142 public Object set(Object root, Object value) { 143 if (tokens.isEmpty()) 144 return value; 145 var parent = navigateToParent(root); 146 rawSet(parent, tokens.get(tokens.size() - 1), value); 147 return root; 148 } 149 150 /** 151 * Removes the member or element addressed by this pointer. 152 * 153 * @param root The root document. Can be <jk>null</jk> (treated as an empty document — nothing is found to remove). 154 * @return The removed value, or <jk>null</jk> if the addressed member/element was absent (or the pointer is the root pointer). 155 */ 156 public Object remove(Object root) { 157 if (tokens.isEmpty()) 158 return null; 159 var parent = root; 160 for (var i = 0; i < tokens.size() - 1; i++) { 161 parent = rawGet(parent, tokens.get(i)); 162 if (parent == MISSING) 163 return null; 164 } 165 return rawRemove(parent, tokens.get(tokens.size() - 1)); 166 } 167 168 @Override /* Overridden from Object */ 169 public String toString() { 170 if (tokens.isEmpty()) 171 return ""; 172 var sb = new StringBuilder(); 173 for (var t : tokens) 174 sb.append('/').append(encodeToken(t)); 175 return sb.toString(); 176 } 177 178 /** 179 * Returns <jk>true</jk> if this is the root pointer (the empty pointer addressing the whole document). 180 */ 181 boolean isRoot() { 182 return tokens.isEmpty(); 183 } 184 185 /** 186 * Same as {@link #eval(Object)} but returns the {@link #MISSING} sentinel (rather than <jk>null</jk>) for an absent 187 * value, so callers can distinguish a present <jk>null</jk> value from a read-miss. 188 */ 189 Object evalOrMissing(Object root) { 190 var current = root; 191 for (var token : tokens) { 192 current = rawGet(current, token); 193 if (current == MISSING) 194 return MISSING; 195 } 196 return current; 197 } 198 199 /** Returns <jk>true</jk> if the specified value is the absent-value sentinel returned by {@link #evalOrMissing(Object)}. */ 200 static boolean isMissing(Object o) { 201 return o == MISSING; 202 } 203 204 private Object navigateToParent(Object root) { 205 var current = root; 206 for (var i = 0; i < tokens.size() - 1; i++) { 207 var token = tokens.get(i); 208 var child = rawGet(current, token); 209 if (! (child instanceof Map || child instanceof List)) { 210 child = isIndexToken(tokens.get(i + 1)) ? new JsonList() : new JsonMap(); 211 rawSet(current, token, child); 212 } 213 current = child; 214 } 215 return current; 216 } 217 218 private static Object rawGet(Object container, String token) { 219 if (container instanceof Map<?,?> container2) 220 return container2.containsKey(token) ? container2.get(token) : MISSING; 221 if (container instanceof List<?> container2) { 222 var idx = toArrayIndex(token); 223 return (idx >= 0 && idx < container2.size()) ? container2.get(idx) : MISSING; 224 } 225 return MISSING; 226 } 227 228 @SuppressWarnings({ 229 "unchecked" // Object-node maps accept String keys / Object values; array-node lists accept Object elements. 230 }) 231 private static void rawSet(Object container, String token, Object value) { 232 if (container instanceof Map) { 233 ((Map<String,Object>)container).put(token, value); 234 return; 235 } 236 if (container instanceof List) { 237 var list = (List<Object>)container; 238 if (eq(token, "-")) { 239 list.add(value); 240 return; 241 } 242 var idx = toArrayIndex(token); 243 if (idx < 0 || idx > list.size()) 244 throw iaex("Invalid JSON Pointer array index '%s' for a list of size %s.", token, list.size()); 245 if (idx == list.size()) 246 list.add(value); 247 else 248 list.set(idx, value); 249 return; 250 } 251 throw iaex("Cannot set value at token '%s' on a non-container value.", token); 252 } 253 254 private static Object rawRemove(Object container, String token) { 255 if (container instanceof Map<?,?> container2) 256 return container2.containsKey(token) ? container2.remove(token) : null; 257 if (container instanceof List<?> container2) { 258 var idx = toArrayIndex(token); 259 return (idx >= 0 && idx < container2.size()) ? container2.remove(idx) : null; 260 } 261 return null; 262 } 263 264 private static boolean isIndexToken(String token) { 265 return eq(token, "-") || toArrayIndex(token) >= 0; 266 } 267 268 /* 269 * Returns the non-negative array index for the specified token, or -1 if the token is not a valid RFC 6901 array 270 * index (no leading zeros except "0" itself; "-" is not a numeric index). 271 */ 272 private static int toArrayIndex(String token) { 273 var len = token.length(); 274 if (len == 0) 275 return -1; 276 if (len > 1 && token.charAt(0) == '0') 277 return -1; 278 for (var i = 0; i < len; i++) { 279 var c = token.charAt(i); 280 if (c < '0' || c > '9') 281 return -1; 282 } 283 try { 284 return Integer.parseInt(token); 285 } catch (NumberFormatException e) { // HTT - guards against overflow on very long digit strings. 286 return -1; 287 } 288 } 289}