1 /*
2 * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.
3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 *
5 * This code is free software; you can redistribute it and/or modify it
6 * under the terms of the GNU General Public License version 2 only, as
7 * published by the Free Software Foundation. Oracle designates this
8 * particular file as subject to the "Classpath" exception as provided
9 * by Oracle in the LICENSE file that accompanied this code.
10 *
11 * This code is distributed in the hope that it will be useful, but WITHOUT
12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
14 * version 2 for more details (a copy is included in the LICENSE file that
15 * accompanied this code).
16 *
17 * You should have received a copy of the GNU General Public License version
18 * 2 along with this work; if not, write to the Free Software Foundation,
19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 *
21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
22 * or visit www.oracle.com if you need additional information or have any
23 * questions.
24 */
25 package optkl.json;
26
27
28 import optkl.json.impl.JsonParser;
29 import optkl.json.impl.Utils;
30
31 import java.math.BigDecimal;
32 import java.math.BigInteger;
33 import java.util.ArrayList;
34 import java.util.Arrays;
35 import java.util.Collections;
36 import java.util.HashMap;
37 import java.util.IdentityHashMap;
38 import java.util.LinkedHashMap;
39 import java.util.List;
40 import java.util.Map;
41 import java.util.Objects;
42 import java.util.Set;
43
44 /**
45 * This class provides static methods for producing and manipulating a {@link JsonValue}.
46 * <p>
47 * {@link #parse(String)} and {@link #parse(char[])} produce a {@code JsonValue}
48 * by parsing data adhering to the JSON syntax defined in RFC 8259.
49 * <p>
50 * {@link #toDisplayString(JsonValue)} is a formatter that produces a
51 * representation of the JSON value suitable for display.
52 * <p>
53 * {@link #fromUntyped(Object)} and {@link #toUntyped(JsonValue)} provide a conversion
54 * between {@code JsonValue} and an untyped object.
55 *
56 * @spec https://datatracker.ietf.org/doc/html/rfc8259 RFC 8259: The JavaScript
57 * Object Notation (JSON) Data Interchange Format
58 * @since 99
59 */
60 public final class Json {
61
62 /**
63 * Parses and creates a {@code JsonValue} from the given JSON document.
64 * If parsing succeeds, it guarantees that the input document conforms to
65 * the JSON syntax. If the document contains any JSON Object that has
66 * duplicate names, a {@code JsonParseException} is thrown.
67 * <p>
68 * {@code JsonValue}s created by this method produce their String and underlying
69 * value representation lazily.
70 * <p>
71 * {@code JsonObject}s preserve the order of their members declared in and parsed from
72 * the JSON document.
73 *
74 * @param in the input JSON document as {@code String}. Non-null.
75 * @throws JsonParseException if the input JSON document does not conform
76 * to the JSON document format or a JSON object containing
77 * duplicate names is encountered.
78 * @throws NullPointerException if {@code in} is {@code null}
79 * @return the parsed {@code JsonValue}
80 */
81 public static JsonValue parse(String in) {
82 Objects.requireNonNull(in);
83 return new JsonParser(in.toCharArray()).parseRoot();
84 }
85
86 /**
87 * Parses and creates a {@code JsonValue} from the given JSON document.
88 * If parsing succeeds, it guarantees that the input document conforms to
89 * the JSON syntax. If the document contains any JSON Object that has
90 * duplicate names, a {@code JsonParseException} is thrown.
91 * <p>
92 * {@code JsonValue}s created by this method produce their String and underlying
93 * value representation lazily.
94 * <p>
95 * {@code JsonObject}s preserve the order of their members declared in and parsed from
96 * the JSON document.
97 *
98 * @param in the input JSON document as {@code char[]}. Non-null.
99 * @throws JsonParseException if the input JSON document does not conform
100 * to the JSON document format or a JSON object containing
101 * duplicate names is encountered.
102 * @throws NullPointerException if {@code in} is {@code null}
103 * @return the parsed {@code JsonValue}
104 */
105 public static JsonValue parse(char[] in) {
106 Objects.requireNonNull(in);
107 return new JsonParser(Arrays.copyOf(in, in.length)).parseRoot();
108 }
109
110 /**
111 * {@return a {@code JsonValue} created from the given {@code src} object}
112 * The mapping from an untyped {@code src} object to a {@code JsonValue}
113 * follows the table below.
114 * <table class="striped">
115 * <caption>Untyped to JsonValue mapping</caption>
116 * <thead>
117 * <tr>
118 * <th scope="col" class="TableHeadingColor">Untyped Object</th>
119 * <th scope="col" class="TableHeadingColor">JsonValue</th>
120 * </tr>
121 * </thead>
122 * <tbody>
123 * <tr>
124 * <th>{@code List<Object>}</th>
125 * <th>{@code JsonArray}</th>
126 * </tr>
127 * <tr>
128 * <th>{@code Boolean}</th>
129 * <th>{@code JsonBoolean}</th>
130 * </tr>
131 * <tr>
132 * <th>{@code `null`}</th>
133 * <th>{@code JsonNull}</th>
134 * </tr>
135 * <tr>
136 * <th>{@code Number*}</th>
137 * <th>{@code JsonNumber}</th>
138 * </tr>
139 * <tr>
140 * <th>{@code Map<String, Object>}</th>
141 * <th>{@code JsonObject}</th>
142 * </tr>
143 * <tr>
144 * <th>{@code String}</th>
145 * <th>{@code JsonString}</th>
146 * </tr>
147 * </tbody>
148 * </table>
149 *
150 * <i><sup>*</sup>The supported {@code Number} subclasses are: {@code Byte},
151 * {@code Short}, {@code Integer}, {@code Long}, {@code Float},
152 * {@code Double}, {@code BigInteger}, and {@code BigDecimal}.</i>
153 *
154 * <p>If {@code src} is an instance of {@code JsonValue}, it is returned as is.
155 * If {@code src} contains a circular reference, {@code IllegalArgumentException}
156 * will be thrown. For example, the following code throws an exception,
157 * {@snippet lang=java:
158 * var map = new HashMap<String, Object>();
159 * map.put("foo", false);
160 * map.put("bar", map);
161 * Json.fromUntyped(map);
162 * }
163 *
164 * @param src the data to produce the {@code JsonValue} from. May be null.
165 * @throws IllegalArgumentException if {@code src} cannot be converted
166 * to {@code JsonValue} or contains a circular reference.
167 * @see #toUntyped(JsonValue)
168 */
169 public static JsonValue fromUntyped(Object src) {
170 return fromUntyped(src, Collections.newSetFromMap(new IdentityHashMap<>()));
171 }
172
173 static JsonValue fromUntyped(Object src, Set<Object> identitySet) {
174 return switch (src) {
175 // Structural: JSON object, JSON array
176 case Map<?, ?> map -> {
177 if (!identitySet.add(map)) {
178 throw new IllegalArgumentException("Circular reference detected");
179 }
180 Map<String, JsonValue> m = LinkedHashMap.newLinkedHashMap(map.size());
181 for (Map.Entry<?, ?> entry : new LinkedHashMap<>(map).entrySet()) {
182 if (!(entry.getKey() instanceof String strKey)) {
183 throw new IllegalArgumentException("Key is not a String: " + entry.getKey());
184 } else {
185 var unescapedKey = Utils.unescape(
186 strKey.toCharArray(), 0, strKey.length());
187 if (m.containsKey(unescapedKey)) {
188 throw new IllegalArgumentException(
189 "Duplicate member name: '%s'".formatted(unescapedKey));
190 } else {
191 m.put(unescapedKey, Json.fromUntyped(entry.getValue(), identitySet));
192 }
193 }
194 }
195 // Bypasses defensive copy in JsonObject.of(m)
196 yield Utils.objectOf(m);
197 }
198 case List<?> list -> {
199 if (!identitySet.add(list)) {
200 throw new IllegalArgumentException("Circular reference detected");
201 }
202 List<JsonValue> l = new ArrayList<>(list.size());
203 for (Object o : list) {
204 l.add(Json.fromUntyped(o, identitySet));
205 }
206 // Bypasses defensive copy in JsonArray.of(l)
207 yield Utils.arrayOf(l);
208 }
209 // JSON primitives
210 case String str -> JsonString.of(str);
211 case Boolean bool -> JsonBoolean.of(bool);
212 case Byte b -> JsonNumber.of(b);
213 case Integer i -> JsonNumber.of(i);
214 case Long l -> JsonNumber.of(l);
215 case Short s -> JsonNumber.of(s);
216 case Float f -> JsonNumber.of(f);
217 case Double d -> JsonNumber.of(d);
218 case BigInteger bi -> JsonNumber.of(bi);
219 case BigDecimal bd -> JsonNumber.of(bd);
220 case null -> JsonNull.of();
221 // JsonValue
222 case JsonValue jv -> jv;
223 default -> throw new IllegalArgumentException("Type not recognized.");
224 };
225 }
226
227 /**
228 * {@return an {@code Object} created from the given {@code src}
229 * {@code JsonValue}} The mapping from a {@code JsonValue} to an
230 * untyped {@code src} object follows the table below.
231 * <table class="striped">
232 * <caption>JsonValue to Untyped mapping</caption>
233 * <thead>
234 * <tr>
235 * <th scope="col" class="TableHeadingColor">JsonValue</th>
236 * <th scope="col" class="TableHeadingColor">Untyped Object</th>
237 * </tr>
238 * </thead>
239 * <tbody>
240 * <tr>
241 * <th>{@code JsonArray}</th>
242 * <th>{@code List<Object>}(unmodifiable)</th>
243 * </tr>
244 * <tr>
245 * <th>{@code JsonBoolean}</th>
246 * <th>{@code Boolean}</th>
247 * </tr>
248 * <tr>
249 * <th>{@code JsonNull}</th>
250 * <th>{@code `null`}</th>
251 * </tr>
252 * <tr>
253 * <th>{@code JsonNumber}</th>
254 * <th>{@code Number}</th>
255 * </tr>
256 * <tr>
257 * <th>{@code JsonObject}</th>
258 * <th>{@code Map<String, Object>}(unmodifiable)</th>
259 * </tr>
260 * <tr>
261 * <th>{@code JsonString}</th>
262 * <th>{@code String}</th>
263 * </tr>
264 * </tbody>
265 * </table>
266 *
267 * <p>
268 * A {@code JsonObject} in {@code src} is converted to a {@code Map} whose
269 * entries occur in the same order as the {@code JsonObject}'s members.
270 *
271 * @param src the {@code JsonValue} to convert to untyped. Non-null.
272 * @throws NullPointerException if {@code src} is {@code null}
273 * @see #fromUntyped(Object)
274 */
275 public static Object toUntyped(JsonValue src) {
276 Objects.requireNonNull(src);
277 return switch (src) {
278 case JsonObject jo -> jo.members().entrySet().stream()
279 .collect(LinkedHashMap::new, // to allow `null` value
280 (m, e) -> m.put(e.getKey(), Json.toUntyped(e.getValue())),
281 HashMap::putAll);
282 case JsonArray ja -> ja.values().stream()
283 .map(Json::toUntyped)
284 .toList();
285 case JsonBoolean jb -> jb.value();
286 case JsonNull _ -> null;
287 case JsonNumber n -> n.toNumber();
288 case JsonString js -> js.value();
289 };
290 }
291
292 /**
293 * {@return the String representation of the given {@code JsonValue} that conforms
294 * to the JSON syntax} As opposed to the compact output returned by {@link
295 * JsonValue#toString()}, this method returns a JSON string that is better
296 * suited for display.
297 *
298 * @param value the {@code JsonValue} to create the display string from. Non-null.
299 * @throws NullPointerException if {@code value} is {@code null}
300 * @see JsonValue#toString()
301 */
302 public static String toDisplayString(JsonValue value) {
303 Objects.requireNonNull(value);
304 return toDisplayString(value, 0 , false);
305 }
306
307 private static String toDisplayString(JsonValue jv, int indent, boolean isField) {
308 return switch (jv) {
309 case JsonObject jo -> toDisplayString(jo, indent, isField);
310 case JsonArray ja -> toDisplayString(ja, indent, isField);
311 default -> " ".repeat(isField ? 1 : indent) + jv;
312 };
313 }
314
315 private static String toDisplayString(JsonObject jo, int indent, boolean isField) {
316 var prefix = " ".repeat(indent);
317 var s = new StringBuilder(isField ? " " : prefix);
318 if (jo.members().isEmpty()) {
319 s.append("{}");
320 } else {
321 s.append("{\n");
322 jo.members().forEach((name, valu) -> {
323 if (valu instanceof JsonValue val) {
324 s.append(prefix)
325 .append(" ".repeat(INDENT))
326 .append("\"")
327 .append(name)
328 .append("\":")
329 .append(Json.toDisplayString(val, indent + INDENT, true))
330 .append(",\n");
331 } else {
332 throw new InternalError("type mismatch");
333 }
334 });
335 s.setLength(s.length() - 2); // trim final comma
336 s.append("\n").append(prefix).append("}");
337 }
338 return s.toString();
339 }
340
341 private static String toDisplayString(JsonArray ja, int indent, boolean isField) {
342 var prefix = " ".repeat(indent);
343 var s = new StringBuilder(isField ? " " : prefix);
344 if (ja.values().isEmpty()) {
345 s.append("[]");
346 } else {
347 s.append("[\n");
348 for (JsonValue v: ja.values()) {
349 if (v instanceof JsonValue jv) {
350 s.append(Json.toDisplayString(jv,indent + INDENT, false)).append(",\n");
351 } else {
352 throw new InternalError("type mismatch");
353 }
354 }
355 s.setLength(s.length() - 2); // trim final comma/newline
356 s.append("\n").append(prefix).append("]");
357 }
358 return s.toString();
359 }
360
361 // default indentation for display string
362 private static final int INDENT = 2;
363
364 // no instantiation is allowed for this class
365 private Json() {}
366 }