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 }