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.json;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.math.*;
023import java.nio.charset.*;
024
025import org.apache.juneau.marshall.stream.*;
026
027/**
028 * Reference implementation of the public {@link TokenWriter} surface for the JSON format.
029 *
030 * <p>
031 * Emits RFC-8259-compliant JSON.  Output style is driven by a small {@link Settings} bundle that
032 * mirrors the structurally-applicable subset of {@link JsonSerializer.Builder}'s formatting flags:
033 * <c>useWhitespace</c>, <c>maxIndent</c>, <c>quoteChar</c>, <c>escapeSolidus</c>, and
034 * <c>trimStrings</c>.  When opened via {@link JsonSerializer#writeTokens(Object)} the writer
035 * inherits the settings from the calling serializer; constructing the writer directly via
036 * {@link #JsonTokenWriter(Writer)} uses {@link Settings#DEFAULT} (compact, double-quoted, RFC-8259
037 * strict).
038 *
039 * <h5 class='section'>Notes:</h5><ul>
040 *    <li class='warn'>This class is not thread safe.
041 *    <li>The writer tracks just enough state to insert structural separators (<c>,</c> between
042 *       array/object members and <c>:</c> between field names and values).  It does not validate
043 *       event ordering &mdash; emitting <c>field</c> twice in a row, for instance, throws.
044 *    <li>The writer is purely structural; object swaps and {@code @Schema} annotations have no
045 *       effect.  Use the POJO databind path on {@link JsonSerializer} for those.
046 * </ul>
047 */
048@SuppressWarnings({
049   "java:S3776", // Cognitive complexity acceptable for JSON encoder dispatch
050   "resource" // The writer's underlying Writer/OutputStream is owned by the caller via try-with-resources on the writer itself; Eclipse JDT flags the inner stream as unclosed but that's by design.
051})
052public class JsonTokenWriter implements TokenWriter {
053
054   /**
055    * Output-formatting settings honored by {@link JsonTokenWriter}.
056    *
057    * <p>
058    * This is the structurally-applicable subset of {@link JsonSerializer.Builder}.  Properties
059    * that operate on the POJO path (<c>addBeanTypes</c>, <c>keepNullProperties</c>,
060    * <c>trimEmptyMaps</c>, <c>trimEmptyCollections</c>, <c>sortMaps</c>, <c>sortCollections</c>,
061    * object swaps, and {@code @Schema} annotations) have no effect on the token surface and are
062    * deliberately not represented here.
063    *
064    * @param useWhitespace If <jk>true</jk>, emit indented output with newlines.
065    * @param maxIndent Maximum indentation depth when <c>useWhitespace</c> is enabled.  Levels
066    *    beyond this depth are emitted compactly.
067    * @param quoteChar The quote character used for strings and field names.  Typically
068    *    <c>'"'</c> (RFC-8259) but may be <c>'\''</c> for JSON5-style output.
069    * @param escapeSolidus If <jk>true</jk>, forward slashes (<c>/</c>) are escaped as <c>\/</c>.
070    * @param trimStrings If <jk>true</jk>, leading/trailing whitespace is trimmed from emitted
071    *    {@link TokenType#VALUE_STRING} and {@link TokenType#FIELD_NAME} values.
072    * @param simpleAttrs If <jk>true</jk>, field names are emitted unquoted when they are valid
073    *    JavaScript identifiers and not reserved words.  This is the JSON5 "lax attribute" mode;
074    *    strict RFC-8259 JSON keeps this <jk>false</jk>.
075    * @param walk The {@link PojoWalker.Options walk options} used by
076    *    {@link #object(Object) object(Object)} when walking a value graph.
077    * @param disableObject If <jk>true</jk>, {@link #object(Object) object(Object)} throws
078    *    {@link UnsupportedOperationException} with a format-specific message.  Used by formats
079    *    whose {@code serialize()} produces non-standard JSON (e.g. {@link JsonSchemaSerializer},
080    *    {@link org.apache.juneau.marshall.jcs.JcsSerializer}) where the cursor's POJO walker would
081    *    emit ordinary JSON instead of the format's special form.
082    */
083   @SuppressWarnings({
084      "javadoc" // Settings record components are described in the record's class-level Javadoc rather than with per-component @param tags
085   })
086   public record Settings(
087         boolean useWhitespace,
088         int maxIndent,
089         char quoteChar,
090         boolean escapeSolidus,
091         boolean trimStrings,
092         boolean simpleAttrs,
093         PojoWalker.Options walk,
094         boolean disableObject) {
095
096      /** Default canonical setting: compact RFC-8259 JSON, double-quoted, no trimming. */
097      public static final Settings DEFAULT = new Settings(false, 100, '"', false, false, false, PojoWalker.Options.DEFAULT, false);
098   }
099
100   private static final int CTX_ROOT = 0;
101   private static final int CTX_OBJECT = 1;
102   private static final int CTX_ARRAY = 2;
103
104   private final Writer out;
105   private final Closeable owned;
106   private final boolean autoFlush;
107   private final Settings settings;
108
109   // Container stack (root, object, array): bit-packed so 64 levels are allocation-free.
110   private long ctxBits;
111   private int[] ctxOverflow;
112   private int depth;
113
114   // True if the next emit inside the current container is the first member (no leading comma).
115   private boolean firstInContainer = true;
116
117   // True if the previous structural event was field(), meaning the next emit MUST be a
118   // value (no leading comma) and must NOT trigger the first/comma logic.
119   private boolean awaitingFieldValue;
120
121   private boolean closed;
122
123   /**
124    * Constructor for a {@link Writer} target with default {@link Settings}.
125    *
126    * @param out The writer to emit JSON to.  Must not be <jk>null</jk>.
127    */
128   public JsonTokenWriter(Writer out) {
129      this(out, null, false, Settings.DEFAULT);
130   }
131
132   /**
133    * Constructor for a {@link Writer} target with explicit {@link Settings}.
134    *
135    * @param out The writer to emit JSON to.  Must not be <jk>null</jk>.
136    * @param settings The output-formatting settings.  Must not be <jk>null</jk>.
137    */
138   public JsonTokenWriter(Writer out, Settings settings) {
139      this(out, null, false, settings);
140   }
141
142   /**
143    * Internal constructor used by {@link JsonSerializer#writeTokens(Object)} factory paths to
144    * record an underlying {@link Closeable} the writer should close on shutdown (e.g. a file
145    * stream).
146    *
147    * @param out The writer to emit JSON to.
148    * @param owned A resource owned by this writer that should be closed on close, or
149    *    <jk>null</jk>.
150    * @param autoFlush If <jk>true</jk>, flushes after every top-level emit.
151    * @param settings The output-formatting settings.
152    */
153   JsonTokenWriter(Writer out, Closeable owned, boolean autoFlush, Settings settings) {
154      reqnn("out", out);
155      reqnn("settings", settings);
156      this.out = out;
157      this.owned = owned;
158      this.autoFlush = autoFlush;
159      this.settings = settings;
160   }
161
162   @Override /* Overridden from TokenWriter */
163   public TokenWriter startObject() throws IOException {
164      assertOpen();
165      preValue();
166      out.write('{');
167      pushContext(CTX_OBJECT);
168      return this;
169   }
170
171   @Override /* Overridden from TokenWriter */
172   public TokenWriter endObject() throws IOException {
173      assertOpen();
174      var wasEmpty = firstInContainer;
175      popContext(CTX_OBJECT);
176      if (settings.useWhitespace && !wasEmpty)
177         writeNewlineAndIndent();
178      out.write('}');
179      postValue();
180      return this;
181   }
182
183   @Override /* Overridden from TokenWriter */
184   public TokenWriter startArray() throws IOException {
185      assertOpen();
186      preValue();
187      out.write('[');
188      pushContext(CTX_ARRAY);
189      return this;
190   }
191
192   @Override /* Overridden from TokenWriter */
193   public TokenWriter endArray() throws IOException {
194      assertOpen();
195      var wasEmpty = firstInContainer;
196      popContext(CTX_ARRAY);
197      if (settings.useWhitespace && !wasEmpty)
198         writeNewlineAndIndent();
199      out.write(']');
200      postValue();
201      return this;
202   }
203
204   @Override /* Overridden from TokenWriter */
205   public TokenWriter fieldName(String name) throws IOException {
206      assertOpen();
207      reqnn("name", name);
208      if (currentContext() != CTX_OBJECT)
209         throw new IllegalStateException("field called outside an object");
210      if (awaitingFieldValue)
211         throw new IllegalStateException("field called twice without an intervening value");
212      if (!firstInContainer)
213         out.write(',');
214      if (settings.useWhitespace)
215         writeNewlineAndIndent();
216      var trimmed = maybeTrim(name);
217      if (settings.simpleAttrs && isSafeBareIdentifier(trimmed))
218         out.write(trimmed);
219      else
220         writeQuotedString(trimmed);
221      out.write(':');
222      if (settings.useWhitespace)
223         out.write(' ');
224      firstInContainer = false;
225      awaitingFieldValue = true;
226      return this;
227   }
228
229   /**
230    * Returns whether the given string is safe to emit as a bare (unquoted) JSON5 field name.
231    * Conservative: requires letters / digits / underscore only, must not start with a digit,
232    * and must not be one of a small set of JavaScript reserved words.
233    */
234   private static boolean isSafeBareIdentifier(String s) {
235      if (ie(s))
236         return false;
237      var first = s.charAt(0);
238      if (!(Character.isLetter(first) || first == '_'))
239         return false;
240      for (var i = 1; i < s.length(); i++) {
241         var c = s.charAt(i);
242         if (!(Character.isLetterOrDigit(c) || c == '_'))
243            return false;
244      }
245      return !RESERVED_WORDS.contains(s);
246   }
247
248   private static final java.util.Set<String> RESERVED_WORDS = java.util.Set.of(
249      "true", "false", "null", "undefined",
250      "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete",
251      "do", "else", "enum", "export", "extends", "finally", "for", "function", "if", "import",
252      "in", "instanceof", "new", "return", "super", "switch", "this", "throw", "try", "typeof",
253      "var", "void", "while", "with", "yield", "let", "static", "implements", "interface",
254      "package", "private", "protected", "public");
255
256   @Override /* Overridden from TokenWriter */
257   public TokenWriter string(String value) throws IOException {
258      assertOpen();
259      if (value == null)
260         return nil();
261      preValue();
262      writeQuotedString(maybeTrim(value));
263      postValue();
264      return this;
265   }
266
267   @Override /* Overridden from TokenWriter */
268   public TokenWriter number(Number value) throws IOException {
269      assertOpen();
270      if (value == null)
271         return nil();
272      preValue();
273      out.write(value.toString());
274      postValue();
275      return this;
276   }
277
278   @Override /* Overridden from TokenWriter */
279   public TokenWriter number(long value) throws IOException {
280      assertOpen();
281      preValue();
282      out.write(Long.toString(value));
283      postValue();
284      return this;
285   }
286
287   @Override /* Overridden from TokenWriter */
288   public TokenWriter number(double value) throws IOException {
289      assertOpen();
290      if (Double.isNaN(value) || Double.isInfinite(value))
291         throw ioex("Cannot serialize non-finite double as JSON: %s", value);
292      preValue();
293      out.write(Double.toString(value));
294      postValue();
295      return this;
296   }
297
298   @Override /* Overridden from TokenWriter */
299   public TokenWriter number(BigDecimal value) throws IOException {
300      assertOpen();
301      if (value == null)
302         return nil();
303      preValue();
304      out.write(value.toString());
305      postValue();
306      return this;
307   }
308
309   @Override /* Overridden from TokenWriter */
310   public TokenWriter number(BigInteger value) throws IOException {
311      assertOpen();
312      if (value == null)
313         return nil();
314      preValue();
315      out.write(value.toString());
316      postValue();
317      return this;
318   }
319
320   @Override /* Overridden from TokenWriter */
321   public TokenWriter bool(boolean value) throws IOException {
322      assertOpen();
323      preValue();
324      out.write(value ? "true" : "false");
325      postValue();
326      return this;
327   }
328
329   @Override /* Overridden from TokenWriter */
330   public TokenWriter nil() throws IOException {
331      assertOpen();
332      preValue();
333      out.write("null");
334      postValue();
335      return this;
336   }
337
338   @Override /* Overridden from TokenWriter */
339   public TokenWriter object(Object value) throws IOException {
340      assertOpen();
341      if (settings.disableObject)
342         throw new UnsupportedOperationException(
343            "object(Object) is not supported on this writer because the format's serialize() " +
344            "produces non-standard JSON (e.g. schema generation, canonical form).  Use the raw " +
345            "structural methods (startObject / field / string / ...) directly, or call the " +
346            "format's serialize() method.");
347      PojoWalker.walk(this, value, settings.walk);
348      return this;
349   }
350
351   /**
352    * Returns the {@link Settings} this writer was configured with.
353    *
354    * <p>
355    * Exposed for delegating wrappers (e.g. {@link org.apache.juneau.marshall.jsonl.JsonlTokenWriter})
356    * that need to read the underlying writer's walk options when implementing {@link #object(Object)}.
357    *
358    * @return The settings.  Never <jk>null</jk>.
359    */
360   public Settings settingsAccess() {
361      return settings;
362   }
363
364   @Override /* Overridden from TokenWriter */
365   public TokenWriter binary(byte[] value) throws IOException {
366      assertOpen();
367      if (value == null)
368         return nil();
369      // JSON has no native binary type.  Encode as base64-encoded string per common convention.
370      preValue();
371      writeQuotedString(java.util.Base64.getEncoder().encodeToString(value));
372      postValue();
373      return this;
374   }
375
376   @Override /* Overridden from TokenWriter */
377   public void flush() throws IOException {
378      out.flush();
379   }
380
381   @Override /* Overridden from TokenWriter */
382   public void close() throws IOException {
383      if (closed)
384         return;
385      closed = true;
386      try {
387         out.flush();
388      } finally {
389         if (owned != null)
390            owned.close();
391      }
392   }
393
394   // =================================================================================
395   // Helpers
396   // =================================================================================
397
398   /**
399    * Throws an {@link IOException} if this writer has been {@link #close() closed}.
400    *
401    * @throws IOException If this writer is closed.
402    */
403   private void assertOpen() throws IOException {
404      if (closed)
405         throw new IOException("Token writer is closed.");
406   }
407
408   /**
409    * Called immediately before emitting any scalar / container-start.  Inserts a leading comma
410    * if this is not the first member of an array, and clears the field-name-awaiting flag if a
411    * field name preceded the value.
412    */
413   private void preValue() throws IOException {
414      if (awaitingFieldValue) {
415         awaitingFieldValue = false;
416         return;
417      }
418      if (currentContext() == CTX_ARRAY) {
419         if (!firstInContainer)
420            out.write(',');
421         if (settings.useWhitespace)
422            writeNewlineAndIndent();
423      }
424      firstInContainer = false;
425   }
426
427   /** Called after emitting any scalar / container-end so subsequent emits see correct state. */
428   private void postValue() throws IOException {
429      if (autoFlush && currentContext() == CTX_ROOT)
430         out.flush();
431   }
432
433   private void writeNewlineAndIndent() throws IOException {
434      out.write('\n');
435      // Indent two spaces per level up to maxIndent; deeper levels emit no extra indent so the
436      // output stays usable on pathologically deep documents.
437      var levels = Math.min(depth, settings.maxIndent);
438      for (var i = 0; i < levels; i++)
439         out.write("  ");
440   }
441
442   private String maybeTrim(String s) {
443      if (!settings.trimStrings || s == null)
444         return s;
445      return s.trim();
446   }
447
448   private void pushContext(int kind) {
449      if (depth < 32) {
450         // Two bits per level: 00 = root, 01 = object, 10 = array.
451         ctxBits |= (kind & 0x3L) << (depth * 2);
452      } else {
453         if (ctxOverflow == null || depth - 32 >= ctxOverflow.length)
454            growOverflow();
455         ctxOverflow[depth - 32] = kind;
456      }
457      depth++;
458      firstInContainer = true;
459   }
460
461   private void popContext(int expectedKind) {
462      if (depth == 0)
463         throw new IllegalStateException("end-container called with no matching start-container");
464      var actual = currentContext();
465      if (actual != expectedKind)
466         throw new IllegalStateException(
467            "end-container kind mismatch: expected " + nameOfContext(expectedKind) +
468            ", got " + nameOfContext(actual));
469      // Clear the bits at the popped level so nested counts stay correct.
470      var top = depth - 1;
471      if (top < 32)
472         ctxBits &= ~(0x3L << (top * 2));
473      depth--;
474      // After closing a container, the next emit at the new depth is no longer the first member.
475      firstInContainer = false;
476      awaitingFieldValue = false;
477   }
478
479   private int currentContext() {
480      if (depth == 0)
481         return CTX_ROOT;
482      var top = depth - 1;
483      if (top < 32)
484         return (int) ((ctxBits >>> (top * 2)) & 0x3L);
485      return ctxOverflow[top - 32];
486   }
487
488   private static String nameOfContext(int kind) {
489      switch (kind) {
490         case CTX_ROOT:   return "root";
491         case CTX_OBJECT: return "object";
492         case CTX_ARRAY:  return "array";
493         default:         return "unknown";
494      }
495   }
496
497   private void growOverflow() {
498      var n = ctxOverflow == null ? 16 : ctxOverflow.length << 1;
499      var bigger = new int[n];
500      if (ctxOverflow != null)
501         System.arraycopy(ctxOverflow, 0, bigger, 0, ctxOverflow.length);
502      ctxOverflow = bigger;
503   }
504
505   private void writeQuotedString(String s) throws IOException {
506      var q = settings.quoteChar;
507      out.write(q);
508      // Escape: the active quote char, backslash, and control chars 0x00-0x1F.  Optionally
509      // escape forward slashes when settings.escapeSolidus is set.
510      for (var i = 0; i < s.length(); i++) {
511         var c = s.charAt(i);
512         if (c == q) {
513            out.write('\\');
514            out.write(q);
515            continue;
516         }
517         switch (c) {
518            case '\\': out.write("\\\\"); break;
519            case '\b': out.write("\\b"); break;
520            case '\f': out.write("\\f"); break;
521            case '\n': out.write("\\n"); break;
522            case '\r': out.write("\\r"); break;
523            case '\t': out.write("\\t"); break;
524            case '/':
525               if (settings.escapeSolidus)
526                  out.write("\\/");
527               else
528                  out.write(c);
529               break;
530            default:
531               if (c < 0x20 || c == 0x7f)
532                  out.write(f("\\u%04x", (int) c));
533               else
534                  out.write(c);
535         }
536      }
537      out.write(q);
538   }
539
540   // =================================================================================
541   // Static factory used by JsonSerializer.writeTokens(Object)
542   // =================================================================================
543
544   /**
545    * Internal factory used by {@link JsonSerializer#writeTokens(Object)}.  Coerces the supported
546    * output types ({@link Writer} / {@link OutputStream}) into a {@link Writer} the JSON encoder can
547    * target.
548    *
549    * @param output The output object.
550    * @param settings The output-formatting settings.
551    * @return A new {@link JsonTokenWriter} writing to the resolved {@link Writer}.
552    * @throws IOException If the output type is not supported or could not be opened.
553    */
554   public static JsonTokenWriter forOutput(Object output, Settings settings) throws IOException {
555      if (output == null)
556         throw new IOException("Output cannot be null.");
557      if (output instanceof Writer output2)
558         return new JsonTokenWriter(output2, null, false, settings);
559      if (output instanceof OutputStream output2)
560         return new JsonTokenWriter(new OutputStreamWriter(output2, StandardCharsets.UTF_8), null, true, settings);
561      throw ioex("Cannot convert object of type %s to a Writer.", output.getClass().getName());
562   }
563}