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.xml; 018 019import static org.apache.juneau.commons.utils.CollectionUtils.*; 020import static org.apache.juneau.commons.utils.IoUtils.*; 021import static org.apache.juneau.commons.utils.ObjectUtils.*; 022import static org.apache.juneau.commons.utils.Shorts.*; 023import static org.apache.juneau.marshall.xml.XmlFormat.*; 024import static org.apache.juneau.marshall.xml.XmlSerializerSession.ContentResult.*; 025import static org.apache.juneau.marshall.xml.XmlSerializerSession.JsonType.*; 026 027import java.io.*; 028import java.lang.reflect.*; 029import java.time.*; 030import java.time.temporal.*; 031import java.util.*; 032import java.util.AbstractMap.*; 033import java.util.function.*; 034 035import org.apache.juneau.commons.bean.*; 036import org.apache.juneau.commons.lang.*; 037import org.apache.juneau.marshall.*; 038import org.apache.juneau.marshall.serializer.*; 039import org.apache.juneau.marshall.stream.*; 040 041/** 042 * Session object that lives for the duration of a single use of {@link XmlSerializer}. 043 * 044 * <h5 class='section'>Notes:</h5><ul> 045 * <li class='warn'>This class is not thread safe and is typically discarded after one use. 046 * </ul> 047 * 048 * <h5 class='section'>See Also:</h5><ul> 049 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/XmlSupport">XML</a> 050 051 * </ul> 052 */ 053@SuppressWarnings({ 054 "java:S110", // Deep inheritance inherent to the serializer/parser session hierarchy 055 "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants 056 "java:S1452", // create() and getXmlWriter() return wildcard Builder<?>/XmlWriter<?> because the concrete self-type is not known statically 057 "java:S3776", // Cognitive complexity acceptable for this specific logic 058 "java:S6541", // Single-threaded session contexts do not require synchronization 059 "null", // Null handling verified by context or framework 060 "rawtypes", // Raw Map/Collection necessary for serializer dispatch over heterogeneous types 061 "resource", // Writer/Closeable resources managed by try-with-resources; analyzer FP in lambdas 062 "unchecked" // Type erasure requires unchecked casts in serializer dispatch 063}) 064public class XmlSerializerSession extends WriterSerializerSession implements RecordWritable { 065 066 /** 067 * Builder class. 068 */ 069 @SuppressWarnings({ 070 "java:S119" // 'SELF' (CRTP self-type) is intentional and clearer than a single-letter name. 071 }) 072 public abstract static class Builder<SELF extends Builder<SELF>> extends WriterSerializerSession.Builder<SELF> { 073 074 private boolean addNamespaceUrisToRoot; 075 private boolean autoDetectNamespaces; 076 private String defaultNamespace; 077 private boolean enableNamespaces; 078 private String textNodeDelimiter; 079 private final XmlSerializer ctx; 080 081 /** 082 * Constructor 083 * 084 * @param ctx The context creating this session. 085 * <br>Cannot be <jk>null</jk>. 086 */ 087 protected Builder(XmlSerializer ctx) { 088 super(reqnn("ctx", ctx)); 089 this.ctx = ctx; 090 addNamespaceUrisToRoot = ctx.isAddNamespaceUrlsToRoot(); 091 autoDetectNamespaces = ctx.isAutoDetectNamespaces(); 092 defaultNamespace = ctx.getDefaultNamespace() == null ? null : ctx.getDefaultNamespace().toString(); 093 enableNamespaces = ctx.isEnableNamespaces(); 094 textNodeDelimiter = ctx.getTextNodeDelimiter(); 095 } 096 097 @Override 098 public XmlSerializerSession build() { 099 return new XmlSerializerSession(this); 100 } 101 102 /** 103 * Add namespace URLs to the root element. 104 * 105 * @param value The new value for this setting. 106 * @return This object. 107 */ 108 public SELF addNamespaceUrisToRoot(boolean value) { 109 addNamespaceUrisToRoot = value; 110 return self(); 111 } 112 113 /** 114 * Auto-detect namespace usage. 115 * 116 * @param value The new value for this setting. 117 * @return This object. 118 */ 119 public SELF autoDetectNamespaces(boolean value) { 120 autoDetectNamespaces = value; 121 return self(); 122 } 123 124 /** 125 * Default namespace. 126 * 127 * @param value The new value for this setting. 128 * @return This object. 129 */ 130 public SELF defaultNamespace(String value) { 131 defaultNamespace = value; 132 return self(); 133 } 134 135 /** 136 * Enable support for XML namespaces. 137 * 138 * @param value The new value for this setting. 139 * @return This object. 140 */ 141 public SELF enableNamespaces(boolean value) { 142 enableNamespaces = value; 143 return self(); 144 } 145 146 /** 147 * Text node delimiter. 148 * 149 * @param value The new value for this setting. 150 * @return This object. 151 */ 152 public SELF textNodeDelimiter(String value) { 153 textNodeDelimiter = value == null ? "" : value; 154 return self(); 155 } 156 157 @Override /* Overridden from Builder */ 158 public SELF property(String key, Object value) { 159 if (key == null) { super.property(key, value); return self(); } 160 switch (key) { 161 case "addNamespaceUrisToRoot", "XmlSerializerSession.addNamespaceUrisToRoot": 162 return addNamespaceUrisToRoot(cvt(value, Boolean.class)); 163 case "autoDetectNamespaces", "XmlSerializerSession.autoDetectNamespaces": 164 return autoDetectNamespaces(cvt(value, Boolean.class)); 165 case "defaultNamespace", "XmlSerializerSession.defaultNamespace": 166 return defaultNamespace(cvt(value, String.class)); 167 case "enableNamespaces", "XmlSerializerSession.enableNamespaces": 168 return enableNamespaces(cvt(value, Boolean.class)); 169 case "textNodeDelimiter", "XmlSerializerSession.textNodeDelimiter": 170 return textNodeDelimiter(cvt(value, String.class)); 171 default: 172 super.property(key, value); 173 return self(); 174 } 175 } 176 177 } 178 179 /** 180 * Concrete default builder leaf for the non-subclassed {@code create()} path (CRTP terminal). 181 */ 182 public static final class DefaultBuilder extends Builder<DefaultBuilder> { 183 184 DefaultBuilder(XmlSerializer ctx) { 185 super(ctx); 186 } 187 } 188 189 /** 190 * Identifies what the contents were of a serialized bean. 191 */ 192 public enum ContentResult { 193 CR_VOID, // No content...append "/>" to the start tag. 194 CR_EMPTY, // No content...append "/>" to the start tag if XML, "/></end>" if HTML. 195 CR_MIXED, // Mixed content...don't add whitespace. 196 CR_ELEMENTS // Elements...use normal whitespace rules. 197 } 198 199 enum JsonType { 200 STRING("string"), BOOLEAN("boolean"), NUMBER("number"), ARRAY("array"), OBJECT("object"), NULL("null"); 201 202 private final String value; 203 204 JsonType(String value) { 205 this.value = value; 206 } 207 208 @Override 209 public String toString() { 210 return value; 211 } 212 213 @SuppressWarnings({ 214 "java:S1871" // return true is the only correct action here; coincidental match with isXmlText is not a logic error 215 }) 216 boolean isOneOf(JsonType...types) { 217 for (var type : types) 218 if (type == this) 219 return true; 220 return false; 221 } 222 } 223 224 /** 225 * Creates a new builder for this object. 226 * 227 * @param ctx The context creating this session. 228 * <br>Cannot be <jk>null</jk>. 229 * @return A new builder. 230 */ 231 public static Builder<?> create(XmlSerializer ctx) { 232 return new DefaultBuilder(reqnn("ctx", ctx)); 233 } 234 235 private final String textNodeDelimiter; 236 private final boolean addNamespaceUrisToRoot; 237 private final boolean autoDetectNamespaces; 238 private final boolean enableNamespaces; 239 private final XmlSerializer ctx; 240 private Namespace defaultNamespace; 241 private List<Namespace> namespaces = l(); 242 243 /** 244 * Constructor. 245 * 246 * @param builder The builder for this object. 247 */ 248 protected XmlSerializerSession(Builder<?> builder) { 249 super(builder); 250 ctx = builder.ctx; 251 addNamespaceUrisToRoot = builder.addNamespaceUrisToRoot; 252 autoDetectNamespaces = builder.autoDetectNamespaces; 253 enableNamespaces = builder.enableNamespaces; 254 textNodeDelimiter = builder.textNodeDelimiter; 255 // namespaces must be populated BEFORE findDefaultNamespace() runs below -- it resolves a name-only 256 // or URI-only default namespace by searching getNamespaces(), which otherwise still sees the 257 // field initializer's empty list here, so both search loops always iterate zero times and the 258 // namespace is returned unresolved (missing whichever of name/uri wasn't supplied). 259 namespaces = new ArrayList<>(ctx.getNamespaces()); 260 defaultNamespace = findDefaultNamespace( 261 builder.defaultNamespace != null ? Namespace.create(builder.defaultNamespace) : ctx.getDefaultNamespace() 262 ); 263 } 264 265 /** 266 * Returns the language-specific metadata on the specified bean. 267 * 268 * @param bm The bean to return the metadata on. 269 * @return The metadata. 270 */ 271 public XmlBeanMeta getXmlBeanMeta(BeanMeta<?> bm) { 272 return ctx.getXmlBeanMeta(bm); 273 } 274 275 /** 276 * Returns the language-specific metadata on the specified bean property. 277 * 278 * @param bpm The bean property to return the metadata on. 279 * @return The metadata. 280 */ 281 public XmlBeanPropertyMeta getXmlBeanPropertyMeta(BeanPropertyMeta bpm) { 282 return bpm == null ? XmlBeanPropertyMeta.DEFAULT : ctx.getXmlBeanPropertyMeta(bpm); 283 } 284 285 /** 286 * Returns the language-specific metadata on the specified class. 287 * 288 * @param cm The class to return the metadata on. 289 * @return The metadata. 290 */ 291 public XmlClassMeta getXmlClassMeta(ClassMeta<?> cm) { 292 return ctx.getXmlClassMeta(cm); 293 } 294 295 /** 296 * Converts the specified output target object to an {@link XmlWriter}. 297 * 298 * @param out The output target object. 299 * <br>Must not be <jk>null</jk>. 300 * @return The output target object wrapped in an {@link XmlWriter}. 301 */ 302 public final XmlWriter<?> getXmlWriter(SerializerPipe out) { 303 var output = out.getRawOutput(); 304 if (output instanceof XmlWriter<?> output2) 305 return output2; 306 var w = new BasicXmlWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isTrimStrings(), getQuoteChar(), getUriResolver(), isEnableNamespaces(), defaultNamespace); 307 out.setWriter(w); 308 return w; 309 } 310 311 /** 312 * Add a namespace to this session. 313 * 314 * @param ns The namespace being added. 315 */ 316 private void addNamespace(Namespace ns) { 317 if (ns == defaultNamespace) 318 return; 319 320 for (var n : namespaces) 321 if (n == ns) 322 return; 323 324 if (nn(defaultNamespace) && (ns.uri.equals(defaultNamespace.uri) || ns.name.equals(defaultNamespace.name))) 325 defaultNamespace = ns; 326 else 327 namespaces = addAll(namespaces, ns); 328 } 329 330 private Namespace findDefaultNamespace(Namespace n) { 331 if (n == null) 332 return null; 333 if (nn(n.name) && nn(n.uri)) 334 return n; 335 if (n.uri == null) { 336 for (var n2 : getNamespaces()) 337 if (n2.name.equals(n.name)) 338 return n2; 339 } 340 if (n.name == null) { 341 for (var n2 : getNamespaces()) 342 if (n2.uri.equals(n.uri)) 343 return n2; 344 } 345 return n; 346 } 347 348 /** 349 * Checks if an object is a text node (String or primitive type). 350 */ 351 private static boolean isTextNode(Object o) { 352 if (o == null) 353 return false; 354 var c = o.getClass(); 355 // Text nodes are strings and primitives (not beans, collections, arrays, or other complex types) 356 return CharSequence.class.isAssignableFrom(c) || Number.class.isAssignableFrom(c) || Boolean.class.isAssignableFrom(c) || c.isPrimitive(); 357 } 358 359 private boolean isXmlText(XmlFormat format, ClassMeta<?> sType) { 360 if (format == XMLTEXT) 361 return true; 362 var xcm = getXmlClassMeta(sType); 363 if (xcm == null) 364 return false; 365 return xcm.getFormat() == XMLTEXT; 366 } 367 368 private boolean isRawText(XmlFormat format, ClassMeta<?> sType) { 369 if (format == RAWTEXT) 370 return true; 371 var xcm = getXmlClassMeta(sType); 372 if (xcm == null) 373 return false; 374 return xcm.getFormat() == RAWTEXT; 375 } 376 377 private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta) { 378 var key = p.getName(); 379 var value = p.getValue(); 380 if (nn(p.getThrown())) 381 onBeanGetterException(pMeta, p.getThrown()); 382 if (canIgnoreValue(pMeta, key, value)) 383 return oe(); 384 return o(new SimpleEntry<>(key, value)); 385 } 386 387 private ContentResult writeBeanMap(XmlWriter<?> out, BeanMap<?> m, Namespace elementNs, boolean isCollapsed, boolean isMixedOrText) throws SerializeException { 388 boolean hasChildren = false; 389 var bm = m.getMeta(); 390 391 var lp = new ArrayList<BeanPropertyValue>(); 392 393 var checkNull = (Predicate<Object>)(x -> isKeepNullProperties() || nn(x)); 394 m.forEachValue(checkNull, (pMeta, key, value, thrown) -> lp.add(new BeanPropertyValue(pMeta, key, value, thrown))); 395 396 var xbm = getXmlBeanMeta(bm); 397 398 // @formatter:off 399 Set<String> attrs = xbm.getAttrPropertyNames(); 400 Set<String> elements = xbm.getElementPropertyNames(); 401 Set<String> collapsedElements = xbm.getCollapsedPropertyNames(); 402 var attrsProperty = xbm.getAttrsPropertyName(); 403 var contentProperty = xbm.getContentPropertyName(); 404 // @formatter:on 405 406 XmlFormat cf = null; 407 408 Object content = null; 409 ClassMeta<?> contentType = null; 410 for (var p : lp) { 411 var n = p.getName(); 412 if (attrs.contains(n) || attrs.contains("*") || n.equals(attrsProperty)) { 413 var pMeta = p.getMeta(); 414 if (pMeta.canRead()) { 415 var kv = getPropertyKeyValueIfNotIgnored(p, pMeta); 416 if (kv.isEmpty()) 417 continue; 418 var key = kv.get().getKey(); 419 var value = kv.get().getValue(); 420 var bpXml = getXmlBeanPropertyMeta(pMeta); 421 var ns = (isEnableNamespaces() && bpXml.getNamespace() != elementNs ? bpXml.getNamespace() : null); 422 423 if (pMeta.isUri()) { 424 out.attrUri(ns, key, value); 425 } else if (n.equals(attrsProperty)) { 426 if (value instanceof BeanMap<?> value2) { 427 value2.forEachValue(x -> true, (pMeta2, key2, value3, thrown2) -> { 428 if (nn(thrown2)) 429 onBeanGetterException(pMeta, thrown2); 430 out.attr(ns, key2, value3); 431 }); 432 } else /* Map */ { 433 var m2 = (Map)value; 434 if (nn(m2)) 435 m2.forEach((k, v) -> out.attr(ns, s(k), v)); 436 } 437 } else { 438 out.attr(ns, key, value); 439 } 440 } 441 } 442 } 443 444 boolean hasContent = false; 445 boolean preserveWhitespace = false; 446 boolean isVoidElement = xbm.getContentFormat() == VOID; 447 448 for (var p : lp) { 449 BeanPropertyMeta pMeta = p.getMeta(); 450 if (pMeta.canRead()) { 451 ClassMeta<?> cMeta = (ClassMeta<?>) p.getBeanInfo(); 452 453 var n = p.getName(); 454 if (n.equals(contentProperty)) { 455 content = p.getValue(); 456 contentType = (ClassMeta<?>) p.getBeanInfo(); 457 hasContent = true; 458 cf = xbm.getContentFormat(); 459 if (cf.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT)) 460 isMixedOrText = true; 461 if (cf.isOneOf(MIXED_PWS, TEXT_PWS)) 462 preserveWhitespace = true; 463 if ((contentType.isCollection() && ((Collection)content).isEmpty()) || (contentType.isArray() && Array.getLength(content) == 0)) 464 hasContent = false; 465 } else if (elements.contains(n) || collapsedElements.contains(n) || elements.contains("*") || collapsedElements.contains("*")) { 466 var kv = getPropertyKeyValueIfNotIgnored(p, pMeta); 467 if (kv.isEmpty()) 468 continue; 469 var key = kv.get().getKey(); 470 var value = kv.get().getValue(); 471 if (! hasChildren) { 472 hasChildren = true; 473 out.appendIf(! isCollapsed, '>').nlIf(! isMixedOrText, indent); 474 } 475 476 var bpXml = getXmlBeanPropertyMeta(pMeta); 477 writeAnything(out, value, cMeta, key, null, bpXml.getNamespace(), false, bpXml.getXmlFormat(), isMixedOrText, false, pMeta); 478 } 479 } 480 } 481 if (contentProperty == null && ! hasContent) { 482 if (hasChildren) { 483 return CR_ELEMENTS; 484 } else if (isVoidElement) { 485 return CR_VOID; 486 } else { 487 return CR_EMPTY; 488 } 489 } 490 491 // Serialize XML content. 492 if (nn(content)) { 493 out.w('>').nlIf(! isMixedOrText, indent); 494 if (contentType == null) { 495 // No content type specified - skip content serialization 496 } else if (contentType.isCollection()) { 497 var c = (Collection)content; 498 boolean previousWasTextNode = false; 499 for (var value : c) { 500 boolean currentIsTextNode = isTextNode(value); 501 // Insert delimiter between consecutive text nodes 502 if (previousWasTextNode && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) { 503 out.append(textNodeDelimiter); 504 } 505 writeAnything(out, value, contentType.getElementType(), null, null, null, false, cf, isMixedOrText, preserveWhitespace, null); 506 previousWasTextNode = currentIsTextNode; 507 } 508 } else if (contentType.isArray()) { 509 var c = toList(Object[].class, content); 510 boolean previousWasTextNode = false; 511 for (var value : c) { 512 boolean currentIsTextNode = isTextNode(value); 513 // Insert delimiter between consecutive text nodes 514 if (previousWasTextNode && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) { 515 out.append(textNodeDelimiter); 516 } 517 writeAnything(out, value, contentType.getElementType(), null, null, null, false, cf, isMixedOrText, preserveWhitespace, null); 518 previousWasTextNode = currentIsTextNode; 519 } 520 } else { 521 writeAnything(out, content, contentType, null, null, null, false, cf, isMixedOrText, preserveWhitespace, null); 522 } 523 } else { 524 // A null FREEFORM content property represents an empty container element (e.g. an empty HTML <table> or 525 // <thead>), which must render as an empty element rather than an explicit null. Emitting nil='true' here 526 // is surprising for such containers. Other content formats (TEXT/TEXT_PWS/XMLTEXT for null-vs-empty-string 527 // round-tripping, and MIXED/MIXED_PWS) retain the nil marker to preserve existing behavior. 528 if (isAddJsonTags() && xbm.getContentFormat() != FREEFORM) 529 out.attr("nil", "true"); 530 out.w('>').nlIf(! isMixedOrText, indent); 531 } 532 return isMixedOrText ? CR_MIXED : CR_ELEMENTS; 533 } 534 535 private XmlWriter<?> writeCollection(XmlWriter<?> out, Object in, ClassMeta<?> sType, ClassMeta<?> eType, BeanPropertyMeta ppMeta, boolean isMixed) throws SerializeException { 536 537 var eeType = eType.getElementType(); 538 539 var c = (sType.isCollection() ? (Collection)in : toList(sType.inner(), in)); 540 541 String type2 = null; 542 543 var eName = Holder.of(type2); 544 var eNs = Holder.<Namespace>empty(); 545 546 if (nn(ppMeta)) { 547 XmlBeanPropertyMeta bpXml = getXmlBeanPropertyMeta(ppMeta); 548 eName.set(bpXml.getChildName()); 549 eNs.set(bpXml.getNamespace()); 550 } 551 552 // Track if previous element was a text node for delimiter insertion 553 var previousWasTextNode = Holder.of(false); 554 555 forEachEntry(c, x -> { 556 var currentIsTextNode = isTextNode(x); 557 558 // Insert delimiter between consecutive text nodes 559 if (isTrue(previousWasTextNode.get()) && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) { 560 out.append(textNodeDelimiter); 561 } 562 563 writeAnything(out, x, eeType, null, eName.get(), eNs.get(), false, XmlFormat.DEFAULT, isMixed, false, null); 564 previousWasTextNode.set(currentIsTextNode); 565 }); 566 567 return out; 568 } 569 570 private XmlWriter<?> writeStreamable(XmlWriter<?> out, Object in, ClassMeta<?> sType, ClassMeta<?> eType, BeanPropertyMeta ppMeta, boolean isMixed) throws SerializeException { 571 572 var eeType = eType.getElementType(); 573 574 var eName = Holder.<String>empty(); 575 var eNs = Holder.<Namespace>empty(); 576 577 if (nn(ppMeta)) { 578 XmlBeanPropertyMeta bpXml = getXmlBeanPropertyMeta(ppMeta); 579 eName.set(bpXml.getChildName()); 580 eNs.set(bpXml.getNamespace()); 581 } 582 583 var previousWasTextNode = Holder.of(false); 584 585 forEachStreamableEntry(in, sType, x -> { 586 var currentIsTextNode = isTextNode(x); 587 588 if (isTrue(previousWasTextNode.get()) && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) { 589 out.append(textNodeDelimiter); 590 } 591 592 writeAnything(out, x, eeType, null, eName.get(), eNs.get(), false, XmlFormat.DEFAULT, isMixed, false, null); 593 previousWasTextNode.set(currentIsTextNode); 594 }); 595 596 return out; 597 } 598 599 private ContentResult writeMap(XmlWriter<?> out, Map m, ClassMeta<?> sType, ClassMeta<?> eKeyType, ClassMeta<?> eValueType, boolean isMixed) throws SerializeException { 600 601 var keyType = eKeyType == null ? sType.getKeyType() : eKeyType; 602 var valueType = eValueType == null ? sType.getValueType() : eValueType; 603 604 var hasChildren = Flag.create(); 605 forEachEntry(m, e -> { 606 607 var k = e.getKey(); 608 if (k == null) { 609 k = "\u0000"; 610 } else { 611 k = generalize(k, keyType); 612 if (isTrimStrings() && k instanceof String) 613 k = k.toString().trim(); 614 } 615 616 var value = e.getValue(); 617 618 hasChildren.ifNotSet(() -> out.w('>').nlIf(! isMixed, indent)).set(); 619 writeAnything(out, value, valueType, toString(k), null, null, false, XmlFormat.DEFAULT, isMixed, false, null); 620 }); 621 622 return hasChildren.isSet() ? CR_ELEMENTS : CR_EMPTY; 623 } 624 625 @Override /* Overridden from Serializer */ 626 protected void doWrite(SerializerPipe out, Object o) throws IOException, SerializeException { 627 if (isEnableNamespaces() && isAutoDetectNamespaces()) 628 findNsfMappings(o); 629 writeAnything(getXmlWriter(out), o, getExpectedRootType(o), null, null, null, isEnableNamespaces() && isAddNamespaceUrisToRoot(), XmlFormat.DEFAULT, false, false, null); 630 } 631 632 /** 633 * Opens a whole-value push generator targeting XML output, bound to this live session. 634 * 635 * <p> 636 * Because XML's wire format (attributes, namespaces, mixed content) is non-trivial to expose 637 * as a fine-grained token writer, this implementation supports only the cursor-level POJO 638 * bridge — {@link RecordWriter#write(Object) write(Object)} delegates to 639 * {@link SerializerSession#write(Object, Object)}. 640 * 641 * @param output The output. 642 * @return A new {@link RecordWriter}. 643 * @throws IOException If a problem occurred opening the underlying output. 644 */ 645 @Override /* RecordWritable */ 646 public RecordWriter writeRecords(Object output) throws IOException { 647 return RecordAdapter.writer(this, output); 648 } 649 650 /** 651 * The XML record writer is buffered/{@link RecordAdapter}-backed, not O(1) streaming. 652 * 653 * @return Always <jk>false</jk>. 654 */ 655 @Override /* RecordWritable */ 656 public boolean isRecordStreaming() { return false; } 657 658 /** 659 * Recursively searches for the XML namespaces on the specified POJO and adds them to the serializer context object. 660 * 661 * @param o The POJO to check. 662 * @throws SerializeException Thrown if bean recursion occurred. 663 */ 664 protected final void findNsfMappings(Object o) throws SerializeException { 665 ClassMeta<?> aType = null; // The actual type 666 667 try { 668 aType = push(null, o, null); 669 } catch (MarshallingRecursionException e) { 670 throw new SerializeException(e); 671 } 672 673 if (nn(aType)) { 674 var ns = getXmlClassMeta(aType).getNamespace(); 675 if (nn(ns) && nn(ns.uri)) 676 addNamespace(ns); 677 } 678 679 // Handle recursion 680 if (nn(aType) && ! aType.isPrimitive()) { 681 682 BeanMap<?> bm = null; 683 if (aType.isBeanMap()) { 684 bm = (BeanMap<?>)o; 685 } else if (aType.isBean()) { 686 bm = toBeanMap(o); 687 } else if (aType.isDelegate()) { 688 var innerType = (ClassMeta<?>)((Delegate<?>)o).getBeanInfo(); 689 var ns = Holder.of(getXmlClassMeta(innerType).getNamespace()); 690 if (ns.isPresent()) { 691 if (nn(ns.get().uri)) 692 addNamespace(ns.get()); 693 else 694 ns.getAndUnset(); 695 } 696 697 if (innerType.isBean()) { 698 innerType.getBeanMeta().getProperties().values().stream().filter(BeanPropertyMeta::canRead).forEach(x -> { 699 ns.set(getXmlBeanPropertyMeta(x).getNamespace()); 700 if (ns.isPresent() && nn(ns.get().uri)) 701 addNamespace(ns.get()); 702 }); 703 } else if (innerType.isMap()) { 704 ((Map<?,?>)o).forEach((k, v) -> findNsfMappings(v)); 705 } else if (innerType.isCollection()) { 706 ((Collection<?>)o).forEach(this::findNsfMappings); 707 } 708 709 } else if (aType.isMap()) { 710 ((Map<?,?>)o).forEach((k, v) -> findNsfMappings(v)); 711 } else if (aType.isCollection()) { 712 ((Collection<?>)o).forEach(this::findNsfMappings); 713 } else if (aType.isArray() && ! aType.getElementType().isPrimitive()) { 714 for (var o2 : ((Object[])o)) 715 findNsfMappings(o2); 716 } 717 if (nn(bm)) { 718 var checkNull = (Predicate<Object>)(x -> isKeepNullProperties() || nn(x)); 719 bm.forEachValue(checkNull, (pMeta, key, value, thrown) -> { 720 var ns = getXmlBeanPropertyMeta(pMeta).getNamespace(); 721 if (nn(ns) && nn(ns.uri)) 722 addNamespace(ns); 723 724 try { 725 findNsfMappings(value); 726 } catch (@SuppressWarnings("unused") Exception x) { 727 // Ignore 728 } 729 }); 730 } 731 } 732 733 pop(); 734 } 735 736 /** 737 * Default namespace. 738 * 739 * @see XmlSerializer.Builder#defaultNamespace(Namespace) 740 * @return 741 * The default namespace URI for this document. 742 */ 743 protected final Namespace getDefaultNamespace() { return defaultNamespace; } 744 745 /** 746 * Default namespaces. 747 * 748 * @see XmlSerializer.Builder#namespaces(Namespace...) 749 * @return 750 * The default list of namespaces associated with this serializer. 751 * <br>Never <jk>null</jk>. 752 * <br>List is modifiable (namespaces can be added during serialization). 753 */ 754 protected final List<Namespace> getNamespaces() { return namespaces == null ? l() : namespaces; } 755 756 @Override 757 public boolean isAddBeanTypes() { return super.isAddBeanTypes(); } 758 759 /** 760 * Add JSON type tags. 761 * 762 * @see XmlSerializer.Builder#disableJsonTags() 763 * @return 764 * <jk>true</jk> if plain strings will be wrapped in <js><string></js> tags when serialized as root elements. 765 */ 766 protected final boolean isAddJsonTags() { return ctx.addJsonTags; } 767 768 /** 769 * Add namespace URLs to the root element. 770 * 771 * @see XmlSerializer.Builder#addNamespaceUrisToRoot() 772 * @return 773 * <jk>true</jk> if {@code xmlns:x} attributes are added to the root element for the default and all mapped namespaces. 774 */ 775 protected final boolean isAddNamespaceUrisToRoot() { return addNamespaceUrisToRoot; } 776 777 /** 778 * Auto-detect namespace usage. 779 * 780 * @see XmlSerializer.Builder#disableAutoDetectNamespaces() 781 * @return 782 * <jk>true</jk> if namespace usage is detected before serialization. 783 */ 784 protected final boolean isAutoDetectNamespaces() { return autoDetectNamespaces; } 785 786 /** 787 * Enable support for XML namespaces. 788 * 789 * @see XmlSerializer.Builder#enableNamespaces() 790 * @return 791 * <jk>false</jk> if XML output will not contain any namespaces regardless of any other settings. 792 */ 793 protected final boolean isEnableNamespaces() { return enableNamespaces; } 794 795 /** 796 * Returns <jk>true</jk> if we're serializing HTML. 797 * 798 * <p> 799 * The difference in behavior is how empty non-void elements are handled. 800 * The XML serializer will produce a collapsed tag, whereas the HTML serializer will produce a start and end tag. 801 * 802 * @return <jk>true</jk> if we're generating HTML. 803 */ 804 protected boolean isHtmlMode() { return false; } 805 806 /** 807 * Workhorse method. 808 * 809 * @param out The writer to send the output to. 810 * <br>Must not be <jk>null</jk>. 811 * @param o The object to serialize. 812 * <br>Can be <jk>null</jk> (written as the XML null representation, e.g. <c>type='null'</c>). 813 * @param eType The expected type if this is a bean property value being serialized. 814 * <br>Can be <jk>null</jk> (treated as the generic {@link Object} type). 815 * @param keyName The property name or map key name. 816 * <br>Can be <jk>null</jk> (the element is written without a separate property/key name). 817 * @param elementName The root element name. 818 * <br>Can be <jk>null</jk> (falls back to the bean dictionary name, then <c>keyName</c>, then the JSON type name). 819 * @param elementNamespace The namespace of the element. 820 * <br>Can be <jk>null</jk> (resolved from the serialized type's, then the actual type's, then the session's default namespace). 821 * @param addNamespaceUris Flag indicating that namespace URIs need to be added. 822 * @param format The format to serialize the output to. 823 * <br>Must not be <jk>null</jk>. 824 * @param isMixedOrText We're serializing mixed content, so don't use whitespace. 825 * @param preserveWhitespace 826 * <jk>true</jk> if we're serializing {@link XmlFormat#MIXED_PWS} or {@link XmlFormat#TEXT_PWS}. 827 * @param pMeta The bean property metadata if this is a bean property being serialized. 828 * <br>Can be <jk>null</jk> (no bean-property-level URI/child-name/namespace metadata is applied). 829 * @return The same writer passed in so that calls to the writer can be chained. 830 * @throws SerializeException General serialization error occurred. 831 */ 832 @SuppressWarnings({ 833 "java:S107" // Method has many parameters; acceptable for builder/configuration methods 834 }) 835 protected ContentResult writeAnything(XmlWriter<?> out, Object o, ClassMeta<?> eType, String keyName, String elementName, Namespace elementNamespace, boolean addNamespaceUris, XmlFormat format, 836 boolean isMixedOrText, boolean preserveWhitespace, BeanPropertyMeta pMeta) throws SerializeException { 837 838 JsonType type = null; // The type string (e.g. <type> or <x x='type'> 839 int i = isMixedOrText ? 0 : indent; // Current indentation 840 ClassMeta<?> aType = null; // The actual type 841 ClassMeta<?> wType = null; // The wrapped type (delegate) 842 ClassMeta<?> sType; // The serialized type 843 844 aType = push2(keyName, o, eType); 845 846 if (eType == null) 847 eType = object(); 848 849 // Handle recursion 850 if (aType == null) { 851 o = null; 852 aType = object(); 853 } 854 855 // Handle Optional<X> 856 if (isOptional(aType)) { 857 o = getOptionalValue(o); 858 eType = getOptionalType(eType); 859 aType = getClassMetaForObject(o, object()); 860 } 861 862 if (nn(o)) { 863 864 if (aType.isDelegate()) { 865 wType = aType; 866 eType = aType = (ClassMeta<?>)((Delegate<?>)o).getBeanInfo(); 867 } 868 869 sType = aType; 870 871 // Swap if necessary 872 var swap = aType.getSwap(this); 873 if (nn(swap)) { 874 o = swap(swap, o); 875 sType = swap.getSwapClassMeta(this); 876 877 // If the getSwapClass() method returns Object, we need to figure out 878 // the actual type now. 879 if (sType.isObject()) 880 sType = getClassMetaForObject(o); 881 } 882 } else { 883 sType = eType.getSerializedClassMeta(this); 884 } 885 886 // Does the actual type match the expected type? 887 boolean isExpectedType = true; 888 if (o == null || ! eType.same(aType)) { 889 if (eType.isNumber()) 890 isExpectedType = aType.isNumber(); 891 else if (eType.isMap()) 892 isExpectedType = aType.isMap(); 893 else if (eType.isCollectionOrArray() || eType.isStreamable()) 894 isExpectedType = aType.isCollectionOrArray() || aType.isStreamable(); 895 else 896 isExpectedType = false; 897 } 898 899 var resolvedDictionaryName = isExpectedType ? null : aType.getBeanDictionaryName(); 900 901 // Note that the dictionary name may be specified on the actual type or the serialized type. 902 // HTML templates will have them defined on the serialized type. 903 var dictionaryName = aType.getBeanDictionaryName(); 904 if (dictionaryName == null) 905 dictionaryName = sType.getBeanDictionaryName(); 906 907 // char '\0' is interpreted as null. 908 if (nn(o) && sType.isChar() && ((Character)o).charValue() == 0) 909 o = null; 910 911 boolean isCollapsed = false; // If 'true', this is a collection and we're not rendering the outer element. 912 boolean isRaw = (sType.isReader() || sType.isInputStream() || o instanceof RawContent) && nn(o); 913 914 // Get the JSON type string. 915 if (o == null) { 916 type = NULL; 917 } else if (sType.isCharSequence() || sType.isChar()) { 918 type = STRING; 919 } else if (sType.isNumber()) { 920 type = NUMBER; 921 } else if (sType.isBoolean()) { 922 type = BOOLEAN; 923 } else if (sType.isMapOrBean()) { 924 isCollapsed = getXmlClassMeta(sType).getFormat() == COLLAPSED; 925 type = OBJECT; 926 } else if (sType.isCollectionOrArray() || sType.isStreamable()) { 927 isCollapsed = (format == COLLAPSED && ! addNamespaceUris); 928 type = ARRAY; 929 } else { 930 type = STRING; 931 } 932 933 if (format.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT) && type.isOneOf(NULL, STRING, NUMBER, BOOLEAN)) 934 isCollapsed = true; 935 936 // Is there a name associated with this bean? 937 938 var name = keyName; 939 if (elementName == null && nn(dictionaryName)) { 940 elementName = dictionaryName; 941 isExpectedType = nn(o); // preserve type='null' when it's null. 942 } 943 944 if (elementName == null) { 945 elementName = name; 946 name = null; 947 } 948 949 if (eq(name, elementName)) 950 name = null; 951 952 if (isEnableNamespaces()) { 953 if (elementNamespace == null) 954 elementNamespace = getXmlClassMeta(sType).getNamespace(); 955 if (elementNamespace == null) 956 elementNamespace = getXmlClassMeta(aType).getNamespace(); 957 if (elementNamespace == null) 958 elementNamespace = defaultNamespace; 959 } else { 960 elementNamespace = null; 961 } 962 963 // Do we need a carriage return after the start tag? 964 boolean cr = nn(o) && (sType.isMapOrBean() || sType.isCollectionOrArray()) && ! isMixedOrText; 965 966 var en = elementName; 967 if (en == null && ! isRaw && isAddJsonTags()) { 968 en = type.toString(); 969 type = null; 970 } 971 972 boolean encodeEn = nn(elementName); 973 var ns = (elementNamespace == null ? null : elementNamespace.name); 974 String dns = null; 975 String elementNs = null; 976 if (isEnableNamespaces()) { 977 dns = elementName == null && nn(defaultNamespace) ? defaultNamespace.name : null; 978 elementNs = elementName == null ? dns : ns; 979 if (elementName == null) 980 elementNamespace = null; 981 } 982 983 // Render the start tag. 984 if (! isCollapsed) { 985 if (nn(en)) { 986 out.oTag(i, elementNs, en, encodeEn); 987 if (addNamespaceUris) { 988 out.attr((String)null, "xmlns", defaultNamespace.getUri()); 989 990 for (var n : namespaces) 991 out.attr("xmlns", n.getName(), n.getUri()); 992 } 993 if (! isExpectedType) { 994 if (nn(resolvedDictionaryName)) 995 out.attr(dns, getBeanTypePropertyName(eType), resolvedDictionaryName); 996 else if (nn(type) && type != STRING) 997 out.attr(dns, getBeanTypePropertyName(eType), type); 998 } 999 if (nn(name)) 1000 out.attr(MarshallingSession.NAME_PROPERTY_NAME, name); 1001 } else { 1002 out.i(i); 1003 } 1004 if (o == null && (sType.isBoolean() || sType.isNumber()) && ! sType.isNullable()) 1005 o = sType.getPrimitiveDefault(); 1006 1007 if (nn(o) && ! (sType.isMapOrBean() || en == null)) 1008 out.w('>'); 1009 1010 if (cr && ! (sType.isMapOrBean())) 1011 out.nl(i + 1); 1012 } 1013 1014 var rc = CR_ELEMENTS; 1015 1016 // Render the tag contents. 1017 if (nn(o)) { 1018 if (o instanceof RawContent) { 1019 // Re-serializable, String-backed raw content: emit verbatim (no entity/whitespace encoding), like the Reader path. 1020 out.append(o.toString()); 1021 } else if (sType.isBean()) { 1022 rc = writeBeanMap(out, toBeanMap(o), elementNamespace, isCollapsed, isMixedOrText); 1023 } else if (sType.isMap() || (nn(wType) && wType.isMap())) { 1024 if (o instanceof BeanMap o2) 1025 rc = writeBeanMap(out, o2, elementNamespace, isCollapsed, isMixedOrText); 1026 else 1027 rc = writeMap(out, (Map)o, sType, eType.getKeyType(), eType.getValueType(), isMixedOrText); 1028 } else if (sType.isCollection() || sType.isArray() || (nn(wType) && (wType.isCollection() || wType.isArray()))) { 1029 if (isCollapsed) 1030 indent--; 1031 writeCollection(out, o, sType, eType, pMeta, isMixedOrText); 1032 if (isCollapsed) 1033 indent++; 1034 } else if (sType.isUri() || (nn(pMeta) && pMeta.isUri())) { 1035 // Must come before isCharSequence/isChar: @Uri-annotated String properties (where sType is String but pMeta.isUri() is true) need to route here for URI text emission. 1036 out.textUri(o); 1037 } else if (sType.isCharSequence() || sType.isChar()) { 1038 if (isXmlText(format, sType) || isRawText(format, sType)) 1039 out.append(o); 1040 else 1041 out.text(o, preserveWhitespace); 1042 } else if (sType.isNumber() || sType.isBoolean()) { 1043 out.append(o); 1044 } else if (sType.isDate()) { 1045 out.text(writeDate((Date)o, sType)); 1046 } else if (sType.isCalendar()) { 1047 out.text(writeCalendar(o, sType)); 1048 } else if (sType.isTemporal()) { 1049 out.text(writeTemporal((TemporalAccessor)o, sType)); 1050 } else if (sType.isDuration()) { 1051 out.text(writeDuration((Duration)o)); 1052 } else if (sType.isPeriod()) { 1053 out.text(writePeriod((Period)o)); 1054 } else if (sType.isStreamable()) { 1055 if (isCollapsed) 1056 indent--; 1057 writeStreamable(out, o, sType, eType, pMeta, isMixedOrText); 1058 if (isCollapsed) 1059 indent++; 1060 } else if (sType.isReader()) { 1061 pipe((Reader)o, out, SerializerSession::handleThrown); 1062 } else if (sType.isInputStream()) { 1063 pipe((InputStream)o, out, SerializerSession::handleThrown); 1064 } else { 1065 if (isXmlText(format, sType) || isRawText(format, sType)) 1066 out.append(toString(o)); 1067 else 1068 out.text(toString(o)); 1069 } 1070 } 1071 1072 pop(); 1073 1074 // Render the end tag. 1075 if (! isCollapsed) { 1076 if (nn(en)) { 1077 if (rc == CR_EMPTY) { 1078 if (isHtmlMode()) 1079 out.w('>').eTag(elementNs, en, encodeEn); 1080 else 1081 out.w('/').w('>'); 1082 } else if (rc == CR_VOID || o == null) { 1083 out.w('/').w('>'); 1084 } else 1085 out.ie(cr && rc != CR_MIXED ? i : 0).eTag(elementNs, en, encodeEn); 1086 } 1087 if (! isMixedOrText) 1088 out.nl(i); 1089 } 1090 1091 return rc; 1092 } 1093}