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 — 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}