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.markdown; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.io.*; 022import java.lang.reflect.*; 023import java.time.*; 024import java.time.temporal.*; 025import java.util.*; 026 027import org.apache.juneau.commons.bean.*; 028import org.apache.juneau.marshall.*; 029import org.apache.juneau.marshall.json.*; 030import org.apache.juneau.marshall.json5.*; 031import org.apache.juneau.marshall.serializer.*; 032import org.apache.juneau.marshall.stream.*; 033import org.apache.juneau.marshall.swap.spi.*; 034 035/** 036 * Session object for {@link MarkdownSerializer}. 037 * 038 * <h5 class='section'>Notes:</h5><ul> 039 * <li class='warn'>This class is not thread safe and is typically discarded after one use. 040 * </ul> 041 * 042 */ 043@SuppressWarnings({ 044 "java:S110", // Inheritance depth acceptable for serializer session hierarchy 045 "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants 046 "java:S3740", // Raw ClassMeta/ObjectSwap types used throughout serializer session; parameterization requires extensive generic changes 047 "java:S3776", // Cognitive complexity acceptable for doWrite 048 "java:S6541", // Brain method acceptable for doWrite / writeAnything 049 "rawtypes", // Raw ClassMeta locals (cm, itemCm, swappedCm) in writeAnything(), writeCollection() and friends are passed to the ObjectSwap API, which is typed on the raw class 050 "resource" // MarkdownWriter/Writer lifecycle managed by SerializerPipe 051}) 052public class MarkdownSerializerSession extends WriterSerializerSession implements RecordWritable { 053 054 final String nullValue; 055 final boolean showHeaders; 056 057 /** 058 * Builder class. 059 */ 060 @SuppressWarnings({ 061 "java:S119" // 'SELF' (CRTP self-type) is intentional and clearer than a single-letter name. 062 }) 063 public abstract static class Builder<SELF extends Builder<SELF>> extends WriterSerializerSession.Builder<SELF> { 064 065 String nullValue; 066 boolean showHeaders; 067 068 /** 069 * Constructor. 070 * 071 * @param ctx The context creating this session. 072 * <br>Cannot be <jk>null</jk>. 073 */ 074 protected Builder(MarkdownSerializer ctx) { 075 super(reqnn("ctx", ctx)); 076 nullValue = ctx.getNullValue(); 077 showHeaders = ctx.isShowHeaders(); 078 } 079 080 @Override 081 public MarkdownSerializerSession build() { 082 return new MarkdownSerializerSession(this); 083 } 084 085 } 086 087 /** 088 * Concrete default builder leaf for the non-subclassed {@code create()} path (CRTP terminal). 089 */ 090 public static final class DefaultBuilder extends Builder<DefaultBuilder> { 091 092 DefaultBuilder(MarkdownSerializer ctx) { 093 super(ctx); 094 } 095 } 096 097 /** 098 * Creates a new builder for this object. 099 * 100 * @param ctx The context creating this session. 101 * <br>Cannot be <jk>null</jk>. 102 * @return A new builder. 103 */ 104 @SuppressWarnings({ 105 "java:S1452" // Builder<?> wildcard return intentional; callers use it to construct session instances polymorphically 106 }) 107 public static Builder<?> create(MarkdownSerializer ctx) { 108 return new DefaultBuilder(reqnn("ctx", ctx)); 109 } 110 111 /** 112 * Constructor. 113 * 114 * @param builder The builder for this object. 115 */ 116 protected MarkdownSerializerSession(Builder<?> builder) { 117 super(builder); 118 nullValue = or(builder.nullValue, "*null*"); 119 showHeaders = builder.showHeaders; 120 } 121 122 @Override /* RecordWritable */ 123 public RecordWriter writeRecords(Object output) throws IOException { 124 return RecordAdapter.writer(this, output); 125 } 126 127 @Override /* RecordWritable */ 128 public boolean isRecordStreaming() { 129 return false; 130 } 131 132 @Override /* Overridden from SerializerSession */ 133 protected void doWrite(SerializerPipe pipe, Object o) throws IOException, SerializeException { 134 try (var w = getMarkdownWriter(pipe)) { 135 writeAnything(w, o, getExpectedRootType(o), null); 136 } 137 } 138 139 /** 140 * Main serialization dispatch method. 141 * 142 * @param w The writer to write to. 143 * @param o The object to serialize. 144 * @param eType The expected type of the object. 145 * @param pMeta The bean property meta (may be null). 146 * @throws IOException Thrown by underlying stream. 147 * @throws SerializeException If serialization fails. 148 */ 149 protected void writeAnything(MarkdownWriter w, Object o, ClassMeta<?> eType, BeanPropertyMeta pMeta) throws IOException, SerializeException { 150 if (o == null) { 151 w.text(nullValue); 152 return; 153 } 154 155 ClassMeta cm = push2("root", o, eType); 156 if (cm == null) { 157 // Recursion detected 158 pop(); 159 w.text(nullValue); 160 return; 161 } 162 163 ObjectSwap swap = cm.getSwap(this); 164 if (swap != null) { 165 o = swap(swap, o); 166 cm = swap.getSwapClassMeta(this); 167 } 168 169 if (cm.isOptional()) { 170 var opt = (Optional<?>) o; 171 if (opt.isEmpty()) { 172 pop(); 173 w.text(nullValue); 174 } else { 175 writeAnything(w, opt.get(), eType, pMeta); 176 pop(); 177 } 178 return; 179 } 180 181 if (cm.isBean()) { 182 writeBeanMap(w, toBeanMap(o), eType, cm); 183 } else if (cm.isMap()) { 184 writeMap(w, (Map<?,?>) o, cm); 185 } else if (cm.isCollectionOrArray()) { 186 var l = cm.isArray() ? toObjectList(o) : (Collection<?>) o; 187 writeCollection(w, l, eType, cm); 188 } else if (cm.isStreamable()) { 189 writeCollection(w, toListFromStreamable(o, cm), eType, cm); 190 } else if (cm.isDate()) { 191 w.text(writeDate((Date)o, cm)); 192 } else if (cm.isCalendar()) { 193 w.text(writeCalendar(o, cm)); 194 } else if (cm.isTemporal()) { 195 w.text(writeTemporal((TemporalAccessor)o, cm)); 196 } else if (cm.isDuration()) { 197 w.text(writeDuration((Duration)o)); 198 } else if (cm.isPeriod()) { 199 w.text(writePeriod((Period)o)); 200 } else { 201 var s = toString(o); 202 // Only use JSON5 wrapping for actual string/enum types where auto-detection could misinterpret 203 // the value; non-bean objects (e.g. those with a valueOf(String) factory) use plain text so 204 // that the parser can use convertToType() on the raw string. 205 if ((cm.isCharSequence() || cm.isEnum()) && isAmbiguousString(s, nullValue)) 206 w.text("`'" + escapeJson5String(s) + "'`"); 207 else 208 w.text(MarkdownWriter.escapeText(s)); 209 } 210 211 pop(); 212 } 213 214 /** 215 * Serializes a bean as a 2-column key/value Markdown table. 216 * 217 * @param w The writer. 218 * @param bm The bean map. 219 * @param eType The expected type (for type name calculation). 220 * @param aType The actual type (for type name calculation). 221 * @throws IOException Thrown by underlying stream. 222 * @throws SerializeException If serialization fails. 223 */ 224 protected void writeBeanMap(MarkdownWriter w, BeanMap<?> bm, ClassMeta<?> eType, ClassMeta<?> aType) throws IOException, SerializeException { 225 if (showHeaders) { 226 w.tableHeader("Property", "Value"); 227 w.tableSeparator(2); 228 } 229 230 // Add _type row if needed for polymorphic type identification 231 // This handles both addBeanTypes (eType != aType) and addRootType (root object with dict name) 232 String typeName = getBeanTypeName(this, eType, aType, null); 233 if (typeName != null) { 234 w.tableRow("_type", typeName); 235 } 236 237 for (var entry : bm.entrySet()) { 238 if (entry instanceof BeanMapEntry entry2 && !entry2.getMeta().canRead()) 239 continue; 240 w.tableRow(MarkdownWriter.escapeCell(entry.getKey()), writeInlineValue(entry.getValue())); 241 } 242 } 243 244 /** 245 * Serializes a Map as a 2-column key/value Markdown table. 246 * 247 * @param w The writer. 248 * @param m The map. 249 * @param cm The class meta. 250 * @throws IOException Thrown by underlying stream. 251 * @throws SerializeException If serialization fails. 252 */ 253 protected void writeMap(MarkdownWriter w, Map<?,?> m, ClassMeta<?> cm) throws IOException, SerializeException { 254 if (showHeaders) { 255 w.tableHeader("Key", "Value"); 256 w.tableSeparator(2); 257 } 258 for (var entry : m.entrySet()) { 259 String rawKey = entry.getKey() == null ? null : toString(entry.getKey()); 260 String keyStr; 261 if (rawKey == null) 262 keyStr = nullValue; 263 else if (isAmbiguousString(rawKey, nullValue)) 264 keyStr = "`'" + escapeJson5String(rawKey) + "'`"; 265 else 266 keyStr = MarkdownWriter.escapeCell(rawKey); 267 w.tableRow(keyStr, writeInlineValue(entry.getValue())); 268 } 269 } 270 271 /** 272 * Serializes a collection as either a multi-column table (uniform beans/maps) or a bulleted list. 273 * 274 * @param w The writer. 275 * @param c The collection. 276 * @param eType The expected type. 277 * @param cm The class meta. 278 * @throws IOException Thrown by underlying stream. 279 * @throws SerializeException If serialization fails. 280 */ 281 protected void writeCollection(MarkdownWriter w, Collection<?> c, ClassMeta<?> eType, ClassMeta<?> cm) throws IOException, SerializeException { 282 if (c.isEmpty()) 283 return; 284 285 var headers = getTableHeaders(c); 286 if (headers != null) { 287 // Uniform collection of beans or maps → multi-column table 288 289 // Determine if we need a _type column 290 boolean needsTypeColumn = isAddBeanTypes(); 291 var elementType = eType != null && eType.isCollectionOrArray() ? eType.getElementType() : cm.getElementType(); 292 293 // Add _type column to headers if needed 294 String[] finalHeaders = headers; 295 if (needsTypeColumn) { 296 finalHeaders = new String[headers.length + 1]; 297 finalHeaders[0] = "_type"; 298 System.arraycopy(headers, 0, finalHeaders, 1, headers.length); 299 } 300 301 if (showHeaders && finalHeaders.length > 0) { 302 w.tableHeader(finalHeaders); 303 w.tableSeparator(finalHeaders.length); 304 } 305 306 for (var item : c) { 307 if (item == null) { 308 var row = new String[finalHeaders.length]; 309 Arrays.fill(row, nullValue); 310 w.tableRow(row); 311 continue; 312 } 313 ClassMeta itemCm = getClassMetaForObject(item); 314 ObjectSwap itemSwap = itemCm.getSwap(this); 315 var swapped = itemSwap != null ? swap(itemSwap, item) : item; 316 ClassMeta swappedCm = itemSwap != null ? itemSwap.getSwapClassMeta(this) : itemCm; 317 318 var row = new String[finalHeaders.length]; 319 int colOffset = needsTypeColumn ? 1 : 0; 320 321 // Add type name if needed 322 // For root-level collections with addRootType, treat each element as root 323 if (needsTypeColumn) { 324 String typeName; 325 if (isRoot() && isAddRootType()) { 326 // Root-level collection: add type name if the bean has a dictionary name 327 typeName = swappedCm.getBeanDictionaryName(); 328 } else { 329 // Nested collection: use standard logic 330 typeName = getBeanTypeName(this, elementType, swappedCm, null); 331 } 332 row[0] = or(typeName, ""); 333 } 334 335 if (swappedCm.isBean()) { 336 var bm = toBeanMap(swapped); 337 for (var i = 0; i < headers.length; i++) { 338 var val = bm.get(headers[i]); 339 row[i + colOffset] = writeInlineValue(val); 340 } 341 } else if (swappedCm.isMap()) { 342 var map = (Map<?,?>) swapped; 343 for (var i = 0; i < headers.length; i++) { 344 var val = map.get(headers[i]); 345 row[i + colOffset] = writeInlineValue(val); 346 } 347 } 348 w.tableRow(row); 349 } 350 } else { 351 // Mixed or simple values → bulleted list 352 for (var item : c) 353 w.bulletItem(0, writeInlineValue(item)); 354 } 355 } 356 357 /** 358 * Returns true if the string could be misinterpreted during parsing when the target type is Object, 359 * or if the string contains characters that would be lost or corrupted in Markdown table cells. 360 * 361 * <p>Strings that are empty, equal to nullValue, look like booleans or numbers, or contain control 362 * characters (< 32 or = 127) are considered ambiguous and should be wrapped in JSON5 backtick syntax. 363 */ 364 private static boolean isAmbiguousString(String s, String nullValue) { 365 if (s.isEmpty()) return true; 366 if (s.equals(nullValue)) return true; 367 if (eqa(s, "true", "false")) return true; 368 for (int i = 0; i < s.length(); i++) { 369 char c = s.charAt(i); 370 if (c < 32 || c == 127) return true; 371 } 372 try { Integer.parseInt(s); return true; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* Not a number; fall through */ } 373 try { Long.parseLong(s); return true; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* Not a number; fall through */ } 374 try { Double.parseDouble(s); return true; } catch (@SuppressWarnings("unused") NumberFormatException ignored) { /* Not a number; fall through */ } 375 return false; 376 } 377 378 /** 379 * Escapes a string for use within a JSON5 single-quoted string literal. 380 */ 381 private static String escapeJson5String(String s) { 382 if (s == null) return ""; 383 var sb = new StringBuilder(s.length() + 4); 384 for (var i = 0; i < s.length(); i++) { 385 var c = s.charAt(i); 386 switch (c) { 387 case '\'' -> sb.append("\\'"); 388 case '\\' -> sb.append("\\\\"); 389 case '\n' -> sb.append("\\n"); 390 case '\r' -> sb.append("\\r"); 391 case '\t' -> sb.append("\\t"); 392 default -> sb.append(c); 393 } 394 } 395 return sb.toString(); 396 } 397 398 /** 399 * Converts an array (including primitive arrays) to a List of boxed objects. 400 */ 401 private static List<Object> toObjectList(Object array) { 402 int len = Array.getLength(array); 403 var list = new ArrayList<>(len); 404 for (int i = 0; i < len; i++) 405 list.add(Array.get(array, i)); 406 return list; 407 } 408 409 /** 410 * Returns a Json5Serializer that shares this session's bean context (swaps, implClasses, dictionary). 411 * Creates a new serializer each time to ensure settings are current. 412 */ 413 private JsonSerializer getJson5Serializer() { 414 var b = Json5Serializer.create().marshallingContext((MarshallingContext) getContext()); 415 if (isAddBeanTypes()) 416 b = b.addBeanTypes(); 417 if (isAddRootType()) 418 b = b.addRootType(); 419 if (isTrimStrings()) 420 b = b.trimStrings(); 421 return b.build(); 422 } 423 424 /** 425 * Serializes a value for placement in a Markdown table cell. 426 * 427 * <p> 428 * Simple values (strings, numbers, booleans, enums, dates) are escaped and returned inline. 429 * Complex values (beans, maps, collections) are serialized as JSON5 in backticks. 430 * 431 * @param o The value to serialize. 432 * @return The cell string value. 433 * @throws SerializeException If serialization fails. 434 */ 435 protected String writeInlineValue(Object o) throws SerializeException { 436 if (o == null) 437 return nullValue; 438 439 ClassMeta cm = getClassMetaForObject(o); 440 ObjectSwap swap = cm.getSwap(this); 441 if (swap != null) { 442 o = swap(swap, o); 443 cm = swap.getSwapClassMeta(this); 444 } 445 446 if (cm.isCharSequence() || cm.isEnum()) { 447 var s = toString(o); 448 if (isAmbiguousString(s, nullValue)) 449 return "`'" + escapeJson5String(s) + "'`"; 450 return MarkdownWriter.escapeCell(s); 451 } 452 453 if (cm.isNumber() || cm.isBoolean()) 454 return o.toString(); 455 456 if (cm.isDate()) 457 return MarkdownWriter.escapeCell(writeDate((Date)o, cm)); 458 459 if (cm.isCalendar()) 460 return MarkdownWriter.escapeCell(writeCalendar(o, cm)); 461 462 if (cm.isTemporal()) 463 return MarkdownWriter.escapeCell(writeTemporal((TemporalAccessor)o, cm)); 464 465 if (cm.isDuration()) 466 return MarkdownWriter.escapeCell(writeDuration((Duration)o)); 467 468 if (cm.isPeriod()) 469 return MarkdownWriter.escapeCell(writePeriod((Period)o)); 470 471 // Complex value: serialize as JSON5 in backticks — use context-aware serializer for swaps 472 try { 473 var json5 = getJson5Serializer().write(o); 474 return "`" + json5 + "`"; 475 } catch (@SuppressWarnings("unused") SerializeException e) { 476 return MarkdownWriter.escapeCell(o.toString()); 477 } 478 } 479 480 /** 481 * Determines if a collection can be rendered as a multi-column table. 482 * 483 * <p> 484 * Returns the column header names if the collection is a uniform set of beans or maps, 485 * or {@code null} if it should be rendered as a bulleted list. 486 * 487 * @param c The collection to inspect. 488 * <br>Must not be <jk>null</jk>. 489 * @return The column header names, or {@code null} for list rendering. 490 * @throws SerializeException If inspection fails. 491 */ 492 @SuppressWarnings({ 493 "java:S1168" // null signals list rendering; empty array would incorrectly trigger table mode 494 }) 495 protected String[] getTableHeaders(Collection<?> c) throws SerializeException { 496 if (c.isEmpty()) 497 return null; 498 499 // Find first non-null element 500 Object first = null; 501 for (var item : c) { 502 if (item != null) { 503 first = item; 504 break; 505 } 506 } 507 if (first == null) 508 return null; 509 510 ClassMeta cm = getClassMetaForObject(first); 511 ObjectSwap swap = cm.getSwap(this); 512 if (swap != null) { 513 try { 514 first = swap(swap, first); 515 cm = swap.getSwapClassMeta(this); 516 } catch (@SuppressWarnings("unused") SerializeException e) { 517 return null; 518 } 519 } 520 521 if (!cm.isMapOrBean()) 522 return null; 523 524 if (cm.isBean()) { 525 var bm = toBeanMap(first); 526 // Skip write-only properties: they are never serialized, so they get no column. 527 return bm.keySet().stream().filter(k -> bm.getProperty(k).getMeta().canRead()).toArray(String[]::new); 528 } 529 530 // Non-bean map: collect keys from first entry 531 if (first instanceof Map<?,?> map2) { 532 return map2.keySet().stream().map(Object::toString).toArray(String[]::new); 533 } 534 535 return null; 536 } 537 538 /** 539 * Gets or creates a {@link MarkdownWriter} from the given pipe. 540 * 541 * @param out The serializer pipe. 542 * <br>Must not be <jk>null</jk>. 543 * @return The writer. 544 */ 545 protected MarkdownWriter getMarkdownWriter(SerializerPipe out) { 546 var output = out.getRawOutput(); 547 if (output instanceof MarkdownWriter output2) 548 return output2; 549 var w = new MarkdownWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isTrimStrings(), getUriResolver()); 550 out.setWriter(w); 551 return w; 552 } 553}